From 07dc9f956555cc39bcb7af6945c41134a628d138 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:00:00 -0500 Subject: [PATCH 01/17] feat: add scanAllImports() to usage/scanner for PD phantom detection --- src/usage/scanner.ts | 58 ++++++++++++++++++++++++++++++++++++++++- tests/usage.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/usage/scanner.ts b/src/usage/scanner.ts index 5f9808e4..d970a9ea 100644 --- a/src/usage/scanner.ts +++ b/src/usage/scanner.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { EXCLUDED_DIRS } from "../constants.js"; const ALLOWED_EXTS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]); +const MAX_FILES_TO_SCAN = 5000; // Matches: // import { foo } from 'pkg' @@ -39,7 +40,6 @@ export function scanProjectForPackageUsage( } // Cap at 5000 files to prevent performance issues on massive projects - const MAX_FILES_TO_SCAN = 5000; let scannedCount = 0; function walk(dir: string) { @@ -113,3 +113,59 @@ export function scanProjectForPackageUsage( return results; } + +export function scanAllImports(projectPath: string): Map { + const results = new Map(); + let scannedCount = 0; + + function walk(dir: string) { + if (scannedCount >= MAX_FILES_TO_SCAN) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (scannedCount >= MAX_FILES_TO_SCAN) return; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith(".")) { + walk(fullPath); + } + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (ALLOWED_EXTS.has(ext)) { + scanFile(fullPath); + scannedCount++; + } + } + } + } + + function scanFile(filePath: string) { + let content: string; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch { + return; + } + if (!content.includes("import") && !content.includes("require")) return; + + const matches = content.matchAll(IMPORT_REQUIRE_REGEX); + const relPath = path.relative(projectPath, filePath); + + for (const match of matches) { + const importPath = match[1] || match[2] || match[3] || match[4]; + if (!importPath) continue; + const bare = getBareModuleName(importPath); + if (!bare) continue; + const existing = results.get(bare) ?? []; + if (!existing.includes(relPath)) existing.push(relPath); + results.set(bare, existing); + } + } + + walk(projectPath); + return results; +} diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 9ec3a778..181f59f3 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -import { scanProjectForPackageUsage } from "../src/usage/scanner.js"; +import { scanProjectForPackageUsage, scanAllImports } from "../src/usage/scanner.js"; import { removeDir } from "./test-utils.js"; describe("scanProjectForPackageUsage", () => { @@ -87,3 +87,63 @@ describe("scanProjectForPackageUsage", () => { expect(results["local-file"].length).toBe(0); // Because relative paths return "" }); }); + +describe("scanAllImports", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-scan-all-")); + }); + + afterEach(() => { + removeDir(tempDir); + }); + + function createFile(filePath: string, content: string) { + const fullPath = path.join(tempDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + + it("returns all bare module names found in source files", () => { + createFile("src/index.ts", ` + import yaml from 'js-yaml'; + import { parse } from 'semver'; + import './local-file'; + `); + createFile("src/utils.ts", ` + import yaml from 'js-yaml'; + const x = require('lodash'); + `); + + const result = scanAllImports(tempDir); + + expect(result.has("js-yaml")).toBe(true); + expect(result.has("semver")).toBe(true); + expect(result.has("lodash")).toBe(true); + expect(result.has("./local-file")).toBe(false); // relative imports excluded + }); + + it("returns file paths for each import", () => { + createFile("src/index.ts", `import yaml from 'js-yaml';`); + createFile("src/other.ts", `import yaml from 'js-yaml';`); + + const result = scanAllImports(tempDir); + + const files = result.get("js-yaml") ?? []; + expect(files).toHaveLength(2); + expect(files.some((f) => f.includes("index.ts"))).toBe(true); + expect(files.some((f) => f.includes("other.ts"))).toBe(true); + }); + + it("returns empty map when no source files exist", () => { + const result = scanAllImports(tempDir); + expect(result.size).toBe(0); + }); + + it("excludes node_modules", () => { + createFile("node_modules/some-pkg/index.js", `import foo from 'bar';`); + const result = scanAllImports(tempDir); + expect(result.has("bar")).toBe(false); + }); +}); From 7943743aefdff9e6a9b672db78dc09a0ebf1c0bc Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:03:00 -0500 Subject: [PATCH 02/17] feat: add importedPackageNames to OverrideContext and populate in context-builder --- src/overrides/context-builder.ts | 4 ++++ src/overrides/context.ts | 3 +++ src/overrides/types.ts | 3 ++- tests/overrides/detectors/oa001.test.ts | 1 + tests/overrides/detectors/oa002.test.ts | 1 + tests/overrides/detectors/oa003.test.ts | 1 + tests/overrides/detectors/oa004.test.ts | 1 + tests/overrides/detectors/oa005.test.ts | 1 + tests/overrides/detectors/oa006.test.ts | 1 + tests/overrides/detectors/oa007.test.ts | 1 + tests/overrides/detectors/oa008.test.ts | 1 + tests/overrides/detectors/oa009.test.ts | 1 + 12 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/overrides/context-builder.ts b/src/overrides/context-builder.ts index 2031048d..e72e6d9d 100644 --- a/src/overrides/context-builder.ts +++ b/src/overrides/context-builder.ts @@ -8,6 +8,7 @@ import { loadFromPackageLock } from "../parsers/package-lock.js"; import { loadFromPnpmLock } from "../parsers/pnpm-lock.js"; import { loadFromYarnLock } from "../parsers/yarn-lock.js"; import { loadFromBunLock } from "../parsers/bun-lock.js"; +import { scanAllImports } from "../usage/scanner.js"; type LockfileReadResult = | { kind: "ok"; names: Set } @@ -81,6 +82,8 @@ export function buildOverrideContext( // OA007 registry: only when --check-network. Empty otherwise. const registryDistTags: OverrideContext["registryDistTags"] = new Map(); + const importedPackageNames = scanAllImports(projectPath); + return { projectPath, packageJson: parsed, @@ -93,6 +96,7 @@ export function buildOverrideContext( parentDeclarations: tree.parentDeclarations, registryDistTags, skippedDetectors: skipped, + importedPackageNames, auditLog: opts.auditLog, logger: opts.logger, }; diff --git a/src/overrides/context.ts b/src/overrides/context.ts index b1cbd3e9..52e6f513 100644 --- a/src/overrides/context.ts +++ b/src/overrides/context.ts @@ -85,6 +85,9 @@ export interface OverrideContext { registryDistTags: Map; /** Detectors the runner pre-skipped (lockfile missing, etc.). */ skippedDetectors: SkippedDetector[]; + /** All bare module names imported in source files, mapped to relative file paths. + * Empty map if no source files were found. Populated by context-builder. */ + importedPackageNames: Map; auditLog: AuditLogHandle; logger: Logger; } diff --git a/src/overrides/types.ts b/src/overrides/types.ts index 35411d10..b505b4d5 100644 --- a/src/overrides/types.ts +++ b/src/overrides/types.ts @@ -10,7 +10,8 @@ export type OverrideRuleId = | "OA007" // registry drift | "OA008" // materialized vulnerable | "OA009" // stale floor - + | "PD001" // override-only phantom + | "PD002" // transitive-only phantom ; export type OverrideSubRuleId = diff --git a/tests/overrides/detectors/oa001.test.ts b/tests/overrides/detectors/oa001.test.ts index 9eda6d0e..b0b03eae 100644 --- a/tests/overrides/detectors/oa001.test.ts +++ b/tests/overrides/detectors/oa001.test.ts @@ -17,6 +17,7 @@ function ctxOf(overrides: OverrideEntry[], lockfileNames: string[]): OverrideCon parentDeclarations: new Map(), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; diff --git a/tests/overrides/detectors/oa002.test.ts b/tests/overrides/detectors/oa002.test.ts index c20e66fd..e348f756 100644 --- a/tests/overrides/detectors/oa002.test.ts +++ b/tests/overrides/detectors/oa002.test.ts @@ -17,6 +17,7 @@ function ctxOf(entries: OverrideEntry[], installed: [string, string][] = []): Ov parentDeclarations: new Map(), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; diff --git a/tests/overrides/detectors/oa003.test.ts b/tests/overrides/detectors/oa003.test.ts index 595d8d7a..66852632 100644 --- a/tests/overrides/detectors/oa003.test.ts +++ b/tests/overrides/detectors/oa003.test.ts @@ -17,6 +17,7 @@ function ctxOf(pm: PackageManager, entries: OverrideEntry[]): OverrideContext { parentDeclarations: new Map(), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; diff --git a/tests/overrides/detectors/oa004.test.ts b/tests/overrides/detectors/oa004.test.ts index 4ecf81d0..1a087c95 100644 --- a/tests/overrides/detectors/oa004.test.ts +++ b/tests/overrides/detectors/oa004.test.ts @@ -17,6 +17,7 @@ function ctxOf(entries: OverrideEntry[], installed: [string, string][]): Overrid parentDeclarations: new Map(), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; diff --git a/tests/overrides/detectors/oa005.test.ts b/tests/overrides/detectors/oa005.test.ts index e81cee51..68f030be 100644 --- a/tests/overrides/detectors/oa005.test.ts +++ b/tests/overrides/detectors/oa005.test.ts @@ -25,6 +25,7 @@ function ctxOf(entries: OverrideEntry[], opts: CtxOpts = {}): OverrideContext & parentDeclarations: new Map(), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, ...(opts.manifestLookup ? { _testManifestLookup: opts.manifestLookup } : {}), diff --git a/tests/overrides/detectors/oa006.test.ts b/tests/overrides/detectors/oa006.test.ts index 08a3b839..9c7cd269 100644 --- a/tests/overrides/detectors/oa006.test.ts +++ b/tests/overrides/detectors/oa006.test.ts @@ -22,6 +22,7 @@ function ctxOf( parentDeclarations: new Map(Object.entries(parentDecls)), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; diff --git a/tests/overrides/detectors/oa007.test.ts b/tests/overrides/detectors/oa007.test.ts index cbba3530..81b600ad 100644 --- a/tests/overrides/detectors/oa007.test.ts +++ b/tests/overrides/detectors/oa007.test.ts @@ -17,6 +17,7 @@ function ctxOf(entries: OverrideEntry[], installed: [string, string][]): Overrid parentDeclarations: new Map(), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; diff --git a/tests/overrides/detectors/oa008.test.ts b/tests/overrides/detectors/oa008.test.ts index e69bbb0c..959acb07 100644 --- a/tests/overrides/detectors/oa008.test.ts +++ b/tests/overrides/detectors/oa008.test.ts @@ -21,6 +21,7 @@ function ctxOf(entries: OverrideEntry[], copies: Record parentDeclarations: new Map(), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; diff --git a/tests/overrides/detectors/oa009.test.ts b/tests/overrides/detectors/oa009.test.ts index e5e44c12..6d83a45b 100644 --- a/tests/overrides/detectors/oa009.test.ts +++ b/tests/overrides/detectors/oa009.test.ts @@ -21,6 +21,7 @@ function ctxOf( parentDeclarations: new Map(parents), registryDistTags: new Map(), skippedDetectors: [], + importedPackageNames: new Map(), auditLog: NULL_AUDIT_LOG, logger: noopLogger, }; From 797c6072785caf8f3b7209b23fe2c9b90d6182e7 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:10:00 -0500 Subject: [PATCH 03/17] feat: implement PD001 override-only phantom detector --- src/overrides/detectors/index.ts | 2 + .../detectors/pd001-override-only-phantom.ts | 55 +++++++++++ tests/overrides/detectors/pd001.test.ts | 96 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 src/overrides/detectors/pd001-override-only-phantom.ts create mode 100644 tests/overrides/detectors/pd001.test.ts diff --git a/src/overrides/detectors/index.ts b/src/overrides/detectors/index.ts index f7b8861b..15350633 100644 --- a/src/overrides/detectors/index.ts +++ b/src/overrides/detectors/index.ts @@ -10,6 +10,7 @@ import { detect as detectOA006 } from "./oa006-coupled-platform-binary.js"; import { detect as detectOA007 } from "./oa007-frozen-latest.js"; import { detect as detectOA008 } from "./oa008-materialized.js"; import { detect as detectOA009 } from "./oa009-stale-floor.js"; +import { detect as detectPD001 } from "./pd001-override-only-phantom.js"; export type DetectorFn = (ctx: OverrideContext) => OverrideFinding[]; @@ -26,6 +27,7 @@ export const ALL_DETECTORS: ReadonlyArray<{ { ruleId: "OA007", detect: detectOA007 }, { ruleId: "OA008", detect: detectOA008 }, { ruleId: "OA009", detect: detectOA009 }, + { ruleId: "PD001", detect: detectPD001 }, ]; /** Verify subset - just OA001 and OA008 - for the post-fix verify path. */ diff --git a/src/overrides/detectors/pd001-override-only-phantom.ts b/src/overrides/detectors/pd001-override-only-phantom.ts new file mode 100644 index 00000000..57c71c22 --- /dev/null +++ b/src/overrides/detectors/pd001-override-only-phantom.ts @@ -0,0 +1,55 @@ +import type { OverrideContext } from "../context.js"; +import type { OverrideFinding } from "../types.js"; + +const RULE_ID = "PD001" as const; + +function getDeclaredPackages(packageJson: Record): Set { + const declared = new Set(); + for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { + const deps = packageJson[section]; + if (deps && typeof deps === "object" && !Array.isArray(deps)) { + for (const name of Object.keys(deps as Record)) { + declared.add(name); + } + } + } + return declared; +} + +function installCmd(pm: OverrideContext["packageManager"]): string { + if (pm === "pnpm") return "pnpm add"; + if (pm === "yarn") return "yarn add"; + if (pm === "bun") return "bun add"; + return "npm install"; +} + +export function detect(ctx: OverrideContext): OverrideFinding[] { + if (ctx.skippedDetectors.some((s) => s.ruleId === RULE_ID)) return []; + if (ctx.importedPackageNames.size === 0) return []; + + const declared = getDeclaredPackages(ctx.packageJson); + const overrideNames = new Set(ctx.overrideEntries.map((e) => e.packageName)); + const cmd = installCmd(ctx.packageManager); + + const findings: OverrideFinding[] = []; + for (const [pkgName, files] of ctx.importedPackageNames) { + if (declared.has(pkgName)) continue; + if (!overrideNames.has(pkgName)) continue; + + const shown = files.slice(0, 3); + const extra = files.length > 3 ? ` (+${files.length - 3} more)` : ""; + findings.push({ + ruleId: RULE_ID, + severity: "high", + package: { name: pkgName }, + location: { file: "package.json" }, + message: `${pkgName} is imported in source but only present via an override pin - declare it as a dependency`, + details: + `Imported in: ${shown.join(", ")}${extra}. ` + + `In pnpm strict mode (default on Vercel), override-only packages are not importable and will cause a deploy failure. ` + + `Run: ${cmd} ${pkgName}`, + references: ["https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/PD001.md"], + }); + } + return findings; +} diff --git a/tests/overrides/detectors/pd001.test.ts b/tests/overrides/detectors/pd001.test.ts new file mode 100644 index 00000000..079a7e9d --- /dev/null +++ b/tests/overrides/detectors/pd001.test.ts @@ -0,0 +1,96 @@ +import { detect } from '../../../src/overrides/detectors/pd001-override-only-phantom.js'; +import type { OverrideContext, OverrideEntry } from '../../../src/overrides/context.js'; +import { NULL_AUDIT_LOG } from '../../../src/audit-log/index.js'; + +const noopLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any; + +function ctxOf(overrides: string[], declared: Record, imported: [string, string[]][]): OverrideContext { + const overrideEntries: OverrideEntry[] = overrides.map((name) => ({ + key: name, packageName: name, value: ">=1.0.0", + path: ["overrides", name], container: "overrides" as const, + })); + return { + projectPath: "/x", + packageJson: { name: "x", dependencies: declared }, + packageJsonRaw: "{}", + packageManager: "npm", + overrideEntries, + lockfilePackageNames: new Set(overrides), + installedVersions: new Map(), + installedCopies: new Map(), + parentDeclarations: new Map(), + registryDistTags: new Map(), + skippedDetectors: [], + importedPackageNames: new Map(imported), + auditLog: NULL_AUDIT_LOG, + logger: noopLogger, + }; +} + +describe("PD001 - override-only phantom", () => { + it("fires when package is in overrides and imported but not declared", () => { + const findings = detect(ctxOf( + ["js-yaml"], + {}, + [["js-yaml", ["src/yaml-engine.ts"]]], + )); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + ruleId: "PD001", + severity: "high", + package: { name: "js-yaml" }, + }); + expect(findings[0]!.message).toContain("js-yaml"); + expect(findings[0]!.details).toContain("src/yaml-engine.ts"); + }); + + it("does NOT fire when package is declared in dependencies", () => { + const findings = detect(ctxOf( + ["js-yaml"], + { "js-yaml": "^4.0.0" }, + [["js-yaml", ["src/index.ts"]]], + )); + expect(findings).toHaveLength(0); + }); + + it("does NOT fire when package is in overrides but not imported", () => { + const findings = detect(ctxOf( + ["js-yaml"], + {}, + [], // no imports + )); + expect(findings).toHaveLength(0); + }); + + it("does NOT fire when package is imported but not in overrides (PD002 territory)", () => { + const findings = detect(ctxOf( + [], // no overrides + {}, + [["lodash", ["src/utils.ts"]]], + )); + expect(findings).toHaveLength(0); + }); + + it("includes npm install command in details for npm projects", () => { + const findings = detect(ctxOf(["js-yaml"], {}, [["js-yaml", ["src/index.ts"]]])); + expect(findings[0]!.details).toContain("npm install js-yaml"); + }); + + it("uses pnpm add command for pnpm projects", () => { + const ctx = ctxOf(["js-yaml"], {}, [["js-yaml", ["src/index.ts"]]]); + ctx.packageManager = "pnpm"; + const findings = detect(ctx); + expect(findings[0]!.details).toContain("pnpm add js-yaml"); + }); + + it("returns empty when importedPackageNames is empty", () => { + const findings = detect(ctxOf(["js-yaml"], {}, [])); + expect(findings).toHaveLength(0); + }); + + it("respects skippedDetectors when PD001 is listed", () => { + const ctx = ctxOf(["js-yaml"], {}, [["js-yaml", ["src/index.ts"]]]); + ctx.skippedDetectors = [{ ruleId: "PD001", reason: "no source files" }]; + expect(detect(ctx)).toHaveLength(0); + }); +}); From 4e864d6cf107e412ced31d017f122d12d12566b9 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:12:00 -0500 Subject: [PATCH 04/17] feat: implement PD002 transitive-only phantom detector --- src/overrides/detectors/index.ts | 2 + .../pd002-transitive-only-phantom.ts | 56 +++++++++ tests/overrides/detectors/pd002.test.ts | 108 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/overrides/detectors/pd002-transitive-only-phantom.ts create mode 100644 tests/overrides/detectors/pd002.test.ts diff --git a/src/overrides/detectors/index.ts b/src/overrides/detectors/index.ts index 15350633..05f7a2b5 100644 --- a/src/overrides/detectors/index.ts +++ b/src/overrides/detectors/index.ts @@ -11,6 +11,7 @@ import { detect as detectOA007 } from "./oa007-frozen-latest.js"; import { detect as detectOA008 } from "./oa008-materialized.js"; import { detect as detectOA009 } from "./oa009-stale-floor.js"; import { detect as detectPD001 } from "./pd001-override-only-phantom.js"; +import { detect as detectPD002 } from "./pd002-transitive-only-phantom.js"; export type DetectorFn = (ctx: OverrideContext) => OverrideFinding[]; @@ -28,6 +29,7 @@ export const ALL_DETECTORS: ReadonlyArray<{ { ruleId: "OA008", detect: detectOA008 }, { ruleId: "OA009", detect: detectOA009 }, { ruleId: "PD001", detect: detectPD001 }, + { ruleId: "PD002", detect: detectPD002 }, ]; /** Verify subset - just OA001 and OA008 - for the post-fix verify path. */ diff --git a/src/overrides/detectors/pd002-transitive-only-phantom.ts b/src/overrides/detectors/pd002-transitive-only-phantom.ts new file mode 100644 index 00000000..b4accb30 --- /dev/null +++ b/src/overrides/detectors/pd002-transitive-only-phantom.ts @@ -0,0 +1,56 @@ +import type { OverrideContext } from "../context.js"; +import type { OverrideFinding } from "../types.js"; + +const RULE_ID = "PD002" as const; + +function getDeclaredPackages(packageJson: Record): Set { + const declared = new Set(); + for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { + const deps = packageJson[section]; + if (deps && typeof deps === "object" && !Array.isArray(deps)) { + for (const name of Object.keys(deps as Record)) { + declared.add(name); + } + } + } + return declared; +} + +function installCmd(pm: OverrideContext["packageManager"]): string { + if (pm === "pnpm") return "pnpm add"; + if (pm === "yarn") return "yarn add"; + if (pm === "bun") return "bun add"; + return "npm install"; +} + +export function detect(ctx: OverrideContext): OverrideFinding[] { + if (ctx.skippedDetectors.some((s) => s.ruleId === RULE_ID)) return []; + if (ctx.importedPackageNames.size === 0) return []; + + const declared = getDeclaredPackages(ctx.packageJson); + const overrideNames = new Set(ctx.overrideEntries.map((e) => e.packageName)); + const cmd = installCmd(ctx.packageManager); + + const findings: OverrideFinding[] = []; + for (const [pkgName, files] of ctx.importedPackageNames) { + if (declared.has(pkgName)) continue; + if (overrideNames.has(pkgName)) continue; // PD001 owns this + if (!ctx.lockfilePackageNames.has(pkgName)) continue; // not in graph + + const shown = files.slice(0, 3); + const extra = files.length > 3 ? ` (+${files.length - 3} more)` : ""; + findings.push({ + ruleId: RULE_ID, + severity: "medium", + package: { name: pkgName }, + location: { file: "package.json" }, + message: `${pkgName} is imported in source but only present as a transitive dependency - declare it explicitly`, + details: + `Imported in: ${shown.join(", ")}${extra}. ` + + `If the parent package drops or changes this dependency, your code will break without warning. ` + + `Run: ${cmd} ${pkgName}`, + references: ["https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/PD002.md"], + }); + } + return findings; +} diff --git a/tests/overrides/detectors/pd002.test.ts b/tests/overrides/detectors/pd002.test.ts new file mode 100644 index 00000000..80d583f4 --- /dev/null +++ b/tests/overrides/detectors/pd002.test.ts @@ -0,0 +1,108 @@ +import { detect } from '../../../src/overrides/detectors/pd002-transitive-only-phantom.js'; +import type { OverrideContext, OverrideEntry } from '../../../src/overrides/context.js'; +import { NULL_AUDIT_LOG } from '../../../src/audit-log/index.js'; + +const noopLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any; + +function ctxOf(opts: { + overrides?: string[]; + declared?: Record; + lockfile?: string[]; + imported?: [string, string[]][]; + pm?: OverrideContext["packageManager"]; +}): OverrideContext { + const overrideEntries: OverrideEntry[] = (opts.overrides ?? []).map((name) => ({ + key: name, packageName: name, value: "1.0.0", + path: ["overrides", name], container: "overrides" as const, + })); + return { + projectPath: "/x", + packageJson: { name: "x", dependencies: opts.declared ?? {} }, + packageJsonRaw: "{}", + packageManager: opts.pm ?? "npm", + overrideEntries, + lockfilePackageNames: new Set(opts.lockfile ?? []), + installedVersions: new Map(), + installedCopies: new Map(), + parentDeclarations: new Map(), + registryDistTags: new Map(), + skippedDetectors: [], + importedPackageNames: new Map(opts.imported ?? []), + auditLog: NULL_AUDIT_LOG, + logger: noopLogger, + }; +} + +describe("PD002 - transitive-only phantom", () => { + it("fires when package is in lockfile, imported, but not declared or in overrides", () => { + const findings = detect(ctxOf({ + lockfile: ["lodash", "cross-env"], + imported: [["lodash", ["src/utils.ts"]]], + })); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + ruleId: "PD002", + severity: "medium", + package: { name: "lodash" }, + }); + expect(findings[0]!.details).toContain("src/utils.ts"); + }); + + it("does NOT fire when package is declared in dependencies", () => { + const findings = detect(ctxOf({ + lockfile: ["lodash"], + declared: { lodash: "^4.0.0" }, + imported: [["lodash", ["src/index.ts"]]], + })); + expect(findings).toHaveLength(0); + }); + + it("does NOT fire when package is in overrides (PD001 owns it)", () => { + const findings = detect(ctxOf({ + overrides: ["js-yaml"], + lockfile: ["js-yaml"], + imported: [["js-yaml", ["src/index.ts"]]], + })); + expect(findings).toHaveLength(0); + }); + + it("does NOT fire when package is imported but not in lockfile", () => { + // Package is not in graph at all - different problem, out of scope. + const findings = detect(ctxOf({ + lockfile: [], + imported: [["not-installed", ["src/index.ts"]]], + })); + expect(findings).toHaveLength(0); + }); + + it("includes npm install command for npm projects", () => { + const findings = detect(ctxOf({ + lockfile: ["lodash"], + imported: [["lodash", ["src/index.ts"]]], + })); + expect(findings[0]!.details).toContain("npm install lodash"); + }); + + it("uses yarn add for yarn projects", () => { + const findings = detect(ctxOf({ + lockfile: ["lodash"], + imported: [["lodash", ["src/index.ts"]]], + pm: "yarn", + })); + expect(findings[0]!.details).toContain("yarn add lodash"); + }); + + it("returns empty when importedPackageNames is empty", () => { + const findings = detect(ctxOf({ lockfile: ["lodash"] })); + expect(findings).toHaveLength(0); + }); + + it("respects skippedDetectors when PD002 is listed", () => { + const ctx = ctxOf({ + lockfile: ["lodash"], + imported: [["lodash", ["src/index.ts"]]], + }); + ctx.skippedDetectors = [{ ruleId: "PD002", reason: "no source files" }]; + expect(detect(ctx)).toHaveLength(0); + }); +}); From 1de0a6d576ac672da91f251773806f1bf863270b Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:20:00 -0500 Subject: [PATCH 05/17] feat: guard OA009 against removing phantom-anchoring overrides --- src/overrides/detectors/oa009-stale-floor.ts | 21 ++++++++++++++ tests/overrides/detectors/oa009.test.ts | 30 ++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/overrides/detectors/oa009-stale-floor.ts b/src/overrides/detectors/oa009-stale-floor.ts index 00d5719b..7baa2f59 100644 --- a/src/overrides/detectors/oa009-stale-floor.ts +++ b/src/overrides/detectors/oa009-stale-floor.ts @@ -9,6 +9,19 @@ function majorOf(version: string): number { return parseInt(version.split(".")[0] ?? "0", 10); } +function getDeclaredPackages(packageJson: Record): Set { + const declared = new Set(); + for (const section of ["dependencies", "devDependencies"]) { + const deps = packageJson[section]; + if (deps && typeof deps === "object" && !Array.isArray(deps)) { + for (const name of Object.keys(deps as Record)) { + declared.add(name); + } + } + } + return declared; +} + export function detect(ctx: OverrideContext): OverrideFinding[] { if (ctx.skippedDetectors.some((s) => s.ruleId === RULE_ID)) return []; @@ -39,6 +52,14 @@ export function detect(ctx: OverrideContext): OverrideFinding[] { }); if (!allParentsMeetFloor) continue; + // Safety guard: if this package is imported in source but not declared in + // dependencies, the override is the only thing making it available. Removing + // it would create a phantom import (PD001). OA009 must not fire. + if (ctx.importedPackageNames.has(entry.packageName)) { + const declared = getDeclaredPackages(ctx.packageJson); + if (!declared.has(entry.packageName)) continue; + } + findings.push({ ruleId: RULE_ID, severity: "low", diff --git a/tests/overrides/detectors/oa009.test.ts b/tests/overrides/detectors/oa009.test.ts index 6d83a45b..f14862fe 100644 --- a/tests/overrides/detectors/oa009.test.ts +++ b/tests/overrides/detectors/oa009.test.ts @@ -182,4 +182,34 @@ describe('OA009-STALE-FLOOR', () => { runnableCommand: 'cve-lite overrides --fix --rule OA009', }); }); + + it('does NOT fire when the override anchors a phantom source import (PD001 guard)', () => { + // js-yaml is floored by the override, parent meets the floor, BUT js-yaml is + // imported in source and not declared in dependencies. Removing the override + // would create a phantom import - OA009 must be suppressed. + const ctx = ctxOf( + [e('js-yaml', '>=4.2.0')], + [['js-yaml', '4.5.0']], + [['js-yaml', [p('some-lib', '^4.5.0')]]], + ); + ctx.importedPackageNames = new Map([['js-yaml', ['src/yaml-engine.ts']]]); + // packageJson has no dependencies declaring js-yaml, so removing the override + // would make it a phantom import. + expect(detect(ctx)).toEqual([]); + }); + + it('still fires when the package is both imported AND declared in dependencies (override is redundant, import is safe)', () => { + // The floor is met, js-yaml IS in source, but it IS also declared in + // dependencies - so removing the override just removes a redundant pin. + const ctx = ctxOf( + [e('js-yaml', '>=4.2.0')], + [['js-yaml', '4.5.0']], + [['js-yaml', [p('some-lib', '^4.5.0')]]], + ); + ctx.importedPackageNames = new Map([['js-yaml', ['src/index.ts']]]); + ctx.packageJson = { name: 'x', dependencies: { 'js-yaml': '^4.0.0' } }; + const findings = detect(ctx); + expect(findings).toHaveLength(1); + expect(findings[0]!.ruleId).toBe('OA009'); + }); }); From ab8a19c6904a69c46e58ae638c276a851d94d2f9 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:24:00 -0500 Subject: [PATCH 06/17] feat: register PD001 and PD002 in SARIF output --- src/output/override-findings-sarif.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/output/override-findings-sarif.ts b/src/output/override-findings-sarif.ts index d37b019e..49662397 100644 --- a/src/output/override-findings-sarif.ts +++ b/src/output/override-findings-sarif.ts @@ -11,6 +11,8 @@ const OA_RULES: Array<{ id: string; name: string; shortDescription: string; help { id: "OA007", name: "FrozenLatest", shortDescription: "\"latest\" tag has moved", helpUri: "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/OA007.md" }, { id: "OA008", name: "MaterializedVulnerable", shortDescription: "Vulnerable copy still on disk", helpUri: "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/OA008.md" }, { id: "OA009", name: "StaleFloor", shortDescription: "Override floor already met by all parent declarations", helpUri: "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/OA009.md" }, + { id: "PD001", name: "OverrideOnlyPhantom", shortDescription: "Package imported in source but only present via override pin - declare it as a dependency", helpUri: "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/PD001.md" }, + { id: "PD002", name: "TransitiveOnlyPhantom", shortDescription: "Package imported in source but only present as a transitive dependency - declare it explicitly", helpUri: "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/PD002.md" }, ]; const SEVERITY_TO_LEVEL: Record = { From 1030cedaeeca75016c4af7c20be5f0355aac6fe2 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:30:00 -0500 Subject: [PATCH 07/17] docs: add PD001 and PD002 rule pages and update index, sidebar, and help text --- src/cli/help.ts | 6 +- website/docs/override-hygiene/index.md | 8 ++- website/docs/override-hygiene/pd001.md | 89 +++++++++++++++++++++++ website/docs/override-hygiene/pd002.md | 99 ++++++++++++++++++++++++++ website/sidebars.ts | 2 + 5 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 website/docs/override-hygiene/pd001.md create mode 100644 website/docs/override-hygiene/pd002.md diff --git a/src/cli/help.ts b/src/cli/help.ts index 37d01307..02344700 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -49,7 +49,7 @@ export function printHelp(): void { " --debug Write verbose runtime/network diagnostics to a timestamped log file", " --verbose Show detailed output with fix plan, paths, and full table", " --prod-only Exclude dev dependencies where available", - " --check-overrides Also audit package.json overrides (OA001-OA009); findings", + " --check-overrides Also audit package.json overrides (OA001-OA009, PD001-PD002); findings", " print, thread to JSON/SARIF/HTML, and count toward --fail-on", " --fail-on Exit non-zero at or above severity (default: critical)", " --batch-size OSV batch size (default: 100)", @@ -96,12 +96,12 @@ export function printOverridesHelp(): void { const lines = [ "cve-lite overrides [path] [flags]", "", - "Audit package.json overrides for hygiene problems (OA001-OA009).", + "Audit package.json overrides and phantom dependency imports (OA001-OA009, PD001-PD002).", "", "Flags:", " --json Emit findings as JSON", " --fix Apply RFC 6902 patches for findings with auto-fix", - " --rule Only run a specific rule (OA001-OA009)", + " --rule Only run a specific rule (OA001-OA009, PD001-PD002)", " --check-network Enable OA007 registry drift check (opt-in network)", " --audit-log Stream NDJSON change-control to ", " --fail-on Exit non-zero at or above this severity (default: critical)", diff --git a/website/docs/override-hygiene/index.md b/website/docs/override-hygiene/index.md index 6a1a248c..305a659e 100644 --- a/website/docs/override-hygiene/index.md +++ b/website/docs/override-hygiene/index.md @@ -7,7 +7,7 @@ description: Find and fix stale, broken, and dangerous dependency overrides acro Dependency overrides are security patches you apply manually when a vulnerable transitive package is not yet fixed upstream. They work - but they accumulate debt silently over time. The package gets updated, the CVE gets fixed, the override stays. Or worse: the override was never effective to begin with, and your project has been exposed the entire time without knowing it. -`cve-lite . --check-overrides` audits your override declarations against 9 rules and tells you exactly which ones are stale, broken, misplaced, or failing to take effect on disk. +`cve-lite . --check-overrides` audits your override declarations against 11 rules and tells you exactly which ones are stale, broken, misplaced, or failing to take effect on disk. Two rules (PD001 and PD002) also detect phantom dependency imports - packages your source code imports that are not declared as a dependency. --- @@ -44,7 +44,7 @@ Note that `--fix`, `--rule`, and `--check-network` require the `overrides` subco --- -## The 9 rules +## The 11 rules | Rule | Name | Severity | Auto-fix | What it detects | |---|---|---|---|---| @@ -57,6 +57,8 @@ Note that `--fix`, `--rule`, and `--check-network` require the `overrides` subco | [OA007](./oa007.md) | Frozen latest | low | yes | Floating tag locked behind a newer registry version (requires `--check-network`) | | [OA008](./oa008.md) | Materialized vulnerable copy | critical | no | Vulnerable package copy still on disk despite an active override floor | | [OA009](./oa009.md) | Stale floor | low | yes | Override range floor already met by all parent declarations - safe to remove | +| [PD001](./pd001.md) | Override-only phantom | high | no | Package imported in source but only present via an override pin - not declared as a dependency | +| [PD002](./pd002.md) | Transitive-only phantom | medium | no | Package imported in source but only present as a transitive dependency - not declared explicitly | --- @@ -108,6 +110,8 @@ Most findings can be fixed automatically. `--fix` applies RFC 6902 JSON patches | OA007 | yes (with `--check-network`) | `replace` with `>=` | | OA008 | suggest only | investigate the parent dependency chain | | OA009 | yes | `remove` the stale floor override | +| PD001 | no | declare the package as a dependency (`npm install `) | +| PD002 | no | declare the package as a dependency (`npm install `) | --- diff --git a/website/docs/override-hygiene/pd001.md b/website/docs/override-hygiene/pd001.md new file mode 100644 index 00000000..07b067ac --- /dev/null +++ b/website/docs/override-hygiene/pd001.md @@ -0,0 +1,89 @@ +--- +title: "PD001: Override-Only Phantom" +description: Package imported in source but only present via an override pin - not declared as a dependency. +--- + +# PD001: Override-Only Phantom + +**Severity:** high  ·  **Auto-fix:** no + +A package is imported directly in source code but is not declared in `dependencies`, `devDependencies`, `peerDependencies`, or `optionalDependencies`. It is only available at runtime because an override pin (`overrides`, `resolutions`, or `pnpm.overrides`) happens to place it in the resolved graph. + +This is a deploy-breaking risk under pnpm strict mode (the default on Vercel and many CI environments). Strict mode does not hoist override-only packages, so the import fails at runtime even though `npm install` completed without errors. + +--- + +## Example + +```json +{ + "overrides": { + "js-yaml": ">=4.2.0" + } +} +``` + +```ts +// src/config.ts +import yaml from "js-yaml"; +``` + +`js-yaml` is not listed in `dependencies` or `devDependencies`. The override pin keeps it in the lockfile, and hoisting makes it importable in npm and non-strict Yarn setups - but the import silently breaks on Vercel, pnpm strict projects, or wherever hoisting is disabled. + +--- + +## Terminal output + +``` +HIGH (1) +┌───────┬─────────┬───────────────────────────────────────────────────────────────┐ +│ Rule │ Package │ Message │ +├───────┼─────────┼───────────────────────────────────────────────────────────────┤ +│ PD001 │ js-yaml │ js-yaml is imported in source but only present via an │ +│ │ │ override pin - declare it as a dependency │ +│ │ │ package.json │ +│ │ │ Imported in: src/config.ts. In pnpm strict mode (default on │ +│ │ │ Vercel), override-only packages are not importable and will │ +│ │ │ cause a deploy failure. Run: npm install js-yaml │ +└───────┴─────────┴───────────────────────────────────────────────────────────────┘ +``` + +--- + +## Fix + +PD001 has no auto-fix patch because declaring a dependency is an explicit intent decision, not a mechanical cleanup. Run the install command shown in the finding: + +```bash +npm install js-yaml # npm +pnpm add js-yaml # pnpm +yarn add js-yaml # Yarn +bun add js-yaml # Bun +``` + +After declaring the dependency, the override pin may still be needed if you require a minimum safe version. Review the override entry after adding the declaration. + +--- + +## Why this is high severity + +**High** because: + +1. **Silent deploy failures.** The import works locally (hoisting) but breaks in strict environments. The mismatch is nearly impossible to catch before it hits production. +2. **No lockfile warning.** `npm install`, `pnpm install`, and `yarn install` all complete without error - the missing declaration is invisible until runtime. +3. **Scope is limited.** PD001 only fires when source code actually imports the package. A package in overrides but not in source produces no finding. + +--- + +## Relationship to OA009 + +If a package triggers PD001, OA009 (Stale Floor) will not fire on the same override entry. OA009 recommends removing redundant override floors - but if the override is the only thing keeping the package available to source imports, removing it would break the import. The OA009 safety guard suppresses that finding when the package is undeclared. + +Once you declare the package as a dependency (fixing PD001), the override floor may become eligible for OA009 cleanup if every parent's declared range already meets the floor. + +--- + +## Complementary rules + +- **[PD002](./pd002.md)** - same problem, but the package is a transitive dependency rather than an override-only package. Medium severity because transitive packages are at least present for all package managers, not just non-strict ones. +- **[OA001](./oa001.md)** - flags override targets not present anywhere in the resolved tree at all. diff --git a/website/docs/override-hygiene/pd002.md b/website/docs/override-hygiene/pd002.md new file mode 100644 index 00000000..d35042b2 --- /dev/null +++ b/website/docs/override-hygiene/pd002.md @@ -0,0 +1,99 @@ +--- +title: "PD002: Transitive-Only Phantom" +description: Package imported in source but only present as a transitive dependency - not declared explicitly. +--- + +# PD002: Transitive-Only Phantom + +**Severity:** medium  ·  **Auto-fix:** no + +A package is imported directly in source code but is not declared in `dependencies`, `devDependencies`, `peerDependencies`, or `optionalDependencies`. It is only present in the resolved graph because a parent package depends on it transitively. + +This is a latent correctness risk. If the parent package drops the dependency, bumps it to a breaking major, or changes how it re-exports the module, the import silently breaks - with no change to your own `package.json` and no lockfile conflict warning. + +--- + +## Example + +```json +{ + "dependencies": { + "semver-utils": "^2.0.0" + } +} +``` + +```ts +// src/release.ts +import semver from "semver"; +``` + +`semver-utils` depends on `semver` transitively. `semver` is present in `node_modules` and importable today. If `semver-utils` v3 drops `semver` from its own dependencies or bundles it differently, the import in `src/release.ts` breaks silently on the next `npm install`. + +--- + +## Terminal output + +``` +MEDIUM (1) +┌───────┬────────┬───────────────────────────────────────────────────────────────┐ +│ Rule │ Package│ Message │ +├───────┼────────┼───────────────────────────────────────────────────────────────┤ +│ PD002 │ semver │ semver is imported in source but only present as a transitive │ +│ │ │ dependency - declare it explicitly │ +│ │ │ package.json │ +│ │ │ Imported in: src/release.ts. If the parent package drops or │ +│ │ │ changes this dependency, your code will break without warning.│ +│ │ │ Run: npm install semver │ +└───────┴────────┴───────────────────────────────────────────────────────────────┘ +``` + +--- + +## Fix + +PD002 has no auto-fix patch because declaring a dependency is an explicit intent decision. Run the install command shown in the finding: + +```bash +npm install semver # npm +pnpm add semver # pnpm +yarn add semver # Yarn +bun add semver # Bun +``` + +Use `--save-dev` / `-D` if the import is only in test, build, or tooling code: + +```bash +npm install --save-dev semver +pnpm add -D semver +``` + +--- + +## Why this is medium severity + +**Medium** because: + +1. **Works today, breaks later.** The package is currently available in `node_modules` for all package managers - the risk is future, not immediate. This distinguishes it from PD001, where strict environments break today. +2. **Silent on upgrade.** When a parent drops or changes a transitive dependency, `npm install` succeeds. The break only surfaces at runtime on the next version upgrade. +3. **Scope is limited.** PD002 only fires when source code actually imports the package and the package is present in the lockfile. Packages that are neither imported nor present produce no finding. + +--- + +## PD001 vs PD002 + +| Aspect | PD001 | PD002 | +|---|---|---| +| How it gets into the graph | Override pin | Transitive dependency of a parent | +| Breaks today? | Yes, under pnpm strict | No - works for all package managers | +| Severity | high | medium | +| Auto-fix? | No | No | + +PD001 and PD002 never fire on the same package. If a package is in `overrides`, PD001 owns it. PD002 only fires when the package reaches the graph purely through the normal dependency tree. + +--- + +## Complementary rules + +- **[PD001](./pd001.md)** - same problem, but the package is only available via an override pin. High severity because strict-mode environments break immediately. +- **[OA001](./oa001.md)** - flags override targets that are not present anywhere in the resolved tree at all. diff --git a/website/sidebars.ts b/website/sidebars.ts index e139427c..9f0c340e 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -47,6 +47,8 @@ const sidebars: SidebarsConfig = { 'override-hygiene/oa007', 'override-hygiene/oa008', 'override-hygiene/oa009', + 'override-hygiene/pd001', + 'override-hygiene/pd002', ], }, 'ai-assistant-integration', From 971d4bf616d17216e6e63cc024a0418f2c1e66e3 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:31:00 -0500 Subject: [PATCH 08/17] test: add PD001 and PD002 e2e fixtures and detector tests --- .../pd001-override-phantom/package-lock.json | 27 +++++++++++++++++++ examples/pd001-override-phantom/package.json | 11 ++++++++ examples/pd001-override-phantom/src/index.ts | 5 ++++ .../package-lock.json | 24 +++++++++++++++++ .../pd002-transitive-phantom/package.json | 8 ++++++ .../pd002-transitive-phantom/src/index.ts | 5 ++++ tests/e2e/detectors.test.ts | 26 ++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 examples/pd001-override-phantom/package-lock.json create mode 100644 examples/pd001-override-phantom/package.json create mode 100644 examples/pd001-override-phantom/src/index.ts create mode 100644 examples/pd002-transitive-phantom/package-lock.json create mode 100644 examples/pd002-transitive-phantom/package.json create mode 100644 examples/pd002-transitive-phantom/src/index.ts diff --git a/examples/pd001-override-phantom/package-lock.json b/examples/pd001-override-phantom/package-lock.json new file mode 100644 index 00000000..ad001a07 --- /dev/null +++ b/examples/pd001-override-phantom/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "cve-lite-example-pd001-override-phantom", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cve-lite-example-pd001-override-phantom", + "version": "1.0.0", + "dependencies": { + "build-tool": "^2.0.0" + }, + "overrides": { + "js-yaml": ">=4.0.0" + } + }, + "node_modules/build-tool": { + "version": "2.0.0", + "dependencies": { + "js-yaml": "^4.1.0" + } + }, + "node_modules/js-yaml": { + "version": "4.2.0" + } + } +} diff --git a/examples/pd001-override-phantom/package.json b/examples/pd001-override-phantom/package.json new file mode 100644 index 00000000..32fbebd4 --- /dev/null +++ b/examples/pd001-override-phantom/package.json @@ -0,0 +1,11 @@ +{ + "name": "cve-lite-example-pd001-override-phantom", + "version": "1.0.0", + "description": "Demo fixture: PD001 - js-yaml imported in source but only present via override pin", + "dependencies": { + "build-tool": "^2.0.0" + }, + "overrides": { + "js-yaml": ">=4.0.0" + } +} diff --git a/examples/pd001-override-phantom/src/index.ts b/examples/pd001-override-phantom/src/index.ts new file mode 100644 index 00000000..1b81482c --- /dev/null +++ b/examples/pd001-override-phantom/src/index.ts @@ -0,0 +1,5 @@ +import yaml from 'js-yaml'; + +export function parse(input: string): unknown { + return yaml.load(input); +} diff --git a/examples/pd002-transitive-phantom/package-lock.json b/examples/pd002-transitive-phantom/package-lock.json new file mode 100644 index 00000000..c6d51ec4 --- /dev/null +++ b/examples/pd002-transitive-phantom/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "cve-lite-example-pd002-transitive-phantom", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cve-lite-example-pd002-transitive-phantom", + "version": "1.0.0", + "dependencies": { + "cross-spawn": "^7.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dependencies": { + "semver": "^7.5.4" + } + }, + "node_modules/semver": { + "version": "7.6.3" + } + } +} diff --git a/examples/pd002-transitive-phantom/package.json b/examples/pd002-transitive-phantom/package.json new file mode 100644 index 00000000..ab6f87d8 --- /dev/null +++ b/examples/pd002-transitive-phantom/package.json @@ -0,0 +1,8 @@ +{ + "name": "cve-lite-example-pd002-transitive-phantom", + "version": "1.0.0", + "description": "Demo fixture: PD002 - semver imported in source but only present as transitive dep", + "dependencies": { + "cross-spawn": "^7.0.0" + } +} diff --git a/examples/pd002-transitive-phantom/src/index.ts b/examples/pd002-transitive-phantom/src/index.ts new file mode 100644 index 00000000..0edc3a21 --- /dev/null +++ b/examples/pd002-transitive-phantom/src/index.ts @@ -0,0 +1,5 @@ +import semver from 'semver'; + +export function isValid(version: string): boolean { + return semver.valid(version) !== null; +} diff --git a/tests/e2e/detectors.test.ts b/tests/e2e/detectors.test.ts index 35c38046..36fe13f7 100644 --- a/tests/e2e/detectors.test.ts +++ b/tests/e2e/detectors.test.ts @@ -277,4 +277,30 @@ describe("e2e detectors OA001-OA008 fire through the real binary", () => { expect(oa008.severity).toBe("critical"); expect(oa008.details).toContain("0.25.12"); }); + + it("PD001 override-only phantom: import backed only by override pin fires PD001", () => { + // Uses the checked-in examples/pd001-override-phantom fixture. + // js-yaml is in overrides but not in dependencies/devDependencies. + // src/index.ts imports js-yaml, so PD001 should fire. + const dir = join(CLI, "..", "..", "examples", "pd001-override-phantom"); + const findings = findingsFor(dir); + expect(ruleIds(findings)).toContain("PD001"); + const pd001 = findings.find((f: any) => f.ruleId === "PD001"); + expect(pd001.package.name).toBe("js-yaml"); + expect(pd001.severity).toBe("high"); + expect(pd001.details).toContain("src/index.ts"); + }); + + it("PD002 transitive-only phantom: import backed only by transitive dep fires PD002", () => { + // Uses the checked-in examples/pd002-transitive-phantom fixture. + // semver is a transitive dep of cross-spawn but not declared directly. + // src/index.ts imports semver, so PD002 should fire. + const dir = join(CLI, "..", "..", "examples", "pd002-transitive-phantom"); + const findings = findingsFor(dir); + expect(ruleIds(findings)).toContain("PD002"); + const pd002 = findings.find((f: any) => f.ruleId === "PD002"); + expect(pd002.package.name).toBe("semver"); + expect(pd002.severity).toBe("medium"); + expect(pd002.details).toContain("src/index.ts"); + }); }); From 388b1fede898ae89813a9c87c2cad1d776bbf3d3 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:40:00 -0500 Subject: [PATCH 09/17] fix: add export to scanAllImports fast-path and extract shared PD helpers --- .../detectors/pd001-override-only-phantom.ts | 21 +------------------ .../pd002-transitive-only-phantom.ts | 21 +------------------ src/overrides/detectors/phantom-utils.ts | 21 +++++++++++++++++++ src/usage/scanner.ts | 2 +- 4 files changed, 24 insertions(+), 41 deletions(-) create mode 100644 src/overrides/detectors/phantom-utils.ts diff --git a/src/overrides/detectors/pd001-override-only-phantom.ts b/src/overrides/detectors/pd001-override-only-phantom.ts index 57c71c22..9ed3e8a4 100644 --- a/src/overrides/detectors/pd001-override-only-phantom.ts +++ b/src/overrides/detectors/pd001-override-only-phantom.ts @@ -1,28 +1,9 @@ import type { OverrideContext } from "../context.js"; import type { OverrideFinding } from "../types.js"; +import { getDeclaredPackages, installCmd } from "./phantom-utils.js"; const RULE_ID = "PD001" as const; -function getDeclaredPackages(packageJson: Record): Set { - const declared = new Set(); - for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { - const deps = packageJson[section]; - if (deps && typeof deps === "object" && !Array.isArray(deps)) { - for (const name of Object.keys(deps as Record)) { - declared.add(name); - } - } - } - return declared; -} - -function installCmd(pm: OverrideContext["packageManager"]): string { - if (pm === "pnpm") return "pnpm add"; - if (pm === "yarn") return "yarn add"; - if (pm === "bun") return "bun add"; - return "npm install"; -} - export function detect(ctx: OverrideContext): OverrideFinding[] { if (ctx.skippedDetectors.some((s) => s.ruleId === RULE_ID)) return []; if (ctx.importedPackageNames.size === 0) return []; diff --git a/src/overrides/detectors/pd002-transitive-only-phantom.ts b/src/overrides/detectors/pd002-transitive-only-phantom.ts index b4accb30..b703a865 100644 --- a/src/overrides/detectors/pd002-transitive-only-phantom.ts +++ b/src/overrides/detectors/pd002-transitive-only-phantom.ts @@ -1,28 +1,9 @@ import type { OverrideContext } from "../context.js"; import type { OverrideFinding } from "../types.js"; +import { getDeclaredPackages, installCmd } from "./phantom-utils.js"; const RULE_ID = "PD002" as const; -function getDeclaredPackages(packageJson: Record): Set { - const declared = new Set(); - for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { - const deps = packageJson[section]; - if (deps && typeof deps === "object" && !Array.isArray(deps)) { - for (const name of Object.keys(deps as Record)) { - declared.add(name); - } - } - } - return declared; -} - -function installCmd(pm: OverrideContext["packageManager"]): string { - if (pm === "pnpm") return "pnpm add"; - if (pm === "yarn") return "yarn add"; - if (pm === "bun") return "bun add"; - return "npm install"; -} - export function detect(ctx: OverrideContext): OverrideFinding[] { if (ctx.skippedDetectors.some((s) => s.ruleId === RULE_ID)) return []; if (ctx.importedPackageNames.size === 0) return []; diff --git a/src/overrides/detectors/phantom-utils.ts b/src/overrides/detectors/phantom-utils.ts new file mode 100644 index 00000000..84ab5308 --- /dev/null +++ b/src/overrides/detectors/phantom-utils.ts @@ -0,0 +1,21 @@ +import type { OverrideContext } from "../context.js"; + +export function getDeclaredPackages(packageJson: Record): Set { + const declared = new Set(); + for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { + const deps = packageJson[section]; + if (deps && typeof deps === "object" && !Array.isArray(deps)) { + for (const name of Object.keys(deps as Record)) { + declared.add(name); + } + } + } + return declared; +} + +export function installCmd(pm: OverrideContext["packageManager"]): string { + if (pm === "pnpm") return "pnpm add"; + if (pm === "yarn") return "yarn add"; + if (pm === "bun") return "bun add"; + return "npm install"; +} diff --git a/src/usage/scanner.ts b/src/usage/scanner.ts index d970a9ea..dfce65b5 100644 --- a/src/usage/scanner.ts +++ b/src/usage/scanner.ts @@ -150,7 +150,7 @@ export function scanAllImports(projectPath: string): Map { } catch { return; } - if (!content.includes("import") && !content.includes("require")) return; + if (!content.includes("import") && !content.includes("require") && !content.includes("export")) return; const matches = content.matchAll(IMPORT_REQUIRE_REGEX); const relPath = path.relative(projectPath, filePath); From ef21b36d5a946f769b0ce7f52dba7160dfeec2b3 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:45:00 -0500 Subject: [PATCH 10/17] fix: write overrides --json to file instead of stdout, consistent with scan path --- src/cli/overrides.ts | 14 +++++++--- tests/cli/overrides-command.test.ts | 24 +++++++++++++----- tests/e2e/commands-and-exit-codes.test.ts | 29 ++++++++++++++++----- tests/e2e/detectors.test.ts | 29 ++++++++++++++------- tests/e2e/fix-and-auditlog.test.ts | 29 ++++++++++++++++----- tests/e2e/harness.smoke.test.ts | 12 +++++++-- tests/e2e/package-managers.test.ts | 31 +++++++++++++++-------- tests/e2e/validation-and-outputs.test.ts | 17 +++++++++---- 8 files changed, 136 insertions(+), 49 deletions(-) diff --git a/src/cli/overrides.ts b/src/cli/overrides.ts index f67b36db..7e294b33 100644 --- a/src/cli/overrides.ts +++ b/src/cli/overrides.ts @@ -1,4 +1,5 @@ -import { resolve } from "node:path"; +import fs from "node:fs"; +import path, { resolve } from "node:path"; import type { ParsedOptions } from "../types.js"; import { buildOverrideContext, audit, applyFix } from "../overrides/index.js"; import { createAuditLog } from "../audit-log/index.js"; @@ -7,6 +8,7 @@ import { EXIT_OK, EXIT_FINDINGS, EXIT_ERROR } from "../types.js"; import { renderOverrideFindings } from "../output/override-findings-terminal.js"; import { reachesFailOn } from "../utils/severity.js"; import type { Logger } from "../overrides/context.js"; +import { chalk } from "../utils/chalk.js"; interface RunArgs { projectArg: string | undefined; @@ -59,7 +61,10 @@ export async function runOverrides({ projectArg, options, logger, auditLog: inje (f) => !appliedKeys.has(`${f.ruleId}:${f.package.name}`) ); if (options.json) { - process.stdout.write(JSON.stringify({ findings: remaining, fixReport: report }, null, 2) + "\n"); + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const jsonFilename = `cve-lite-overrides-${ts}.json`; + fs.writeFileSync(path.join(process.cwd(), jsonFilename), JSON.stringify({ findings: remaining, fixReport: report }, null, 2)); + console.log(`${chalk.gray("JSON saved to")} ${chalk.cyan(jsonFilename)}`); } else { logger.info( `Applied ${report.appliedPatches.length} fix${report.appliedPatches.length === 1 ? "" : "es"}; skipped ${report.skipped.length}.` @@ -73,7 +78,10 @@ export async function runOverrides({ projectArg, options, logger, auditLog: inje } if (options.json) { - process.stdout.write(JSON.stringify({ findings }, null, 2) + "\n"); + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const jsonFilename = `cve-lite-overrides-${ts}.json`; + fs.writeFileSync(path.join(process.cwd(), jsonFilename), JSON.stringify({ findings }, null, 2)); + console.log(`${chalk.gray("JSON saved to")} ${chalk.cyan(jsonFilename)}`); } else { process.stdout.write(renderOverrideFindings(findings, { projectPath }) + "\n"); } diff --git a/tests/cli/overrides-command.test.ts b/tests/cli/overrides-command.test.ts index 50281341..31d462d5 100644 --- a/tests/cli/overrides-command.test.ts +++ b/tests/cli/overrides-command.test.ts @@ -1,5 +1,5 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -21,9 +21,18 @@ describe("cve-lite overrides (end-to-end)", () => { lockfileVersion: 3, packages: { "": { name: "x" } }, })); - const out = execFileSync(process.execPath, [CLI, "overrides", dir, "--json"]); - const result = JSON.parse(out.toString()); - expect(result.findings).toHaveLength(0); + const outDir = mkdtempSync(join(tmpdir(), "overrides-out-")); + try { + const res = spawnSync(process.execPath, [CLI, "overrides", dir, "--json"], { cwd: outDir, encoding: "utf8" }); + expect(res.status).toBe(0); + const match = res.stdout.match(/cve-lite-overrides-[^\s]+\.json/); + if (!match) throw new Error(`overrides --json did not emit a filename.\nstdout:\n${res.stdout}`); + const filePath = join(outDir, match[0]); + const result = JSON.parse(readFileSync(filePath, "utf8")); + expect(result.findings).toHaveLength(0); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } }); it("returns 1 on a project with an orphan override above --fail-on", () => { @@ -38,11 +47,14 @@ describe("cve-lite overrides (end-to-end)", () => { "node_modules/lodash": { version: "4.17.21" }, }, })); + const outDir = mkdtempSync(join(tmpdir(), "overrides-out-")); let exitCode = 0; try { - execFileSync(process.execPath, [CLI, "overrides", dir, "--json", "--fail-on", "high"]); + execFileSync(process.execPath, [CLI, "overrides", dir, "--json", "--fail-on", "high"], { cwd: outDir }); } catch (err: any) { exitCode = err.status ?? -1; + } finally { + rmSync(outDir, { recursive: true, force: true }); } expect(exitCode).toBe(1); }); diff --git a/tests/e2e/commands-and-exit-codes.test.ts b/tests/e2e/commands-and-exit-codes.test.ts index ab75d0ea..8cbdcc52 100644 --- a/tests/e2e/commands-and-exit-codes.test.ts +++ b/tests/e2e/commands-and-exit-codes.test.ts @@ -12,11 +12,21 @@ * EXIT_VERIFY_FAILED=2, EXIT_ERROR=3. */ -import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, existsSync, readFileSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCli, mkProject, rmProject, npmProject, SAFE_PKG } from "./harness.js"; +/** Read the JSON file written by `overrides --json`, parse it, and clean up. */ +function readOverridesJson(stdout: string, cwd: string): Record { + const match = stdout.match(/cve-lite-overrides-[^\s]+\.json/); + if (!match) throw new Error(`overrides --json did not emit a filename.\nstdout:\n${stdout}`); + const filePath = join(cwd, match[0]); + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as Record; + unlinkSync(filePath); + return parsed; +} + // Track temp dirs created without mkProject (install-skill cwd, temp HOME) so // afterEach can sweep them. let scratchDirs: string[] = []; @@ -90,6 +100,7 @@ describe("commands + meta", () => { it("multi-folder scan with --check-overrides surfaces per-folder override hygiene", () => { // No root lockfile + two nested lockfiles triggers multi-folder mode. Each // nested package overrides a package absent from its lockfile -> OA001 orphan. + // Multi-folder --json writes JSON to stdout directly (not a file). const dir = mkProject({ "packages/a/package.json": { name: "a", overrides: { ghostpkg: "1.0.0" } }, "packages/a/package-lock.json": { lockfileVersion: 3, packages: { "": { name: "a" }, "node_modules/lodash": { version: "4.17.21" } } }, @@ -98,6 +109,7 @@ describe("commands + meta", () => { }); try { const r = runCli([dir, "--offline", "--check-overrides", "--json"]); + expect(r.status).toBe(0); const out = JSON.parse(r.stdout); expect(out.multiFolder).toBe(true); expect(Array.isArray(out.overrideFindings)).toBe(true); @@ -158,10 +170,11 @@ describe("commands + meta", () => { it("overrides --json exits 0 with a findings array", () => { const dir = mkProject(npmProject({ name: "x" }, { [SAFE_PKG]: "1.0.0" })); + const cwd = scratch(); try { - const r = runCli(["overrides", dir, "--json"]); + const r = runCli(["overrides", dir, "--json"], { cwd }); expect(r.status).toBe(0); - const out = JSON.parse(r.stdout); + const out = readOverridesJson(r.stdout, cwd); expect(Array.isArray(out.findings)).toBe(true); } finally { rmProject(dir); @@ -228,8 +241,9 @@ describe("commands + meta", () => { describe("exit codes", () => { it("0: clean overrides run", () => { const dir = mkProject(npmProject({ name: "x" }, { [SAFE_PKG]: "1.0.0" })); + const cwd = scratch(); try { - const r = runCli(["overrides", dir, "--json"]); + const r = runCli(["overrides", dir, "--json"], { cwd }); expect(r.status).toBe(0); } finally { rmProject(dir); @@ -250,11 +264,12 @@ describe("exit codes", () => { }, }, }); + const cwd2 = scratch(); try { - const r = runCli(["overrides", dir, "--fail-on", "low", "--json"]); + const r = runCli(["overrides", dir, "--fail-on", "low", "--json"], { cwd: cwd2 }); expect(r.status).toBe(1); - const out = JSON.parse(r.stdout); - expect(out.findings.some((f: { ruleId: string }) => f.ruleId === "OA001")).toBe(true); + const out = readOverridesJson(r.stdout, cwd2); + expect((out.findings as Array<{ ruleId: string }>).some((f) => f.ruleId === "OA001")).toBe(true); } finally { rmProject(dir); } diff --git a/tests/e2e/detectors.test.ts b/tests/e2e/detectors.test.ts index 36fe13f7..60ed64f5 100644 --- a/tests/e2e/detectors.test.ts +++ b/tests/e2e/detectors.test.ts @@ -18,22 +18,33 @@ * network-gated test below. */ -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { CLI, runCli, mkProject, rmProject } from "./harness.js"; /** Run `overrides --json` and return the parsed findings array. */ function findingsFor(dir: string, extraArgs: string[] = []): any[] { - const r = runCli(["overrides", dir, "--json", ...extraArgs]); - let parsed: { findings?: any[] }; + const cwd = mkdtempSync(join(tmpdir(), "e2e-overrides-")); try { - parsed = JSON.parse(r.stdout); - } catch { - throw new Error( - `overrides did not emit JSON.\nstatus=${r.status}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`, - ); + const r = runCli(["overrides", dir, "--json", ...extraArgs], { cwd }); + const match = r.stdout.match(/cve-lite-overrides-[^\s]+\.json/); + if (!match) { + throw new Error( + `overrides --json did not emit a filename.\nstatus=${r.status}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`, + ); + } + const filePath = join(cwd, match[0]); + let parsed: { findings?: any[] }; + try { + parsed = JSON.parse(readFileSync(filePath, "utf8")); + } catch { + throw new Error(`Could not read or parse ${filePath}`); + } + return parsed.findings ?? []; + } finally { + rmSync(cwd, { recursive: true, force: true }); } - return parsed.findings ?? []; } const ruleIds = (findings: any[]): string[] => findings.map((f) => f.ruleId); diff --git a/tests/e2e/fix-and-auditlog.test.ts b/tests/e2e/fix-and-auditlog.test.ts index fa3f4966..8375ed9e 100644 --- a/tests/e2e/fix-and-auditlog.test.ts +++ b/tests/e2e/fix-and-auditlog.test.ts @@ -10,7 +10,7 @@ * is exercised in-process in tests/cli/fix-overrides-hook.test.ts. */ -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, unlinkSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -30,6 +30,16 @@ function readPkg(dir: string): Record { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); } +/** Read the JSON file written by `overrides --json`, parse it, and clean up. */ +function readOverridesJson(stdout: string, cwd: string): Record { + const match = stdout.match(/cve-lite-overrides-[^\s]+\.json/); + if (!match) throw new Error(`overrides --json did not emit a filename.\nstdout:\n${stdout}`); + const filePath = join(cwd, match[0]); + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as Record; + unlinkSync(filePath); + return parsed; +} + /** Parse an NDJSON audit-log file into an array of events. */ function readNdjson(path: string): Array> { return readFileSync(path, "utf8") @@ -82,11 +92,13 @@ function makeOrphanProject(): string { describe("e2e: overrides --fix tiering", () => { it("Tier 1 auto-apply: orphan override (OA001) is removed by --fix, exit 0", () => { const dir = makeOrphanProject(); + const cwd = mkdtempSync(join(tmpdir(), "e2e-fix1-")); try { // Sanity: OA001 is present before the fix. - const before = runCli(["overrides", dir, "--json"]); + const before = runCli(["overrides", dir, "--json"], { cwd }); expect(before.status).toBe(0); - expect(JSON.parse(before.stdout).findings.map((f: any) => f.ruleId)).toContain( + const beforeData = readOverridesJson(before.stdout, cwd); + expect((beforeData.findings as any[]).map((f: any) => f.ruleId)).toContain( "OA001" ); @@ -98,16 +110,18 @@ describe("e2e: overrides --fix tiering", () => { expect(readPkg(dir).overrides).toBeUndefined(); } finally { rmProject(dir); + rmSync(cwd, { recursive: true, force: true }); } }); it("Tier 2 NOT auto-applied: OA006 relocate (proposed) survives --fix", () => { const dir = makeOa006Project(); + const cwd = mkdtempSync(join(tmpdir(), "e2e-fix2-")); try { // OA006 is the only finding and its fix is tier "proposed". - const before = runCli(["overrides", dir, "--json"]); + const before = runCli(["overrides", dir, "--json"], { cwd }); expect(before.status).toBe(0); - const beforeFindings = JSON.parse(before.stdout).findings; + const beforeFindings = readOverridesJson(before.stdout, cwd).findings as any[]; expect(beforeFindings.map((f: any) => f.ruleId)).toEqual(["OA006"]); expect(beforeFindings[0].fix.tier).toBe("proposed"); @@ -122,12 +136,13 @@ describe("e2e: overrides --fix tiering", () => { expect(pkg.dependencies).toBeUndefined(); // And OA006 still fires on a re-run (the proposed fix was not consumed). - const after = runCli(["overrides", dir, "--json"]); - expect(JSON.parse(after.stdout).findings.map((f: any) => f.ruleId)).toContain( + const after = runCli(["overrides", dir, "--json"], { cwd }); + expect((readOverridesJson(after.stdout, cwd).findings as any[]).map((f: any) => f.ruleId)).toContain( "OA006" ); } finally { rmProject(dir); + rmSync(cwd, { recursive: true, force: true }); } }); diff --git a/tests/e2e/harness.smoke.test.ts b/tests/e2e/harness.smoke.test.ts index 184f7980..805983be 100644 --- a/tests/e2e/harness.smoke.test.ts +++ b/tests/e2e/harness.smoke.test.ts @@ -3,6 +3,9 @@ * runCli spawns the real binary. */ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runCli, mkProject, rmProject, npmProject } from "./harness.js"; describe("e2e harness", () => { @@ -14,13 +17,18 @@ describe("e2e harness", () => { it("mkProject + runCli round-trip: overrides on a clean project exits 0", () => { const dir = mkProject(npmProject({ name: "x" }, { lodash: "4.17.21" })); + const cwd = mkdtempSync(join(tmpdir(), "e2e-smoke-")); try { - const r = runCli(["overrides", dir, "--json"]); + const r = runCli(["overrides", dir, "--json"], { cwd }); expect(r.status).toBe(0); - const out = JSON.parse(r.stdout); + const match = r.stdout.match(/cve-lite-overrides-[^\s]+\.json/); + if (!match) throw new Error(`overrides --json did not emit a filename.\nstdout:\n${r.stdout}`); + const filePath = join(cwd, match[0]); + const out = JSON.parse(readFileSync(filePath, "utf8")); expect(Array.isArray(out.findings)).toBe(true); } finally { rmProject(dir); + rmSync(cwd, { recursive: true, force: true }); } }); }); diff --git a/tests/e2e/package-managers.test.ts b/tests/e2e/package-managers.test.ts index 95c0db82..f4adf793 100644 --- a/tests/e2e/package-managers.test.ts +++ b/tests/e2e/package-managers.test.ts @@ -26,8 +26,10 @@ * Run with: npm test -- tests/e2e/package-managers.test.ts */ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runCli, mkProject, rmProject, npmProject, pnpmProject } from "./harness.js"; -import type { CliResult } from "./harness.js"; const ORPHAN = "completely-unused-pkg"; const REAL = "lodash"; @@ -42,11 +44,20 @@ interface OverridesJson { } /** Parse `{ findings }` from a green `overrides --json` run, asserting exit 0. */ -function parseFindings(r: CliResult): OverridesJson["findings"] { - expect(r.status).toBe(0); - const out = JSON.parse(r.stdout) as OverridesJson; - expect(Array.isArray(out.findings)).toBe(true); - return out.findings; +function runOverridesJson(dir: string, extraArgs: string[] = []): OverridesJson["findings"] { + const cwd = mkdtempSync(join(tmpdir(), "e2e-overrides-")); + try { + const r = runCli(["overrides", dir, "--json", ...extraArgs], { cwd }); + expect(r.status).toBe(0); + const match = r.stdout.match(/cve-lite-overrides-[^\s]+\.json/); + if (!match) throw new Error(`overrides --json did not emit a filename.\nstdout:\n${r.stdout}`); + const filePath = join(cwd, match[0]); + const out = JSON.parse(readFileSync(filePath, "utf8")) as OverridesJson; + expect(Array.isArray(out.findings)).toBe(true); + return out.findings; + } finally { + rmSync(cwd, { recursive: true, force: true }); + } } /** Assert OA001 fired for the orphan and not for the real resolved package. */ @@ -70,7 +81,7 @@ describe("e2e: each package manager pipeline through the real binary", () => { ), ); try { - expectOrphanOA001(parseFindings(runCli(["overrides", dir, "--json"]))); + expectOrphanOA001(runOverridesJson(dir)); } finally { rmProject(dir); } @@ -88,7 +99,7 @@ describe("e2e: each package manager pipeline through the real binary", () => { ), ); try { - expectOrphanOA001(parseFindings(runCli(["overrides", dir, "--json"]))); + expectOrphanOA001(runOverridesJson(dir)); } finally { rmProject(dir); } @@ -116,7 +127,7 @@ describe("e2e: each package manager pipeline through the real binary", () => { "yarn.lock": yarnLock, }); try { - expectOrphanOA001(parseFindings(runCli(["overrides", dir, "--json"]))); + expectOrphanOA001(runOverridesJson(dir)); } finally { rmProject(dir); } @@ -148,7 +159,7 @@ describe("e2e: each package manager pipeline through the real binary", () => { "bun.lock": bunLock, }); try { - expectOrphanOA001(parseFindings(runCli(["overrides", dir, "--json"]))); + expectOrphanOA001(runOverridesJson(dir)); } finally { rmProject(dir); } diff --git a/tests/e2e/validation-and-outputs.test.ts b/tests/e2e/validation-and-outputs.test.ts index 7494bc8b..ccb65f7c 100644 --- a/tests/e2e/validation-and-outputs.test.ts +++ b/tests/e2e/validation-and-outputs.test.ts @@ -15,7 +15,7 @@ * non-offline paths stay deterministic and offline-free. */ -import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { runCli, mkProject, rmProject, npmProject, SAFE_PKG } from "./harness.js"; @@ -118,18 +118,25 @@ describe("output channels", () => { } }); - // overrides subcommand --json prints a JSON document with .findings to stdout. - it("overrides --json prints parseable JSON with a findings array to stdout", () => { + // overrides subcommand --json writes a timestamped file and prints "JSON saved to ". + it("overrides --json writes a JSON file with a findings array", () => { const proj = orphanOverrideProject(); + const cwd = mkProject({}); try { - const r = runCli(["overrides", proj, "--json"], { env: CI_ENV }); + const r = runCli(["overrides", proj, "--json"], { cwd, env: CI_ENV }); // Findings present but below the default fail-on threshold, so exit 0. - const payload = JSON.parse(r.stdout); + expect(r.status).toBe(0); + const match = r.stdout.match(/cve-lite-overrides-[^\s]+\.json/); + expect(match).not.toBeNull(); + const filePath = join(cwd, match![0]); + const payload = JSON.parse(readFileSync(filePath, "utf8")); + unlinkSync(filePath); expect(payload).toHaveProperty("findings"); expect(Array.isArray(payload.findings)).toBe(true); expect(payload.findings.length).toBeGreaterThan(0); } finally { rmProject(proj); + rmProject(cwd); } }); From 3f4a9dd8d521bc95d1b07176f17407ced06f0c63 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:48:00 -0500 Subject: [PATCH 11/17] feat: add all-scenarios example fixture covering all detectable override hygiene rules Adds examples/all-scenarios - a single static fixture that triggers all nine override hygiene findings in one scan (OA001x2, OA002, OA003, OA004, OA005, OA009, PD001, PD002). OA006/OA007/OA008 are excluded since they require live node_modules or network registry access. Includes minimal node_modules stubs for OA004 and OA009 (which read installed package manifests on disk). Extends .gitignore to commit those stubs. --- .gitignore | 1 + .../node_modules/floating-dep/package.json | 1 + .../node_modules/old-pin/package.json | 1 + .../node_modules/parent-lib/package.json | 1 + .../node_modules/phantom-import/package.json | 1 + .../node_modules/stale-floor-dep/package.json | 1 + .../node_modules/transitive-host/package.json | 1 + .../node_modules/transitive-only/package.json | 1 + examples/all-scenarios/package-lock.json | 53 +++++++++++++++++++ examples/all-scenarios/package.json | 22 ++++++++ examples/all-scenarios/src/index.ts | 4 ++ 11 files changed, 87 insertions(+) create mode 100644 examples/all-scenarios/node_modules/floating-dep/package.json create mode 100644 examples/all-scenarios/node_modules/old-pin/package.json create mode 100644 examples/all-scenarios/node_modules/parent-lib/package.json create mode 100644 examples/all-scenarios/node_modules/phantom-import/package.json create mode 100644 examples/all-scenarios/node_modules/stale-floor-dep/package.json create mode 100644 examples/all-scenarios/node_modules/transitive-host/package.json create mode 100644 examples/all-scenarios/node_modules/transitive-only/package.json create mode 100644 examples/all-scenarios/package-lock.json create mode 100644 examples/all-scenarios/package.json create mode 100644 examples/all-scenarios/src/index.ts diff --git a/.gitignore b/.gitignore index 601fecb6..5e459c04 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ cve-lite-fix-result.json !tests/fixtures/**/node_modules !tests/fixtures/**/node_modules/** +!examples/all-scenarios/node_modules/** diff --git a/examples/all-scenarios/node_modules/floating-dep/package.json b/examples/all-scenarios/node_modules/floating-dep/package.json new file mode 100644 index 00000000..41862d36 --- /dev/null +++ b/examples/all-scenarios/node_modules/floating-dep/package.json @@ -0,0 +1 @@ +{ "name": "floating-dep", "version": "2.0.0" } diff --git a/examples/all-scenarios/node_modules/old-pin/package.json b/examples/all-scenarios/node_modules/old-pin/package.json new file mode 100644 index 00000000..5894c907 --- /dev/null +++ b/examples/all-scenarios/node_modules/old-pin/package.json @@ -0,0 +1 @@ +{ "name": "old-pin", "version": "3.2.0" } diff --git a/examples/all-scenarios/node_modules/parent-lib/package.json b/examples/all-scenarios/node_modules/parent-lib/package.json new file mode 100644 index 00000000..3c577515 --- /dev/null +++ b/examples/all-scenarios/node_modules/parent-lib/package.json @@ -0,0 +1 @@ +{ "name": "parent-lib", "version": "3.0.0", "dependencies": { "stale-floor-dep": "^3.0.0" } } diff --git a/examples/all-scenarios/node_modules/phantom-import/package.json b/examples/all-scenarios/node_modules/phantom-import/package.json new file mode 100644 index 00000000..7f188ee0 --- /dev/null +++ b/examples/all-scenarios/node_modules/phantom-import/package.json @@ -0,0 +1 @@ +{ "name": "phantom-import", "version": "1.1.0" } diff --git a/examples/all-scenarios/node_modules/stale-floor-dep/package.json b/examples/all-scenarios/node_modules/stale-floor-dep/package.json new file mode 100644 index 00000000..5096e4e3 --- /dev/null +++ b/examples/all-scenarios/node_modules/stale-floor-dep/package.json @@ -0,0 +1 @@ +{ "name": "stale-floor-dep", "version": "3.1.0" } diff --git a/examples/all-scenarios/node_modules/transitive-host/package.json b/examples/all-scenarios/node_modules/transitive-host/package.json new file mode 100644 index 00000000..929163f9 --- /dev/null +++ b/examples/all-scenarios/node_modules/transitive-host/package.json @@ -0,0 +1 @@ +{ "name": "transitive-host", "version": "1.0.0", "dependencies": { "transitive-only": "^1.5.0" } } diff --git a/examples/all-scenarios/node_modules/transitive-only/package.json b/examples/all-scenarios/node_modules/transitive-only/package.json new file mode 100644 index 00000000..ee97b720 --- /dev/null +++ b/examples/all-scenarios/node_modules/transitive-only/package.json @@ -0,0 +1 @@ +{ "name": "transitive-only", "version": "1.5.0" } diff --git a/examples/all-scenarios/package-lock.json b/examples/all-scenarios/package-lock.json new file mode 100644 index 00000000..d5fb4fa3 --- /dev/null +++ b/examples/all-scenarios/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "cve-lite-example-all-scenarios", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cve-lite-example-all-scenarios", + "version": "1.0.0", + "dependencies": { + "parent-lib": "^3.0.0", + "transitive-host": "^1.0.0" + }, + "overrides": { + "ghost-package": "^1.0.0", + "floating-dep": "latest", + "old-pin": "3.1.0", + "stale-floor-dep": ">=2.0.0", + "phantom-import": ">=1.0.0", + "nested-outer": { + "nested-inner": "1.0.0" + } + } + }, + "node_modules/parent-lib": { + "version": "3.0.0", + "dependencies": { + "stale-floor-dep": "^3.0.0" + } + }, + "node_modules/stale-floor-dep": { + "version": "3.1.0" + }, + "node_modules/transitive-host": { + "version": "1.0.0", + "dependencies": { + "transitive-only": "^1.5.0" + } + }, + "node_modules/transitive-only": { + "version": "1.5.0" + }, + "node_modules/floating-dep": { + "version": "2.0.0" + }, + "node_modules/old-pin": { + "version": "3.2.0" + }, + "node_modules/phantom-import": { + "version": "1.1.0" + } + } +} diff --git a/examples/all-scenarios/package.json b/examples/all-scenarios/package.json new file mode 100644 index 00000000..71e05ff9 --- /dev/null +++ b/examples/all-scenarios/package.json @@ -0,0 +1,22 @@ +{ + "name": "cve-lite-example-all-scenarios", + "version": "1.0.0", + "description": "Demo fixture: triggers OA001-OA005, OA009, PD001, PD002 in a single scan. OA006/OA007/OA008 require node_modules on disk or network access and are not included.", + "dependencies": { + "parent-lib": "^3.0.0", + "transitive-host": "^1.0.0" + }, + "resolutions": { + "wrong-section-pkg": "2.0.0" + }, + "overrides": { + "ghost-package": "^1.0.0", + "floating-dep": "latest", + "old-pin": "3.1.0", + "stale-floor-dep": ">=2.0.0", + "phantom-import": ">=1.0.0", + "nested-outer": { + "nested-inner": "1.0.0" + } + } +} diff --git a/examples/all-scenarios/src/index.ts b/examples/all-scenarios/src/index.ts new file mode 100644 index 00000000..63067fe1 --- /dev/null +++ b/examples/all-scenarios/src/index.ts @@ -0,0 +1,4 @@ +import phantomImport from 'phantom-import'; +import transitiveOnly from 'transitive-only'; + +export { phantomImport, transitiveOnly }; From 98919ecb2c3f28a7bb8906544d1fca33db7427df Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:55:00 -0500 Subject: [PATCH 12/17] feat: show fix-all command before per-rule hints when 2+ rules are fixable When multiple rules have auto-fix support, the hint block now leads with `cve-lite overrides --fix` so users can fix everything in one shot. Per-rule commands follow for surgical control. All hints are consolidated into a single block after all severity tables instead of appearing after each table separately. --- src/output/override-findings-terminal.ts | 34 +++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/output/override-findings-terminal.ts b/src/output/override-findings-terminal.ts index 8e400d1f..d6f8146a 100644 --- a/src/output/override-findings-terminal.ts +++ b/src/output/override-findings-terminal.ts @@ -71,27 +71,37 @@ export function renderOverrideFindings( } lines.push(hr("└", "┴", "┘")); + lines.push(""); + } - const relPath = options?.projectPath - ? relative(process.cwd(), options.projectPath) - : ""; - const projectInfix = relPath && relPath !== "." ? ` ${relPath}` : ""; - const seenRules = new Set(); - for (const f of sorted) { + const relPath = options?.projectPath + ? relative(process.cwd(), options.projectPath) + : ""; + const projectInfix = relPath && relPath !== "." ? ` ${relPath}` : ""; + + const seenRules = new Set(); + const fixableRules: { ruleId: string; cmd: string }[] = []; + for (const sev of SEVERITY_ORDER) { + const bucket = [...(grouped.get(sev) ?? [])].sort((a, b) => a.ruleId.localeCompare(b.ruleId)); + for (const f of bucket) { if (f.fix?.runnableCommand && !seenRules.has(f.ruleId)) { seenRules.add(f.ruleId); const cmd = projectInfix - ? f.fix.runnableCommand.replace( - "cve-lite overrides", - `cve-lite${projectInfix} overrides` - ) + ? f.fix.runnableCommand.replace("cve-lite overrides", `cve-lite${projectInfix} overrides`) : f.fix.runnableCommand; - lines.push(chalk.bold.cyan(`> ${cmd}`)); + fixableRules.push({ ruleId: f.ruleId, cmd }); } } + } - lines.push(""); + if (fixableRules.length >= 2) { + const fixAllCmd = `cve-lite${projectInfix} overrides --fix`; + lines.push(chalk.bold.cyan(`> ${fixAllCmd}`)); + } + for (const { cmd } of fixableRules) { + lines.push(chalk.bold.cyan(`> ${cmd}`)); } + if (fixableRules.length > 0) lines.push(""); const worstSev = SEVERITY_ORDER.find(s => (grouped.get(s) ?? []).length > 0); const footerColor = worstSev === "critical" || worstSev === "high" ? chalk.redBright : chalk.yellow; From 32c182672000cc296cc4cf2ae6ecea3feb608fc8 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 09:57:00 -0500 Subject: [PATCH 13/17] feat: restore per-box fix hints and add fix-all tip when 2+ rules are fixable Per-rule `--fix --rule XYZ` commands now appear below each severity box, matching the pattern verbose mode uses for main scan fix commands. When 2+ rules have auto-fix support, a tip line is added after the last box: Tip: run `cve-lite overrides --fix` to apply all auto-fixable fixes in one shot. The tip appears in both the standalone overrides command and verbose mode with --check-overrides, since both share the same renderer. Single-rule scans continue to show only the per-rule command with no tip. --- src/output/override-findings-terminal.ts | 38 ++++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/output/override-findings-terminal.ts b/src/output/override-findings-terminal.ts index d6f8146a..eccecf42 100644 --- a/src/output/override-findings-terminal.ts +++ b/src/output/override-findings-terminal.ts @@ -71,37 +71,37 @@ export function renderOverrideFindings( } lines.push(hr("└", "┴", "┘")); - lines.push(""); - } - - const relPath = options?.projectPath - ? relative(process.cwd(), options.projectPath) - : ""; - const projectInfix = relPath && relPath !== "." ? ` ${relPath}` : ""; - const seenRules = new Set(); - const fixableRules: { ruleId: string; cmd: string }[] = []; - for (const sev of SEVERITY_ORDER) { - const bucket = [...(grouped.get(sev) ?? [])].sort((a, b) => a.ruleId.localeCompare(b.ruleId)); - for (const f of bucket) { + const relPath = options?.projectPath + ? relative(process.cwd(), options.projectPath) + : ""; + const projectInfix = relPath && relPath !== "." ? ` ${relPath}` : ""; + const seenRules = new Set(); + for (const f of sorted) { if (f.fix?.runnableCommand && !seenRules.has(f.ruleId)) { seenRules.add(f.ruleId); const cmd = projectInfix ? f.fix.runnableCommand.replace("cve-lite overrides", `cve-lite${projectInfix} overrides`) : f.fix.runnableCommand; - fixableRules.push({ ruleId: f.ruleId, cmd }); + lines.push(chalk.bold.cyan(`> ${cmd}`)); } } + + lines.push(""); } - if (fixableRules.length >= 2) { + const relPath = options?.projectPath + ? relative(process.cwd(), options.projectPath) + : ""; + const projectInfix = relPath && relPath !== "." ? ` ${relPath}` : ""; + const allFixableRules = new Set( + findings.filter(f => f.fix?.runnableCommand).map(f => f.ruleId), + ); + if (allFixableRules.size >= 2) { const fixAllCmd = `cve-lite${projectInfix} overrides --fix`; - lines.push(chalk.bold.cyan(`> ${fixAllCmd}`)); - } - for (const { cmd } of fixableRules) { - lines.push(chalk.bold.cyan(`> ${cmd}`)); + lines.push("💡 " + chalk.gray(`Tip: run \`${fixAllCmd}\` to apply all auto-fixable fixes in one shot.`)); + lines.push(""); } - if (fixableRules.length > 0) lines.push(""); const worstSev = SEVERITY_ORDER.find(s => (grouped.get(s) ?? []).length > 0); const footerColor = worstSev === "critical" || worstSev === "high" ? chalk.redBright : chalk.yellow; From dae28250e62c5719cb5e6dd87967bca88a3ea88f Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 10:03:00 -0500 Subject: [PATCH 14/17] fix: remove backticks from overrides fix-all tip line --- src/output/override-findings-terminal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/output/override-findings-terminal.ts b/src/output/override-findings-terminal.ts index eccecf42..14542389 100644 --- a/src/output/override-findings-terminal.ts +++ b/src/output/override-findings-terminal.ts @@ -99,7 +99,7 @@ export function renderOverrideFindings( ); if (allFixableRules.size >= 2) { const fixAllCmd = `cve-lite${projectInfix} overrides --fix`; - lines.push("💡 " + chalk.gray(`Tip: run \`${fixAllCmd}\` to apply all auto-fixable fixes in one shot.`)); + lines.push("💡 " + chalk.gray(`Tip: run ${fixAllCmd} to apply all auto-fixable fixes in one shot.`)); lines.push(""); } From 2fcbbea89d977387a84ba3025eee06b49e3871ed Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 10:07:00 -0500 Subject: [PATCH 15/17] fix: remove double blank line before Override Hygiene section in standalone overrides renderOverrideFindings was prepending its own leading blank line, which collided with the blank line printBanner already emits. Moved ownership of the leading blank to callers: removed lines.push("") from the renderer and added "\n" in the two inline --check-overrides call sites in index.ts. The standalone overrides command now shows a single blank line, and verbose mode retains the gap before the Override Hygiene section. --- src/index.ts | 4 ++-- src/output/override-findings-terminal.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6eb58b9e..dd577bf6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -651,7 +651,7 @@ if (parsedArgs) { } } if (options.checkOverrides && overrideFindings.length > 0) { - console.log(renderOverrideFindings(overrideFindings, { verbose: true, projectPath })); + console.log("\n" + renderOverrideFindings(overrideFindings, { verbose: true, projectPath })); } printCoverage([...scanInput.notes, ...scanState.coverage]); printFinalStatus(scanState.sorted, overrideCount); @@ -662,7 +662,7 @@ if (parsedArgs) { : undefined; printCompactOutput(scanState.sorted, scanInput, { offline, all: !!options.all, packageManager: compactPmLabel }); if (options.checkOverrides) { - console.log(renderOverrideFindings(overrideFindings, { verbose: false, projectPath })); + console.log("\n" + renderOverrideFindings(overrideFindings, { verbose: false, projectPath })); } if (showOverrideHint) printOverrideHint(); } diff --git a/src/output/override-findings-terminal.ts b/src/output/override-findings-terminal.ts index 14542389..f1afc18c 100644 --- a/src/output/override-findings-terminal.ts +++ b/src/output/override-findings-terminal.ts @@ -14,7 +14,6 @@ export function renderOverrideFindings( options?: { verbose?: boolean; projectPath?: string }, ): string { const lines: string[] = []; - lines.push(""); if (options?.verbose) { lines.push(chalk.bold.cyan("🔍 Override Hygiene")); } else { From 13f516063c22cc1a55277b63f0dc7ec76d5d83ee Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 10:15:00 -0500 Subject: [PATCH 16/17] docs: add PD001/PD002 to README and clarify overrides vs --check-overrides distinction README: updates nine to eleven rules, adds PD001/PD002 rows to the table, and adds an explicit paragraph explaining that the overrides subcommand is hygiene-only (no CVE scan, faster, offline-capable) while --check-overrides runs both in one pass. Also fixes the inline comment in the quick-start examples. override-hygiene/index.md: rewrites the --check-overrides/subcommand note to explicitly state that --check-overrides runs the full CVE scan alongside hygiene, and that the overrides subcommand skips the CVE scan entirely. --- README.md | 13 +++++++++---- website/docs/override-hygiene/index.md | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0678049a..eeb77008 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ cve-lite /path/to/project --fail-on high # exit non-zero on high severity and cve-lite /path/to/project --json # JSON output cve-lite /path/to/project --sarif # SARIF output for GitHub Code Scanning cve-lite /path/to/project --report # interactive HTML dashboard -cve-lite /path/to/project --check-overrides # audit override hygiene (OA001-OA009) +cve-lite /path/to/project --check-overrides # audit override hygiene alongside the CVE scan cve-lite advisories sync # sync advisory DB for offline use cve-lite /path/to/project --offline # scan with no runtime API calls ``` @@ -105,7 +105,7 @@ For the full flag reference and exit codes see the [CLI Reference guide](https:/ ## Override hygiene (`overrides`) -`overrides` and `resolutions` are powerful, but they rot. `--check-overrides` audits them across npm, pnpm, yarn, and bun, catching nine classes of problem: +`overrides` and `resolutions` are powerful, but they rot. CVE Lite CLI audits them across npm, pnpm, yarn, and bun, catching eleven classes of problem: | Rule | What it catches | |---|---| @@ -118,10 +118,15 @@ For the full flag reference and exit codes see the [CLI Reference guide](https:/ | `OA007` | Frozen latest: registry has drifted past the pinned version (needs `--check-network`) | | `OA008` | Materialized vulnerable copy still on disk despite the override | | `OA009` | Stale floor: override range floor already met by all parent declarations | +| `PD001` | Override-only phantom: package imported in source but only present via an override pin | +| `PD002` | Transitive-only phantom: package imported in source but only present as a transitive dependency | + +Use the `overrides` subcommand for a hygiene-only run (no CVE scan, faster, fully offline-capable) with auto-fix support. Use `--check-overrides` to append hygiene results to a regular CVE scan in one pass. ```bash -cve-lite /path/to/project --check-overrides # audit -cve-lite /path/to/project overrides --fix # audit and auto-fix where possible +cve-lite /path/to/project overrides # hygiene only - no CVE scan +cve-lite /path/to/project overrides --fix # hygiene only, auto-fix where possible +cve-lite /path/to/project --check-overrides # CVE scan + hygiene in one pass ``` For the full guide with per-rule examples and CI patterns, see the [Override Hygiene Auditing guide](https://owasp.org/cve-lite-cli/docs/override-hygiene). diff --git a/website/docs/override-hygiene/index.md b/website/docs/override-hygiene/index.md index 305a659e..3fc76111 100644 --- a/website/docs/override-hygiene/index.md +++ b/website/docs/override-hygiene/index.md @@ -33,14 +33,19 @@ cve-lite . overrides --fix cve-lite . overrides --fix --rule OA001 ``` -For a quick read-only audit alongside the main CVE scan, you can also use the `--check-overrides` flag: +Use `--check-overrides` to run the full CVE scan and override hygiene together in one pass. It does not support `--fix`, `--rule`, or `--check-network` - use the `overrides` subcommand for those. ```bash -cve-lite . --check-overrides -cve-lite . --check-overrides --json +cve-lite . --check-overrides # CVE scan + hygiene in one pass +cve-lite . --check-overrides --json # combined JSON output ``` -Note that `--fix`, `--rule`, and `--check-network` require the `overrides` subcommand - they are not available via `--check-overrides`. +The `overrides` subcommand skips the CVE scan entirely - hygiene only, faster, and fully offline-capable: + +```bash +cve-lite . overrides # hygiene only +cve-lite . overrides --fix # hygiene only, auto-fix where possible +``` --- From b199d58c9677d348e95bd2db7ab6f57b0a6a24b2 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Thu, 9 Jul 2026 10:16:00 -0500 Subject: [PATCH 17/17] docs: add PD001 cross-reference to OA009 complementary rules --- website/docs/override-hygiene/oa009.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/override-hygiene/oa009.md b/website/docs/override-hygiene/oa009.md index f236f9b9..cbb8dba9 100644 --- a/website/docs/override-hygiene/oa009.md +++ b/website/docs/override-hygiene/oa009.md @@ -81,3 +81,4 @@ cve-lite . overrides --fix --rule OA009 - **[OA004](./oa004.md)** flags surpassed *concrete* pins (`"4.2.0"` when the installed version is already higher). OA009 is the range-floor equivalent. - **[OA001](./oa001.md)** flags orphaned override targets where no parent declares the package at all. OA009 assumes the opposite: every parent declares the target, and each parent's minimum already meets the floor. +- **[PD001](./pd001.md)** - OA009 will not fire on an override entry that anchors a phantom import. If source code imports the package and it is not declared as a dependency, PD001 fires instead and the OA009 removal suggestion is suppressed - removing the override would break the import.