From 899aa5a94d10c19696e48560f2a230a638d0ab1a Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:51:55 +0700 Subject: [PATCH 1/3] fix(ratchet): gate like --fail-on when no baseline exists --- src/scan/single-scan.ts | 17 +++++++++-- tests/cli-integration.test.ts | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/scan/single-scan.ts b/src/scan/single-scan.ts index 4e4f3e79..0379d815 100644 --- a/src/scan/single-scan.ts +++ b/src/scan/single-scan.ts @@ -452,15 +452,28 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr if (options.checkOverrides) { console.log(chalk.gray(`Note: override hygiene (--check-overrides) is not part of the ratchet baseline; run \`${OVERRIDES_COMMAND}\` to audit overrides.`)); } + // #1293: with no baseline, --ratchet must not be a silent permanent pass. + // A CI job checks out fresh every run, so a missing baseline is the normal + // case there. Apply --fail-on exactly as if --ratchet were absent, so a + // first run on a tree with existing debt fails until the baseline is + // committed. The baseline is still written, and the line below names + // committing it as the way to accept the existing debt. + const gateLine = failingGateSummary( + [{ label: "vulnerability", findings: scanState.sorted }], + options.failOn, + ); + if (gateLine && !options.json && !options.fix) console.log(chalk.red(gateLine)); + console.log(chalk.gray("Commit .cve-lite/baseline.json to accept the existing debt; until then --ratchet gates like --fail-on.")); + const exitCode = gateLine && !options.fix ? 1 : 0; auditLog.emit({ ts: new Date().toISOString(), type: "scan.finished", schemaVersion: 1, durationMs: Date.now() - scanStartTime, findingsCount: scanState.sorted.length, - exitCode: 0, + exitCode, }); - return 0; + return exitCode; } const { newFindings, suppressedCount: sc } = outcome; diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index 03166f4e..40f4a9cb 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -471,6 +471,61 @@ describe("CLI integration", () => { } }); + it("--ratchet with no baseline and a finding at or above --fail-on exits non-zero and writes the baseline", async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-ratchet-gate-")); + parseArgsMock.mockReturnValue({ + command: "scan", + options: { ratchet: true, failOn: "high", batchSize: "100", searchDepth: "4", minSeverity: "medium" }, + projectArg: projectDir, + }); + loadPackagesMock.mockReturnValue(createScanInput({ + packages: [{ name: "lodash", version: "4.17.20", ecosystem: "npm" }], + })); + scanPackagesMock.mockResolvedValue(createScanResult([ + createFinding({ severity: "high" }), + ])); + + try { + const result = await runIndexModule(); + const output = result.stdout.join("\n"); + + expect(result.exitCode).toBe(1); + expect(fs.existsSync(path.join(projectDir, ".cve-lite", "baseline.json"))).toBe(true); + expect(output).toContain("Baseline saved to .cve-lite/baseline.json"); + expect(output).toContain("Failing: 1 vulnerability finding at or above high"); + expect(output).toContain("Commit .cve-lite/baseline.json to accept the existing debt"); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it("--ratchet with no baseline and a clean tree exits 0 and writes the baseline", async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-ratchet-clean-")); + parseArgsMock.mockReturnValue({ + command: "scan", + options: { ratchet: true, failOn: "high", batchSize: "100", searchDepth: "4", minSeverity: "medium" }, + projectArg: projectDir, + }); + loadPackagesMock.mockReturnValue(createScanInput({ + packages: [{ name: "lodash", version: "4.17.20", ecosystem: "npm" }], + })); + scanPackagesMock.mockResolvedValue(createScanResult([ + createFinding({ severity: "low" }), + ])); + + try { + const result = await runIndexModule(); + const output = result.stdout.join("\n"); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(path.join(projectDir, ".cve-lite", "baseline.json"))).toBe(true); + expect(output).toContain("Baseline saved to .cve-lite/baseline.json"); + expect(output).not.toContain("Failing:"); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("suppresses compact output when --cdx is used", async () => { parseArgsMock.mockReturnValue({ command: "scan", From 24ea7aaf5b741e4a6dc15bf2211e5ddd9cd623c4 Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:15:50 +0700 Subject: [PATCH 2/3] fix(ratchet): gate first-run multi-folder scans and guard output --- src/constants.ts | 2 + src/scan/multi-folder-scan.ts | 22 +++++- src/scan/single-scan.ts | 17 ++-- tests/cli-integration.test.ts | 30 ++++++++ tests/e2e/ratchet-first-run.test.ts | 115 ++++++++++++++++++++++++++++ tests/multi-folder-scan.test.ts | 57 ++++++++++++-- 6 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/ratchet-first-run.test.ts diff --git a/src/constants.ts b/src/constants.ts index 177f2663..1a683245 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,6 +9,8 @@ export const severityOrder: Record = { unknown: 1 }; +export const RATCHET_BASELINE_COMMIT_HINT = "to accept the existing debt; until then --ratchet gates like --fail-on."; + export const DEFAULT_BATCH_SIZE = 100; export const DEFAULT_SEARCH_DEPTH = 4; export const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index cce247e3..0f23f330 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -14,7 +14,7 @@ import { normalizeSeverity } from "../osv/severity.js"; import { selectFindingsForTable } from "../output/finding-display.js"; import { buildSuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import { readDirectDependencyNames, hasOverrideEntries } from "../utils/package-json.js"; -import { DEFAULT_BATCH_SIZE, DEFAULT_SEARCH_DEPTH, OVERRIDES_COMMAND } from "../constants.js"; +import { DEFAULT_BATCH_SIZE, DEFAULT_SEARCH_DEPTH, OVERRIDES_COMMAND, RATCHET_BASELINE_COMMIT_HINT } from "../constants.js"; import { printMultiFolderResults } from "../output/multi-folder-printer.js"; import { printOverrideHint } from "../output/printers.js"; import { writeMultiFolderHtmlReport } from "../output/multi-folder-html-reporter.js"; @@ -211,8 +211,8 @@ export async function runMultiFolderScan(params: { /** * Per-subfolder --ratchet: each folder gets its own `.cve-lite/baseline.json`. - * Folders without a baseline are saved; folders with a baseline are gated. - * Exit 1 if any gated folder has findings above its baseline. + * Folders without a baseline are saved and gated by --fail-on. + * Folders with a baseline fail only on findings above that baseline. */ function handleMultiFolderRatchet( results: MultiFolderScanResult[], @@ -220,6 +220,7 @@ function handleMultiFolderRatchet( options: ParsedOptions, ): ExitCode { let anyNewFindings = false; + let anyFirstRunGateFailure = false; let hasDetectionGaps = false; for (const r of results) { @@ -253,6 +254,19 @@ function handleMultiFolderRatchet( `✓ ${r.subfolder}/: Baseline saved to .cve-lite/baseline.json with ${count} ${pluralize(count, "finding")}. Future scans will only report findings above this baseline.`, ), ); + const gateLine = failingGateSummary( + [{ label: "vulnerability", findings: r.sorted }], + options.failOn, + ); + if (gateLine) { + anyFirstRunGateFailure = true; + if (!options.json) { + console.log(chalk.red(`${r.subfolder}/: ${gateLine}`)); + } + } + if (count > 0 && !options.json) { + console.log(chalk.gray(`Commit ${r.subfolder}/.cve-lite/baseline.json ${RATCHET_BASELINE_COMMIT_HINT}`)); + } continue; } @@ -296,7 +310,7 @@ function handleMultiFolderRatchet( return EXIT_FINDINGS; } - return EXIT_OK; + return anyFirstRunGateFailure ? EXIT_FINDINGS : EXIT_OK; } export async function handleMultiFolderScan(params: { diff --git a/src/scan/single-scan.ts b/src/scan/single-scan.ts index 0379d815..579081fd 100644 --- a/src/scan/single-scan.ts +++ b/src/scan/single-scan.ts @@ -3,7 +3,7 @@ import process from "node:process"; import { loadPackages, buildNoPackagesMessage } from "../parsers/index.js"; import { scanPackages, buildCoverageNotes, createAdvisorySource } from "../scanner.js"; import { normalizeSeverity } from "../osv/severity.js"; -import { OVERRIDES_COMMAND } from "../constants.js"; +import { OVERRIDES_COMMAND, RATCHET_BASELINE_COMMIT_HINT } from "../constants.js"; import { chalk } from "../utils/chalk.js"; import type { DebugLogger } from "../output/debug.js"; import { buildSuggestedFixCommandPlan } from "../remediation/fix-commands.js"; @@ -452,18 +452,17 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr if (options.checkOverrides) { console.log(chalk.gray(`Note: override hygiene (--check-overrides) is not part of the ratchet baseline; run \`${OVERRIDES_COMMAND}\` to audit overrides.`)); } - // #1293: with no baseline, --ratchet must not be a silent permanent pass. - // A CI job checks out fresh every run, so a missing baseline is the normal - // case there. Apply --fail-on exactly as if --ratchet were absent, so a - // first run on a tree with existing debt fails until the baseline is - // committed. The baseline is still written, and the line below names - // committing it as the way to accept the existing debt. + // A missing baseline accepts no debt, including on fresh CI checkouts. const gateLine = failingGateSummary( [{ label: "vulnerability", findings: scanState.sorted }], options.failOn, ); - if (gateLine && !options.json && !options.fix) console.log(chalk.red(gateLine)); - console.log(chalk.gray("Commit .cve-lite/baseline.json to accept the existing debt; until then --ratchet gates like --fail-on.")); + if (gateLine && !options.json && !options.fix) { + console.log(chalk.red(gateLine)); + } + if (count > 0 && !options.json) { + console.log(chalk.gray(`Commit .cve-lite/baseline.json ${RATCHET_BASELINE_COMMIT_HINT}`)); + } const exitCode = gateLine && !options.fix ? 1 : 0; auditLog.emit({ ts: new Date().toISOString(), diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index 40f4a9cb..96a0565a 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -526,6 +526,36 @@ describe("CLI integration", () => { } }); + it.each([ + { label: "clean tree", findings: [], json: false, fix: false, exitCode: 0 }, + { label: "JSON gate", findings: [createFinding({ severity: "high" })], json: true, fix: false, exitCode: 1 }, + { label: "fix mode", findings: [createFinding({ severity: "high" })], json: false, fix: true, exitCode: 0 }, + ])("first-run ratchet preserves $label behavior", async ({ findings, json, fix, exitCode }) => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-ratchet-controls-")); + parseArgsMock.mockReturnValue({ + command: "scan", + options: { ratchet: true, failOn: "high", batchSize: "100", searchDepth: "4", minSeverity: "medium", json, fix }, + projectArg: projectDir, + }); + loadPackagesMock.mockReturnValue(createScanInput({ + packages: [{ name: "lodash", version: "4.17.20", ecosystem: "npm" }], + })); + scanPackagesMock.mockResolvedValue(createScanResult(findings)); + + try { + const result = await runIndexModule(); + const output = result.stdout.join("\n"); + expect(result.exitCode).toBe(exitCode); + expect(output).not.toContain("Failing:"); + expect(output).not.toContain("accept the existing debt"); + if (!fix) { + expect(fs.existsSync(path.join(projectDir, ".cve-lite", "baseline.json"))).toBe(true); + } + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("suppresses compact output when --cdx is used", async () => { parseArgsMock.mockReturnValue({ command: "scan", diff --git a/tests/e2e/ratchet-first-run.test.ts b/tests/e2e/ratchet-first-run.test.ts new file mode 100644 index 00000000..381f8926 --- /dev/null +++ b/tests/e2e/ratchet-first-run.test.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { runCli, mkProject, rmProject, npmProject, SEEDED_DB } from "./harness.js"; + +const CI_ENV = { CI: "1" } as const; +const VULNERABLE_PACKAGE = "cve-lite-scenario-vuln-high"; + +function projectFiles(vulnerable: boolean) { + const name = vulnerable ? VULNERABLE_PACKAGE : "cve-lite-scenario-clean"; + return npmProject({ name: "ratchet-test", dependencies: { [name]: "1.0.0" } }, { [name]: "1.0.0" }); +} + +function makeProject(multi: boolean, vulnerable: boolean): string { + const files = projectFiles(vulnerable); + if (!multi) { + return mkProject(files); + } + return mkProject(Object.fromEntries( + ["a", "b"].flatMap(folder => Object.entries(files).map(([name, value]) => [`${folder}/${name}`, value])), + )); +} + +function scan(project: string, flags: string[] = []) { + return runCli([project, "--ratchet", "--fail-on", "high", "--offline-db", SEEDED_DB, ...flags], { + cwd: project, + env: CI_ENV, + }); +} + +describe.each([false, true])("first-run ratchet (multi-folder: %s)", multi => { + it("fails on unaccepted high findings, writes baselines, then accepts the existing baseline", () => { + const project = makeProject(multi, true); + try { + const first = scan(project); + expect(first.status).toBe(1); + expect(first.stdout).toContain("Failing: 1 vulnerability finding at or above high"); + const folders = multi ? ["a", "b"] : [""]; + for (const folder of folders) { + const baselinePath = join(project, folder, ".cve-lite", "baseline.json"); + const baseline = JSON.parse(readFileSync(baselinePath, "utf8")); + expect(baseline.findings).toHaveLength(1); + expect(first.stdout).toContain(`Commit ${folder ? `${folder}/` : ""}.cve-lite/baseline.json to accept the existing debt`); + } + const second = scan(project); + expect(second.status).toBe(0); + expect(second.stdout).toContain("No new findings above baseline"); + if (multi) { + rmSync(join(project, "b", ".cve-lite"), { recursive: true }); + const mixed = scan(project); + expect(mixed.status).toBe(1); + expect(mixed.stdout).toContain("a/: No new findings above baseline"); + expect(mixed.stdout).toContain("b/: Failing:"); + } + } finally { + rmProject(project); + } + }); + + it("accepts a clean first run without claiming existing debt", () => { + const project = makeProject(multi, false); + try { + const result = scan(project); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Baseline saved"); + expect(result.stdout).not.toContain("Failing:"); + expect(result.stdout).not.toContain("accept the existing debt"); + for (const folder of multi ? ["a", "b"] : [""]) { + expect(existsSync(join(project, folder, ".cve-lite", "baseline.json"))).toBe(true); + } + } finally { + rmProject(project); + } + }); + + it("keeps the gate active while suppressing both explanatory lines under --json", () => { + const project = makeProject(multi, true); + try { + const result = scan(project, ["--json"]); + expect(result.status).toBe(1); + expect(result.stdout).not.toContain("Failing:"); + expect(result.stdout).not.toContain("accept the existing debt"); + } finally { + rmProject(project); + } + }); + + it("does not fail when all findings are below --fail-on", () => { + const project = makeProject(multi, true); + try { + const result = scan(project, ["--fail-on", "critical"]); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("Failing:"); + } finally { + rmProject(project); + } + }); +}); + +it("preserves single-folder --fix exit 0 with an unfixable high finding and no baseline", () => { + const project = mkProject(npmProject( + { name: "ratchet-fix-test", dependencies: { parent: "1.0.0" } }, + { parent: "1.0.0", [VULNERABLE_PACKAGE]: "1.0.0" }, + )); + try { + const result = scan(project, ["--fix"]); + expect(result.status).toBe(0); + const fixResult = JSON.parse(readFileSync(join(project, "cve-lite-fix-result.json"), "utf8")); + expect(fixResult.findingsBeforeFix).toBe(1); + expect(fixResult.findingsAfterFix).toBe(1); + expect(fixResult.appliedFixCount).toBe(0); + expect(result.stdout).not.toContain("Failing:"); + } finally { + rmProject(project); + } +}); diff --git a/tests/multi-folder-scan.test.ts b/tests/multi-folder-scan.test.ts index db82074b..017bc951 100644 --- a/tests/multi-folder-scan.test.ts +++ b/tests/multi-folder-scan.test.ts @@ -1125,11 +1125,11 @@ describe("handleMultiFolderScan - ratchet / baseline", () => { sortFindingsForOutputMock.mockImplementation((f: any[]) => f); }); - it("saves a per-folder baseline and exits 0 on first --ratchet run", async () => { + it("saves each first-run baseline and applies the fail-on gate", async () => { readBaselineMock.mockReturnValue(null); ratchetOutcomeMock.mockReturnValue({ action: "save" }); - const { EXIT_OK } = await import("../src/types.js"); + const { EXIT_FINDINGS } = await import("../src/types.js"); const exitCode = await handleMultiFolderScan({ projectRoot: "/project", batchSize: 100, @@ -1139,12 +1139,59 @@ describe("handleMultiFolderScan - ratchet / baseline", () => { expect(writeBaselineMock).toHaveBeenCalledTimes(2); expect(writeBaselineMock).toHaveBeenCalledWith("/project/a", expect.any(Array)); expect(writeBaselineMock).toHaveBeenCalledWith("/project/b", expect.any(Array)); - expect(exitCode).toBe(EXIT_OK); + expect(exitCode).toBe(EXIT_FINDINGS); const output = consoleLogMock.mock.calls.flat().join("\n"); expect(output).toMatch(/a\/: Baseline saved/i); expect(output).toMatch(/b\/: Baseline saved/i); }); + it.each([ + { label: "high finding", findings: [finding], failOn: "high", json: false, expected: 1 }, + { label: "below threshold", findings: [finding], failOn: "critical", json: false, expected: 0 }, + { label: "clean tree", findings: [], failOn: "high", json: false, expected: 0 }, + { label: "JSON gate", findings: [finding], failOn: "high", json: true, expected: 1 }, + ])("first-run multi-folder ratchet gates $label like --fail-on", async ({ findings, failOn, json, expected }) => { + readBaselineMock.mockReturnValue(null); + ratchetOutcomeMock.mockReturnValue({ action: "save" }); + scanPackagesMock.mockResolvedValue({ findings, completeness: scanCompleteness }); + normalizeSeverityMock.mockImplementation((severity: string) => severity); + try { + const exitCode = await handleMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, ratchet: true, failOn, json }, + }); + expect(exitCode).toBe(expected); + expect(writeBaselineMock).toHaveBeenCalledWith("/project/a", findings); + expect(writeBaselineMock).toHaveBeenCalledWith("/project/b", findings); + const output = consoleLogMock.mock.calls.flat().join("\n"); + if (expected === 1 && !json) { + expect(output).toContain("a/: Failing: 1 vulnerability finding at or above high"); + expect(output).toContain("b/: Failing: 1 vulnerability finding at or above high"); + expect(output).toContain("Commit a/.cve-lite/baseline.json to accept the existing debt"); + expect(output).toContain("Commit b/.cve-lite/baseline.json to accept the existing debt"); + expect(output).not.toContain("deleting the folder's"); + } else { + expect(output).not.toContain("Failing:"); + } + if (json || findings.length === 0) { + expect(output).not.toContain("accept the existing debt"); + } + } finally { + normalizeSeverityMock.mockReturnValue("medium"); + } + }); + + it("preserves the unsupported multi-folder --fix error with --ratchet", async () => { + const exitCode = await handleMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, ratchet: true, fix: true, failOn: "high" }, + }); + expect(exitCode).toBe(EXIT_ERROR); + expect(writeBaselineMock).not.toHaveBeenCalled(); + }); + it("processes clean folders and rejects incomplete folders under --ratchet", async () => { scanPackagesMock.mockResolvedValueOnce({ findings: [finding], @@ -1203,8 +1250,8 @@ describe("handleMultiFolderScan - ratchet / baseline", () => { options: { ...baseOptions, ratchet: true }, }); - const { EXIT_OK } = await import("../src/types.js"); - expect(exitCode).toBe(EXIT_OK); + const { EXIT_FINDINGS } = await import("../src/types.js"); + expect(exitCode).toBe(EXIT_FINDINGS); expect(ratchetOutcomeMock).toHaveBeenCalledTimes(2); expect(writeBaselineMock).toHaveBeenCalledTimes(2); }); From 8c98b8459697015cf9e2f791811691aecd5aaa5f Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:50:10 +0700 Subject: [PATCH 3/3] fix(ratchet): print one baseline commit hint and document the first-run gate Address review on #1314: - Multi-folder --ratchet collects the saved baselines that carry findings and prints a single commit hint after the loop instead of one per folder. - Build the first-run gate input from GATE_CLASS_LABELS (#1288) in both scan paths, so no "vulnerability" literal is left. - Rename the single-folder test that seeds a low finding to say it is below the threshold, and pass an explicit failOn "high" to the two multi-folder tests that relied on baseOptions' "none". - ratcheting.md no longer says the first run exits 0; it gates on --fail-on. Note the same in cli-reference.md and --help. --- src/cli/help.ts | 1 + src/scan/multi-folder-scan.ts | 12 +++++++++--- src/scan/single-scan.ts | 2 +- tests/cli-integration.test.ts | 2 +- tests/e2e/ratchet-first-run.test.ts | 4 +++- tests/multi-folder-scan.test.ts | 10 ++++++---- website/docs/cli-reference.md | 2 +- website/docs/ratcheting.md | 4 ++-- 8 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index a0d0031c..b7075ca3 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -46,6 +46,7 @@ export function printHelp(): void { " --no-open Don't auto-open the report in the browser", " --fix Apply validated direct dependency fixes and rescan", " --ratchet Save current findings as baseline or, if baseline exists, only fail on new findings", + " (the run that saves a baseline still gates on --fail-on)", " --create-pr After --fix, commit changes and open a GitHub pull request (requires gh)", " --base Base branch for --create-pr (default: main)", " --osv-url Use a custom OSV-compatible advisory endpoint (HTTPS only)", diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index 9faf1a63..5157ac45 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -223,6 +223,7 @@ function handleMultiFolderRatchet( let anyNewFindings = false; let anyFirstRunGateFailure = false; let hasDetectionGaps = false; + const baselinesToCommit: string[] = []; for (const r of results) { for (const warning of r.scanInput.warnings) { @@ -256,7 +257,7 @@ function handleMultiFolderRatchet( ), ); const gateLine = failingGateSummary( - [{ label: "vulnerability", findings: r.sorted }], + [{ label: GATE_CLASS_LABELS.vulnerability, findings: r.sorted }], options.failOn, ); if (gateLine) { @@ -265,8 +266,8 @@ function handleMultiFolderRatchet( console.log(chalk.red(`${r.subfolder}/: ${gateLine}`)); } } - if (count > 0 && !options.json) { - console.log(chalk.gray(`Commit ${r.subfolder}/.cve-lite/baseline.json ${RATCHET_BASELINE_COMMIT_HINT}`)); + if (count > 0) { + baselinesToCommit.push(`${r.subfolder}/.cve-lite/baseline.json`); } continue; } @@ -290,6 +291,11 @@ function handleMultiFolderRatchet( } } + // One hint for every saved baseline that carries debt, rather than one per folder. + if (baselinesToCommit.length > 0 && !options.json) { + console.log(chalk.gray(`Commit ${baselinesToCommit.join(", ")} ${RATCHET_BASELINE_COMMIT_HINT}`)); + } + if (hasDetectionGaps) { return EXIT_ERROR; } diff --git a/src/scan/single-scan.ts b/src/scan/single-scan.ts index 5f3d5e99..2f59506b 100644 --- a/src/scan/single-scan.ts +++ b/src/scan/single-scan.ts @@ -466,7 +466,7 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr } // A missing baseline accepts no debt, including on fresh CI checkouts. const gateLine = failingGateSummary( - [{ label: "vulnerability", findings: scanState.sorted }], + [{ label: GATE_CLASS_LABELS.vulnerability, findings: scanState.sorted }], options.failOn, ); if (gateLine && !options.json && !options.fix) { diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index 9bde94a0..7d540bec 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -505,7 +505,7 @@ describe("CLI integration", () => { } }); - it("--ratchet with no baseline and a clean tree exits 0 and writes the baseline", async () => { + it("--ratchet with no baseline and findings below the threshold exits 0 and writes the baseline", async () => { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-ratchet-clean-")); parseArgsMock.mockReturnValue({ command: "scan", diff --git a/tests/e2e/ratchet-first-run.test.ts b/tests/e2e/ratchet-first-run.test.ts index 381f8926..ebaf70a6 100644 --- a/tests/e2e/ratchet-first-run.test.ts +++ b/tests/e2e/ratchet-first-run.test.ts @@ -39,8 +39,10 @@ describe.each([false, true])("first-run ratchet (multi-folder: %s)", multi => { const baselinePath = join(project, folder, ".cve-lite", "baseline.json"); const baseline = JSON.parse(readFileSync(baselinePath, "utf8")); expect(baseline.findings).toHaveLength(1); - expect(first.stdout).toContain(`Commit ${folder ? `${folder}/` : ""}.cve-lite/baseline.json to accept the existing debt`); } + const baselinePaths = folders.map(folder => `${folder ? `${folder}/` : ""}.cve-lite/baseline.json`).join(", "); + expect(first.stdout).toContain(`Commit ${baselinePaths} to accept the existing debt`); + expect(first.stdout.split("accept the existing debt").length - 1).toBe(1); const second = scan(project); expect(second.status).toBe(0); expect(second.stdout).toContain("No new findings above baseline"); diff --git a/tests/multi-folder-scan.test.ts b/tests/multi-folder-scan.test.ts index 690653fd..638e9909 100644 --- a/tests/multi-folder-scan.test.ts +++ b/tests/multi-folder-scan.test.ts @@ -1173,7 +1173,7 @@ describe("handleMultiFolderScan - ratchet / baseline", () => { const exitCode = await handleMultiFolderScan({ projectRoot: "/project", batchSize: 100, - options: { ...baseOptions, ratchet: true }, + options: { ...baseOptions, ratchet: true, failOn: "high" }, }); expect(writeBaselineMock).toHaveBeenCalledTimes(2); @@ -1208,8 +1208,10 @@ describe("handleMultiFolderScan - ratchet / baseline", () => { if (expected === 1 && !json) { expect(output).toContain("a/: Failing: 1 vulnerability finding at or above high"); expect(output).toContain("b/: Failing: 1 vulnerability finding at or above high"); - expect(output).toContain("Commit a/.cve-lite/baseline.json to accept the existing debt"); - expect(output).toContain("Commit b/.cve-lite/baseline.json to accept the existing debt"); + expect(output).toContain( + "Commit a/.cve-lite/baseline.json, b/.cve-lite/baseline.json to accept the existing debt", + ); + expect(output.split("accept the existing debt").length - 1).toBe(1); expect(output).not.toContain("deleting the folder's"); } else { expect(output).not.toContain("Failing:"); @@ -1287,7 +1289,7 @@ describe("handleMultiFolderScan - ratchet / baseline", () => { const exitCode = await handleMultiFolderScan({ projectRoot: "/project", batchSize: 100, - options: { ...baseOptions, ratchet: true }, + options: { ...baseOptions, ratchet: true, failOn: "high" }, }); const { EXIT_FINDINGS } = await import("../src/types.js"); diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index da56da6e..3e7ce1cf 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -116,7 +116,7 @@ See [Corporate SSL Proxy](./corporate-proxy.md) for the full setup workflow. |---|---|---|---| | `--fail-on` | `critical` | Exit with code `1` if any finding meets or exceeds this severity (`critical`, `high`, `medium`, `low`); exit `0` otherwise. When the gate trips, the CLI prints one line naming the finding class and the threshold, for example `Failing: 1 override hygiene finding at or above low (--fail-on low).` | `cve-lite . --fail-on high` | | `--incomplete-policy` | `warn` | How to handle incomplete scan data: `warn` (default) prints diagnostics but exits based on findings; `error` exits with code `3` when detection data is incomplete | `cve-lite . --incomplete-policy error` | -| `--ratchet` | off | Save current CVE findings as a baseline, or if a baseline exists, only fail on findings above it. In multi-folder mode each subfolder gets its own `.cve-lite/baseline.json` | `cve-lite . --ratchet` | +| `--ratchet` | off | Save current CVE findings as a baseline, or if a baseline exists, only fail on findings above it. The run that saves a baseline still gates on `--fail-on`, so it exits `1` when a finding is at or above the threshold. In multi-folder mode each subfolder gets its own `.cve-lite/baseline.json` | `cve-lite . --ratchet` | | `--fix` | off | Auto-apply direct-dependency fix commands (direct deps only, v1); cannot be used with `--json`, `--sarif`, or `--sbom`/`--cdx` | `cve-lite . --fix` | | `--check-overrides` | off | Audit `overrides` and `resolutions` entries as part of the scan (OA001-OA008); results appear in the scan output | `cve-lite . --check-overrides` | | `--check-maintenance` | off | Run maintenance risk checks alongside the CVE scan (DM001); surfaces dependency drag and checks for deprecated packages | `cve-lite . --check-maintenance` | diff --git a/website/docs/ratcheting.md b/website/docs/ratcheting.md index 37c06af3..94b59501 100644 --- a/website/docs/ratcheting.md +++ b/website/docs/ratcheting.md @@ -19,7 +19,7 @@ Run once locally to snapshot the current findings: cve-lite . --ratchet ``` -This creates `.cve-lite/baseline.json` in your project root and exits 0. Commit the file so your whole team and CI share the same baseline. +This creates `.cve-lite/baseline.json` in your project root. A missing baseline accepts no debt, so this first run still applies the `--fail-on` gate (default `critical`): it exits 1 if any finding is at or above the threshold, and 0 otherwise. That keeps a fresh CI checkout without a committed baseline from passing silently. Commit the file so your whole team and CI share the same baseline; from then on, only findings above it fail. ```bash git add .cve-lite/baseline.json @@ -37,7 +37,7 @@ cve-lite . --ratchet git add '**/.cve-lite/baseline.json' ``` -Subsequent multi-folder scans with an existing per-folder baseline suppress known findings in that folder. `--ratchet` again gates each folder independently and fails if any folder has findings above its baseline. +Subsequent multi-folder scans with an existing per-folder baseline suppress known findings in that folder. `--ratchet` again gates each folder independently and fails if any folder has findings above its baseline. A folder with no baseline yet is saved and gated by `--fail-on`, the same as the single-folder first run. ---