Skip to main content

06 - Supply Chain Security: npm audit, SBOM and Dependencies

npm audit, SBOM, lockfile integrity, install scripts and movable GitHub actions: each risk shown on test projects, with the countermeasure.

What do you think about this article?

06 - Supply Chain Security: npm audit, SBOM and Dependencies
Sicurezza WebPhoto:Bibek ghoshPexels

At a Glance

npm audit, SBOM, lockfile integrity, install scripts and movable GitHub actions: each risk shown on test projects, with the countermeasure.

The code you install is the code you run

When you run npm install you download bytes written by strangers, write them into your folder and, by default, run the scripts they bring along. In 2024 the most studied case was not even JavaScript: the NVD entry for CVE-2024-3094 says that malicious code had been inserted into the upstream xz tarballs, from version 5.6.0, extracted during the build from a disguised test file; NVD assigns it a CVSS 3.1 score of 10.0.

From here come four questions, and the article answers each with an experiment that was run: what is inside the project (SBOM), what is already known to be vulnerable (npm audit), where the bytes come from and who guarantees they are the expected ones (lockfile and signatures), what gets executed at install time (lifecycle scripts). For the OWASP Top 10:2025 the whole is the category A03:2025, Software Supply Chain Failures, which in the OWASP community survey, according to the category page, ranked first: 50% of respondents put it in first place.

All the experiments run on test projects created in temporary folders, with npm 11.6.3 and Node v24.11.0; for two steps (the audit and the integrity check) npm queries the public registry registry.npmjs.org read-only, and that is the only network used. Every output shown is identical across two consecutive runs, with one exception declared here: the outputs of the audit and integrity listings depend on the day and on the registry, so they are reported exactly as obtained in the Italian edition of this article on 2026-11-13 and were not run again for this version; for the same reason those two listings keep their printed labels in Italian (vulnerabilità = vulnerabilities, gravità massima = maximum severity, correzione disponibile = fix available, npm ci con lockfile manomesso = npm ci with a tampered lockfile, originale = original, nessun EINTEGRITY = no EINTEGRITY), while their comments are translated. The versions and audit results are those of 2026-11-13: the list of advisories changes when new ones are published, which is the reason the audit must be repeated, not memorized.

npm audit: what it says and what it cannot say

The test project has a single dependency, lodash@4.17.15, chosen because it is old. The script calls npm audit --json with execFileSync (arguments in an array, no shell) and prints a sorted summary. A trap you fall into on first use: npm audit exits with code 1 when it finds vulnerabilities, and execFileSync treats that as an error, which is why the script recovers stdout from the exception. In continuous integration it is precisely this exit code that stops the pipeline.

import { execFileSync } from 'node:child_process';

