Skip to content
22 changes: 12 additions & 10 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, severityOrder, OVERRIDES_COMMAND } from "../constants.js";
import { DEFAULT_BATCH_SIZE, DEFAULT_SEARCH_DEPTH, OVERRIDES_COMMAND } 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 All @@ -28,7 +28,7 @@ import type { MaintenanceFinding } from "../maintenance/types.js";
import { detectLicenseIssues } from "../licenses/license-check.js";
import { renderLicenseFindings } from "../output/license-terminal.js";
import type { LicenseFinding } from "../licenses/types.js";
import { reachesFailOn } from "../utils/severity.js";
import { failingGateSummary } from "../utils/severity.js";
import { readBaseline, writeBaseline, filterNewFindings, ratchetOutcome } from "../utils/baseline.js";
import { pluralize } from "../utils/string.js";
import {
Expand Down Expand Up @@ -433,17 +433,18 @@ export async function handleMultiFolderScan(params: {
console.log(`${chalk.gray("Report:")} ${chalk.cyan(reportPath)}`);
}

const failLevel = normalizeSeverity(params.options.failOn);
const allSorted = results.flatMap(r => r.sorted);
const allOverrideFindings = results.flatMap(r => r.overrideFindings);
const allMaintenanceFindings = results.flatMap(r => r.maintenanceFindings);
// Mirror single-folder exit policy in src/index.ts: CVE + override + maintenance
// all count toward --fail-on. Without overrides here, multi-folder CI using
// --check-overrides --fail-on high would exit 0 despite high OA findings.
const shouldFail =
allSorted.some(f => severityOrder[f.severity] >= severityOrder[failLevel]) ||
reachesFailOn(allOverrideFindings, params.options.failOn) ||
reachesFailOn(allMaintenanceFindings, params.options.failOn);
// Mirror single-folder exit policy in src/scan/single-scan.ts: CVE + override +
// maintenance all count toward --fail-on. Without overrides here, multi-folder CI
// using --check-overrides --fail-on high would exit 0 despite high OA findings.
const gateLine = failingGateSummary([
{ label: "vulnerability", findings: allSorted },
{ label: "override hygiene", findings: allOverrideFindings },
{ label: "maintenance risk", findings: allMaintenanceFindings },
], params.options.failOn);
const shouldFail = gateLine !== null;
const incompleteFailure = shouldFailForIncompleteScan(
aggregatedCompleteness,
params.options.incompletePolicy,
Expand All @@ -453,6 +454,7 @@ export async function handleMultiFolderScan(params: {
: shouldFail
? EXIT_FINDINGS
: EXIT_OK;
if (gateLine && !params.options.json) console.log(chalk.red(gateLine));

auditLog.emit({
ts: new Date(scanFinishedAt).toISOString(),
Expand Down
19 changes: 11 additions & 8 deletions src/scan/single-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
printCacheSummary,
sortFindingsForOutput,
} from "../output/formatters.js";
import { countBySeverity, reachesFailOn } from "../utils/severity.js";
import { countBySeverity, failingGateSummary } from "../utils/severity.js";
import { buildReportData, writeHtmlReport } from "../output/html-reporter.js";
import { buildScanJson } from "../output/scan-json.js";
import { writeOutputs } from "../output/write-outputs.js";
Expand Down Expand Up @@ -670,16 +670,19 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr
}
}

// Override hygiene findings count toward --fail-on too, using the shared
// reachesFailOn helper from src/utils/severity.ts, the same logic used by
// the standalone `overrides` command. Without this, a CI run
// Override hygiene findings count toward --fail-on too. Without this, a CI run
// of `cve-lite . --check-overrides --fail-on high` would exit 0 despite high-severity
// override findings, giving a false sense of protection. overrideFindings is empty
// unless --check-overrides (and non-ratchet), so this is a no-op otherwise.
const shouldFail =
reachesFailOn(scanState.sorted, options.failOn) ||
reachesFailOn(overrideFindings, options.failOn) ||
reachesFailOn(maintenanceFindings, options.failOn);
// The gate and the line that explains it come from one call, so the exit code
// and the printed reason cannot drift apart.
const gateLine = failingGateSummary([
{ label: "vulnerability", findings: scanState.sorted },
{ label: "override hygiene", findings: overrideFindings },
{ label: "maintenance risk", findings: maintenanceFindings },
], options.failOn);
const shouldFail = gateLine !== null;
if (gateLine && !options.json && !options.fix) console.log(chalk.red(gateLine));
// In fix mode, remaining transitive findings cannot be auto-fixed.
// Exiting non-zero would prevent the Action PR step from running.
const incompleteFailure = shouldFailForIncompleteScan(
Expand Down
23 changes: 23 additions & 0 deletions src/utils/severity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ export function reachesFailOn<T extends { severity: SeverityLabel }>(
return findings.some((f) => severityOrder[f.severity] >= severityOrder[failLevel]);
}

// One line that names what tripped the --fail-on gate, so a red CI job says why
// it is red. The gate ORs three finding classes (CVE, override hygiene,
// maintenance risk) and none of the rendered output mentioned the gate before,
// which read as a broken exit code. Returns null when the gate would not fire,
// so callers derive the exit decision from it (gateLine !== null) and the two
// cannot disagree. The parenthetical echoes the raw flag value: --fail-on is not
// validated and normalizeSeverity falls back to critical, so echoing the
// normalized level would show the user a value they never typed.
export function failingGateSummary(
Comment thread
sonukapoor marked this conversation as resolved.
classes: ReadonlyArray<{ label: string; findings: ReadonlyArray<{ severity: SeverityLabel }> }>,
failOn: string,
): string | null {
if (!failOn) return null;
const failLevel = normalizeSeverity(failOn);
const parts: string[] = [];
for (const { label, findings } of classes) {
const count = findings.filter((f) => severityOrder[f.severity] >= severityOrder[failLevel]).length;
if (count > 0) parts.push(`${count} ${label} ${count === 1 ? "finding" : "findings"}`);
}
if (parts.length === 0) return null;
return `Failing: ${parts.join(", ")} at or above ${failLevel} (--fail-on ${failOn}).`;
}

export function formatSeverityLabel(severity: string): string {
const lower = severity.toLowerCase();
if (lower === "critical") return chalk.redBright(severity);
Expand Down
45 changes: 45 additions & 0 deletions tests/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,51 @@ describe("CLI integration", () => {
);
});

it("prints the fail-on gate line when a terminal run trips the gate", async () => {
const finding = createFinding();
parseArgsMock.mockReturnValue({
command: "scan",
options: {
failOn: "high",
batchSize: "100",
searchDepth: "4",
minSeverity: "medium",
},
projectArg: ".",
});
loadPackagesMock.mockReturnValue(createScanInput({ packages: [finding.pkg] }));
scanPackagesMock.mockResolvedValue(createScanResult([finding]));

const result = await runIndexModule();

expect(result.exitCode).toBe(1);
expect(result.stdout.map(line => stripAnsi(line))).toContain(
"Failing: 1 vulnerability finding at or above high (--fail-on high).",
);
});

it("does not print the fail-on gate line under --json", async () => {
const finding = createFinding();
parseArgsMock.mockReturnValue({
command: "scan",
options: {
json: true,
failOn: "high",
batchSize: "100",
searchDepth: "4",
minSeverity: "medium",
},
projectArg: ".",
});
loadPackagesMock.mockReturnValue(createScanInput({ packages: [finding.pkg] }));
scanPackagesMock.mockResolvedValue(createScanResult([finding]));

const result = await runIndexModule();

expect(result.exitCode).toBe(1);
expect(result.stdout.some(line => stripAnsi(line).includes("Failing:"))).toBe(false);
});

it("warns and exits cleanly when no scannable packages are found", async () => {
loadPackagesMock.mockReturnValue(createScanInput({ packages: [] }));

Expand Down
35 changes: 35 additions & 0 deletions tests/multi-folder-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,41 @@ describe("handleMultiFolderScan - maintenance risk fail-on", () => {
expect(exitCode).toBe(EXIT_FINDINGS);
});

it("prints the fail-on gate line when the multi-folder gate trips", async () => {
detectDM001Mock.mockResolvedValue([
{ ruleId: "DM001", severity: "high", package: { name: "gray-matter", version: "4.0.3" }, drag: [], message: "x" },
]);

const { EXIT_FINDINGS } = await import("../src/types.js");
const exitCode = await handleMultiFolderScan({
projectRoot: "/project",
batchSize: 100,
options: { ...baseOptions, checkMaintenance: true, failOn: "high" },
});

expect(exitCode).toBe(EXIT_FINDINGS);
const logged = consoleLogMock.mock.calls.map(call => String(call[0] ?? ""));
// normalizeSeverity is mocked to "medium" in this file; the flag value is echoed raw.
expect(logged.some(line => line.includes("Failing: 1 maintenance risk finding at or above medium (--fail-on high)."))).toBe(true);
});

it("does not print the fail-on gate line in multi-folder JSON mode", async () => {
detectDM001Mock.mockResolvedValue([
{ ruleId: "DM001", severity: "high", package: { name: "gray-matter", version: "4.0.3" }, drag: [], message: "x" },
]);

const { EXIT_FINDINGS } = await import("../src/types.js");
const exitCode = await handleMultiFolderScan({
projectRoot: "/project",
batchSize: 100,
options: { ...baseOptions, checkMaintenance: true, failOn: "high", json: true },
});

expect(exitCode).toBe(EXIT_FINDINGS);
const logged = consoleLogMock.mock.calls.map(call => String(call[0] ?? ""));
expect(logged.some(line => line.includes("Failing:"))).toBe(false);
});

it("does not fail when maintenance findings exist but --check-maintenance was not requested", async () => {
const { EXIT_OK } = await import("../src/types.js");
const exitCode = await handleMultiFolderScan({
Expand Down
67 changes: 67 additions & 0 deletions tests/utils/failon-gate-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { failingGateSummary } from "../../src/utils/severity.js";

describe("failingGateSummary", () => {
it("names the class and threshold for the maintainer reproduction in issue #1000", () => {
const line = failingGateSummary(
[{ label: "override hygiene", findings: [{ severity: "low" }] }],
"low",
);
expect(line).toBe(
"Failing: 1 override hygiene finding at or above low (--fail-on low).",
);
});

it("counts only findings that meet or exceed the threshold", () => {
const line = failingGateSummary(
[
{ label: "vulnerability", findings: [{ severity: "critical" }, { severity: "low" }] },
{ label: "override hygiene", findings: [{ severity: "low" }] },
],
"high",
);
expect(line).toBe(
"Failing: 1 vulnerability finding at or above high (--fail-on high).",
);
});

it("joins multiple tripped classes with correct pluralization", () => {
const line = failingGateSummary(
[
{ label: "vulnerability", findings: [{ severity: "high" }, { severity: "high" }] },
{ label: "maintenance risk", findings: [{ severity: "high" }] },
],
"medium",
);
expect(line).toBe(
"Failing: 2 vulnerability findings, 1 maintenance risk finding at or above medium (--fail-on medium).",
);
});

it("returns null on a clean run so output is unchanged", () => {
expect(
failingGateSummary([{ label: "vulnerability", findings: [{ severity: "low" }] }], "high"),
).toBeNull();
});

it("returns null when --fail-on is not set", () => {
expect(
failingGateSummary([{ label: "vulnerability", findings: [{ severity: "critical" }] }], ""),
).toBeNull();
});

it("returns null for an empty class list", () => {
expect(failingGateSummary([], "low")).toBeNull();
});

it("echoes the raw --fail-on value instead of the normalized level", () => {
// normalizeSeverity falls back to critical for an unknown value, so the
// parenthetical must show what the user typed, not a value they did not.
const line = failingGateSummary(
[{ label: "vulnerability", findings: [{ severity: "critical" }] }],
"hgih",
);
expect(line).toBe(
"Failing: 1 vulnerability finding at or above critical (--fail-on hgih).",
);
});
});
4 changes: 2 additions & 2 deletions website/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,14 @@ 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` |
| `--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` |
| `--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` |
| `--usage` | off | Scan source files to detect which packages are actually imported | `cve-lite . --usage` |
| `--only-used` | off | Show only findings for packages that are imported in source code (implies `--usage`) | `cve-lite . --only-used` |
| `--only-used` | off | Show only findings for packages that are imported in source code (implies `--usage`). This scopes vulnerability findings only. Override hygiene and maintenance risk findings still count toward `--fail-on` unfiltered, because neither is a reachability question. | `cve-lite . --only-used` |

**Note:** `--usage-hints` is a deprecated alias for `--usage`.

Expand Down
Loading