Skip to content
Merged
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
16 changes: 16 additions & 0 deletions src/output/multi-folder-printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
),
);
}
}
}
104 changes: 98 additions & 6 deletions src/scan/multi-folder-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: {
Expand All @@ -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`);
}

Expand All @@ -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: () => {} },
Expand All @@ -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);
}

Expand All @@ -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;
Expand All @@ -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 =>
Expand Down
9 changes: 9 additions & 0 deletions tests/multi-folder-printer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
121 changes: 121 additions & 0 deletions tests/multi-folder-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ jest.unstable_mockModule("../src/maintenance/dm001-maintenance-risk.js", () => (
detectDM001: detectDM001Mock,
}));

const readBaselineMock = jest.fn<any>(() => null);
const writeBaselineMock = jest.fn<any>();
const filterNewFindingsMock = jest.fn<any>((findings: any[]) => ({ newFindings: findings, suppressedCount: 0 }));
const ratchetOutcomeMock = jest.fn<any>(() => ({ 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() })),
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions website/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
13 changes: 13 additions & 0 deletions website/docs/ratcheting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down