// Test project: a single package, lodash 4.17.15, pinned in package-lock.json
const run = (args) => {
  try { return execFileSync('npm', args, { cwd: 'proj-audit', encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); }
  catch (e) { return e.stdout; }               // npm audit exits with code 1 if it finds vulnerabilities
};
const report = JSON.parse(run(['audit', '--json', '--cache', 'npm-cache']));

console.log('vulnerabilità:', JSON.stringify(report.metadata.vulnerabilities));
for (const [name, v] of Object.entries(report.vulnerabilities)) {
  console.log(`${name}: gravità massima ${v.severity}, correzione disponibile: ${v.fixAvailable.version}`);
  const advisories = v.via.filter((x) => typeof x === 'object').sort((a, b) => a.source - b.source);
  for (const a of advisories) console.log(`  ${a.severity.padEnd(8)} ${a.url.split('/').pop()}  ${a.range.padEnd(16)} ${a.title}`);
}
// Output:
// vulnerabilità: {"info":0,"low":0,"moderate":0,"high":1,"critical":0,"total":1}
// lodash: gravità massima high, correzione disponibile: 4.18.1
//   high     GHSA-35jh-r3h4-6jhm  <4.17.21         Command Injection in lodash
//   high     GHSA-p6mc-m468-83gw  >=3.7.0 <4.17.19 Prototype Pollution in lodash
//   moderate GHSA-29mw-wpgm-hmr9  >=4.0.0 <4.17.21 Regular Expression Denial of Service (ReDoS) in lodash
//   high     GHSA-r5fr-rjxr-66jc  >=4.0.0 <=4.17.23 lodash vulnerable to Code Injection via `_.template` imports key names
//   moderate GHSA-xxjr-mmjv-4gpg  >=4.0.0 <=4.17.22 Lodash has Prototype Pollution Vulnerability in `_.unset`, `_.omit`, and `_.pullAt` functions

The five lines are five distinct advisories on the same package, each with a GHSA identifier, range of affected versions and severity. The datum you need to decide is fixAvailable: here it indicates version 4.18.1, and isSemVerMajor is false, so the update should not break the interface. What the audit does not say is whether the vulnerable code is reached by your program: an advisory on _.template weighs little if you never call it, but weighs a lot if you pass user input to _.template. That is why the severity reported is a starting point for triage, not a priority. From the npm documentation it also emerges that with --audit-level you choose the threshold below which the command exits with 0: the threshold is a project choice to declare in the repository, not a habit.

  • Is the vulnerable package a direct or a transitive dependency?
  • Is the affected code reached with data that a user controls?
  • Is there a fixed version, and is the update compatible?
  • If you cannot update, is there a mitigation, and who reviews it at its expiry?

The four questions should be asked in this order because each answer narrows the next: if the package is not reached by external input, urgency drops and the update can wait for the next release; if a compatible fix exists, there is no reason to discuss. The uncomfortable case is the last, and it must be closed with a written deadline, because an exception without a date becomes permanent.

SBOM: the inventory that lets you answer quickly

An SBOM (Software Bill of Materials) is the machine-readable list of what makes up a piece of software. npm sbom generates it in two formats, CycloneDX and SPDX, and with --package-lock-only it derives it from the lockfile without reading node_modules, so it can be produced in the pipeline before installation. The experiment's script generates both and prints only the stable fields: the serial number and the timestamp change on every run and were left out on purpose.

import { execFileSync } from 'node:child_process';

const sbom = (format) => JSON.parse(
  execFileSync('npm', ['sbom', '--sbom-format', format, '--package-lock-only'], { cwd: 'proj-audit', encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
);

// CycloneDX: list of components with a unique identifier (purl) and the tarball hash
const cdx = sbom('cyclonedx');
console.log('format:', cdx.bomFormat, cdx.specVersion);
for (const c of cdx.components) {
  console.log(`component: ${c.name}@${c.version}`);
  console.log(`  purl:     ${c.purl}`);
  console.log(`  hash:     ${c.hashes?.map((h) => h.alg).join(',')}`);
}
console.log('root dependencies -> children:', cdx.dependencies.map((d) => `${d['ref'].split('@')[0]}->${d.dependsOn.length}`).join(' '));

// SPDX: same inventory, another format
const spdx = sbom('spdx');
console.log('format:', spdx.spdxVersion, '- packages:', spdx.packages.map((p) => p.name).join(', '));
// Output:
// format: CycloneDX 1.5
// component: lodash@4.17.15
//   purl:     pkg:npm/lodash@4.17.15
//   hash:     SHA-512
// root dependencies -> children: demo-audit->1 lodash->0
// format: SPDX-2.3 - packages: demo-audit, lodash

The output shows three things. The format (CycloneDX 1.5, SPDX 2.3) is the one produced by npm 11.6.3. Each component has a purl, pkg:npm/lodash@4.17.15, an identifier that other tools can cross-reference with vulnerability databases. It also has a SHA-512 hash of the tarball. The dependency tree (demo-audit->1, lodash->0) says who depends on whom. An SBOM does not say whether a component is vulnerable, and does not guarantee that the data is true: it is an inventory. It is useful on the day a serious advisory comes out and the question is "which of our services contain it?": with the SBOMs already archived you answer with a search instead of a week of investigation.

Lockfile and integrity: the expected bytes, the bytes that arrived

package-lock.json does not only pin versions: for each package it records the resolved URL and the integrity hash of the tarball. The npm ci documentation lists the differences from npm install: it requires an existing lockfile, exits with an error if it does not match package.json instead of updating it, and never writes to either package.json or the lockfile. For the pipeline a simple rule follows: npm ci, never npm install. The script below demonstrates it on a project with ms@2.1.3: it generates the lockfile, alters its hash as a malicious pull request would and compares the outcome of npm ci.

import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = mkdtempSync(join(tmpdir(), 'lab-int-'));
const npm = (args) => spawnSync('npm', [...args, '--cache', join(dir, '.cache'), '--no-audit', '--no-fund'], { cwd: dir, encoding: 'utf8' });

writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'demo-int', version: '1.0.0', private: true, dependencies: { ms: '2.1.3' } }));
npm(['install']);                                         // generates package-lock.json with integrity
const lockPath = join(dir, 'package-lock.json');
const original = readFileSync(lockPath, 'utf8');
const lock = JSON.parse(original);
const entry = lock.packages['node_modules/ms'];
console.log('resolved :', entry.resolved);
console.log('integrity:', entry.integrity.slice(0, 14) + '...');

