DEV Community

SEN LLC
SEN LLC

Posted on

npm audit --json Is Unreadable. I Wrote a Formatter With Zero Dependencies.

npm audit --json Is Unreadable. I Wrote a Formatter With Zero Dependencies.

npm audit has two output modes. The human one is a wall of text that scrolls off your terminal. The JSON one is nested four levels deep and nobody opens it. This post is about the tool I wrote to bridge the two, and the things I learned about the audit schema along the way.

📦 GitHub: https://github.com/sen-ltd/npm-audit-report

Screenshot

The problem

Here's a thing that happens in every Node.js CI pipeline I've ever seen:

  1. A step runs npm audit and its output becomes a 400-line scrollback that nobody reads.
  2. A well-meaning engineer changes it to npm audit --json so it's "machine-readable."
  3. Now nobody reads it, because it's 40 KB of nested objects with keys like fixAvailable: { isSemVerMajor: true }.
  4. Six weeks later the team is shipping lodash@4.17.11.

The fundamental issue is that the JSON output is shaped like an inventory, not like a report. Each vulnerable package is a key under vulnerabilities, its via[] field can hold either strings (transitive pointers to other keys) or objects (real advisories with titles and CVSS scores), and the severity rollup lives in a separate metadata.vulnerabilities block. To actually understand whether your build is on fire, you'd need to walk the whole graph. Nobody walks the whole graph.

There are existing tools, but I didn't want to use any of them:

  • npm-audit-html pulls in handlebars, marked, and 20 other runtime deps for what should be a pure string transformation.
  • audit-ci focuses on the gating question ("fail the build?") but hides the details.
  • better-npm-audit is close to what I wanted but is JavaScript, not TypeScript, and doesn't emit SARIF.

So I wrote npm-audit-report. It's a zero-runtime-dependency TypeScript CLI that eats npm audit --json and spits out any of five formats:

  • human (default): a colored, severity-sorted terminal table.
  • markdown: a GitHub-flavored table you can drop straight into a PR comment.
  • json: a slim, canonical shape that isn't the raw npm audit shape.
  • github: GitHub Actions workflow commands (::error::, ::warning::) for inline PR annotations.
  • sarif: SARIF 2.1.0 for upload to GitHub Security, so the vulnerabilities show up as Code Scanning alerts.

Plus a --fail-on low|moderate|high|critical|none gate for the CI question. Whole thing is about 800 lines including tests. Let's walk through the interesting parts.

Design: normalize first, format later

The core insight is that all five formats are just different renderings of the same intermediate shape. The interesting code lives in the parser that walks npm's schema and produces a flat Vulnerability[]; every formatter is then a pure function of that shape.

export interface Vulnerability {
  packageName: string;
  severity: Severity;
  range: string;
  advisories: Advisory[];
  via: string[];
  effects: string[];
  nodes: string[];
  fix:
    | { kind: 'none' }
    | { kind: 'available' }
    | { kind: 'breaking'; name: string; version: string }
    | { kind: 'upgrade'; name: string; version: string };
}
Enter fullscreen mode Exit fullscreen mode

Notice the discriminated union for fix. In the raw npm output, fixAvailable is one of three things: false, true, or an object with name, version, and isSemVerMajor. That's a classic "avoid this pattern" in API design — three different shapes for the same key — but we can normalize it away in one place and then pretend it never happened:

function parseFix(raw: unknown): Vulnerability['fix'] {
  if (raw === false || raw == null) return { kind: 'none' };
  if (raw === true) return { kind: 'available' };
  if (isObj(raw)) {
    const name = asString(raw.name) ?? '<unknown>';
    const version = asString(raw.version) ?? '<unknown>';
    if (raw.isSemVerMajor === true) {
      return { kind: 'breaking', name, version };
    }
    return { kind: 'upgrade', name, version };
  }
  return { kind: 'none' };
}
Enter fullscreen mode Exit fullscreen mode

The via field is even worse. Here's what it actually looks like in the real output:

