diff --git a/src/output/multi-folder-printer.ts b/src/output/multi-folder-printer.ts index b7964707..a8037172 100644 --- a/src/output/multi-folder-printer.ts +++ b/src/output/multi-folder-printer.ts @@ -60,7 +60,23 @@ export function printMultiFolderResults( } const totalFindings = results.reduce((sum, r) => sum + r.sorted.length, 0); + const totalSuppressed = results.reduce((sum, r) => sum + (r.suppressedCount ?? 0), 0); const folderNames = results.map(r => `${r.subfolder}/`).join(", "); console.log(chalk.gray(`\nScanned ${results.length} ${pluralize(results.length, "folder")}: ${folderNames}`)); console.log(chalk.gray(`${totalFindings} total ${pluralize(totalFindings, "finding")} across all folders`)); + if (totalSuppressed > 0) { + if (totalFindings === 0) { + console.log( + chalk.green( + `No new findings above baseline - ${totalSuppressed} existing ${pluralize(totalSuppressed, "finding")} suppressed`, + ), + ); + } else { + console.log( + chalk.yellow( + `${totalFindings} new ${pluralize(totalFindings, "finding")} above baseline - ${totalSuppressed} existing ${pluralize(totalSuppressed, "finding")} suppressed`, + ), + ); + } + } } diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index 5fe80d38..66a2f8b6 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -23,6 +23,8 @@ import type { OverrideFinding } from "../overrides/types.js"; import { detectDM001 } from "../maintenance/dm001-maintenance-risk.js"; import type { MaintenanceFinding } from "../maintenance/types.js"; import { reachesFailOn } from "../utils/severity.js"; +import { readBaseline, writeBaseline, filterNewFindings, ratchetOutcome } from "../utils/baseline.js"; +import { pluralize } from "../utils/string.js"; export interface MultiFolderScanResult { subfolder: string; @@ -37,6 +39,8 @@ export interface MultiFolderScanResult { overrideFindings: OverrideFinding[]; /** Maintenance risk (DM001) findings for this folder, populated when --check-maintenance is set. */ maintenanceFindings: MaintenanceFinding[]; + /** CVE findings suppressed by an existing per-folder baseline (non-ratchet scans). */ + suppressedCount: number; } export async function runMultiFolderScan(params: { @@ -53,7 +57,7 @@ export async function runMultiFolderScan(params: { for (const { scanInput, subfolder } of folders) { if (scanInput.packages.length === 0) continue; - if (!params.options.json) { + if (!params.options.json && !params.options.ratchet) { process.stdout.write(`\n${chalk.bold.cyan(`📁 ${subfolder}/`)}\n`); } @@ -65,15 +69,30 @@ export async function runMultiFolderScan(params: { scanFilePath: scanInput.filePath, }, undefined, params.fetchImpl); - const sorted = sortFindingsForOutput(findings); + let sorted = sortFindingsForOutput(findings); + let suppressedCount = 0; + + // Non-ratchet: auto-apply an existing per-folder baseline before fix plan, + // table selection, and maintenance (mirrors single-folder index.ts). + // --ratchet keeps the full set so handleMultiFolderScan can save or gate. + if (!params.options.ratchet) { + const baseline = readBaseline(subfolderAbs); + if (baseline) { + const filtered = filterNewFindings(sorted, baseline); + sorted = filtered.newFindings; + suppressedCount = filtered.suppressedCount; + } + } + const coverage = buildCoverageNotes(scanInput, offline); const minSeverity = normalizeSeverity(params.options.minSeverity || "medium"); const tableFindings = params.options.all ? sorted : selectFindingsForTable(sorted, minSeverity); const suggestedFixCommands = buildSuggestedFixCommandPlan(sorted, scanInput, { offline, subfolder }); - // Override hygiene per folder, mirroring the single-folder --check-overrides path. + // Override hygiene is independent of the CVE ratchet/baseline. A --ratchet + // run skips the override audit entirely (same boundary as single-folder). let overrideFindings: OverrideFinding[] = []; - if (params.options.checkOverrides) { + if (params.options.checkOverrides && !params.options.ratchet) { const overrideCtx = buildOverrideContext(subfolderAbs, { auditLog: NULL_AUDIT_LOG, logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, @@ -83,9 +102,10 @@ export async function runMultiFolderScan(params: { overrideFindings = overrideAudit.findings; } - // Maintenance risk per folder, mirroring the --check-overrides pattern above. + // Maintenance from the baseline-filtered set. Skipped under --ratchet because + // that path always early-exits on CVE save/gate (same as single-folder). let maintenanceFindings: MaintenanceFinding[] = []; - if (params.options.checkMaintenance) { + if (params.options.checkMaintenance && !params.options.ratchet) { maintenanceFindings = await detectDM001(sorted, offline); } @@ -100,12 +120,80 @@ export async function runMultiFolderScan(params: { allPackages: scanInput.packages, overrideFindings, maintenanceFindings, + suppressedCount, }); } return results; } +/** + * 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. + */ +function handleMultiFolderRatchet( + results: MultiFolderScanResult[], + projectRoot: string, + options: ParsedOptions, +): ExitCode { + let anyNewFindings = false; + + for (const r of results) { + const subfolderAbs = path.join(projectRoot, r.subfolder); + const baseline = readBaseline(subfolderAbs); + const outcome = ratchetOutcome(baseline, r.sorted); + + if (outcome.action === "save") { + writeBaseline(subfolderAbs, r.sorted); + const count = r.sorted.length; + console.log( + chalk.green( + `✓ ${r.subfolder}/: Baseline saved to .cve-lite/baseline.json with ${count} ${pluralize(count, "finding")}. Future scans will only report findings above this baseline.`, + ), + ); + continue; + } + + const { newFindings, suppressedCount } = outcome; + const suppressedLabel = `${suppressedCount} existing ${pluralize(suppressedCount, "finding")} suppressed`; + if (newFindings.length === 0) { + console.log(chalk.green(`✓ ${r.subfolder}/: No new findings above baseline - ${suppressedLabel}`)); + continue; + } + + anyNewFindings = true; + console.log( + chalk.red( + `${r.subfolder}/: ${newFindings.length} new ${pluralize(newFindings.length, "finding")} above baseline - ${suppressedLabel}`, + ), + ); + for (const f of newFindings) { + const ids = f.vulnerabilities.map(v => v.id).join(", "); + console.log(` ${chalk.yellow(f.severity)} ${f.pkg.name}@${f.pkg.version}${ids ? ` (${ids})` : ""}`); + } + } + + if (options.checkOverrides) { + console.log( + chalk.gray( + "Note: override hygiene (--check-overrides) is not part of the ratchet baseline; run `cve-lite overrides` to audit overrides.", + ), + ); + } + + if (anyNewFindings) { + console.log( + chalk.gray( + "To accept these, re-baseline intentionally by deleting the folder's .cve-lite/baseline.json and re-running --ratchet.", + ), + ); + return EXIT_FINDINGS; + } + + return EXIT_OK; +} + export async function handleMultiFolderScan(params: { projectRoot: string; batchSize: number; @@ -129,6 +217,10 @@ export async function handleMultiFolderScan(params: { return EXIT_ERROR; } + if (params.options.ratchet) { + return handleMultiFolderRatchet(results, params.projectRoot, params.options); + } + if (params.options.json) { const { serializeFinding } = await import("../output/formatters.js"); const allFindings = results.flatMap(r => diff --git a/tests/multi-folder-printer.test.ts b/tests/multi-folder-printer.test.ts index 52ce65eb..df2f72ef 100644 --- a/tests/multi-folder-printer.test.ts +++ b/tests/multi-folder-printer.test.ts @@ -101,6 +101,15 @@ describe("printMultiFolderResults — compact mode (default)", () => { ); }); + it("prints baseline suppression summary when findings were filtered", () => { + printMultiFolderResults( + [makeResult("api", { suppressedCount: 3 }), makeResult("web", { suppressedCount: 2 })], + baseOptions, + ); + const output = consoleLogMock.mock.calls.flat().join("\n"); + expect(output).toMatch(/No new findings above baseline - 5 existing findings suppressed/i); + }); + it("emits a warning for each scanInput warning", () => { const result = makeResult("sessionManager", { scanInput: { diff --git a/tests/multi-folder-scan.test.ts b/tests/multi-folder-scan.test.ts index 20816e04..4ebb22a6 100644 --- a/tests/multi-folder-scan.test.ts +++ b/tests/multi-folder-scan.test.ts @@ -60,6 +60,18 @@ jest.unstable_mockModule("../src/maintenance/dm001-maintenance-risk.js", () => ( detectDM001: detectDM001Mock, })); +const readBaselineMock = jest.fn(() => null); +const writeBaselineMock = jest.fn(); +const filterNewFindingsMock = jest.fn((findings: any[]) => ({ newFindings: findings, suppressedCount: 0 })); +const ratchetOutcomeMock = jest.fn(() => ({ action: "save" })); + +jest.unstable_mockModule("../src/utils/baseline.js", () => ({ + readBaseline: readBaselineMock, + writeBaseline: writeBaselineMock, + filterNewFindings: filterNewFindingsMock, + ratchetOutcome: ratchetOutcomeMock, +})); + jest.unstable_mockModule("../src/audit-log/index.js", () => ({ NULL_AUDIT_LOG: { emit: jest.fn(), close: jest.fn() }, createAuditLog: jest.fn(() => ({ emit: jest.fn(), close: jest.fn() })), @@ -221,6 +233,20 @@ describe("runMultiFolderScan", () => { expect(results[0].overrideFindings ?? []).toHaveLength(0); }); + it("does not run the override audit when --ratchet is set even with --check-overrides", async () => { + loadMultiplePackagesMock.mockReturnValue([ + { subfolder: "a", scanInput: makeScanInput() }, + ]); + + await runMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, checkOverrides: true, ratchet: true }, + }); + + expect(auditMock).not.toHaveBeenCalled(); + }); + it("attaches subfolder to suggestedFixCommands plan", async () => { loadMultiplePackagesMock.mockReturnValue([ { subfolder: "sessionManager", scanInput: makeScanInput() }, @@ -389,3 +415,98 @@ describe("handleMultiFolderScan - maintenance risk terminal render", () => { expect(written).toContain("maintenance risk"); }); }); + +describe("handleMultiFolderScan - ratchet / baseline", () => { + const finding = { + pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-xxx", aliases: [], summary: "" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "lodash"]], + relationship: "direct", + firstFixedVersion: null, + }; + + beforeEach(() => { + loadMultiplePackagesMock.mockReturnValue([ + { subfolder: "a", scanInput: makeScanInput() }, + { subfolder: "b", scanInput: makeScanInput() }, + ]); + scanPackagesMock.mockResolvedValue([finding]); + sortFindingsForOutputMock.mockImplementation((f: any[]) => f); + }); + + it("saves a per-folder baseline and exits 0 on first --ratchet run", async () => { + readBaselineMock.mockReturnValue(null); + ratchetOutcomeMock.mockReturnValue({ action: "save" }); + + const { EXIT_OK } = await import("../src/types.js"); + const exitCode = await handleMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, ratchet: true }, + }); + + 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); + const output = consoleLogMock.mock.calls.flat().join("\n"); + expect(output).toMatch(/a\/: Baseline saved/i); + expect(output).toMatch(/b\/: Baseline saved/i); + }); + + it("exits 0 when existing baselines suppress all findings under --ratchet", async () => { + readBaselineMock.mockReturnValue({ version: 1, createdAt: "x", findings: [] }); + ratchetOutcomeMock.mockReturnValue({ action: "gate", newFindings: [], suppressedCount: 1 }); + + const { EXIT_OK } = await import("../src/types.js"); + const exitCode = await handleMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, ratchet: true }, + }); + + expect(writeBaselineMock).not.toHaveBeenCalled(); + expect(exitCode).toBe(EXIT_OK); + const output = consoleLogMock.mock.calls.flat().join("\n"); + expect(output).toMatch(/No new findings above baseline/i); + }); + + it("exits EXIT_FINDINGS when any folder has findings above its baseline under --ratchet", async () => { + readBaselineMock.mockReturnValue({ version: 1, createdAt: "x", findings: [] }); + ratchetOutcomeMock + .mockReturnValueOnce({ action: "gate", newFindings: [], suppressedCount: 1 }) + .mockReturnValueOnce({ action: "gate", newFindings: [finding], suppressedCount: 0 }); + + const { EXIT_FINDINGS } = await import("../src/types.js"); + const exitCode = await handleMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, ratchet: true }, + }); + + expect(writeBaselineMock).not.toHaveBeenCalled(); + expect(exitCode).toBe(EXIT_FINDINGS); + const output = consoleLogMock.mock.calls.flat().join("\n"); + expect(output).toMatch(/b\/: 1 new finding above baseline/i); + }); + + it("filters findings via existing baseline when --ratchet is not set", async () => { + loadMultiplePackagesMock.mockReturnValue([ + { subfolder: "a", scanInput: makeScanInput() }, + ]); + readBaselineMock.mockReturnValue({ version: 1, createdAt: "x", findings: [] }); + filterNewFindingsMock.mockReturnValue({ newFindings: [], suppressedCount: 1 }); + + const results = await runMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: baseOptions, + }); + + expect(filterNewFindingsMock).toHaveBeenCalled(); + expect(results[0].sorted).toHaveLength(0); + expect(results[0].suppressedCount).toBe(1); + }); +}); diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index 50de8bdc..9a188f42 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -94,6 +94,7 @@ See [Corporate SSL Proxy](./corporate-proxy.md) for the full setup workflow. | Flag | Default | Description | Example | |---|---|---|---| | `--fail-on` | `critical` | Exit with code `1` if any finding meets or exceeds this severity (`critical`, `high`, `medium`, `low`); exit `0` otherwise | `cve-lite . --fail-on high` | +| `--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` | | `--fix` | off | Auto-apply direct-dependency fix commands (direct deps only, v1); cannot be used with `--json` | `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 7d1da7e2..37c06af3 100644 --- a/website/docs/ratcheting.md +++ b/website/docs/ratcheting.md @@ -26,6 +26,19 @@ git add .cve-lite/baseline.json git commit -m "chore: establish vulnerability baseline" ``` +### Multi-folder / workspace projects + +When there is no root lockfile and CVE Lite scans nested packages (multi-folder mode), `--ratchet` writes a **per-subfolder** baseline — each scanned folder gets its own `.cve-lite/baseline.json`. That matches scanning a folder on its own (`cve-lite packages/api --ratchet`), so a baseline stays valid whether the folder is scanned alone or as part of the monorepo. + +```bash +cve-lite . --ratchet +# packages/api/.cve-lite/baseline.json +# packages/web/.cve-lite/baseline.json +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. + --- ## How it works in CI