diff --git a/src/output/sarif.ts b/src/output/sarif.ts index 26c40e7..6706749 100644 --- a/src/output/sarif.ts +++ b/src/output/sarif.ts @@ -66,6 +66,7 @@ const SARIF_RULE_NAME = "VulnerableDependency"; const LOCKFILE_NAMES: Record = { "package-lock": "package-lock.json", + "npm-shrinkwrap": "npm-shrinkwrap.json", "pnpm-lock": "pnpm-lock.yaml", "yarn-lock": "yarn.lock", "bun-lock": "bun.lockb", diff --git a/src/parsers/index.ts b/src/parsers/index.ts index ae0181e..3d1d174 100644 --- a/src/parsers/index.ts +++ b/src/parsers/index.ts @@ -10,6 +10,7 @@ import { loadFromYarnLock } from "./yarn-lock.js"; export function loadPackages(projectRoot: string, prodOnly: boolean, maxDepth: number): ScanInput { const rootBunLock = path.join(projectRoot, "bun.lock"); + const rootShrinkwrap = path.join(projectRoot, "npm-shrinkwrap.json"); const rootPackageLock = path.join(projectRoot, "package-lock.json"); const rootPnpmLock = path.join(projectRoot, "pnpm-lock.yaml"); const rootYarnLock = path.join(projectRoot, "yarn.lock"); @@ -29,6 +30,21 @@ export function loadPackages(projectRoot: string, prodOnly: boolean, maxDepth: n }; } + if (fs.existsSync(rootShrinkwrap)) { + return { + mode: "resolved-lockfile", + source: "npm-shrinkwrap", + filePath: rootShrinkwrap, + packages: loadFromPackageLock(rootShrinkwrap, prodOnly), + notes: [ + "Scanned resolved dependency versions from npm-shrinkwrap.json.", + "Dependency paths are derived from lockfile package locations." + ], + warnings: [], + skippedDependencies: [] + }; + } + if (fs.existsSync(rootPackageLock)) { return { mode: "resolved-lockfile", @@ -74,7 +90,7 @@ export function loadPackages(projectRoot: string, prodOnly: boolean, maxDepth: n }; } - const discoveredLockfiles = findFiles(projectRoot, ["bun.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"], maxDepth); + const discoveredLockfiles = findFiles(projectRoot, ["bun.lock", "npm-shrinkwrap.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"], maxDepth); if (discoveredLockfiles.length > 0) { const selected = chooseBestLockfile(discoveredLockfiles); const selectedName = path.basename(selected); @@ -92,6 +108,20 @@ export function loadPackages(projectRoot: string, prodOnly: boolean, maxDepth: n skippedDependencies: [] }; } + if (selectedName === "npm-shrinkwrap.json") { + return { + mode: "resolved-lockfile", + source: "npm-shrinkwrap", + filePath: selected, + packages: loadFromPackageLock(selected, prodOnly), + notes: [ + `Scanned resolved dependency versions from ${relativeOrName(projectRoot, selected)}.`, + "Dependency paths are derived from lockfile package locations." + ], + warnings: ["No supported lockfile was found at the repo root, so a nested lockfile was used instead."], + skippedDependencies: [] + }; + } if (selectedName === "package-lock.json") { return { mode: "resolved-lockfile", @@ -180,7 +210,7 @@ export function loadPackages(projectRoot: string, prodOnly: boolean, maxDepth: n export function buildNoPackagesMessage(projectRoot: string): string { return [ "No scannable packages were found.", - "Supported inputs: bun.lock, package-lock.json, pnpm-lock.yaml, yarn.lock, or package.json with exact pinned versions.", + "Supported inputs: bun.lock, npm-shrinkwrap.json, package-lock.json, pnpm-lock.yaml, yarn.lock, or package.json with exact pinned versions.", `Searched under: ${projectRoot}` ].join(" "); } diff --git a/src/types.ts b/src/types.ts index 6625067..ff70ac7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,7 +49,7 @@ export type NpmTransitiveGraph = { }; export type ScanMode = "resolved-lockfile" | "manifest-fallback"; -export type ScanSource = "package-lock" | "pnpm-lock" | "yarn-lock" | "bun-lock" | "package-json" | "unknown"; +export type ScanSource = "package-lock" | "npm-shrinkwrap" | "pnpm-lock" | "yarn-lock" | "bun-lock" | "package-json" | "unknown"; export type ScanInput = { mode: ScanMode; diff --git a/tests/parsers.test.ts b/tests/parsers.test.ts index 25fb983..cf3c8f6 100644 --- a/tests/parsers.test.ts +++ b/tests/parsers.test.ts @@ -1147,6 +1147,76 @@ packages: } }); + it("detects npm-shrinkwrap.json at root and reports npm-shrinkwrap source", () => { + const projectDir = createTempProjectDir(); + + fs.writeFileSync( + path.join(projectDir, "npm-shrinkwrap.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/lodash": { version: "4.17.21" }, + }, + }), + "utf8", + ); + + try { + const result = loadPackages(projectDir, false, 4); + + expect(result.source).toBe("npm-shrinkwrap"); + expect(path.basename(result.filePath ?? "")).toBe("npm-shrinkwrap.json"); + expect(result.mode).toBe("resolved-lockfile"); + expect(result.warnings).toEqual([]); + expect(result.packages).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "lodash", version: "4.17.21" })]), + ); + } finally { + removeDir(projectDir); + } + }); + + it("prefers npm-shrinkwrap.json over package-lock.json when both exist", () => { + const projectDir = createTempProjectDir(); + + fs.writeFileSync( + path.join(projectDir, "npm-shrinkwrap.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/lodash": { version: "4.17.21" }, + }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(projectDir, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/express": { version: "4.18.0" }, + }, + }), + "utf8", + ); + + try { + const result = loadPackages(projectDir, false, 4); + + expect(result.source).toBe("npm-shrinkwrap"); + expect(path.basename(result.filePath ?? "")).toBe("npm-shrinkwrap.json"); + expect(result.packages).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "lodash" })]), + ); + expect(result.packages.map(p => p.name)).not.toContain("express"); + } finally { + removeDir(projectDir); + } + }); + it("falls back to package.json and surfaces the npmrc package-lock warning", () => { const projectDir = createTempProjectDir();