"mkdirp": {
  "via": ["minimist"],
  ...
},
"minimist": {
  "via": [
    {
      "source": 1179,
      "title": "Prototype Pollution in minimist",
      "severity": "moderate",
      "cvss": { "score": 5.6, ... }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

So mkdirp is vulnerable because minimist is vulnerable, and to know why minimist is vulnerable you have to follow the string pointer back to another key in the same map and look at its via[]. The first time I wrote parser code for this I didn't realize strings and objects could coexist in the same array, and my type annotations were a mess. The second time, I wrote a single parseAdvisory(raw: unknown) that handles both shapes at once and returns a normalized Advisory:

function parseAdvisory(raw: unknown): Advisory | null {
  if (typeof raw === 'string') {
    // `via` can be a bare package name referring to another vuln entry.
    return {
      source: null,
      packageName: raw,
      title: `via ${raw}`,
      url: null,
      severity: 'info',
      cwe: [],
      cvssScore: null,
      cvssVector: null,
      vulnerableRange: null,
    };
  }
  if (!isObj(raw)) return null;
  // ... full object shape with cvss, cwe, url, etc.
}
Enter fullscreen mode Exit fullscreen mode

The types and the schema are now decoupled. Every formatter downstream just works with Advisory and Vulnerability. Adding a sixth output format is about 100 lines.

The SARIF secret weapon

If you care about one output format, make it this one. SARIF 2.1.0 is the format GitHub Code Scanning consumes, and it's the reason vulnerabilities show up as proper Security Alerts on your repository's Security tab instead of getting buried in CI logs.

Here's the Actions workflow:

- name: Audit
  run: npm audit --json > audit.json || true
- name: Format to SARIF
  run: |
    docker run --rm -v $PWD:/w -w /w npm-audit-report \
      audit.json --format sarif > audit.sarif
- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: audit.sarif
Enter fullscreen mode Exit fullscreen mode

The || true is important — npm audit exits non-zero when vulnerabilities exist, and we want to format the output, not bail. The --fail-on gate comes later, from npm-audit-report itself, once we've decided what we're actually willing to fail on.

Producing SARIF is less scary than it sounds. The document has three required layers: runs → tool.driver (who scanned), runs → tool.driver.rules[] (what rules exist), and runs → results[] (what was found, each pointing to a rule by id). Here's the rule-building core:

for (const a of advisories) {
  const ruleId = a.source != null
    ? `npm-audit-${a.source}`
    : `npm-audit-${v.packageName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;

  if (!seenRules.has(ruleId)) {
    rules.push({
      id: ruleId,
      name: v.packageName,
      shortDescription: { text: a.title },
      fullDescription: {
        text: `${a.title} — affected range: ${a.vulnerableRange ?? v.range}`,
      },
      helpUri: a.url ?? undefined,
      properties: {
        'security-severity': securitySeverity(a.severity, a.cvssScore),
        tags: ['security', 'npm', ...a.cwe],
      },
    });
    seenRules.add(ruleId);
  }
  results.push({
    ruleId,
    level: sarifLevel(a.severity),
    message: { text: `${v.packageName} ${v.range}: ${a.title}` },
    locations: [{
      physicalLocation: {
        artifactLocation: { uri: 'package.json' },
      },
    }],
  });
}
Enter fullscreen mode Exit fullscreen mode

Two GitHub-specific tricks that took me a while to figure out:

  1. properties['security-severity'] must be a numeric string from 0 to 10 (CVSS-style). This is what GitHub uses to map a SARIF result into its own Critical/High/Medium/Low buckets. If you omit it, everything shows up as "Warning" regardless of severity. I pass the real CVSS score if the advisory has one, and fall back to a fixed ladder (9.5 / 7.5 / 5.0 / 2.0) otherwise.
  2. Every result needs a locations[] with at least one entry, even for vulnerabilities that don't have a meaningful file location. For transitive deps I point at package.json — not strictly correct, but it's what the GitHub UI expects and the alerts render cleanly.

I also tag each rule with the CWEs from the advisory, so CWE-1321 (Prototype Pollution) becomes a filterable tag in the Code Scanning UI. Free metadata, might as well use it.

The --fail-on gate

The other CI-critical feature is the exit code. Every team I've seen has a different answer to "should a moderate advisory break the build?" Some fail on any vulnerability, some ignore everything below high, some only fail on critical. So the tool takes a --fail-on flag with five values:

export function shouldFail(report: AuditReport, failOn: FailOn): boolean {
  if (failOn === 'none') return false;
  const threshold = severityRank(failOn);
  for (const v of report.vulnerabilities) {
    if (severityRank(v.severity) >= threshold) return true;
  }
  return false;
}
Enter fullscreen mode Exit fullscreen mode

The default is critical — conservative, lets your build survive day-to-day advisory churn while still failing on the "stop everything" class. The none value is for the case where you're uploading SARIF to Code Scanning and want the vulnerabilities to appear as alerts without breaking the build, because your security team will triage them out of band.

Exit codes end up being the most important API this tool has, so I wrote them down explicitly in the --help:

Exit codes:
  0  clean, or nothing at/above --fail-on
  1  vulnerabilities at/above --fail-on
  2  bad arguments or invalid JSON
Enter fullscreen mode Exit fullscreen mode

And then there's a test for each of the five --fail-on values on each of the three fixtures (clean, mixed, critical). Fifteen tests for what sounds like a single feature, but exit codes are the surface every CI system touches, so getting them wrong would silently break every consumer.

Tradeoffs I made on purpose

A few design decisions I'd defend in a PR review:

  • npm 9+ only. The audit schema has changed multiple times over npm 6 / 7 / 8 / 9. Version 2 (the auditReportVersion: 2 you get from npm 9+) is stable and has been for two years. I reject any other auditReportVersion with exit code 2 and a clear error. The alternative — supporting all historical shapes — would double the parser's size and the test surface, for zero value to a team that's on modern Node.

  • No yarn audit / pnpm audit normalization. Both tools emit completely different (and mutually incompatible) JSON shapes. Adding them means either a second parser module or a third-party abstraction layer. Neither is free, and lifting pnpm audit --json through the same Vulnerability interface would give most of the win of this tool, but that's a separate project.

  • No deduplication across nodes[]. If lodash@4.17.11 shows up at six places in your dependency tree, SARIF will get six results. I decided not to dedupe because GitHub Code Scanning fingerprints by ruleId + location and will collapse identical alerts anyway. Aggressive dedup would also hide the fact that you have six different projects all pulling in the bad version, which is actually useful info.

  • Hand-rolled ANSI. chalk would save me ~40 lines and cost a runtime dependency. I picked the 40 lines. The whole color.ts is ~50 lines and includes an identity fallback for --no-color that replaces every wrapper with (s) => s, so the formatter code doesn't branch on color mode — it just calls p.red(x) unconditionally.

Try it in 30 seconds

git clone https://github.com/sen-ltd/npm-audit-report
cd npm-audit-report
docker build -t npm-audit-report .

# in some other project...
cd ~/some-project
npm audit --json | docker run --rm -i npm-audit-report -

# Markdown for a PR comment
npm audit --json | docker run --rm -i npm-audit-report - --format markdown

# SARIF for GitHub Security
npm audit --json | docker run --rm -i npm-audit-report - --format sarif > audit.sarif
Enter fullscreen mode Exit fullscreen mode

The Docker image is node:20-alpine + the compiled dist/ folder, no node_modules, about 136 MB. The CLI is also runnable as a plain Node script if you build from source.

Closing

The amount of information in npm audit --json is fine. The encoding is not. Writing this tool made me realize how much we tolerate bad JSON schemas from tools we inherited: the three-way union of fixAvailable, the string-or-object via[], the split between the vuln map and the counts block. None of that is essential complexity — it's just schema debt that everyone in the ecosystem routes around.

If you write a CLI tool that emits JSON, do the next person a favor: give them a normalized shape with a stable schema, and let them decide if they want to add more fields later. It's cheap, and your downstream formatters will thank you.

Code and tests live at sen-ltd/npm-audit-report. Patches welcome, especially for pnpm audit normalization if anyone wants to take a swing at it.

Top comments (0)