diff --git a/DOCS.md b/DOCS.md index 0b92bf2..f6c1fd6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1912,7 +1912,7 @@ Direct and transitive scope, and the production, development, and optional signa How a declared license is copied into each format is in [Declared licenses](#declared-licenses). `vg sbom export` reports the full resolved dependency tree, not just what's declared -in the manifest: it reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock` (npm, +in the manifest: it reads `package-lock.json` / `npm-shrinkwrap.json` / `pnpm-lock.yaml` / `yarn.lock` (npm, pnpm, and yarn) from `--root` (defaults to the current directory) and folds every transitive package in alongside the directly-scanned ones. Each component carries a `vibgrate:scope` property (`direct` or `transitive`) so consumers can still tell the @@ -2263,7 +2263,7 @@ is omitted on every package. | Source | Where it is written | CycloneDX `type` | SPDX `primaryPackagePurpose` | | --- | --- | --- | --- | | Scan root (the artifact's root directory name) | CycloneDX `metadata.component` only. It is not copied into `components`. SPDX has no package for the root. | `application` | Omitted. There is no package to carry the field. | -| Lockfile package (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`, `go.sum`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, `Gemfile.lock`, `composer.lock`, `packages.lock.json`), including a package that only the lockfile lists | One CycloneDX component and one SPDX package | `library` | Omitted | +| Lockfile package (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`, `go.sum`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, `Gemfile.lock`, `composer.lock`, `packages.lock.json`), including a package that only the lockfile lists | One CycloneDX component and one SPDX package | `library` | Omitted | | Manifest dependency from a scanned project (Node, Python, Rust, Go, Java, Kotlin, Scala, Ruby, PHP, .NET, Swift, Dart, and any other project the scan records) | One CycloneDX component and one SPDX package | `library` | Omitted | | Container image named by a `Dockerfile` `FROM` or a Compose `image:` line | The same kind of row. A Docker Hub image with no namespace is named `library/`. The tag is the version. | `library` | Omitted | | Helm chart dependency (`Chart.yaml` `dependencies`) | The same kind of row. The chart itself is not a row. | `library` | Omitted | @@ -2465,7 +2465,7 @@ Switches (flags that take no value, such as `--vulns`, `--offline` or `--no-grap By default, the scan writes `.vibgrate/scan_result.json`. Use `--no-local-artifacts` or `--max-privacy` to suppress local JSON artifact files. -A truncated or syntactically invalid lockfile stops the scan before a dependency graph is built. The error names the file (for example `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) and tells you to regenerate it with your package manager. The message does not include the lockfile, and the process exits non-zero. A valid lockfile is scanned as before. +A truncated or syntactically invalid lockfile stops the scan before a dependency graph is built. The error names the file (for example `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, or `yarn.lock`) and tells you to regenerate it with your package manager. The message does not include the lockfile, and the process exits non-zero. A valid lockfile is scanned as before. `vg scan` does not follow symlinks while it indexes the tree. A skipped link is named once, as `warning [VG_WARN_SYMLINK_SKIPPED]`. See [Symlinks](#symlinks). Archive files (`.zip`, `.tar`, `.gz`, `.bz2`, `.7z`, `.rar`) are not opened during that walk. @@ -4100,11 +4100,12 @@ The label uses the first version it can read: `--json` reports that choice as `source`: `lockfile`, `installed`, `declared`, or `unknown`. -Readers open these filenames in the directory you run from (`-C` changes it). A lockfile that lives only in a subdirectory is left unread. For npm, the first file that contains the name wins. +Readers open these filenames in the directory you run from (`-C` changes it). A lockfile that lives only in a subdirectory is left unread. For npm, the first file that contains the name wins. `package-lock.json` is preferred over `npm-shrinkwrap.json` when both are present. | Ecosystem | File | What is read | |-----------|------|----------------| | npm | `package-lock.json` | v2/v3: `packages["node_modules/"].version`. v1: `dependencies..version`. | +| npm | `npm-shrinkwrap.json` | The same fields as `package-lock.json`. Read when `package-lock.json` is absent. | | npm | `pnpm-lock.yaml` | The importer entry's `version`. A peer suffix such as `(zod@4.0.0)` is removed. | | npm | `yarn.lock` | The `version` line in the block whose header names the package. | | Python | `poetry.lock`, then `uv.lock` | `version` on the `[[package]]` table with that name. Matching ignores case and treats `-`, `_`, and `.` as the same character. | @@ -4117,7 +4118,7 @@ Readers open these filenames in the directory you run from (`-C` changes it). A | Dart | `pubspec.lock` | The `version` field on that package. | | Java | `gradle.lockfile` | The version in a `group:artifact:version=` line. Pass the name as `group:artifact`. | -The pin lookup skips `go.sum` (Go keeps the version declared in `go.mod`), `pdm.lock`, `npm-shrinkwrap.json`, `bun.lock`, and Maven lockfiles. A Java dependency declared only in `pom.xml` uses that declared version. +The pin lookup skips `go.sum` (Go keeps the version declared in `go.mod`), `pdm.lock`, `bun.lock`, and Maven lockfiles. A Java dependency declared only in `pom.xml` uses that declared version. The text is chosen separately from the label: diff --git a/src/core-open/scanners/npm-lockfile.ts b/src/core-open/scanners/npm-lockfile.ts index 2d57040..0917f9c 100644 --- a/src/core-open/scanners/npm-lockfile.ts +++ b/src/core-open/scanners/npm-lockfile.ts @@ -10,9 +10,10 @@ * This module parses the npm-family lockfiles into a name → installed-version index the node scanner * prefers over the registry guess. * - * Clean-room: formats are read from their public, documented shapes (npm package-lock v1/v2/v3 JSON; - * Yarn classic + Berry text; pnpm-lock v5/v6/v9 YAML) — no third-party (AGPL or otherwise) parser is - * used or ported. The only dependency is the `yaml` package, for the genuinely-YAML pnpm lockfile. + * Clean-room: formats are read from their public, documented shapes (npm package-lock v1/v2/v3 JSON, + * including the same JSON under the `npm-shrinkwrap.json` filename; Yarn classic + Berry text; + * pnpm-lock v5/v6/v9 YAML) — no third-party (AGPL or otherwise) parser is used or ported. The only + * dependency is the `yaml` package, for the genuinely-YAML pnpm lockfile. */ import * as path from 'node:path'; import { parse as parseYaml } from 'yaml'; @@ -225,15 +226,42 @@ export interface LockfileIo { readJson(p: string): Promise; } +/** + * Read one npm JSON lockfile (`package-lock.json` or `npm-shrinkwrap.json`) with + * {@link parsePackageLock}. Returns null when the file is absent or has no resolved + * versions. Invalid JSON throws {@link LockfileParseError} and does not fall through. + */ +async function loadPackageLockFile(lockPath: string, io: LockfileIo): Promise { + if (!(await io.exists(lockPath).catch(() => false))) return null; + let json: unknown; + try { + json = await io.readJson(lockPath); + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(lockPath, 'JSON'); + } + const map = parsePackageLock(json, lockPath); + if (!map.size) return null; + return { + source: 'package-lock', + size: map.size, + resolve: (name) => map.get(name) ?? null, + graph: memoGraph(() => parsePackageLockGraph(json)), + }; +} + /** * Build the lockfile index for a directory. Prefers the lockfile that matches the resolved package - * manager (npm → package-lock, yarn → yarn) but falls back to whichever is present. Returns null when - * no supported lockfile exists (the scanner then keeps using the registry-resolved version). + * manager (npm → package-lock, yarn → yarn) but falls back to whichever is present. `package-lock.json` + * wins over `npm-shrinkwrap.json` when both exist; the shrinkwrap is the same JSON and is read only + * when `package-lock.json` is absent. Returns null when no supported lockfile exists (the scanner then + * keeps using the registry-resolved version). */ export async function loadNpmLockIndex(dir: string, io: LockfileIo): Promise { const pnpmPath = path.join(dir, 'pnpm-lock.yaml'); const yarnPath = path.join(dir, 'yarn.lock'); const lockPath = path.join(dir, 'package-lock.json'); + const shrinkwrapPath = path.join(dir, 'npm-shrinkwrap.json'); // pnpm first (its lockfile is the most explicit about installed versions). // A file that exists but is truncated or invalid is an error — do not fall @@ -253,19 +281,15 @@ export async function loadNpmLockIndex(dir: string, io: LockfileIo): Promise map.get(name) ?? null, graph: memoGraph(() => parsePnpmLockGraph(doc)) }; } } - // package-lock next: structured JSON and unambiguous. - if (await io.exists(lockPath).catch(() => false)) { - let json: unknown; - try { - json = await io.readJson(lockPath); - } catch (err) { - rethrowLockfileParseError(err); - throw new LockfileParseError(lockPath, 'JSON'); - } - const map = parsePackageLock(json, lockPath); - if (map.size) { - return { source: 'package-lock', size: map.size, resolve: (name) => map.get(name) ?? null, graph: memoGraph(() => parsePackageLockGraph(json)) }; - } + // package-lock next: structured JSON and unambiguous. A present file is preferred + // over npm-shrinkwrap.json (same parser) even when it yields no versions. + const packageLockPresent = await io.exists(lockPath).catch(() => false); + if (packageLockPresent) { + const index = await loadPackageLockFile(lockPath, io); + if (index) return index; + } else { + const shrinkwrap = await loadPackageLockFile(shrinkwrapPath, io); + if (shrinkwrap) return shrinkwrap; } if (await io.exists(yarnPath).catch(() => false)) { const text = await io.readText(yarnPath); diff --git a/src/engine/lockfile.test.ts b/src/engine/lockfile.test.ts index 41e6e58..eaddb25 100644 --- a/src/engine/lockfile.test.ts +++ b/src/engine/lockfile.test.ts @@ -1,12 +1,26 @@ import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { discover } from './discover.js'; +import { loadNpmLockIndex, type LockfileIo } from '../core-open/scanners/npm-lockfile.js'; import { LockfileParseError } from '../core-open/utils/lockfile-parse.js'; +import { pathExists, readJsonFile, readTextFile } from '../core-open/utils/fs.js'; import { lockfileVersion, fullDependencyTree, fullDependencyGraph, gemfileSpecPattern } from './lockfile.js'; +const SHRINKWRAP_ONLY = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../test/fixtures/npm-shrinkwrap-only', +); + +const diskLockIo: LockfileIo = { + exists: pathExists, + readText: readTextFile, + readJson: readJsonFile, +}; + /** * The point of reading lockfiles at all is that `node_modules` is empty in CI * and on a fresh clone, so "what is installed" is not an answer. A lockfile we @@ -153,6 +167,76 @@ describe('lockfileVersion — npm precedence', () => { expect(lockfileVersion(root, 'npm', 'commander')).toBe('15.2.1'); }); + it('reads pinned versions from a fixture that only has npm-shrinkwrap.json', async () => { + expect(fs.readdirSync(SHRINKWRAP_ONLY).sort()).toEqual(['npm-shrinkwrap.json']); + expect(lockfileVersion(SHRINKWRAP_ONLY, 'npm', 'left-pad')).toBe('1.3.0'); + expect(lockfileVersion(SHRINKWRAP_ONLY, 'npm', 'once')).toBe('1.4.0'); + const graph = fullDependencyGraph(SHRINKWRAP_ONLY); + expect(graph?.components).toEqual([ + { package: 'left-pad', version: '1.3.0' }, + { package: 'once', version: '1.4.0' }, + ]); + expect(graph?.rootDependsOn).toEqual(['left-pad@1.3.0']); + expect(graph?.edges?.get('left-pad@1.3.0')).toEqual(['once@1.4.0']); + + const index = await loadNpmLockIndex(SHRINKWRAP_ONLY, diskLockIo); + expect(index?.source).toBe('package-lock'); + expect(index?.resolve('left-pad')).toBe('1.3.0'); + expect(index?.resolve('once')).toBe('1.4.0'); + expect(index?.graph().edges.get('left-pad@1.3.0')).toEqual(['once@1.4.0']); + }); + + it('prefers package-lock.json over npm-shrinkwrap.json when both exist', async () => { + write('package-lock.json', JSON.stringify({ packages: { 'node_modules/left-pad': { version: '9.9.9' } } })); + write('npm-shrinkwrap.json', JSON.stringify({ packages: { 'node_modules/left-pad': { version: '1.3.0' } } })); + expect(lockfileVersion(root, 'npm', 'left-pad')).toBe('9.9.9'); + expect(fullDependencyTree(root)).toEqual([{ package: 'left-pad', version: '9.9.9' }]); + + const read = new Set(); + const io: LockfileIo = { + exists: pathExists, + async readText(p) { + read.add(path.basename(p)); + return readTextFile(p); + }, + async readJson(p: string): Promise { + read.add(path.basename(p)); + return readJsonFile(p); + }, + }; + const index = await loadNpmLockIndex(root, io); + expect(index?.resolve('left-pad')).toBe('9.9.9'); + expect(read.has('package-lock.json')).toBe(true); + expect(read.has('npm-shrinkwrap.json')).toBe(false); + }); + + it('rejects a truncated npm-shrinkwrap.json on the same path as package-lock.json', async () => { + const secret = 'npm_CCCCCCCCCCCCCCCCCCCC'; + write('npm-shrinkwrap.json', `{"lockfileVersion":3,"packages":{"node_modules/left-pad":{"version":"1.3.0","integrity":"${secret}"`); + expect(() => fullDependencyGraph(root)).toThrow(LockfileParseError); + try { + fullDependencyGraph(root); + } catch (err) { + const message = (err as Error).message; + expect(message).toContain(path.join(root, 'npm-shrinkwrap.json')); + expect(message).toContain('truncated or invalid JSON'); + expect(message).not.toContain(secret); + expect(message).not.toContain('left-pad'); + } + await expect(loadNpmLockIndex(root, diskLockIo)).rejects.toThrow(LockfileParseError); + await expect(loadNpmLockIndex(root, diskLockIo)).rejects.toThrow(/npm-shrinkwrap\.json/); + await expect(loadNpmLockIndex(root, diskLockIo)).rejects.toThrow(/truncated or invalid JSON/); + }); + + it('does not fall through to npm-shrinkwrap.json when package-lock.json is truncated', async () => { + write('package-lock.json', '{"lockfileVersion":3,"packages":{'); + write('npm-shrinkwrap.json', JSON.stringify({ packages: { 'node_modules/left-pad': { version: '1.3.0' } } })); + expect(() => fullDependencyGraph(root)).toThrow(/package-lock\.json/); + expect(() => lockfileVersion(root, 'npm', 'left-pad')).toThrow(/package-lock\.json/); + await expect(loadNpmLockIndex(root, diskLockIo)).rejects.toThrow(/package-lock\.json/); + await expect(loadNpmLockIndex(root, diskLockIo)).rejects.toThrow(/truncated or invalid JSON/); + }); + it('returns undefined when there is no lockfile at all', () => { expect(lockfileVersion(root, 'npm', 'commander')).toBeUndefined(); }); diff --git a/src/engine/lockfile.ts b/src/engine/lockfile.ts index 4b9ac80..e87fe31 100644 --- a/src/engine/lockfile.ts +++ b/src/engine/lockfile.ts @@ -95,9 +95,10 @@ function reportLockfileFields(root: string, file: string, fill: (rel: string, no * A file that exists but is truncated or syntactically invalid throws * {@link LockfileParseError} instead of pretending the pin is absent. * - * npm covers `package-lock.json` (v1/v2/v3), `pnpm-lock.yaml` (v6/v9) and - * `yarn.lock`. Other ecosystems follow the same shape and return `undefined` - * until their parser lands — honest degradation, no false mismatch. + * npm covers `package-lock.json` and `npm-shrinkwrap.json` (v1/v2/v3, same JSON), + * `pnpm-lock.yaml` (v6/v9) and `yarn.lock`. `package-lock.json` is preferred when + * both npm JSON lockfiles are present. Other ecosystems follow the same shape and + * return `undefined` until their parser lands — honest degradation, no false mismatch. */ export function lockfileVersion(root: string, ecosystem: DepRecord['ecosystem'], name: string): string | undefined { if (ecosystem === 'npm') return packageLockVersion(root, name) ?? pnpmLockVersion(root, name) ?? yarnLockVersion(root, name); @@ -377,7 +378,8 @@ export interface LockfileGraph { * *this* declared dependency resolve to") and stays a point lookup by * design. An SBOM needs the opposite shape: the whole installed graph, since * that is what a vulnerability scanner or supply-chain review actually - * walks. Tries `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, + * walks. Tries `package-lock.json` (or `npm-shrinkwrap.json` when that file is + * absent), `pnpm-lock.yaml`, `yarn.lock`, * `Cargo.lock`, `go.sum`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, `Gemfile.lock`, * `composer.lock`, then `packages.lock.json`, and returns `undefined` when * none is present. A lockfile that exists but is @@ -492,7 +494,18 @@ interface NpmV2Package { } /** - * npm `package-lock.json` v2/v3 `packages` map, falling back to v1's nested + * npm JSON lockfile to read. `package-lock.json` wins when it is present, including + * when it has no usable versions; `npm-shrinkwrap.json` is the same document under + * the filename npm publishes with a package. + */ +function npmPackageLockFile(root: string): 'package-lock.json' | 'npm-shrinkwrap.json' | undefined { + if (fs.existsSync(path.join(root, 'package-lock.json'))) return 'package-lock.json'; + if (fs.existsSync(path.join(root, 'npm-shrinkwrap.json'))) return 'npm-shrinkwrap.json'; + return undefined; +} + +/** + * npm `package-lock.json` / `npm-shrinkwrap.json` v2/v3 `packages` map, falling back to v1's nested * `dependencies` tree (which carries no resolvable edges — see * `LockfileGraph.edges`). * @@ -505,9 +518,11 @@ interface NpmV2Package { * `packages` encodes without needing a second (real) install. */ function npmLockGraph(root: string): LockfileGraph | undefined { - const parsed = readCheckedJson(root, 'package-lock.json'); + const file = npmPackageLockFile(root); + if (!file) return undefined; + const parsed = readCheckedJson(root, file); if (!parsed || typeof parsed !== 'object') return undefined; - reportLockfileFields(root, 'package-lock.json', (rel, notes) => notePackageLock(parsed, rel, notes)); + reportLockfileFields(root, file, (rel, notes) => notePackageLock(parsed, rel, notes)); const data = parsed as { packages?: Record; dependencies?: Record }>; @@ -1140,11 +1155,13 @@ function packagesLockGraph(root: string): LockfileGraph | undefined { }; } -/** npm `package-lock.json` — JSON, deterministic, no dependency. */ +/** npm `package-lock.json` or `npm-shrinkwrap.json` — JSON, deterministic, no dependency. */ function packageLockVersion(root: string, name: string): string | undefined { - const parsed = readCheckedJson(root, 'package-lock.json'); + const file = npmPackageLockFile(root); + if (!file) return undefined; + const parsed = readCheckedJson(root, file); if (!parsed || typeof parsed !== 'object') return undefined; - reportLockfileFields(root, 'package-lock.json', (rel, notes) => notePackageLock(parsed, rel, notes)); + reportLockfileFields(root, file, (rel, notes) => notePackageLock(parsed, rel, notes)); const data = parsed as { packages?: Record; dependencies?: Record }; // v2/v3: packages keyed by install path; the top-level dep is "node_modules/". const top = data.packages?.[`node_modules/${name}`]?.version; diff --git a/test/fixtures/npm-shrinkwrap-only/npm-shrinkwrap.json b/test/fixtures/npm-shrinkwrap-only/npm-shrinkwrap.json new file mode 100644 index 0000000..4f2f5b3 --- /dev/null +++ b/test/fixtures/npm-shrinkwrap-only/npm-shrinkwrap.json @@ -0,0 +1,27 @@ +{ + "name": "shrinkwrap-only", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shrinkwrap-only", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0" + } + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-shrinkwrap-left-pad", + "dependencies": { + "once": "1.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-shrinkwrap-once" + } + } +}