Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch> Base branch for --create-pr (default: main)",
" --osv-url <url> Use a custom OSV-compatible advisory endpoint (HTTPS only)",
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const severityOrder: Record<SeverityLabel, number> = {
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
Expand Down
28 changes: 24 additions & 4 deletions src/scan/multi-folder-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -212,16 +212,18 @@ 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[],
projectRoot: string,
options: ParsedOptions,
): ExitCode {
let anyNewFindings = false;
let anyFirstRunGateFailure = false;
let hasDetectionGaps = false;
const baselinesToCommit: string[] = [];

for (const r of results) {
for (const warning of r.scanInput.warnings) {
Expand Down Expand Up @@ -254,6 +256,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: GATE_CLASS_LABELS.vulnerability, findings: r.sorted }],
options.failOn,
);
if (gateLine) {
anyFirstRunGateFailure = true;
if (!options.json) {
console.log(chalk.red(`${r.subfolder}/: ${gateLine}`));
}
}
if (count > 0) {
baselinesToCommit.push(`${r.subfolder}/.cve-lite/baseline.json`);
}
continue;
}

Expand All @@ -276,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;
}
Expand All @@ -297,7 +317,7 @@ function handleMultiFolderRatchet(
return EXIT_FINDINGS;
}

return EXIT_OK;
return anyFirstRunGateFailure ? EXIT_FINDINGS : EXIT_OK;
}

export async function handleMultiFolderScan(params: {
Expand Down
18 changes: 15 additions & 3 deletions src/scan/single-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -464,15 +464,27 @@ 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.`));
}
// A missing baseline accepts no debt, including on fresh CI checkouts.
const gateLine = failingGateSummary(
[{ label: GATE_CLASS_LABELS.vulnerability, findings: scanState.sorted }],
options.failOn,
);
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(),
type: "scan.finished",
schemaVersion: 1,
durationMs: Date.now() - scanStartTime,
findingsCount: scanState.sorted.length,
exitCode: 0,
exitCode,
});
return 0;
return exitCode;
}
Comment thread
sonukapoor marked this conversation as resolved.

Comment thread
sonukapoor marked this conversation as resolved.
const { newFindings, suppressedCount: sc } = outcome;
Comment thread
sonukapoor marked this conversation as resolved.
Expand Down
85 changes: 85 additions & 0 deletions tests/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,91 @@ 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 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",
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.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",
Expand Down
117 changes: 117 additions & 0 deletions tests/e2e/ratchet-first-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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);
}
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");
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);
}
});
Loading
Loading