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
12 changes: 12 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function printHelp(): void {
" --cache-dir <path> Override cache directory",
" --no-cache Skip the query cache and fetch fresh results from OSV",
" --search-depth <number> Recursive search depth (default: 4)",
" --scan-timeout <duration> Abort the scan after this long (e.g. 90, 90s, 5m, max 60m)",
" --all Show all findings in the main table",
" --min-severity <level> Minimum severity shown in table (default: medium)",
"",
Expand Down
9 changes: 9 additions & 0 deletions src/cli/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
if (options.allowPrivateOsvUrl && !options.osvUrl) {
Expand Down Expand Up @@ -86,5 +87,13 @@ export async function validateOptions(options: ParsedOptions): Promise<string |
}
}

if (options.scanTimeout !== undefined) {
try {
parseScanTimeoutMs(options.scanTimeout);
} catch (err) {
throw new Error(err instanceof Error ? err.message : String(err));
}
}

return ssrfWarning;
}
24 changes: 22 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { formatAdvisoryDbFreshness } from "./utils/time.js";
import { pluralize, nearestCommand } from "./utils/string.js";
import type { FetchLike, ParsedOptions } from "./types.js";
import { EXIT_ERROR } from "./types.js";
import { resolveScanTimeoutMs, withScanTimeout, ScanTimeoutError, formatTimeout } from "./utils/scan-timeout.js";
import {
formatAdvisorySourceLine,
formatHintLines,
Expand Down Expand Up @@ -243,18 +244,25 @@ if (parsedArgs) {

const validationWarning = await validateOptions(options);
if (validationWarning) logWarn(validationWarning, options);
const scanTimeoutMs = resolveScanTimeoutMs(options);
if (scanTimeoutMs !== undefined) {
logInfo(`Scan timeout: ${formatTimeout(scanTimeoutMs)} (--scan-timeout)`, options);
}

// Multi-folder mode: if no root lockfile and 2+ nested lockfiles exist,
// route to dedicated multi-folder handler instead of single-lockfile scan
const nestedLockfiles = findNestedLockfiles(projectPath, searchDepth);
if (!hasRootLockfile(projectPath) && nestedLockfiles.length >= 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);
}
Expand Down Expand Up @@ -373,14 +381,17 @@ if (parsedArgs) {
}

if (!options.json && !options.ratchet) console.log();
let scanState = await scanProject({
const scanWork = scanProject({
scanInput,
batchSize,
options,
projectPath,
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;
Expand Down Expand Up @@ -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<typeof loadPackages>;
batchSize: number;
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <duration> - abort the scan after this long (e.g. 90, 90s, 5m). */
scanTimeout?: string;
};

/**
Expand Down
89 changes: 89 additions & 0 deletions src/utils/scan-timeout.ts
Original file line number Diff line number Diff line change
@@ -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<T>(work: Promise<T>, timeoutMs: number): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, 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`;
}
104 changes: 104 additions & 0 deletions tests/scan-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(() => {});
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");
});
});
1 change: 1 addition & 0 deletions website/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>` | `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` |
Expand Down