diff --git a/examples/bun-mal-private-registry/bun.lock b/examples/bun-mal-private-registry/bun.lock new file mode 100644 index 0000000..562638f --- /dev/null +++ b/examples/bun-mal-private-registry/bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "cve-lite-example-bun-mal-private-registry", + "dependencies": { + "node-ipc": "9.2.3", + }, + }, + }, + "packages": { + "node-ipc": ["node-ipc@9.2.3", "https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz", {}, ""], + }, +} diff --git a/examples/bun-mal-private-registry/package.json b/examples/bun-mal-private-registry/package.json new file mode 100644 index 0000000..3987ec5 --- /dev/null +++ b/examples/bun-mal-private-registry/package.json @@ -0,0 +1,10 @@ +{ + "name": "cve-lite-example-bun-mal-private-registry", + "version": "1.0.0", + "private": true, + "description": "Bun fixture: node-ipc resolved from a private registry — should show Unverifiable (private source) instead of Malicious.", + "license": "ISC", + "dependencies": { + "node-ipc": "9.2.3" + } +} diff --git a/examples/pnpm-legacy-mal-private-registry/package.json b/examples/pnpm-legacy-mal-private-registry/package.json new file mode 100644 index 0000000..057552f --- /dev/null +++ b/examples/pnpm-legacy-mal-private-registry/package.json @@ -0,0 +1,10 @@ +{ + "name": "cve-lite-example-pnpm-legacy-mal-private-registry", + "version": "1.0.0", + "private": true, + "description": "pnpm legacy (v6) fixture: node-ipc resolved from a private registry — should show Unverifiable (private source) instead of Malicious.", + "license": "ISC", + "dependencies": { + "node-ipc": "9.2.3" + } +} diff --git a/examples/pnpm-legacy-mal-private-registry/pnpm-lock.yaml b/examples/pnpm-legacy-mal-private-registry/pnpm-lock.yaml new file mode 100644 index 0000000..a1bb2a9 --- /dev/null +++ b/examples/pnpm-legacy-mal-private-registry/pnpm-lock.yaml @@ -0,0 +1,18 @@ +lockfileVersion: 6.0 + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + node-ipc: + specifier: 9.2.3 + version: /node-ipc/9.2.3 + +packages: + + /node-ipc/9.2.3: + resolution: {tarball: 'https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz'} + dev: false diff --git a/examples/pnpm-mal-private-registry/package.json b/examples/pnpm-mal-private-registry/package.json new file mode 100644 index 0000000..a98df48 --- /dev/null +++ b/examples/pnpm-mal-private-registry/package.json @@ -0,0 +1,10 @@ +{ + "name": "cve-lite-example-pnpm-mal-private-registry", + "version": "1.0.0", + "private": true, + "description": "pnpm v9 fixture: node-ipc resolved from a private registry — should show Unverifiable (private source) instead of Malicious.", + "license": "ISC", + "dependencies": { + "node-ipc": "9.2.3" + } +} diff --git a/examples/pnpm-mal-private-registry/pnpm-lock.yaml b/examples/pnpm-mal-private-registry/pnpm-lock.yaml new file mode 100644 index 0000000..28dede8 --- /dev/null +++ b/examples/pnpm-mal-private-registry/pnpm-lock.yaml @@ -0,0 +1,19 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + node-ipc: + specifier: 9.2.3 + version: 9.2.3 + +packages: + node-ipc@9.2.3: + resolution: {tarball: 'https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz'} + +snapshots: + node-ipc@9.2.3: {} diff --git a/examples/readme.md b/examples/readme.md index c8810ed..7466810 100644 --- a/examples/readme.md +++ b/examples/readme.md @@ -32,6 +32,10 @@ Small curated projects committed to the repository. Clone the repo and scan imme | `dev-only-finding` | npm | Vulnerable package that only appears in devDependencies — classified as a direct finding in full scans and excluded by `--prod-only`. | | `any fixture` + `.cve-lite/baseline.json` | any | Run `cve-lite . --ratchet` on any fixture to establish a baseline. Rescan without the flag to see only new findings. `.cve-lite/` directories should NOT be committed from example fixtures. | | `mal-private-registry` | npm | `node-ipc@9.2.3` with `resolved` pointing to a private registry — demonstrates `Unverifiable (private source)` output for MAL- advisories where the artifact origin cannot be confirmed. | +| `pnpm-mal-private-registry` | pnpm v9 | `node-ipc@9.2.3` resolved from a private registry — demonstrates `Unverifiable (private source)` detection for pnpm v9 lockfiles. | +| `pnpm-legacy-mal-private-registry` | pnpm legacy (v6) | `node-ipc@9.2.3` resolved from a private registry — demonstrates `Unverifiable (private source)` detection for pnpm v6/v7/v8 lockfiles. | +| `yarn-classic-mal-private-registry` | Yarn Classic (v1) | `node-ipc@9.2.3` resolved from a private registry — demonstrates `Unverifiable (private source)` detection for Yarn Classic lockfiles. | +| `bun-mal-private-registry` | Bun | `node-ipc@9.2.3` resolved from a private registry — demonstrates `Unverifiable (private source)` detection for Bun lockfiles. | | `lima-site` | npm | Dev-dependency scanning in a documentation site. | ## In-repo snapshot: Astro diff --git a/examples/yarn-classic-mal-private-registry/package.json b/examples/yarn-classic-mal-private-registry/package.json new file mode 100644 index 0000000..6a212f3 --- /dev/null +++ b/examples/yarn-classic-mal-private-registry/package.json @@ -0,0 +1,10 @@ +{ + "name": "cve-lite-example-yarn-classic-mal-private-registry", + "version": "1.0.0", + "private": true, + "description": "Yarn Classic fixture: node-ipc resolved from a private registry — should show Unverifiable (private source) instead of Malicious.", + "license": "ISC", + "dependencies": { + "node-ipc": "9.2.3" + } +} diff --git a/examples/yarn-classic-mal-private-registry/yarn.lock b/examples/yarn-classic-mal-private-registry/yarn.lock new file mode 100644 index 0000000..9edf689 --- /dev/null +++ b/examples/yarn-classic-mal-private-registry/yarn.lock @@ -0,0 +1,7 @@ +# yarn lockfile v1 + + +node-ipc@9.2.3: + version "9.2.3" + resolved "https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz" + integrity sha512-placeholder== diff --git a/src/parsers/bun-lock.ts b/src/parsers/bun-lock.ts index 737ae07..b1b7334 100644 --- a/src/parsers/bun-lock.ts +++ b/src/parsers/bun-lock.ts @@ -139,12 +139,14 @@ export function loadFromBunLock(filePath: string, prodOnly: boolean): PackageRef const dev = devNames.has(pkgName) && !prodNames.has(pkgName); if (prodOnly && dev) continue; + const resolvedUrl = typeof entry[1] === "string" && entry[1] !== "" ? entry[1] : undefined; upsertPackage(map, { name, version, ecosystem: "npm", dev, paths: [], + resolvedUrl, }); } diff --git a/src/parsers/pnpm-lock.ts b/src/parsers/pnpm-lock.ts index 07780ea..2d16657 100644 --- a/src/parsers/pnpm-lock.ts +++ b/src/parsers/pnpm-lock.ts @@ -59,7 +59,8 @@ function loadLegacy(parsed: any, prodOnly: boolean): PackageRef[] { graph.set(ref.key, [...depKeys]); const dev = !!meta?.dev; if (prodOnly && dev) continue; - upsertPackage(map, { name: ref.name, version: ref.version, ecosystem: "npm", dev, paths: [] }); + const resolvedUrl = meta?.resolution?.tarball as string | undefined; + upsertPackage(map, { name: ref.name, version: ref.version, ecosystem: "npm", dev, paths: [], resolvedUrl }); } const rootDeps: string[] = []; @@ -98,6 +99,15 @@ function loadV9(parsed: any, prodOnly: boolean): PackageRef[] { const graph = new Map(); const map = new Map(); + const packagesMetaSection = parsed?.packages ?? {}; + const resolutionUrls = new Map(); + for (const [key, pkgMeta] of Object.entries(packagesMetaSection)) { + const ref = parsePnpmPackageKeyV9(String(key)); + if (!ref) continue; + const tarball = pkgMeta?.resolution?.tarball as string | undefined; + if (tarball) resolutionUrls.set(ref.key, tarball); + } + for (const [key, meta] of Object.entries(snapshotsSection)) { const ref = parsePnpmPackageKeyV9(String(key)); if (!ref) continue; @@ -114,7 +124,7 @@ function loadV9(parsed: any, prodOnly: boolean): PackageRef[] { graph.set(ref.key, [...depKeys]); const dev = !!meta?.dev; if (prodOnly && dev) continue; - upsertPackage(map, { name: ref.name, version: ref.version, ecosystem: "npm", dev, paths: [] }); + upsertPackage(map, { name: ref.name, version: ref.version, ecosystem: "npm", dev, paths: [], resolvedUrl: resolutionUrls.get(ref.key) }); } const rootDeps: string[] = []; diff --git a/src/parsers/yarn-lock.ts b/src/parsers/yarn-lock.ts index 650537b..a922fe7 100644 --- a/src/parsers/yarn-lock.ts +++ b/src/parsers/yarn-lock.ts @@ -268,7 +268,7 @@ function loadFromYarnClassicLock(parsed: any, filePath: string): PackageRef[] { selectorToKey.set(selector, canonicalKey); } - upsertPackage(map, { name, version, ecosystem: "npm", paths: [] }); + upsertPackage(map, { name, version, ecosystem: "npm", paths: [], resolvedUrl: meta?.resolved as string | undefined }); } for (const [selectorKey, meta] of Object.entries(parsed.object)) { diff --git a/tests/fixture-scan.test.ts b/tests/fixture-scan.test.ts index e1a3d5d..5eff24a 100644 --- a/tests/fixture-scan.test.ts +++ b/tests/fixture-scan.test.ts @@ -191,4 +191,36 @@ describe("fixture remediation scans", () => { expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); expect(isPrivateRegistrySource(nodeIpc!)).toBe(true); }); + + it("pnpm-mal-private-registry fixture - node-ipc resolvedUrl is from a private registry", () => { + const scanInput = loadFixture("pnpm-mal-private-registry"); + const nodeIpc = scanInput.packages.find(p => p.name === "node-ipc"); + expect(nodeIpc).toBeDefined(); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + expect(isPrivateRegistrySource(nodeIpc!)).toBe(true); + }); + + it("pnpm-legacy-mal-private-registry fixture - node-ipc resolvedUrl is from a private registry", () => { + const scanInput = loadFixture("pnpm-legacy-mal-private-registry"); + const nodeIpc = scanInput.packages.find(p => p.name === "node-ipc"); + expect(nodeIpc).toBeDefined(); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + expect(isPrivateRegistrySource(nodeIpc!)).toBe(true); + }); + + it("yarn-classic-mal-private-registry fixture - node-ipc resolvedUrl is from a private registry", () => { + const scanInput = loadFixture("yarn-classic-mal-private-registry"); + const nodeIpc = scanInput.packages.find(p => p.name === "node-ipc"); + expect(nodeIpc).toBeDefined(); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + expect(isPrivateRegistrySource(nodeIpc!)).toBe(true); + }); + + it("bun-mal-private-registry fixture - node-ipc resolvedUrl is from a private registry", () => { + const scanInput = loadFixture("bun-mal-private-registry"); + const nodeIpc = scanInput.packages.find(p => p.name === "node-ipc"); + expect(nodeIpc).toBeDefined(); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + expect(isPrivateRegistrySource(nodeIpc!)).toBe(true); + }); }); diff --git a/tests/parsers.test.ts b/tests/parsers.test.ts index 7c9d864..b863e9e 100644 --- a/tests/parsers.test.ts +++ b/tests/parsers.test.ts @@ -922,6 +922,50 @@ snapshots: removeDir(projectDir); } }); + + it("captures resolvedUrl from pnpm legacy lockfile resolution.tarball", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "pnpm-lock.yaml"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ dependencies: { "node-ipc": "9.2.3" } }), + "utf8", + ); + fs.writeFileSync( + lockPath, + `lockfileVersion: 6.0\n\nimporters:\n .:\n dependencies:\n node-ipc:\n specifier: 9.2.3\n version: /node-ipc/9.2.3\n\npackages:\n\n /node-ipc/9.2.3:\n resolution: {tarball: 'https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz'}\n dev: false\n`, + "utf8", + ); + try { + const packages = loadFromPnpmLock(lockPath, false); + const nodeIpc = packages.find(p => p.name === "node-ipc"); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + } finally { + removeDir(projectDir); + } + }); + + it("captures resolvedUrl from pnpm v9 lockfile resolution.tarball", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "pnpm-lock.yaml"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ dependencies: { "node-ipc": "9.2.3" } }), + "utf8", + ); + fs.writeFileSync( + lockPath, + `lockfileVersion: '9.0'\n\nimporters:\n .:\n dependencies:\n node-ipc:\n specifier: 9.2.3\n version: 9.2.3\n\npackages:\n node-ipc@9.2.3:\n resolution: {tarball: 'https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz'}\n\nsnapshots:\n node-ipc@9.2.3: {}\n`, + "utf8", + ); + try { + const packages = loadFromPnpmLock(lockPath, false); + const nodeIpc = packages.find(p => p.name === "node-ipc"); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + } finally { + removeDir(projectDir); + } + }); }); describe("yarn.lock parser", () => { @@ -1171,6 +1215,28 @@ follow-redirects@^1.10.0: removeDir(projectDir); } }); + + it("captures resolvedUrl from Yarn Classic lockfile resolved field", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ dependencies: { "node-ipc": "9.2.3" } }), + "utf8", + ); + fs.writeFileSync( + lockPath, + `# yarn lockfile v1\n\n\nnode-ipc@9.2.3:\n version "9.2.3"\n resolved "https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"\n`, + "utf8", + ); + try { + const packages = loadFromYarnLock(lockPath); + const nodeIpc = packages.find(p => p.name === "node-ipc"); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + } finally { + removeDir(projectDir); + } + }); }); describe("bun.lock parser", () => { @@ -1387,6 +1453,34 @@ describe("bun.lock parser", () => { removeDir(projectDir); } }); + + it("captures resolvedUrl from Bun lockfile package array second element", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "bun.lock"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ dependencies: { "node-ipc": "9.2.3" } }), + "utf8", + ); + fs.writeFileSync( + lockPath, + JSON.stringify({ + lockfileVersion: 1, + workspaces: { "": { name: "test", dependencies: { "node-ipc": "9.2.3" } } }, + packages: { + "node-ipc": ["node-ipc@9.2.3", "https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz", {}, ""], + }, + }), + "utf8", + ); + try { + const packages = loadFromBunLock(lockPath, false); + const nodeIpc = packages.find(p => p.name === "node-ipc"); + expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); + } finally { + removeDir(projectDir); + } + }); }); describe("loadPackages", () => {