Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
07dc9f9
feat: add scanAllImports() to usage/scanner for PD phantom detection
sonukapoor Jul 9, 2026
7943743
feat: add importedPackageNames to OverrideContext and populate in con…
sonukapoor Jul 9, 2026
797c607
feat: implement PD001 override-only phantom detector
sonukapoor Jul 9, 2026
4e864d6
feat: implement PD002 transitive-only phantom detector
sonukapoor Jul 9, 2026
1de0a6d
feat: guard OA009 against removing phantom-anchoring overrides
sonukapoor Jul 9, 2026
ab8a19c
feat: register PD001 and PD002 in SARIF output
sonukapoor Jul 9, 2026
1030ced
docs: add PD001 and PD002 rule pages and update index, sidebar, and h…
sonukapoor Jul 9, 2026
971d4bf
test: add PD001 and PD002 e2e fixtures and detector tests
sonukapoor Jul 9, 2026
388b1fe
fix: add export to scanAllImports fast-path and extract shared PD hel…
sonukapoor Jul 9, 2026
ef21b36
fix: write overrides --json to file instead of stdout, consistent wit…
sonukapoor Jul 9, 2026
3f4a9dd
feat: add all-scenarios example fixture covering all detectable overr…
sonukapoor Jul 9, 2026
98919ec
feat: show fix-all command before per-rule hints when 2+ rules are fi…
sonukapoor Jul 9, 2026
32c1826
feat: restore per-box fix hints and add fix-all tip when 2+ rules are…
sonukapoor Jul 9, 2026
dae2825
fix: remove backticks from overrides fix-all tip line
sonukapoor Jul 9, 2026
2fcbbea
fix: remove double blank line before Override Hygiene section in stan…
sonukapoor Jul 9, 2026
13f5160
docs: add PD001/PD002 to README and clarify overrides vs --check-over…
sonukapoor Jul 9, 2026
b199d58
docs: add PD001 cross-reference to OA009 complementary rules
sonukapoor Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ cve-lite-fix-result.json

!tests/fixtures/**/node_modules
!tests/fixtures/**/node_modules/**
!examples/all-scenarios/node_modules/**
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 |
|---|---|
Expand All @@ -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).
Expand Down

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

1 change: 1 addition & 0 deletions examples/all-scenarios/node_modules/old-pin/package.json

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

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

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

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

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

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

53 changes: 53 additions & 0 deletions examples/all-scenarios/package-lock.json

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

22 changes: 22 additions & 0 deletions examples/all-scenarios/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
4 changes: 4 additions & 0 deletions examples/all-scenarios/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import phantomImport from 'phantom-import';
import transitiveOnly from 'transitive-only';

export { phantomImport, transitiveOnly };
27 changes: 27 additions & 0 deletions examples/pd001-override-phantom/package-lock.json

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

11 changes: 11 additions & 0 deletions examples/pd001-override-phantom/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions examples/pd001-override-phantom/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import yaml from 'js-yaml';

export function parse(input: string): unknown {
return yaml.load(input);
}
24 changes: 24 additions & 0 deletions examples/pd002-transitive-phantom/package-lock.json

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

8 changes: 8 additions & 0 deletions examples/pd002-transitive-phantom/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions examples/pd002-transitive-phantom/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import semver from 'semver';

export function isValid(version: string): boolean {
return semver.valid(version) !== null;
}
6 changes: 3 additions & 3 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <severity> Exit non-zero at or above severity (default: critical)",
" --batch-size <number> OSV batch size (default: 100)",
Expand Down Expand Up @@ -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 <id> Only run a specific rule (OA001-OA009)",
" --rule <id> Only run a specific rule (OA001-OA009, PD001-PD002)",
" --check-network Enable OA007 registry drift check (opt-in network)",
" --audit-log <path> Stream NDJSON change-control to <path>",
" --fail-on <severity> Exit non-zero at or above this severity (default: critical)",
Expand Down
14 changes: 11 additions & 3 deletions src/cli/overrides.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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}.`
Expand All @@ -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");
}
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
}
Expand Down
2 changes: 2 additions & 0 deletions src/output/override-findings-sarif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, "error" | "warning" | "note"> = {
Expand Down
19 changes: 14 additions & 5 deletions src/output/override-findings-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -81,10 +80,7 @@ export function renderOverrideFindings(
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}`));
}
Expand All @@ -93,6 +89,19 @@ export function renderOverrideFindings(
lines.push("");
}

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.gray(`Tip: run ${fixAllCmd} to apply all auto-fixable fixes in one shot.`));
lines.push("");
}

const worstSev = SEVERITY_ORDER.find(s => (grouped.get(s) ?? []).length > 0);
const footerColor = worstSev === "critical" || worstSev === "high" ? chalk.redBright : chalk.yellow;
lines.push(footerColor(`${findings.length} override hygiene ${pluralize(findings.length, "issue")} found.`));
Expand Down
4 changes: 4 additions & 0 deletions src/overrides/context-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> }
Expand Down Expand Up @@ -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,
Expand All @@ -93,6 +96,7 @@ export function buildOverrideContext(
parentDeclarations: tree.parentDeclarations,
registryDistTags,
skippedDetectors: skipped,
importedPackageNames,
auditLog: opts.auditLog,
logger: opts.logger,
};
Expand Down
3 changes: 3 additions & 0 deletions src/overrides/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export interface OverrideContext {
registryDistTags: Map<string, RegistryDistTags>;
/** 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<string, string[]>;
auditLog: AuditLogHandle;
logger: Logger;
}
Loading