// Tampering: we change the expected hash, as a lockfile altered in a pull request would
entry.integrity = entry.integrity.slice(0, -6) + 'AAAAA=';
writeFileSync(lockPath, JSON.stringify(lock, null, 2));
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
const bad = npm(['ci']);
console.log('npm ci con lockfile manomesso -> exit', bad.status, '|', (bad.stderr.match(/EINTEGRITY/) ?? ['nessun EINTEGRITY'])[0]);

writeFileSync(lockPath, original);                       // original lockfile
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
console.log('npm ci con lockfile originale -> exit', npm(['ci']).status);

// Registry signatures: npm compares each installed package with the key published by the registry
const sig = npm(['audit', 'signatures']).stdout.trim().split('\n').filter(Boolean).pop();
console.log('npm audit signatures ->', sig);
rmSync(dir, { recursive: true, force: true });
// Output:
// resolved : https://registry.npmjs.org/ms/-/ms-2.1.3.tgz
// integrity: sha512-6FlzubT...
// npm ci con lockfile manomesso -> exit 1 | EINTEGRITY
// npm ci con lockfile originale -> exit 0
// npm audit signatures -> 1 package has a verified registry signature

With the altered hash npm ci exits with 1 and reports EINTEGRITY: the downloaded tarball does not match the declared one and the installation stops. With the original lockfile it exits with 0, and npm audit signatures confirms that the package has a valid registry signature (npm documentation: it also verifies provenance attestations where present). The check protects against tampering with the tarball after publication and against a lockfile that diverges from the sources, but not against the publication of a malicious version by a compromised maintainer: in that case the hash is the right one for the wrong version. That is why the lockfile should be read in pull requests, in particular host changes in the resolved field.

A nearby attack is dependency confusion: an unscoped internal name that an attacker registers on the public registry. I do not demonstrate it here, because it would require publishing a package. The remedy documented by npm is the scope: the guide explains that each user or organization has its own scope and only the owner can add packages to it, and that therefore there is no risk of someone taking the name before you. Giving internal packages a name @yourorganization/name and associating that scope with the internal registry in the .npmrc file (a configuration line to adapt, not run here) closes the road.

Install scripts: code executed before you have read it

A package can declare preinstall, install and postinstall, and npm runs them on the machine of whoever installs. In the experiment the package demo-postinstall is mine: its script writes a file and notes which environment variables it sees with names that look like secrets. The child process starts with a clean environment, and I put a single fake variable in it, FAKE_CI_TOKEN, to make the exposure visible without showing anything real. Then the same package is installed twice, with and without --ignore-scripts.

