diff --git a/src/cli/args.ts b/src/cli/args.ts index f37c9d98..8fa96186 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -371,6 +371,18 @@ export function parseArgs(argv: string[]): { options.noCache = true; continue; } + if (arg === "--scan-timeout") { + const val = argv[++i]; + if (!val) throw new Error("--scan-timeout requires a duration, e.g. --scan-timeout 120s"); + options.scanTimeout = val; + continue; + } + if (arg.startsWith("--scan-timeout=")) { + const val = arg.slice("--scan-timeout=".length); + if (!val) throw new Error("--scan-timeout requires a duration, e.g. --scan-timeout 120s"); + options.scanTimeout = val; + continue; + } if (arg === "--ca-cert") { const val = argv[++i]; if (!val) throw new Error("--ca-cert requires a path argument"); diff --git a/src/cli/help.ts b/src/cli/help.ts index dfbeb75b..4cdd072a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -69,6 +69,7 @@ export function printHelp(): void { " --cache-dir Override cache directory", " --no-cache Skip the query cache and fetch fresh results from OSV", " --search-depth Recursive search depth (default: 4)", + " --scan-timeout Abort the scan after this long (e.g. 90, 90s, 5m, max 60m)", " --all Show all findings in the main table", " --min-severity Minimum severity shown in table (default: medium)", "", diff --git a/src/cli/validate.ts b/src/cli/validate.ts index cb04c0fc..a4c7b86f 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -2,6 +2,7 @@ import { validateCaCertFile } from "./config.js"; import { validateOsvUrl } from "../utils/validate-url.js"; import type { ParsedOptions } from "../types.js"; import { resolveSbomFormat } from "../utils/sbom-format.js"; +import { parseScanTimeoutMs } from "../utils/scan-timeout.js"; export async function validateOptions(options: ParsedOptions): Promise { if (options.allowPrivateOsvUrl && !options.osvUrl) { @@ -86,5 +87,13 @@ export async function validateOptions(options: ParsedOptions): Promise= 2) { - const exitCode = await handleMultiFolderScan({ + const multiFolderWork = handleMultiFolderScan({ projectRoot: projectPath, batchSize, options, fetchImpl: certFetch, auditLog: auditLogHandle, }); + const exitCode = scanTimeoutMs !== undefined + ? await withScanTimeout(multiFolderWork, scanTimeoutMs).catch(handleScanTimeout) + : await multiFolderWork; auditLogHandle.close(); process.exit(exitCode); } @@ -373,7 +381,7 @@ if (parsedArgs) { } if (!options.json && !options.ratchet) console.log(); - let scanState = await scanProject({ + const scanWork = scanProject({ scanInput, batchSize, options, @@ -381,6 +389,9 @@ if (parsedArgs) { debugLog, fetchImpl: certFetch, }); + let scanState = scanTimeoutMs !== undefined + ? await withScanTimeout(scanWork, scanTimeoutMs).catch(handleScanTimeout) + : await scanWork; const findingsBeforeFixList = scanState.sorted; const findingsBeforeFix = findingsBeforeFixList.length; let fixResult: FixExecutionResult | null = null; @@ -898,6 +909,15 @@ if (parsedArgs) { } } +function handleScanTimeout(error: unknown): never { + if (error instanceof ScanTimeoutError) { + console.error(chalk.red(`Error: ${error.message}`)); + console.error(chalk.gray("Run with a larger --scan-timeout or scope the scan down for large projects.")); + process.exit(EXIT_ERROR); + } + throw error; +} + async function scanProject(params: { scanInput: ReturnType; batchSize: number; diff --git a/src/types.ts b/src/types.ts index 5ceb6f48..b8f0ed8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -295,6 +295,8 @@ export type ParsedOptions = { rule?: string; /** --allow-private-osv-url - allow --osv-url to resolve to private/reserved IPs. */ allowPrivateOsvUrl?: boolean; + /** --scan-timeout - abort the scan after this long (e.g. 90, 90s, 5m). */ + scanTimeout?: string; }; /** diff --git a/src/utils/scan-timeout.ts b/src/utils/scan-timeout.ts new file mode 100644 index 00000000..9140e32e --- /dev/null +++ b/src/utils/scan-timeout.ts @@ -0,0 +1,89 @@ +/** + * Scan timeout support (issue #983). + * + * Large monorepos can stall a scan for minutes (lockfile parsing, OSV batch + * fan-out, usage scanning). `--scan-timeout` gives CI a deterministic guard: + * the whole scan is raced against a timer and fails with exit code 3 and an + * actionable message instead of hanging until the CI job times out. + */ + +export const SCAN_TIMEOUT_ENV = "CVE_LITE_SCAN_TIMEOUT"; +export const MAX_SCAN_TIMEOUT_MS = 3600_000; +export const MIN_SCAN_TIMEOUT_MS = 1000; + +export class ScanTimeoutError extends Error { + readonly timeoutMs: number; + constructor(timeoutMs: number) { + super( + `Scan timed out after ${formatTimeout(timeoutMs)}. ` + + `Increase --scan-timeout, narrow the scope with --prod-only/--only-used, ` + + `or split the scan per workspace.`, + ); + this.name = "ScanTimeoutError"; + this.timeoutMs = timeoutMs; + } +} + +/** + * Accepts seconds ("90"), explicit units ("90s", "5m", "1500ms"), or bare ms + * when suffixed. Returns milliseconds. Throws on invalid input so arg + * validation can surface a clear error before the scan starts. + */ +export function parseScanTimeoutMs(raw: string): number { + const value = raw.trim().toLowerCase(); + if (value.length === 0) throw new Error("--scan-timeout requires a value, e.g. --scan-timeout 120s"); + const match = value.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)?$/); + if (!match) { + throw new Error( + `Invalid --scan-timeout value "${raw}". Use a number with optional unit, e.g. 90, 90s, 5m, 1500ms.`, + ); + } + const amount = Number(match[1]); + const unit = match[2] ?? "s"; + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error(`Invalid --scan-timeout value "${raw}". Timeout must be greater than zero.`); + } + let ms: number; + if (unit === "ms") ms = amount; + else if (unit === "m" || unit === "min" || unit === "mins" || unit === "minute" || unit === "minutes") ms = amount * 60_000; + else ms = amount * 1000; + if (ms < MIN_SCAN_TIMEOUT_MS) { + throw new Error(`Invalid --scan-timeout value "${raw}". Minimum timeout is 1s.`); + } + if (ms > MAX_SCAN_TIMEOUT_MS) { + throw new Error(`Invalid --scan-timeout value "${raw}". Maximum timeout is 60m.`); + } + return Math.round(ms); +} + +/** Resolve the effective timeout: explicit flag wins, then env var. */ +export function resolveScanTimeoutMs(options: { scanTimeout?: string }): number | undefined { + const raw = options.scanTimeout ?? process.env[SCAN_TIMEOUT_ENV]; + if (raw === undefined || raw === "") return undefined; + return parseScanTimeoutMs(raw); +} + +/** Race any scan promise against the timeout. Rejects with ScanTimeoutError. */ +export function withScanTimeout(work: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new ScanTimeoutError(timeoutMs)), timeoutMs); + timer.unref?.(); + }); + return Promise.race([work, timeout]).then( + (result) => { + if (timer) clearTimeout(timer); + return result; + }, + (error) => { + if (timer) clearTimeout(timer); + throw error; + }, + ); +} + +export function formatTimeout(ms: number): string { + if (ms % 60_000 === 0) return `${ms / 60_000}m`; + if (ms % 1000 === 0) return `${ms / 1000}s`; + return `${ms}ms`; +} diff --git a/tests/scan-timeout.test.ts b/tests/scan-timeout.test.ts new file mode 100644 index 00000000..db48e007 --- /dev/null +++ b/tests/scan-timeout.test.ts @@ -0,0 +1,104 @@ +import { parseArgs } from "../src/cli/args.js"; +import { validateOptions } from "../src/cli/validate.js"; +import { + parseScanTimeoutMs, + resolveScanTimeoutMs, + withScanTimeout, + ScanTimeoutError, + formatTimeout, +} from "../src/utils/scan-timeout.js"; + +describe("parseScanTimeoutMs", () => { + it("parses bare seconds and explicit units", () => { + expect(parseScanTimeoutMs("90")).toBe(90_000); + expect(parseScanTimeoutMs("90s")).toBe(90_000); + expect(parseScanTimeoutMs("1500ms")).toBe(1500); + expect(parseScanTimeoutMs("5m")).toBe(300_000); + expect(parseScanTimeoutMs("2min")).toBe(120_000); + expect(parseScanTimeoutMs(" 30 SECONDS ")).toBe(30_000); + }); + + it("rejects invalid, zero, too-small, and too-large values", () => { + expect(() => parseScanTimeoutMs("")).toThrow("--scan-timeout requires a value"); + expect(() => parseScanTimeoutMs("fast")).toThrow("Invalid --scan-timeout value"); + expect(() => parseScanTimeoutMs("0s")).toThrow("greater than zero"); + expect(() => parseScanTimeoutMs("500ms")).toThrow("Minimum timeout is 1s"); + expect(() => parseScanTimeoutMs("61m")).toThrow("Maximum timeout is 60m"); + expect(() => parseScanTimeoutMs("-5s")).toThrow("Invalid --scan-timeout value"); + }); +}); + +describe("resolveScanTimeoutMs", () => { + const ENV_KEY = "CVE_LITE_SCAN_TIMEOUT"; + const saved = process.env[ENV_KEY]; + + afterEach(() => { + if (saved === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = saved; + }); + + it("returns undefined when neither flag nor env is set", () => { + delete process.env[ENV_KEY]; + expect(resolveScanTimeoutMs({})).toBeUndefined(); + }); + + it("prefers the explicit flag over the env var", () => { + process.env[ENV_KEY] = "5m"; + expect(resolveScanTimeoutMs({ scanTimeout: "30s" })).toBe(30_000); + }); + + it("falls back to the env var", () => { + process.env[ENV_KEY] = "2m"; + expect(resolveScanTimeoutMs({})).toBe(120_000); + }); +}); + +describe("withScanTimeout", () => { + it("resolves fast work before the deadline", async () => { + await expect(withScanTimeout(Promise.resolve("ok"), 1000)).resolves.toBe("ok"); + }); + + it("rejects slow work with an actionable ScanTimeoutError", async () => { + const slow = new Promise(() => {}); + const error = await withScanTimeout(slow, 20).catch((e) => e); + expect(error).toBeInstanceOf(ScanTimeoutError); + expect((error as Error).message).toMatch(/Scan timed out after/); + expect((error as Error).message).toMatch(/Increase --scan-timeout/); + }); + + it("propagates the work error instead of masking it as a timeout", async () => { + const failing = Promise.reject(new Error("osv exploded")); + await expect(withScanTimeout(failing, 1000)).rejects.toThrow("osv exploded"); + }); +}); + +describe("--scan-timeout CLI wiring", () => { + it("parses --scan-timeout and --scan-timeout= forms", () => { + expect(parseArgs([".", "--scan-timeout", "90s"]).options.scanTimeout).toBe("90s"); + expect(parseArgs([".", "--scan-timeout=5m"]).options.scanTimeout).toBe("5m"); + }); + + it("requires a duration value", () => { + expect(() => parseArgs([".", "--scan-timeout"])).toThrow("--scan-timeout requires a duration"); + }); + + it("rejects invalid timeouts during validation", async () => { + await expect(validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", minSeverity: "medium", scanTimeout: "fast" } as never)).rejects.toThrow( + "Invalid --scan-timeout value", + ); + }); + + it("accepts a valid timeout during validation", async () => { + await expect( + validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", minSeverity: "medium", scanTimeout: "2m", incompletePolicy: "warn" } as never), + ).resolves.toBeUndefined(); + }); +}); + +describe("formatTimeout", () => { + it("formats minutes, seconds, and milliseconds compactly", () => { + expect(formatTimeout(300_000)).toBe("5m"); + expect(formatTimeout(90_000)).toBe("90s"); + expect(formatTimeout(1500)).toBe("1500ms"); + }); +}); diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index c95fefcd..a2f3b1a1 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -26,6 +26,7 @@ cve-lite install-skill | `--all` | off | Show all findings including low and unknown; appends a full table in compact mode | `cve-lite . --all` | | `--search-depth` | `4` | How many directory levels deep to search for a lockfile | `cve-lite . --search-depth 2` | | `--batch-size` | `100` | Number of packages sent per OSV API request | `cve-lite . --batch-size 50` | +| `--scan-timeout` | _(none)_ | Abort the scan after this long (`90`, `90s`, `5m`, `1500ms`; max `60m`). Exits `3` with guidance on timeout. Also settable via `CVE_LITE_SCAN_TIMEOUT` | `cve-lite . --scan-timeout 5m` | | `--create-pr` | off | After --fix, commit changes and open a GitHub pull request (requires `gh`) | `cve-lite --fix --create-pr` | | `--base ` | `main` | Base branch for `--create-pr` | `cve-lite --fix --create-pr --base develop` | | `--debug` | off | Write verbose runtime/network diagnostics to a timestamped log file | `cve-lite --debug` |