Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<image>`. 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 |
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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/<name>"].version`. v1: `dependencies.<name>.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. |
Expand All @@ -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:

Expand Down
60 changes: 42 additions & 18 deletions src/core-open/scanners/npm-lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -225,15 +226,42 @@ export interface LockfileIo {
readJson<T>(p: string): Promise<T>;
}

/**
* 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<NpmLockIndex | null> {
if (!(await io.exists(lockPath).catch(() => false))) return null;
let json: unknown;
try {
json = await io.readJson<unknown>(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<NpmLockIndex | null> {
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
Expand All @@ -253,19 +281,15 @@ export async function loadNpmLockIndex(dir: string, io: LockfileIo): Promise<Npm
return { source: 'pnpm', size: map.size, resolve: (name) => 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<unknown>(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);
Expand Down
84 changes: 84 additions & 0 deletions src/engine/lockfile.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string>();
const io: LockfileIo = {
exists: pathExists,
async readText(p) {
read.add(path.basename(p));
return readTextFile(p);
},
async readJson<T>(p: string): Promise<T> {
read.add(path.basename(p));
return readJsonFile<T>(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();
});
Expand Down
37 changes: 27 additions & 10 deletions src/engine/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`).
*
Expand All @@ -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<string, NpmV2Package>;
dependencies?: Record<string, { version?: string; dependencies?: Record<string, unknown> }>;
Expand Down Expand Up @@ -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<string, { version?: string }>; dependencies?: Record<string, { version?: string }> };
// v2/v3: packages keyed by install path; the top-level dep is "node_modules/<name>".
const top = data.packages?.[`node_modules/${name}`]?.version;
Expand Down
Loading
Loading