import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const root = mkdtempSync(join(tmpdir(), 'lab-scripts-'));
const run = (cwd, args, env = {}) =>
  spawnSync('npm', [...args, '--cache', join(root, '.cache'), '--no-audit', '--no-fund'], {
    cwd, encoding: 'utf8', env: { PATH: process.env.PATH, HOME: root, ...env } });  // clean environment: no variables from the machine running the test

// "Malicious" test package: its postinstall writes a file and notes which environment variables it sees
const pkg = join(root, 'pkg'); mkdirSync(pkg);
writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: 'demo-postinstall', version: '1.0.0', scripts: { postinstall: 'node hook.js' } }));
writeFileSync(join(pkg, 'hook.js'), `
const fs = require('fs'), path = require('path');
const seen = Object.keys(process.env).filter((k) => /TOKEN|SECRET|KEY/.test(k));
fs.writeFileSync(path.join(__dirname, 'ran.txt'), 'postinstall ran; sensitive variables visible: ' + seen.join(','));
`);
const tgz = run(pkg, ['pack', '--silent']).stdout.trim();

const attempt = (label, extra) => {
  const app = mkdtempSync(join(root, 'app-'));
  writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0', private: true }));
  run(app, ['install', join(pkg, tgz), ...extra], { FAKE_CI_TOKEN: 'test-value' });
  const marker = join(app, 'node_modules', 'demo-postinstall', 'ran.txt');
  console.log(label.padEnd(24), existsSync(marker) ? readFileSync(marker, 'utf8') : 'postinstall NOT run');
};
attempt('npm install', []);
attempt('npm install --ignore-scripts', ['--ignore-scripts']);
rmSync(root, { recursive: true, force: true });
// Output:
// npm install              postinstall ran; sensitive variables visible: FAKE_CI_TOKEN
// npm install --ignore-scripts postinstall NOT run

The first line is the concrete risk: the script was executed during installation and had access to the variable. In a pipeline that environment often contains publishing and deploy tokens, and it is what a compromised package would look for. The second line shows the countermeasure: with --ignore-scripts the script does not start. The cost is real: packages that compile native code or download binaries get installed halfway, and then have to be rebuilt explicitly for only the packages that require it (npm rebuild followed by the name). It is better to start from refusal for all and readmit one by one, rather than the opposite.

Look-alike names and movable actions: two checks you can write in ten lines

Typosquatting exploits the typo: lodahs in place of lodash. The listing's first check computes the edit distance, with transposition, between the requested name and a list of known packages, and flags whatever is at distance 1 or 2 without being identical. It is a heuristic, not a verdict: it produces false positives (two legitimate packages with close names) and false negatives (a malicious name far away), and the list of known packages is a test one. The second check concerns GitHub Actions: GitHub's guide recommends pinning actions to a full SHA, because a tag can be moved and a SHA cannot, and giving the GITHUB_TOKEN the minimum of permissions. The script reads a test workflow, written in the listing, and executes nothing.

// 1) Typosquatting: edit distance (with transposition) between a requested name and the known packages
const popular = ['lodash', 'express', 'react', 'axios', 'moment', 'chalk', 'commander', 'debug', 'typescript'];

function osa(a, b) {                               // optimal string alignment distance
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
  for (let j = 0; j <= b.length; j++) d[0][j] = j;
  for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
    const cost = a[i - 1] === b[j - 1] ? 0 : 1;
    d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
    if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
  }
  return d[a.length][b.length];
}
const suspicious = (name) => popular.filter((p) => p !== name && osa(name, p) <= 2);
for (const name of ['lodash', 'lodahs', 'expresss', 'axois', 'my-internal-lib']) {
  const near = suspicious(name);
  console.log(name.padEnd(16), near.length ? `SUSPICIOUS, close to: ${near.join(', ')}` : 'ok');
}

