NPM Audit + Jenkins Warnings Next Generation (Native JSON Format)

NPM Audit + Jenkins Warnings Next Generation (Native JSON Format)

Recently I wrote a story about how I displayed the NodeJS dependency audit report in Jenkins. In that blog post, I investigated the "custom parser" approach, which is a good option because you don't need to change anything besides your build configuration. You can read that story here (which you should definitely do if you don't know what is NPM audit and why do you need it in Jenkins):

⚠️Warning: the output format of npm audit completely changed for NPM v7. Please consider reading my other blogpost, where I discuss the changes and suggest solutions for the new format.

In this blog post, I will investigate another approach: we will generate the warnings report in an XML or JSON format natively supported by the WarningsNG plugin. This approach is described in the official documentation. As we are working with NodeJS, we will focus on the JSON format, an example of which is available in the official repo. In a nutshell, we need to provide a JSON object with a top-level property issues represented by an array of issue-objects. Each such object holds details about a single issue such as a fileName, severity, category, line start, etc:

{
   "issues": [
      {
         "fileName": "test.xml",
         "severity": "ERROR",
         "lineStart": 110,
         "lineEnd": 111,
         "columnStart": 210,
         "columnEnd": 220,
         "message": "some message",
         "description": "some description"
      },
      {
         "fileName": "some.properties",
         "severity": "NORMAL",
         "lineStart": 20,
         "other":true,
         "additional": "stuff"
      }
   ]
}

Parsing the NPM Audit

Luckily for us, the is an option to have JSON as the NPM audit output format: npm audit --json. Out of a complete audit output, we will be looking mainly at two top level properties: actions and advisories, as they contain detailed information about each issue. While actions are represented by an array of action-objects, advisories is another single object with keys that resemble IDs and values represented by advisory-objects.

{
   "actions": [
      {...},
      {...},
      {...}
   ],
   "advisories": {
      "566": {...},
      "1555": {...}
   },
   "muted": [],
   "metadata": {...},
   "runId": "af645807-9b57-4dc9-994e-616e79bcbf48"
}

Actions

Each action is meant to tell you what to do to resolve a vulnerability. Some of them are pretty straight forward (like "install this of that package), while others state that one has to review a vulnerability. If we look that the standard NPM audit output (without the --json flag), the actions are clearly stated on top of the "vulnerability tables."

# Run  npm update bl --depth 4  to resolve 1 vulnerability
┌───────────────┬──────────────────────────────────────────────┐
│ High          │ Remote Memory Exposure                       │
├───────────────┼──────────────────────────────────────────────┤
│ Package       │ bl                                           │
├───────────────┼──────────────────────────────────────────────┤
│ Dependency of │ exceljs                                      │
├───────────────┼──────────────────────────────────────────────┤
│ Path          │ exceljs > archiver > tar-stream > bl         │
├───────────────┼──────────────────────────────────────────────┤
│ More info     │ https://npmjs.com/advisories/1555            │
└───────────────┴──────────────────────────────────────────────┘

So npm update bl --depth 4 is an update action that will solve the "Remote Memory Exposure" vulnerability caused by the bl package. Now let's investigate the same action in the JSON format:

{
   "action": "update",
   "resolves": [
      {
         "id": 1555,
         "path": "exceljs>archiver>tar-stream>bl",
         "dev": false,
         "optional": false,
         "bundled": false
      }
   ],
   "module": "bl",
   "target": "1.2.3",
   "depth": 4
}

We can recreate the exact action command when we read the properties action, module, and depth. Now the issue that this action should solve is described in the resolves. There are two essential pieces of information: path and id. The former one specifies the root cause of the vulnerability as a dependency path. In the current example, we don't depend on the bl package directly, but we depend on exceljs, which depends on archiver, which depends on tar-stream, which finally depends on bl. And while path is particular to our project, id refers to an actual npm issue report. For example, you can go to npmjs.com/advisories/1555 (which has the id at the end of the URI) and read all the details about that violation. Luckily for us, the same detailed information is available in the JSON report in the advisories object behind the 1555 key.

Advisories

Compared to actions, advisories have way more properties, as they hold all the details about a vulnerability. While the founder and reporter are essential people in the vulnerability detection process, it's not likely you will make good use of their names. Here is a subset of advisory properties that can be useful for you:

{
   "findings": [
      {
         "version": "1.2.2",
         "paths": [
            "exceljs>archiver>tar-stream>bl"
         ]
      }
   ],
   "id": 1555,
   "title": "Remote Memory Exposure",
   "module_name": "bl",
   "vulnerable_versions": "<1.2.3 || >2.0.0 < 2.2.1 || >=3.0.0 <3.0.1 || >= 4.0.0 <4.0.3",
   "patched_versions": ">=1.2.3 <2.0.0 || >=2.2.1 <3.0.0 || >=3.0.1 <4.0.0 || >=4.0.3",
   "overview": "A buffer over-read vulnerability exists in bl <4.0.3, <3.0.1 <2.2.1 and <1.2.3 which could allow an attacker to supply user input (even typed) that if it ends up in consume() argument and can become negative, the BufferList state can be corrupted, tricking it into exposing uninitialized memory via regular .slice() calls.",
   "recommendation": "Upgrade to version 4.0.3, 3.0.1, 2.2.1 or 1.2.3.",
   "severity": "high",
   "url": "https://npmjs.com/advisories/1555"
}

irst of all, take a look at findings. This object tells you which version of the vulnerable package you're using and which packages depend on it. So technically, you can get all the issues of your project just by analyzing advisories and digging down into findings. However, I prefer to start with actions, as you can immediately know if some problems can be fixed with a simple update or if you need to inspect them manually.

Then you also have title which is kind of a "category" of the advisory; severity which speaks for itself, as well as many descriptions and details provided in overview, recommendation, url, vulnerable_versions, and patched_versions.

Transforming the JSON

We need to create a script that will read the NPM audit JSON and output another JSON in the native format of WarningsNG. To keep thing simple, our script will read data from the input stream and write it to the output stream (this way we can easily run everything in a single shel command with pipes). The skeleton of our script will look like this:

let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
   let chunk = process.stdin.read();
   if (chunk !== null) {
      data += chunk;
   }
});

