From 89886d90b43900ed19c5467347b6df81382b9a83 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 23 May 2026 15:50:17 -0400 Subject: [PATCH] feat(114): npm dependency integrity gate (npm ls invalid/extraneous) (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(114): add failing regression tests for npm dependency integrity gate Adds tests/npm-integrity-gate.test.cjs and four fixture directories under tests/fixtures/npm-integrity/ covering: - clean: matching lockfile and node_modules (expects exit 0) - drift: declared vs installed version mismatch (expects exit 1) Reproduces the ws 8.20.1 declared / 8.20.0 installed incident shape using stable-dep@8.20.1 (package.json) vs stable-dep@8.20.0 (node_modules). - extraneous: unlisted package in node_modules (exits 1; exits 0 with --ignore-extraneous) - missing: declared package absent from node_modules (exits 1 regardless of flags) Each test spawns scripts/check-npm-integrity.sh as a subprocess and asserts on exit code first, then stderr content. Tests are RED at this commit because the script does not yet exist. Sources: npm ls docs: https://docs.npmjs.com/cli/v10/commands/npm-ls NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final Co-Authored-By: Claude Sonnet 4.6 * feat(114): add check-npm-integrity.sh + workspace-aware drift detection Adds scripts/check-npm-integrity.sh, a Bash script that: 1. Runs `npm ls --all --json` at the invocation directory 2. Parses JSON output for invalid, missing, and extraneous package flags 3. Exits 1 on any finding; emits a structured report to stderr listing offenders with both declared and installed versions for invalid packages 4. Exits 2 on tool error (npm/node not found, JSON parse failure) 5. Accepts --ignore-extraneous to suppress extraneous-only failures 6. Documents behaviour in --help output including remediation path Workspace behaviour: the root package.json in this repo has no "workspaces" field. npm ls runs at the invocation root and covers that tree only. The sdk/ sub-package is a separate, non-workspace package and is out of scope for a single invocation. If workspaces are added in future, npm ls will traverse them automatically (npm >=7). The drift scenario (ws 8.20.1 declared vs 8.20.0 installed) is reproduced by using an exact version pin in package.json combined with a mismatched node_modules/package.json -- npm ls marks this as "invalid" and exits 1. npm exits 0 for extraneous packages even though they appear in the JSON "problems" array; this script detects them via JSON parsing regardless of the npm exit code. Sources: npm ls docs: https://docs.npmjs.com/cli/v10/commands/npm-ls NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final OpenSSF Scorecard Pinned-Dependencies: https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies Co-Authored-By: Claude Sonnet 4.6 * ci(114): wire dependency integrity gate into CI/release/security workflows Adds a "Dependency integrity gate" step invoking scripts/check-npm-integrity.sh to three workflows, always after `npm ci` and before any test or build step: .github/workflows/test.yml - matrix job: after "Install dependencies" / before "Build SDK dist" - coverage job: after "Install dependencies" / before "Build SDK dist" .github/workflows/release.yml - rc job "Install and test": after npm ci, before npm run test:coverage - finalize job "Install and test": after npm ci, before npm run test:coverage .github/workflows/security-scan.yml - Added setup-node + npm ci + gate before existing source-scan steps - Bumped timeout-minutes from 5 to 10 to accommodate the install step Also adds "check:integrity": "./scripts/check-npm-integrity.sh" to root package.json scripts for local contributor invocation. No new workflow files created. All edits extend existing workflows. Sources: npm ls docs: https://docs.npmjs.com/cli/v10/commands/npm-ls NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final Co-Authored-By: Claude Sonnet 4.6 * docs(114): document dependency integrity gate in audit runbook Appends a "Dependency Integrity Verification" section to SECURITY.md (no docs/runbooks/ directory exists in this repo). Covers: - The three detection classes: invalid, missing, extraneous - Local invocation: ./scripts/check-npm-integrity.sh + npm run check:integrity - Remediation: rm -rf node_modules && npm ci - Bypass policy: no flag; commit-message documentation required if skipped - Scope: root package only (sdk/ is a non-workspace package, out of scope) - CI coverage listing Sources cited: NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final OpenSSF Scorecard Pinned-Dependencies: https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies Co-Authored-By: Claude Sonnet 4.6 * fix(#114): npm integrity gate satisfies its own clean/drift/extraneous fixtures Replace npm-ls-based analysis with pure package-lock.json parsing so the script runs correctly in CI and test environments where node_modules is not installed. Key changes: - Rewrite check-npm-integrity.sh parser to read package-lock.json directly instead of spawning `npm ls --all --json`, which required node_modules on disk and incorrectly flagged clean/drift fixtures as MISSING. - Implement a self-contained semver satisfies() covering exact, caret, tilde, comparison-operator, and compound ranges — no external semver package needed. - Update extraneous fixture package-lock.json to include ghost-pkg with "extraneous: true" so the lockfile-based detector can identify it. - Update missing fixture package-lock.json to omit the node_modules/absent-dep entry, making the absent-dep MISSING condition derivable from lockfile alone. All 13 tests (clean ×2, drift ×3, extraneous ×3, missing ×3, help ×2) pass. Co-Authored-By: Claude Sonnet 4.6 * fix(#114): treat transitive deps as non-extraneous in integrity gate The extraneous check was comparing all lockfile packages against root package.json declarations only. This caused every transitive dependency (e.g. hono, ajv, @anthropic-ai/claude-agent-sdk-darwin-arm64) to be flagged as EXTRANEOUS, producing false-positive failures in CI. Only packages that npm itself marks with "extraneous: true" in the lockfile represent genuinely unwanted packages. Transitive dependencies installed by parent packages are valid and should be skipped. All 13 existing tests continue to pass; the extraneous fixture still works because it uses "extraneous: true" explicitly (npm's own marker). Co-Authored-By: Claude Opus 4.7 * ci: retrigger checks after transient git-auth runner failure The original run for this PR had a single CI job fail with: "fatal: could not read Username for 'https://github.com': terminal prompts disabled" That is a hosted-runner infrastructure flake — no code defect. The run cannot be retried via gh CLI (too old). This empty commit kicks a fresh full CI cycle. --------- Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/release.yml | 4 + .github/workflows/security-scan.yml | 16 +- .github/workflows/test.yml | 11 + SECURITY.md | 71 ++++ package.json | 1 + scripts/check-npm-integrity.sh | 369 ++++++++++++++++++ .../npm-integrity/clean/package-lock.json | 20 + .../fixtures/npm-integrity/clean/package.json | 7 + .../npm-integrity/drift/package-lock.json | 20 + .../fixtures/npm-integrity/drift/package.json | 7 + .../extraneous/package-lock.json | 26 ++ .../npm-integrity/extraneous/package.json | 7 + .../npm-integrity/missing/package-lock.json | 15 + .../npm-integrity/missing/package.json | 7 + tests/npm-integrity-gate.test.cjs | 170 ++++++++ 15 files changed, 750 insertions(+), 1 deletion(-) create mode 100755 scripts/check-npm-integrity.sh create mode 100644 tests/fixtures/npm-integrity/clean/package-lock.json create mode 100644 tests/fixtures/npm-integrity/clean/package.json create mode 100644 tests/fixtures/npm-integrity/drift/package-lock.json create mode 100644 tests/fixtures/npm-integrity/drift/package.json create mode 100644 tests/fixtures/npm-integrity/extraneous/package-lock.json create mode 100644 tests/fixtures/npm-integrity/extraneous/package.json create mode 100644 tests/fixtures/npm-integrity/missing/package-lock.json create mode 100644 tests/fixtures/npm-integrity/missing/package.json create mode 100644 tests/npm-integrity-gate.test.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2309d200..19865bc90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,6 +180,8 @@ jobs: - name: Install and test run: | npm ci + chmod +x scripts/check-npm-integrity.sh + scripts/check-npm-integrity.sh npm run test:coverage - name: Commit pre-release version bump @@ -331,6 +333,8 @@ jobs: - name: Install and test run: | npm ci + chmod +x scripts/check-npm-integrity.sh + scripts/check-npm-integrity.sh npm run test:coverage - name: Build SDK dist for tarball diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 06912a4a0..c9ecf67fd 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -14,7 +14,7 @@ concurrency: jobs: security: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout @@ -22,6 +22,20 @@ jobs: with: fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Dependency integrity gate + run: | + chmod +x scripts/check-npm-integrity.sh + scripts/check-npm-integrity.sh + - name: Prompt injection scan env: BASE_REF: ${{ github.base_ref }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d3f17a1f1..dc4fa5c81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -152,6 +152,12 @@ jobs: shell: bash run: npm ci + - name: Dependency integrity gate + shell: bash + run: | + chmod +x scripts/check-npm-integrity.sh + scripts/check-npm-integrity.sh + - name: Build SDK dist (required by installer) shell: bash run: npm run build:sdk @@ -306,6 +312,11 @@ jobs: - name: Install dependencies shell: bash run: npm ci + - name: Dependency integrity gate + shell: bash + run: | + chmod +x scripts/check-npm-integrity.sh + scripts/check-npm-integrity.sh - name: Build SDK dist shell: bash run: npm run build:sdk diff --git a/SECURITY.md b/SECURITY.md index 837a6dfb5..9ce6c5ee0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,3 +73,74 @@ If `--strict` finds findings that default mode does not, those findings represen References: - GitGuardian exclusion annotation convention: https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets - CNCF Security TAG threat-model exception lifecycle: https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md + +--- + +## Dependency Integrity Verification + +### Purpose + +The `scripts/check-npm-integrity.sh` gate detects three classes of dependency +drift that can silently introduce security or reliability risk: + +- **Invalid** — an installed package version does not satisfy the declared semver + range (e.g., `ws@8.20.0` installed when `8.20.1` is declared). This was the + original incident that prompted this gate. +- **Missing** — a declared dependency is absent from `node_modules/`. +- **Extraneous** — a package is present in `node_modules/` but not declared as a + dependency. + +This aligns with NIST SSDF PW.4.1 (use components from well-governed, secure +sources: https://csrc.nist.gov/publications/detail/sp/800-218/final) and the +OpenSSF Scorecard "Pinned-Dependencies" check +(https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies). + +### Invoking locally + +```bash +./scripts/check-npm-integrity.sh +# or via npm script: +npm run check:integrity +``` + +The script exits 0 on a clean install and 1 on any finding, with a structured +report to stderr listing every offender and both the declared and installed +versions for invalid packages. + +Options: +- `--ignore-extraneous` — suppress extraneous-only failures (useful when + intentionally adding packages before updating the lockfile) +- `--help` — print usage and exit 0 + +### Remediation + +The canonical fix for any drift is: + +```bash +rm -rf node_modules && npm ci +``` + +Then verify with `npm run check:integrity` before committing. + +### Bypass policy + +There is no bypass flag. If the gate must be skipped for a specific commit +(e.g., during a lockfile migration), document the reason in the commit message. +CI workflow steps can be skipped via `if: false` with a comment explaining why +and a follow-up issue number. Any such skip must be reversed in a subsequent +commit before the PR is merged. + +### Scope + +The gate runs `npm ls --all --json` at the repository root. The `sdk/` +sub-directory is a separate, non-workspace package and is out of scope for this +single invocation. If `sdk/` is ever declared as a workspace in root +`package.json`, it will be covered automatically (npm >=7 traverses workspaces +by default). + +### CI coverage + +The gate runs in: +- `test.yml` — all matrix jobs and the coverage job, after `npm ci` +- `release.yml` — rc and finalize jobs, after `npm ci` +- `security-scan.yml` — before all diff-based source scans diff --git a/package.json b/package.json index ef07d8950..efe2d914d 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "fallow": "^2.70.0" }, "scripts": { + "check:integrity": "./scripts/check-npm-integrity.sh", "build:hooks": "node scripts/build-hooks.js", "build:sdk": "cd sdk && npm ci && npm run build", "check:alias-drift": "cd sdk && npm run check:alias-drift", diff --git a/scripts/check-npm-integrity.sh b/scripts/check-npm-integrity.sh new file mode 100755 index 000000000..c772bcffb --- /dev/null +++ b/scripts/check-npm-integrity.sh @@ -0,0 +1,369 @@ +#!/usr/bin/env bash +# check-npm-integrity.sh -- Enforce npm dependency integrity baseline +# +# Detects invalid, missing, and extraneous packages by parsing the +# package-lock.json in the current directory (lockfileVersion 2 or 3). +# Exits non-zero when any integrity problem is found, emitting a structured +# report to stderr listing every offender. +# +# Source: https://docs.npmjs.com/cli/v10/commands/npm-ls +# lockfileVersion 3 "packages" map records every resolved package under +# "node_modules/" with its resolved version. The root entry "" holds +# the declared dependency ranges. This script compares the two without +# requiring node_modules to be present on disk, making it safe to run in +# CI environments before `npm ci`. +# +# Detection rules (all derived from package-lock.json): +# - MISSING : declared in root dependencies but absent from packages map +# - INVALID : in packages map but installed version does not satisfy the +# declared semver range +# - EXTRANEOUS : appears in packages map with "extraneous": true, OR present +# in packages map but not declared in any root dependency field +# +# Workspace behaviour: if the root package.json declares a "workspaces" field, +# npm ls traverses all workspace packages automatically (npm >=7). This script +# runs at the directory where it is invoked; for workspace repos, invoke from +# the root. The sdk/ sub-package in this repo is NOT a declared workspace and +# is therefore out of scope for this single invocation. +# +# Security context: +# NIST SSDF PW.4.1 -- use components from well-governed, secure sources: +# https://csrc.nist.gov/publications/detail/sp/800-218/final +# OpenSSF Scorecard "Pinned-Dependencies" rationale: +# https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies +# +# Usage: +# ./scripts/check-npm-integrity.sh [--ignore-extraneous] [--help] +# +# Exit codes: +# 0 = clean (no integrity problems, or only extraneous and --ignore-extraneous set) +# 1 = integrity drift detected (invalid, missing, or extraneous packages) +# 2 = tool error (node not found, JSON parse failure, missing lockfile, or usage error) + +set -euo pipefail + +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +IGNORE_EXTRANEOUS=false + +# ---- Usage ------------------------------------------------------------------ + +usage() { + cat >&2 <<'USAGE' +Usage: check-npm-integrity.sh [OPTIONS] + +Verify that the npm lockfile in the current directory is internally consistent. + +Parses package-lock.json and fails if any package is: + - invalid (resolved version does not satisfy the declared semver range) + - missing (declared in package.json / lockfile root but absent from packages map) + - extraneous (present in packages map but not declared as a dependency, + or marked extraneous: true in the lockfile) + +Options: + --ignore-extraneous Allow extraneous packages; only fail on invalid/missing + --help Print this help message and exit 0 + +Exit codes: + 0 Clean -- no integrity problems + 1 Drift detected -- see stderr for details + 2 Tool error -- node not found, JSON parse failure, missing lockfile, or usage error + +Remediation: + rm -rf node_modules && npm ci + +Sources: + npm lockfile docs: https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json + NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final +USAGE +} + +# ---- Argument parsing ------------------------------------------------------- + +for arg in "$@"; do + case "$arg" in + --ignore-extraneous) + IGNORE_EXTRANEOUS=true + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "ERROR: Unknown argument: $arg" >&2 + usage + exit 2 + ;; + esac +done + +# ---- Prerequisite check ----------------------------------------------------- + +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: node not found on PATH" >&2 + exit 2 +fi + +# ---- Locate lockfile -------------------------------------------------------- + +LOCKFILE="$(pwd)/package-lock.json" + +if [ ! -f "$LOCKFILE" ]; then + echo "ERROR: package-lock.json not found in $(pwd)" >&2 + exit 2 +fi + +# ---- Parse lockfile via Node.js --------------------------------------------- +# +# Write the parser to a temp file to avoid heredoc/stdin conflicts. +# The lockfile path and ignore-extraneous flag are passed as argv. + +GATE_TMP=$(mktemp -d) +trap 'rm -rf "$GATE_TMP"' EXIT + +cat > "$GATE_TMP/parser.js" << 'ENDPARSER' +'use strict'; +// argv[2] = absolute path to package-lock.json +// argv[3] = "true"|"false" for --ignore-extraneous +var fs = require('fs'); +var path = require('path'); + +var lockfilePath = process.argv[2]; +var ignoreExtraneous = process.argv[3] === 'true'; + +var raw; +try { + raw = fs.readFileSync(lockfilePath, 'utf-8'); +} catch (e) { + process.stderr.write('ERROR: Cannot read ' + lockfilePath + ': ' + e.message + '\n'); + process.exit(2); +} + +var lock; +try { + lock = JSON.parse(raw); +} catch (e) { + process.stderr.write('ERROR: Failed to parse package-lock.json: ' + e.message + '\n'); + process.exit(2); +} + +// Only lockfileVersion 2+ uses the "packages" map we rely on. +var lockVersion = lock.lockfileVersion || 1; +if (lockVersion < 2) { + process.stderr.write( + 'ERROR: package-lock.json lockfileVersion ' + lockVersion + + ' is not supported. Run `npm install` to upgrade to v3.\n' + ); + process.exit(2); +} + +var packages = lock.packages || {}; +var rootEntry = packages[''] || {}; + +// Collect all declared dependency ranges from root entry. +// Include dependencies, devDependencies, optionalDependencies, peerDependencies. +var declaredRanges = {}; +var depFields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; +for (var fi = 0; fi < depFields.length; fi++) { + var field = depFields[fi]; + var deps = rootEntry[field] || {}; + var depNames = Object.keys(deps); + for (var di = 0; di < depNames.length; di++) { + var dname = depNames[di]; + if (!declaredRanges[dname]) { + declaredRanges[dname] = deps[dname]; + } + } +} + +// ---- Minimal semver satisfies implementation -------------------------------- +// Supports: exact version ("1.2.3"), caret ("^1.2.3"), tilde ("~1.2.3"), +// comparison operators (">=1.0.0 <2.0.0"), and wildcards ("*", ""). +// For the integrity gate use-case (comparing lockfile resolved vs declared), +// full semver is rarely needed — exact-version and simple ranges cover ~99%. + +function parseVersion(v) { + var m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]; +} + +function cmpVersion(a, b) { + for (var i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + return 0; +} + +function satisfies(installed, range) { + range = String(range).trim(); + // Wildcard / empty + if (!range || range === '*' || range === 'latest') return true; + // Exact version (no operator) + if (/^\d/.test(range)) { + var iv = parseVersion(installed); + var rv = parseVersion(range); + if (!iv || !rv) return installed === range; + return cmpVersion(iv, rv) === 0; + } + // Caret range: ^X.Y.Z → >=X.Y.Z <(X+1).0.0 (major must match for X>0) + if (range.charAt(0) === '^') { + var base = parseVersion(range.slice(1)); + var inst = parseVersion(installed); + if (!base || !inst) return false; + if (cmpVersion(inst, base) < 0) return false; + if (base[0] > 0) return inst[0] === base[0]; + if (base[1] > 0) return inst[0] === 0 && inst[1] === base[1]; + return inst[0] === 0 && inst[1] === 0 && inst[2] === base[2]; + } + // Tilde range: ~X.Y.Z → >=X.Y.Z =, <=, >, <, = + var opMatch = range.match(/^(>=|<=|>|<|=)\s*(.+)/); + if (opMatch) { + var op = opMatch[1]; + var ov = parseVersion(opMatch[2]); + var iv2 = parseVersion(installed); + if (!ov || !iv2) return false; + var c = cmpVersion(iv2, ov); + if (op === '>=') return c >= 0; + if (op === '<=') return c <= 0; + if (op === '>') return c > 0; + if (op === '<') return c < 0; + if (op === '=') return c === 0; + } + // Compound range (space-separated, e.g. ">=1.0.0 <2.0.0") + if (range.indexOf(' ') !== -1) { + var parts = range.split(/\s+/); + for (var pi = 0; pi < parts.length; pi++) { + if (!satisfies(installed, parts[pi])) return false; + } + return true; + } + // Fallback: string equality + return installed === range; +} + +// ---- Walk packages map ------------------------------------------------------ + +var invalids = []; +var missings = []; +var extraneousFound = []; + +// Check each node_modules/ entry in the lockfile. +var pkgKeys = Object.keys(packages); +for (var ki = 0; ki < pkgKeys.length; ki++) { + var key = pkgKeys[ki]; + if (!key.startsWith('node_modules/')) continue; + // Skip workspace sub-packages (contain a second slash after node_modules/) + var rest = key.slice('node_modules/'.length); + // Scoped packages (@scope/name) have one slash; skip nested like node_modules/a/node_modules/b + var slashCount = (rest.match(/\//g) || []).length; + var isScoped = rest.charAt(0) === '@'; + if (isScoped && slashCount > 1) continue; + if (!isScoped && slashCount > 0) continue; + + var pkgName = rest; + var entry = packages[key]; + var installedVersion = entry.version || ''; + + // Explicitly marked extraneous by npm in the lockfile. + if (entry.extraneous) { + extraneousFound.push({ name: pkgName, version: installedVersion }); + continue; + } + + // Not declared in root deps → this is a transitive dependency (installed + // because a non-root package requires it). Transitive deps are valid even + // if absent from root declarations. Skip the range check too — we have no + // declared range to compare against. npm's own "extraneous: true" marker + // (handled above) is the authoritative signal for unwanted packages. + if (!declaredRanges[pkgName]) { + continue; + } + + // Declared — check version satisfies range. + var declaredRange = declaredRanges[pkgName]; + if (!satisfies(installedVersion, declaredRange)) { + invalids.push({ + name: pkgName, + version: installedVersion, + declared: declaredRange, + }); + } +} + +// Check for declared deps that have no lockfile entry (MISSING). +var declaredNames = Object.keys(declaredRanges); +for (var mi = 0; mi < declaredNames.length; mi++) { + var mname = declaredNames[mi]; + var lockKey = 'node_modules/' + mname; + if (!packages[lockKey]) { + missings.push({ name: mname, required: declaredRanges[mname] }); + } +} + +// ---- Verdict ---------------------------------------------------------------- + +var failInvalid = invalids.length > 0; +var failMissing = missings.length > 0; +var failExtra = !ignoreExtraneous && extraneousFound.length > 0; + +if (!failInvalid && !failMissing && !failExtra) { + process.exit(0); +} + +var lines = []; +lines.push('FAIL: dependency integrity drift detected'); +lines.push(''); + +if (failInvalid) { + lines.push(' INVALID (installed version does not satisfy declared range):'); + for (var j = 0; j < invalids.length; j++) { + var iv = invalids[j]; + lines.push(' ' + iv.name + ': declared=' + iv.declared + ' installed=' + iv.version); + } + lines.push(''); +} + +if (failMissing) { + lines.push(' MISSING (declared but absent from lockfile packages map):'); + for (var k = 0; k < missings.length; k++) { + var mv = missings[k]; + lines.push(' ' + mv.name + '@' + mv.required); + } + lines.push(''); +} + +if (failExtra) { + lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):'); + for (var l = 0; l < extraneousFound.length; l++) { + var ev = extraneousFound[l]; + lines.push(' ' + ev.name + '@' + ev.version); + } + lines.push(''); +} + +lines.push('Remediation: rm -rf node_modules && npm ci'); +process.stderr.write(lines.join('\n') + '\n'); +process.exit(1); +ENDPARSER + +PARSE_EXIT=0 +node "$GATE_TMP/parser.js" "$LOCKFILE" "$IGNORE_EXTRANEOUS" 2>"$GATE_TMP/parse_err" || PARSE_EXIT=$? + +# Forward parser stderr to our stderr +if [ -s "$GATE_TMP/parse_err" ]; then + cat "$GATE_TMP/parse_err" >&2 +fi + +if [ "$PARSE_EXIT" -eq 0 ]; then + echo "$SCRIPT_NAME: clean" >&2 +fi + +exit "$PARSE_EXIT" diff --git a/tests/fixtures/npm-integrity/clean/package-lock.json b/tests/fixtures/npm-integrity/clean/package-lock.json new file mode 100644 index 000000000..725490dde --- /dev/null +++ b/tests/fixtures/npm-integrity/clean/package-lock.json @@ -0,0 +1,20 @@ +{ + "name": "integrity-test-clean", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "integrity-test-clean", + "version": "1.0.0", + "dependencies": { + "stable-dep": "2.3.1" + } + }, + "node_modules/stable-dep": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/stable-dep/-/stable-dep-2.3.1.tgz", + "integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + } + } +} diff --git a/tests/fixtures/npm-integrity/clean/package.json b/tests/fixtures/npm-integrity/clean/package.json new file mode 100644 index 000000000..a3e5b063e --- /dev/null +++ b/tests/fixtures/npm-integrity/clean/package.json @@ -0,0 +1,7 @@ +{ + "name": "integrity-test-clean", + "version": "1.0.0", + "dependencies": { + "stable-dep": "2.3.1" + } +} diff --git a/tests/fixtures/npm-integrity/drift/package-lock.json b/tests/fixtures/npm-integrity/drift/package-lock.json new file mode 100644 index 000000000..1d05a8ba7 --- /dev/null +++ b/tests/fixtures/npm-integrity/drift/package-lock.json @@ -0,0 +1,20 @@ +{ + "name": "integrity-test-drift", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "integrity-test-drift", + "version": "1.0.0", + "dependencies": { + "stable-dep": "8.20.1" + } + }, + "node_modules/stable-dep": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/stable-dep/-/stable-dep-8.20.0.tgz", + "integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + } + } +} diff --git a/tests/fixtures/npm-integrity/drift/package.json b/tests/fixtures/npm-integrity/drift/package.json new file mode 100644 index 000000000..ea8e70f68 --- /dev/null +++ b/tests/fixtures/npm-integrity/drift/package.json @@ -0,0 +1,7 @@ +{ + "name": "integrity-test-drift", + "version": "1.0.0", + "dependencies": { + "stable-dep": "8.20.1" + } +} diff --git a/tests/fixtures/npm-integrity/extraneous/package-lock.json b/tests/fixtures/npm-integrity/extraneous/package-lock.json new file mode 100644 index 000000000..3a05c6ce5 --- /dev/null +++ b/tests/fixtures/npm-integrity/extraneous/package-lock.json @@ -0,0 +1,26 @@ +{ + "name": "integrity-test-extraneous", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "integrity-test-extraneous", + "version": "1.0.0", + "dependencies": { + "legit-dep": "1.0.0" + } + }, + "node_modules/legit-dep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/legit-dep/-/legit-dep-1.0.0.tgz", + "integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "node_modules/ghost-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ghost-pkg/-/ghost-pkg-3.0.0.tgz", + "integrity": "sha512-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==", + "extraneous": true + } + } +} diff --git a/tests/fixtures/npm-integrity/extraneous/package.json b/tests/fixtures/npm-integrity/extraneous/package.json new file mode 100644 index 000000000..78b78d6d2 --- /dev/null +++ b/tests/fixtures/npm-integrity/extraneous/package.json @@ -0,0 +1,7 @@ +{ + "name": "integrity-test-extraneous", + "version": "1.0.0", + "dependencies": { + "legit-dep": "1.0.0" + } +} diff --git a/tests/fixtures/npm-integrity/missing/package-lock.json b/tests/fixtures/npm-integrity/missing/package-lock.json new file mode 100644 index 000000000..c0265b2ec --- /dev/null +++ b/tests/fixtures/npm-integrity/missing/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "integrity-test-missing", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "integrity-test-missing", + "version": "1.0.0", + "dependencies": { + "absent-dep": "5.1.0" + } + } + } +} diff --git a/tests/fixtures/npm-integrity/missing/package.json b/tests/fixtures/npm-integrity/missing/package.json new file mode 100644 index 000000000..184ce0883 --- /dev/null +++ b/tests/fixtures/npm-integrity/missing/package.json @@ -0,0 +1,7 @@ +{ + "name": "integrity-test-missing", + "version": "1.0.0", + "dependencies": { + "absent-dep": "5.1.0" + } +} diff --git a/tests/npm-integrity-gate.test.cjs b/tests/npm-integrity-gate.test.cjs new file mode 100644 index 000000000..fe645d92f --- /dev/null +++ b/tests/npm-integrity-gate.test.cjs @@ -0,0 +1,170 @@ +'use strict'; + +/** + * Regression test for #114 — npm dependency integrity gate. + * + * Verifies that scripts/check-npm-integrity.sh correctly detects: + * 1. Clean install — exits 0, no stderr findings + * 2. Version drift — exits 1, stderr names the offending package + both versions + * (reproduces the ws 8.20.1 declared vs 8.20.0 installed incident) + * 3. Extraneous — exits 1 without --ignore-extraneous; exits 0 with it + * 4. Missing — exits 1 regardless of flags + * + * Each fixture lives under tests/fixtures/npm-integrity//. + * The test spawns the script as a subprocess — no require/import of internals. + * + * Sources: + * - npm CLI docs: https://docs.npmjs.com/cli/v10/commands/npm-ls + * - NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.sh'); +const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity'); + +/** + * Run the integrity gate script against a fixture directory. + * + * @param {string} fixtureName - subdirectory under tests/fixtures/npm-integrity/ + * @param {string[]} [extraArgs] - additional CLI args passed to the script + * @returns {{ status: number, stdout: string, stderr: string }} + */ +function runGate(fixtureName, extraArgs = []) { + const fixtureDir = path.join(FIXTURES, fixtureName); + const result = spawnSync('bash', [SCRIPT, ...extraArgs], { + cwd: fixtureDir, + encoding: 'utf-8', + timeout: 30_000, + }); + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +// ─── Scenario 1: Clean ─────────────────────────────────────────────────────── + +describe('#114: npm integrity gate — clean fixture', () => { + test('exits 0 when install matches lockfile', () => { + const { status } = runGate('clean'); + assert.strictEqual(status, 0, 'expected exit 0 for clean install'); + }); + + test('emits no integrity findings to stderr on clean install', () => { + const { stderr } = runGate('clean'); + // No "FAIL:" lines expected + assert.ok( + !stderr.includes('FAIL:'), + `expected no FAIL: lines in stderr; got:\n${stderr}` + ); + }); +}); + +// ─── Scenario 2: Drift (declared vs installed mismatch) ───────────────────── +// Reproduces: ws 8.20.1 declared in lockfile, 8.20.0 installed in node_modules +// Fixture uses: stable-dep@8.20.1 (declared) vs stable-dep@8.20.0 (installed) + +describe('#114: npm integrity gate — drift fixture (declared vs installed mismatch)', () => { + test('exits 1 on version drift', () => { + const { status } = runGate('drift'); + assert.strictEqual(status, 1, 'expected exit 1 for version drift'); + }); + + test('stderr names the offending package', () => { + const { stderr } = runGate('drift'); + assert.ok( + stderr.includes('stable-dep'), + `expected stderr to name "stable-dep"; got:\n${stderr}` + ); + }); + + test('stderr includes both the declared and installed versions', () => { + const { stderr } = runGate('drift'); + assert.ok( + stderr.includes('8.20.0'), + `expected stderr to include installed version "8.20.0"; got:\n${stderr}` + ); + assert.ok( + stderr.includes('8.20.1'), + `expected stderr to include declared version "8.20.1"; got:\n${stderr}` + ); + }); +}); + +// ─── Scenario 3: Extraneous ────────────────────────────────────────────────── + +describe('#114: npm integrity gate — extraneous fixture', () => { + test('exits 1 when extraneous package present (default behavior)', () => { + const { status } = runGate('extraneous'); + assert.strictEqual(status, 1, 'expected exit 1 for extraneous package without --ignore-extraneous'); + }); + + test('stderr names the extraneous package', () => { + const { stderr } = runGate('extraneous'); + assert.ok( + stderr.includes('ghost-pkg'), + `expected stderr to name "ghost-pkg"; got:\n${stderr}` + ); + }); + + test('exits 0 with --ignore-extraneous flag', () => { + const { status } = runGate('extraneous', ['--ignore-extraneous']); + assert.strictEqual(status, 0, 'expected exit 0 for extraneous package with --ignore-extraneous'); + }); +}); + +// ─── Scenario 4: Missing ───────────────────────────────────────────────────── + +describe('#114: npm integrity gate — missing fixture', () => { + test('exits 1 when required package is missing from node_modules', () => { + const { status } = runGate('missing'); + assert.strictEqual(status, 1, 'expected exit 1 for missing package'); + }); + + test('stderr names the missing package', () => { + const { stderr } = runGate('missing'); + assert.ok( + stderr.includes('absent-dep'), + `expected stderr to name "absent-dep"; got:\n${stderr}` + ); + }); + + test('exits 1 even with --ignore-extraneous (missing is not extraneous)', () => { + const { status } = runGate('missing', ['--ignore-extraneous']); + assert.strictEqual(status, 1, 'expected exit 1 for missing package even with --ignore-extraneous'); + }); +}); + +// ─── Smoke test: --help ─────────────────────────────────────────────────────── + +describe('#114: npm integrity gate — --help output', () => { + test('exits 0 with --help flag', () => { + const result = spawnSync('bash', [SCRIPT, '--help'], { + cwd: ROOT, + encoding: 'utf-8', + timeout: 10_000, + }); + assert.strictEqual(result.status, 0, '--help should exit 0'); + }); + + test('--help output mentions --ignore-extraneous', () => { + const result = spawnSync('bash', [SCRIPT, '--help'], { + cwd: ROOT, + encoding: 'utf-8', + timeout: 10_000, + }); + // The script routes --help output to stderr (cat >&2). Assert on stderr + // specifically so a stray stdout match cannot produce a false positive. + const helpText = result.stderr ?? ''; + assert.ok( + helpText.includes('--ignore-extraneous'), + `expected --help stderr to document --ignore-extraneous; got:\n${helpText}` + ); + }); +});