// 2) GitHub Actions: every "uses:" must point to a 40-hex SHA, not to a tag that can be moved
const workflow = `
name: ci
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@0123456789abcdef0123456789abcdef01234567
      - uses: docker://alpine:3.20
`;
const SHA = /^[0-9a-f]{40}$/;
for (const m of workflow.matchAll(/uses:\s*(\S+)/g)) {
  const ref = m[1];
  const version = ref.split('@')[1] ?? '';
  const verdict = ref.startsWith('docker://') ? 'to check: image without digest' : SHA.test(version) ? 'ok, pinned' : 'NOT pinned (tag or branch)';
  console.log(ref.padEnd(64), verdict);
}
console.log('top-level permissions declared:', /^permissions:/m.test(workflow) ? 'yes' : 'NO (declare at least contents: read)');
// Output:
// lodash           ok
// lodahs           SUSPICIOUS, close to: lodash
// expresss         SUSPICIOUS, close to: express
// axois            SUSPICIOUS, close to: axios
// my-internal-lib  ok
// actions/checkout@v4                                              NOT pinned (tag or branch)
// actions/setup-node@0123456789abcdef0123456789abcdef01234567      ok, pinned
// docker://alpine:3.20                                             to check: image without digest
// top-level permissions declared: NO (declare at least contents: read)

The three names with typos are flagged together with the package they seem to derive from; my-internal-lib and lodash pass. In the workflow actions/checkout@v4 turns out not pinned, the one with 40 hexadecimal characters turns out pinned (the SHA in the listing is invented, it only serves the experiment), the docker:// image has no digest, and the top-level permissions are missing. A note on currency: the same GitHub guide today also describes workflow dependency lockfiles, which record the exact commit of each action, including transitive ones, and defines them as the strongest protection, replacing SHA pinning for whoever adopts them. The SHA remains the minimum for whoever cannot.

What these checks do not cover

None of these checks sees a malicious package not yet reported: the audit compares with advisories already published, the SBOM takes a snapshot, the lockfile guarantees that the bytes are the registered ones. The defense on the first day of an attack is reducing the surface: fewer dependencies, no automatic updates to the latest version just released, scripts disabled, pipelines with minimal permissions. To go deeper on the perimeter of requests and tokens there is the previous article, API Security: OAuth 2.1, JWT and rate limiting; for the secrets these scripts could read, Secret management and credential rotation; for the overall picture, OWASP Top 10 2025. The next one, Cryptographic failures, moves on to what you do with secrets once you have them in hand.

What you can do now

You can read an npm audit report and decide with four questions instead of the color of the severity. You can produce a CycloneDX or SPDX SBOM from the lockfile and say what it is for. You can make the pipeline fail on a tampered lockfile with npm ci, check the registry signatures and recognize an unpinned action. You also know where these checks end: they are the first line, and they do not replace reading what you install.

Frequently asked questions

What is the difference between npm install and npm ci?

According to the npm documentation, npm ci requires a lockfile, exits with an error if it does not match package.json, and never writes to either package.json or the lockfile. It is the right choice for the pipeline.

Does an SBOM tell you whether a dependency is vulnerable?

No, it is an inventory of components with versions and identifiers. To know whether a component is vulnerable you cross-reference it with an advisory database, for example with npm audit.

Should you disable install scripts?

As a rule yes, with --ignore-scripts, because those scripts run with the environment of whoever installs. The cost is that packages with native parts have to be rebuilt by hand one by one.

What is dependency confusion?

It is the attack in which an internal, unscoped package name is registered on the public registry so that the installation downloads the attacker's. It is prevented with scoped packages and with the scope associated to the internal registry.

The same series also includes XSS, CSRF and CSP, SQL injection and input validation and sessions and cookies.

Sources

The five listings were each run twice with identical output in the Italian edition; in this edition three were run again with translated labels (SBOM, install scripts, look-alike names) and the audit and integrity listings, which query the public registry read-only, were not run again and keep the 2026-11-13 output of the Italian edition. Not run: dependency confusion, the .npmrc configuration with an internal registry and the GitHub Actions workflow (read, not launched).

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects