Skip to content
Merged
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
10 changes: 10 additions & 0 deletions examples/pnpm-dual-document/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "cve-lite-example-pnpm-dual-document",
"version": "1.0.0",
"private": true,
"description": "Minimal dual-document pnpm v9 lockfile (bootstrap + project sections) for regression testing. lodash@4.17.20 is intentionally vulnerable for e2e scan coverage - do not bump without updating tests.",
"license": "MIT",
"dependencies": {
"lodash": "4.17.20"
}
}
30 changes: 30 additions & 0 deletions examples/pnpm-dual-document/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions examples/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Small curated projects committed to the repository. Clone the repo and scan imme
| `bun-within-range` | Bun | Transitive follow-redirects fix within axios range — suggests `bun update follow-redirects`. |
| `bun-workspace` | Bun (workspace) | Bun workspace monorepo with workspace-scoped fix commands. |
| `pnpm-simple` | pnpm | Minimal pnpm v9 lockfile with a single direct vulnerability. |
| `pnpm-dual-document` | pnpm | Dual-document pnpm v9 lockfile (bootstrap + project sections) - regression for #669. Uses intentionally vulnerable `lodash@4.17.20` for e2e scan coverage. |
| `pnpm-within-range` | pnpm | Transitive `qs` via `body-parser` where the parent range already covers the fix — expects `pnpm update qs`, not a parent bump. |
| `pnpm-aliased-chain` | pnpm | Deep transitive chain through a pnpm v9 aliased intermediate — path resolution must use the real package name. |
| `pnpm-workspace` | pnpm (workspace) | pnpm workspace monorepo with workspace-scoped fix commands. |
Expand Down Expand Up @@ -178,6 +179,7 @@ node dist/index.js examples/bun-simple --verbose
node dist/index.js examples/bun-within-range --verbose
node dist/index.js examples/bun-workspace --verbose
node dist/index.js examples/pnpm-simple --verbose
node dist/index.js examples/pnpm-dual-document --verbose
node dist/index.js examples/pnpm-within-range --verbose
node dist/index.js examples/pnpm-aliased-chain --verbose
node dist/index.js examples/pnpm-workspace --verbose
Expand Down
56 changes: 53 additions & 3 deletions src/parsers/pnpm-lock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs";
import YAML from "yaml";
import YAML, { YAMLParseError } from "yaml";
import type { PackageRef } from "../types.js";
import { looksLikeVersion, normalizeRawVersion } from "../utils/version.js";
import { upsertPackage, markDevPackages } from "./utils.js";
Expand All @@ -8,9 +8,59 @@ import { uniquePathArrays } from "../utils/array.js";
const MAX_PATHS_PER_PACKAGE = 5;
const MAX_PATH_DEPTH = 10;

function parsePnpmLockContent(content: string): any {
try {
return YAML.parse(content);
} catch (error) {
if (!(error instanceof YAMLParseError) || error.code !== "MULTIPLE_DOCS") {
throw error;
}
}

const documents = YAML.parseAllDocuments(content)
.map(doc => doc.toJSON())
.filter((doc): doc is Record<string, unknown> => !!doc && typeof doc === "object");

if (documents.length === 0) {
throw new Error("pnpm-lock.yaml is empty or unreadable");
}
if (documents.length === 1) {
return documents[0];
}

return selectBestPnpmLockDocument(documents);
}

function selectBestPnpmLockDocument(documents: Record<string, unknown>[]): Record<string, unknown> {
const projectDoc = documents.find(doc => hasProjectImporterSections(doc));
if (projectDoc) return projectDoc;

const withImporters = documents.find(doc => doc.importers && typeof doc.importers === "object");
if (withImporters) return withImporters;

throw new Error(
"pnpm-lock.yaml contains multiple YAML documents but none could be identified as the project lockfile. Please open an issue at https://github.com/OWASP/cve-lite-cli/issues",
);
}

function hasProjectImporterSections(document: Record<string, unknown>): boolean {
const importers = document.importers;
if (!importers || typeof importers !== "object") return false;

for (const importer of Object.values(importers as Record<string, unknown>)) {
if (!importer || typeof importer !== "object") continue;
const sections = importer as Record<string, unknown>;
if (sections.dependencies || sections.devDependencies || sections.optionalDependencies || sections.peerDependencies) {
return true;
}
}

return false;
}

export function buildPnpmWorkspaceMap(filePath: string): Map<string, string[]> {
const content = fs.readFileSync(filePath, "utf8");
const parsed = YAML.parse(content) as any;
const parsed = parsePnpmLockContent(content) as any;
const importers = parsed?.importers ?? {};
const map = new Map<string, string[]>();

Expand All @@ -32,7 +82,7 @@ export function buildPnpmWorkspaceMap(filePath: string): Map<string, string[]> {

export function loadFromPnpmLock(filePath: string, prodOnly: boolean): PackageRef[] {
const content = fs.readFileSync(filePath, "utf8");
const parsed = YAML.parse(content) as any;
const parsed = parsePnpmLockContent(content) as any;
const majorVersion = parseInt(String(parsed?.lockfileVersion ?? "0"), 10);
return majorVersion >= 9 ? loadV9(parsed, prodOnly) : loadLegacy(parsed, prodOnly);
}
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/cve-scanning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ describe("output channels carry real CVE data (astro)", () => {
describe("package-manager coverage with real CVEs", () => {
const cases: Array<{ pm: string; fixture: string }> = [
{ pm: "pnpm", fixture: "astro" },
{ pm: "pnpm", fixture: "pnpm-dual-document" },
{ pm: "npm", fixture: "vscode" },
{ pm: "yarn", fixture: "storybook" },
];
Expand Down
8 changes: 8 additions & 0 deletions tests/fixture-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,12 @@ describe("fixture remediation scans", () => {
expect(hasCommitShaPinning(nodeIpc!)).toBe(true);
expect(nodeIpc?.resolvedUrl).toContain("codeload.github.com");
});

itWithFixture("pnpm-dual-document", "pnpm-dual-document fixture - parses bootstrap + project lockfile sections", () => {
const scanInput = loadFixture("pnpm-dual-document");
expect(scanInput.source).toBe("pnpm-lock");
const lodash = requirePackage(scanInput, "lodash", "4.17.20");
expect(lodash.paths).toEqual(expect.arrayContaining([["project", "lodash"]]));
expect(scanInput.packages.some(pkg => pkg.name === "pnpm")).toBe(false);
});
});
93 changes: 93 additions & 0 deletions tests/parsers/pnpm-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,97 @@ snapshots:
}
});

it("loads the project lockfile from dual-document pnpm v9 files", () => {
const projectDir = createTempProjectDir();
const lockPath = path.join(projectDir, "pnpm-lock.yaml");

fs.writeFileSync(
lockPath,
`---
lockfileVersion: '9.0'

importers:
.:
packageManagerDependencies:
pnpm:
specifier: 11.1.3
version: 11.1.3

packages:
pnpm@11.1.3: {}

---
lockfileVersion: '9.0'

importers:
.:
dependencies:
left-pad:
specifier: 1.0.1
version: 1.0.1
devDependencies:
chalk:
specifier: 5.6.2
version: 5.6.2

packages:
left-pad@1.0.1: {}
chalk@5.6.2:
dev: true

snapshots:
left-pad@1.0.1: {}
chalk@5.6.2:
dev: true
`,
"utf8",
);

try {
const allPackages = loadFromPnpmLock(lockPath, false);
const prodPackages = loadFromPnpmLock(lockPath, true);

expect(allPackages).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "left-pad", version: "1.0.1" }),
expect.objectContaining({ name: "chalk", version: "5.6.2", dev: true }),
]),
);
expect(prodPackages).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "left-pad", version: "1.0.1" })]),
);
expect(prodPackages.some(pkg => pkg.name === "chalk")).toBe(false);
expect(allPackages.some(pkg => pkg.name === "pnpm")).toBe(false);
} finally {
removeDir(projectDir);
}
});

it("throws when dual-document lockfiles have no identifiable project section", () => {
const projectDir = createTempProjectDir();
const lockPath = path.join(projectDir, "pnpm-lock.yaml");

fs.writeFileSync(
lockPath,
`---
lockfileVersion: '9.0'
packages:
foo@1.0.0: {}
---
lockfileVersion: '9.0'
packages:
bar@2.0.0: {}
`,
"utf8",
);

try {
expect(() => loadFromPnpmLock(lockPath, false)).toThrow(
/none could be identified as the project lockfile/,
);
} finally {
removeDir(projectDir);
}
});

});