Skip to content

Commit de0f760

Browse files
authored
feat: ratcheting mode for CI adoption on codebases with existing vulnerabilities (#598)
* feat: add baseline read/write utilities for ratcheting * feat: add filterNewFindings to baseline utilities * feat: add --ratchet flag to CLI * feat: integrate baseline loading and filtering into scan pipeline * docs: note ratcheting usage in examples readme * fix: move baseline filtering before output so suppressed findings are not rendered * fix: suppress scan progress output when --ratchet is used, keep banner * fix: add checkmark to baseline saved confirmation message
1 parent c9047be commit de0f760

8 files changed

Lines changed: 263 additions & 9 deletions

File tree

examples/readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Small curated projects committed to the repository. Clone the repo and scan imme
2828
| `pnpm-workspace` | pnpm (workspace) | pnpm workspace monorepo with workspace-scoped fix commands. |
2929
| `wrong-parent` | npm | 3-level transitive chain where the immediate parent's range already covers the fix — expects `npm update js-cookie`, not a parent bump. |
3030
| `no-findings` | npm | Clean project with no known vulnerabilities — demonstrates success output. |
31+
| `any fixture` + `.cve-lite/baseline.json` | any | Run `cve-lite . --ratchet` on any fixture to establish a baseline. Rescan without the flag to see only new findings. `.cve-lite/` directories should NOT be committed from example fixtures. |
3132
| `mal-private-registry` | npm | `node-ipc@9.2.3` with `resolved` pointing to a private registry — demonstrates `Unverifiable (private source)` output for MAL- advisories where the artifact origin cannot be confirmed. |
3233
| `lima-site` | npm | Dev-dependency scanning in a documentation site. |
3334

src/cli/args.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ export function parseArgs(argv: string[]): {
121121
options.fix = true;
122122
continue;
123123
}
124+
if (arg === "--ratchet") {
125+
options.ratchet = true;
126+
continue;
127+
}
124128
if (arg === "--create-pr") {
125129
options.createPr = true;
126130
continue;

src/cli/help.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function printHelp(): void {
3939
" --cdx Write CycloneDX 1.4 SBOM to a timestamped .cdx.json file",
4040
" --no-open Don't auto-open the report in the browser",
4141
" --fix Apply validated direct dependency fixes and rescan",
42+
" --ratchet Save current findings as baseline or, if baseline exists, only fail on new findings",
4243
" --create-pr After --fix, commit changes and open a GitHub pull request (requires gh)",
4344
" --base <branch> Base branch for --create-pr (default: main)",
4445
" --osv-url <url> Use a custom OSV-compatible advisory endpoint",

src/index.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
createPullRequestForFixes,
6868
findingsMeetFailOnThreshold,
6969
} from "./utils/create-pr.js";
70+
import { readBaseline, writeBaseline, filterNewFindings } from "./utils/baseline.js";
7071
let parsedArgs: ReturnType<typeof parseArgs> | null = null;
7172
try {
7273
parsedArgs = parseArgs(process.argv.slice(2));
@@ -219,7 +220,7 @@ if (parsedArgs) {
219220
throw error;
220221
}
221222

222-
if (!options.json) {
223+
if (!options.json && !options.ratchet) {
223224
if (options.offline || options.offlineDb) {
224225
console.log(chalk.gray("Offline mode:") + " " + chalk.yellow("enabled") + " " + chalk.gray("(no external advisory calls will be made)"));
225226
}
@@ -247,13 +248,15 @@ if (parsedArgs) {
247248
source: scanInput.source,
248249
});
249250

250-
logInfo(
251-
`Parsed ${packages.length} ${pluralize(packages.length, "package")} from ${scanInput.source}${
252-
scanInput.filePath ? ` (${path.relative(projectPath, scanInput.filePath) || path.basename(scanInput.filePath)})` : ""
253-
}`,
254-
options
255-
);
256-
printCacheSummary(options.cacheDir, options);
251+
if (!options.ratchet) {
252+
logInfo(
253+
`Parsed ${packages.length} ${pluralize(packages.length, "package")} from ${scanInput.source}${
254+
scanInput.filePath ? ` (${path.relative(projectPath, scanInput.filePath) || path.basename(scanInput.filePath)})` : ""
255+
}`,
256+
options
257+
);
258+
printCacheSummary(options.cacheDir, options);
259+
}
257260

258261
if (scanInput.warnings.length > 0) {
259262
for (const warning of scanInput.warnings) {
@@ -268,7 +271,7 @@ if (parsedArgs) {
268271
return;
269272
}
270273

271-
if (!options.json) console.log();
274+
if (!options.json && !options.ratchet) console.log();
272275
let scanState = await scanProject({
273276
scanInput,
274277
batchSize,
@@ -279,6 +282,8 @@ if (parsedArgs) {
279282
const findingsBeforeFixList = scanState.sorted;
280283
const findingsBeforeFix = findingsBeforeFixList.length;
281284
let fixResult: FixExecutionResult | null = null;
285+
let baseline = readBaseline(projectArg ?? ".");
286+
let suppressedCount = 0;
282287

283288
if (options.fix) {
284289
fixResult = await applyFixesIfRequested({
@@ -342,6 +347,25 @@ if (parsedArgs) {
342347
}
343348
}
344349
} else {
350+
// --ratchet: save baseline and exit 0
351+
if (options.ratchet) {
352+
writeBaseline(projectArg ?? ".", scanState.sorted);
353+
const count = scanState.sorted.length;
354+
console.log(chalk.green(`✓ Baseline saved to .cve-lite/baseline.json with ${count} ${count === 1 ? "finding" : "findings"}. Future scans will only report findings above this baseline.`));
355+
process.exit(0);
356+
return;
357+
}
358+
359+
// auto-apply baseline if it exists - filter before output
360+
if (baseline) {
361+
const filtered = filterNewFindings(scanState.sorted, baseline);
362+
scanState.sorted = filtered.newFindings;
363+
scanState.tableFindings = scanState.tableFindings.filter(f =>
364+
filtered.newFindings.some(nf => nf.pkg.name === f.pkg.name && nf.pkg.version === f.pkg.version)
365+
);
366+
suppressedCount = filtered.suppressedCount;
367+
}
368+
345369
await writeOutputs(options, {
346370
sorted: scanState.sorted,
347371
allPackages: scanState.allPackages,
@@ -408,6 +432,14 @@ if (parsedArgs) {
408432
packages: packages.length,
409433
});
410434

435+
if (baseline) {
436+
if (scanState.sorted.length === 0) {
437+
console.log(chalk.green(`No new findings above baseline - ${suppressedCount} existing ${suppressedCount === 1 ? "finding" : "findings"} suppressed`));
438+
} else {
439+
console.log(chalk.yellow(`${scanState.sorted.length} new ${scanState.sorted.length === 1 ? "finding" : "findings"} above baseline - ${suppressedCount} existing ${suppressedCount === 1 ? "finding" : "findings"} suppressed`));
440+
}
441+
}
442+
411443
const failLevel = normalizeSeverity(options.failOn);
412444
const shouldFail = scanState.sorted.some(f => severityOrder[f.severity] >= severityOrder[failLevel]);
413445
process.exit(shouldFail ? 1 : 0);

src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ export type Spinner = {
155155
stop: () => void;
156156
};
157157

158+
export type BaselineEntry = {
159+
name: string;
160+
version: string;
161+
advisoryIds: string[];
162+
};
163+
164+
export type Baseline = {
165+
version: 1;
166+
createdAt: string;
167+
findings: BaselineEntry[];
168+
};
169+
158170
export type CliCommand = "scan" | "advisories-sync" | "install-skill" | "config";
159171

160172
export type ParsedOptions = {
@@ -163,6 +175,7 @@ export type ParsedOptions = {
163175
debug?: boolean;
164176
verbose?: boolean;
165177
fix?: boolean;
178+
ratchet?: boolean;
166179
createPr?: boolean;
167180
prBase?: string;
168181
prodOnly?: boolean;

src/utils/baseline.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import type { Baseline, Finding } from "../types.js";
4+
5+
const BASELINE_DIR = ".cve-lite";
6+
const BASELINE_FILE = "baseline.json";
7+
8+
function baselinePath(projectRoot: string): string {
9+
return path.join(projectRoot, BASELINE_DIR, BASELINE_FILE);
10+
}
11+
12+
export function readBaseline(projectRoot: string): Baseline | null {
13+
const filePath = baselinePath(projectRoot);
14+
if (!fs.existsSync(filePath)) return null;
15+
try {
16+
return JSON.parse(fs.readFileSync(filePath, "utf8")) as Baseline;
17+
} catch {
18+
return null;
19+
}
20+
}
21+
22+
export function writeBaseline(projectRoot: string, findings: Finding[]): void {
23+
const dir = path.join(projectRoot, BASELINE_DIR);
24+
if (!fs.existsSync(dir)) {
25+
fs.mkdirSync(dir, { recursive: true });
26+
}
27+
const baseline: Baseline = {
28+
version: 1,
29+
createdAt: new Date().toISOString(),
30+
findings: findings.map(f => ({
31+
name: f.pkg.name,
32+
version: f.pkg.version,
33+
advisoryIds: f.vulnerabilities.map(v => v.id),
34+
})),
35+
};
36+
fs.writeFileSync(baselinePath(projectRoot), JSON.stringify(baseline, null, 2) + "\n", "utf8");
37+
}
38+
39+
export function filterNewFindings(
40+
findings: Finding[],
41+
baseline: Baseline,
42+
): { newFindings: Finding[]; suppressedCount: number } {
43+
const baselineMap = new Map<string, Set<string>>();
44+
for (const entry of baseline.findings) {
45+
baselineMap.set(`${entry.name}@${entry.version}`, new Set(entry.advisoryIds));
46+
}
47+
48+
const newFindings: Finding[] = [];
49+
let suppressedCount = 0;
50+
51+
for (const finding of findings) {
52+
const key = `${finding.pkg.name}@${finding.pkg.version}`;
53+
const baselineAdvisories = baselineMap.get(key);
54+
if (!baselineAdvisories) {
55+
newFindings.push(finding);
56+
continue;
57+
}
58+
const allKnown = finding.vulnerabilities.every(v => baselineAdvisories.has(v.id));
59+
if (allKnown) {
60+
suppressedCount++;
61+
} else {
62+
newFindings.push(finding);
63+
}
64+
}
65+
66+
return { newFindings, suppressedCount };
67+
}

tests/baseline.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { readBaseline, writeBaseline, filterNewFindings } from "../src/utils/baseline.js";
5+
import type { Finding } from "../src/types.js";
6+
7+
function makeTempDir(): string {
8+
return fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-baseline-test-"));
9+
}
10+
11+
function removeDir(dir: string): void {
12+
fs.rmSync(dir, { recursive: true, force: true });
13+
}
14+
15+
function makeFinding(name: string, version: string, advisoryIds: string[]): Finding {
16+
return {
17+
pkg: { name, version, ecosystem: "npm" },
18+
vulnerabilities: advisoryIds.map(id => ({ id, aliases: [], summary: "" })),
19+
severity: "high",
20+
cveAliases: [],
21+
dependencyPaths: [["project", name]],
22+
relationship: "direct",
23+
firstFixedVersion: null,
24+
};
25+
}
26+
27+
describe("baseline utilities", () => {
28+
it("readBaseline returns null when file does not exist", () => {
29+
const dir = makeTempDir();
30+
try {
31+
expect(readBaseline(dir)).toBeNull();
32+
} finally {
33+
removeDir(dir);
34+
}
35+
});
36+
37+
it("writeBaseline creates .cve-lite directory and baseline.json", () => {
38+
const dir = makeTempDir();
39+
try {
40+
const findings = [makeFinding("lodash", "4.17.20", ["GHSA-xxx"])];
41+
writeBaseline(dir, findings);
42+
expect(fs.existsSync(path.join(dir, ".cve-lite", "baseline.json"))).toBe(true);
43+
} finally {
44+
removeDir(dir);
45+
}
46+
});
47+
48+
it("readBaseline returns written baseline", () => {
49+
const dir = makeTempDir();
50+
try {
51+
const findings = [makeFinding("lodash", "4.17.20", ["GHSA-xxx"])];
52+
writeBaseline(dir, findings);
53+
const baseline = readBaseline(dir);
54+
expect(baseline).not.toBeNull();
55+
expect(baseline!.version).toBe(1);
56+
expect(baseline!.findings).toHaveLength(1);
57+
expect(baseline!.findings[0]).toEqual({ name: "lodash", version: "4.17.20", advisoryIds: ["GHSA-xxx"] });
58+
} finally {
59+
removeDir(dir);
60+
}
61+
});
62+
63+
it("writeBaseline overwrites existing baseline", () => {
64+
const dir = makeTempDir();
65+
try {
66+
writeBaseline(dir, [makeFinding("lodash", "4.17.20", ["GHSA-xxx"])]);
67+
writeBaseline(dir, [makeFinding("axios", "0.21.1", ["GHSA-yyy"])]);
68+
const baseline = readBaseline(dir);
69+
expect(baseline!.findings).toHaveLength(1);
70+
expect(baseline!.findings[0].name).toBe("axios");
71+
} finally {
72+
removeDir(dir);
73+
}
74+
});
75+
76+
it("filterNewFindings suppresses findings present in baseline", () => {
77+
const dir = makeTempDir();
78+
try {
79+
const findings = [makeFinding("lodash", "4.17.20", ["GHSA-xxx"])];
80+
writeBaseline(dir, findings);
81+
const baseline = readBaseline(dir)!;
82+
const { newFindings, suppressedCount } = filterNewFindings(findings, baseline);
83+
expect(newFindings).toHaveLength(0);
84+
expect(suppressedCount).toBe(1);
85+
} finally {
86+
removeDir(dir);
87+
}
88+
});
89+
90+
it("filterNewFindings surfaces findings for packages not in baseline", () => {
91+
const dir = makeTempDir();
92+
try {
93+
writeBaseline(dir, [makeFinding("lodash", "4.17.20", ["GHSA-xxx"])]);
94+
const baseline = readBaseline(dir)!;
95+
const newPkg = makeFinding("axios", "0.21.1", ["GHSA-yyy"]);
96+
const { newFindings, suppressedCount } = filterNewFindings([newPkg], baseline);
97+
expect(newFindings).toHaveLength(1);
98+
expect(suppressedCount).toBe(0);
99+
} finally {
100+
removeDir(dir);
101+
}
102+
});
103+
104+
it("filterNewFindings surfaces findings with new advisory IDs not in baseline", () => {
105+
const dir = makeTempDir();
106+
try {
107+
writeBaseline(dir, [makeFinding("lodash", "4.17.20", ["GHSA-xxx"])]);
108+
const baseline = readBaseline(dir)!;
109+
const findingWithNewAdvisory = makeFinding("lodash", "4.17.20", ["GHSA-xxx", "GHSA-new"]);
110+
const { newFindings, suppressedCount } = filterNewFindings([findingWithNewAdvisory], baseline);
111+
expect(newFindings).toHaveLength(1);
112+
expect(suppressedCount).toBe(0);
113+
} finally {
114+
removeDir(dir);
115+
}
116+
});
117+
118+
it("filterNewFindings suppresses when all advisory IDs are in baseline", () => {
119+
const dir = makeTempDir();
120+
try {
121+
writeBaseline(dir, [makeFinding("lodash", "4.17.20", ["GHSA-xxx", "GHSA-yyy"])]);
122+
const baseline = readBaseline(dir)!;
123+
const finding = makeFinding("lodash", "4.17.20", ["GHSA-xxx", "GHSA-yyy"]);
124+
const { newFindings, suppressedCount } = filterNewFindings([finding], baseline);
125+
expect(newFindings).toHaveLength(0);
126+
expect(suppressedCount).toBe(1);
127+
} finally {
128+
removeDir(dir);
129+
}
130+
});
131+
});

tests/helpers.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ describe("parseArgs", () => {
167167
expect(result.options.json).toBe(true);
168168
expect(result.options.sarif).toBe(true);
169169
});
170+
171+
it("parses --ratchet flag", () => {
172+
const { options } = parseArgs([".", "--ratchet"]);
173+
expect(options.ratchet).toBe(true);
174+
});
170175
});
171176

172177
describe("severity helpers", () => {

0 commit comments

Comments
 (0)