process.stdin.on('end', () => {

   const audit = JSON.parse(data);
   const actions = audit.actions;
   const advisories = audit.advisories;

   const issues = ...
   console.log(JSON.stringify({ issues: issues }));
});

In the first half, we read the whole input into one string; in the second half, we parse the input string into a javascript object and create actions and advisories constants for convenience. Then we have to form the issues object and output it as a JSON string (console log writes to the output stream behind the scenes).

To achieve the same issue information that I assembled in the previous blogpost we need to assemble issues in the following way:

function warningsNGSeverity(string) {
   switch (string) {
      case 'low': return 'LOW';
      case 'moderate': return 'NORMAL';
      case 'high': return 'HIGH';
      case 'critical': return 'ERROR';

      default: return 'NORMAL';
   }
}

const issues = actions.flatMap((action) =>
   action.resolves.map((resolve) => {
      const advisory = advisories[resolve.id];
      return {
         fileName: resolve.path,
         cattegory: advisory.title,
         message: advisory.url,
         severity: warningsNGSeverity(advisory.severity)
      };
   }));

Where we combine each of the actions resolve with a corresponding advisory and generate the following issue for the previously discussed example:

{
     "fileName": "exceljs>archiver>tar-stream>bl",
     "cattegory": "Remote Memory Exposure",
     "message": "https://npmjs.com/advisories/1555",
     "severity": "HIGH"
}

More Sophisticated Issues

Now as we can access all the info about NPM audit, and we have a simple way of delivering it to the Warnings NG plugin, let's make use of this situation. I changed the issue-computing script to the following code:

const issues = actions.flatMap((action) =>
   action.resolves.map((resolve) => {
      const advisory = advisories[resolve.id];
      return {
         fileName: resolve.path,
         packageName: advisory.module_name,
         cattegory: advisory.title,
         type: action.action,
         message: advisory.recommendation,
         description: `${advisory.overview}\nRead more at: ${advisory.url}`,
         severity: warningsNGSeverity(advisory.severity)
      };
   }));

This will result in the following JSON:

{
   "fileName": "exceljs>archiver>tar-stream>bl",
   "packageName": "bl",
   "cattegory": "Remote Memory Exposure",
   "type": "update",
   "message": "Upgrade to version 4.0.3, 3.0.1, 2.2.1 or 1.2.3.",
   "description": "A buffer over-read vulnerability exists in bl <4.0.3, <3.0.1 <2.2.1 and <1.2.3 which could allow an attacker to supply user input (even typed) that if it ends up in consume() argument and can become negative, the BufferList state can be corrupted, tricking it into exposing uninitialized memory via regular .slice() calls.\nRead more at: https://npmjs.com/advisories/1555",
   "severity": "HIGH"
}

And as far as I can tell, this is the most you can get the Warnings NG plugin UI. This way, the issues are going to be grouped by the vulnerable package in the folders tab.

Screenshot 2020-10-30 at 18.20.20.png

The detailed issue list will also have plenty of useful information such as the vulnerable package, severity, type of a proposed fix, and the detailed description, which ends with a link.

Screenshot 2020-10-30 at 18.20.07.png

Recording The Audit in Jenkins

This approach requires only minimal changes to your Jenkins declarative pipeline (sorry, I'm going to stick with pipelines exclusively). In the beginning, you have to run the audit, pipe it to our transformation script (presumably bin/transform-audit.js) and write the result into a file. Of course, don't forget to create the output directory. To record the issues with the Warnings NG plugin, you have to:

  1. specify the issues tool, which reads issues in one of the native formats;
  2. specify the file location;
  3. provide a meaningful name (otherwise, the report will be labeled "Warnings Plugin Native Format Warnings").
stage('NPM Audit') {
   steps {
      sh 'mkdir -p .tmp/'
      sh 'npm audit --json | node bin/transform-audit.js > .tmp/npm-audit.json || true'
   }
   post {
      always {
         recordIssues(
            tool: issues(name: 'NPM Audit', pattern:'.tmp/npm-audit.json'),
            qualityGates: [
               [threshold: 100, type: 'TOTAL', unstable: true],
               [threshold: 1, type: 'TOTAL_ERROR', unstable: false]
            ]
         )
      }
   }
}

If you want to learn more about the quality gates, please read the first blog post in this series.

Summary

This approach proved to be much cleaner than the creation of a custom groovy parser on the go. Among the advantages:

  1. You don't have to rely on regex parsing;
  2. You don't have to write executable code in a string of another code (to create issue objects in the parser);
  3. You don't have to grant permissions to specific commands;
  4. You have easier access to much more properties of the issues;
  5. It's much easier to test a local script than a regex matcher that runs on Jenkins.

Of course, this is not generally applicable to all situations.

If you feel adventurous, you can use the script to look up the positions of the package definitions in the package.json file and create issues that point to the package file, and know the correct line number.

You can get a full source code of the audit-transforming script here:

Also, take a look at the upcoming breaking changes in NPM v7: