diff --git a/src/index.ts b/src/index.ts index 30627b5..08048d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,16 @@ import { createSpinner } from "./output/spinner.js"; import { buildSuggestedFixCommandPlan } from "./remediation/fix-commands.js"; import { scanProjectForPackageUsage } from "./usage/scanner.js"; import { getCliVersion } from "./utils/version-info.js"; -import { isLikelyBlockedAdvisoryRequestError, isSslCertificateError, sslCertificateErrorHint, blockedAdvisoryRequestHint } from "./utils/network.js"; +import { + blockedAdvisoryRequestHint, + isLikelyBlockedAdvisoryRequestError, + isRateLimitError, + isServerError, + isSslCertificateError, + rateLimitAdvisoryRequestHint, + serverAdvisoryRequestHint, + sslCertificateErrorHint, +} from "./utils/network.js"; import { formatAdvisoryDbFreshness } from "./utils/time.js"; import type { SuggestedFixCommandPlan, SuggestedFixTarget } from "./remediation/fix-commands.js"; import type { ParsedOptions } from "./types.js"; @@ -290,6 +299,14 @@ if (parsedArgs) { const [hint, ...rest] = sslCertificateErrorHint(); console.error(chalk.yellow(hint)); rest.forEach(line => console.error(chalk.gray(line))); + } else if (isRateLimitError(errorMessage)) { + const [hint, ...rest] = rateLimitAdvisoryRequestHint(); + console.error(chalk.yellow(hint)); + rest.forEach(line => console.error(chalk.gray(line))); + } else if (isServerError(errorMessage)) { + const [hint, ...rest] = serverAdvisoryRequestHint(); + console.error(chalk.yellow(hint)); + rest.forEach(line => console.error(chalk.gray(line))); } else if (isLikelyBlockedAdvisoryRequestError(errorMessage)) { const [hint, ...rest] = blockedAdvisoryRequestHint(); console.error(chalk.yellow(hint)); diff --git a/src/utils/network.ts b/src/utils/network.ts index 69e4e3b..7b25fcf 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -23,6 +23,25 @@ export function blockedAdvisoryRequestHint(): string[] { ]; } +const OFFLINE_FALLBACK_LINES = [ + " cve-lite . --offline", + " (requires a local advisory DB - run `cve-lite advisories sync` to build one)", +]; + +export function rateLimitAdvisoryRequestHint(): string[] { + return [ + "Hint: OSV API rate limit reached. Wait a moment and retry, or scan offline:", + ...OFFLINE_FALLBACK_LINES, + ]; +} + +export function serverAdvisoryRequestHint(): string[] { + return [ + "Hint: OSV API may be temporarily unavailable. Wait a moment and retry, or scan offline:", + ...OFFLINE_FALLBACK_LINES, + ]; +} + const SSL_ERROR_CODES = new Set([ "SELF_SIGNED_CERT_IN_CHAIN", "CERT_UNTRUSTED", @@ -41,6 +60,23 @@ const SSL_ERROR_MESSAGE_FRAGMENTS = [ "unable to verify the first certificate", ]; +function finalErrorCause(message: string): string { + const normalized = message.toLowerCase(); + return normalized.split(":").pop()?.trim() ?? normalized; +} + +function isOsvError(message: string): boolean { + return message.toLowerCase().includes("osv"); +} + +function isRateLimitCause(finalCause: string): boolean { + return /^429\b/.test(finalCause); +} + +function isServerCause(finalCause: string): boolean { + return /^5\d\d\b/.test(finalCause); +} + export function isSslCertificateError(error: unknown): boolean { if (!(error instanceof Error)) return false; const code = (error as NodeJS.ErrnoException).code; @@ -51,13 +87,32 @@ export function isSslCertificateError(error: unknown): boolean { return SSL_ERROR_MESSAGE_FRAGMENTS.some(f => normalized.includes(f)); } +export function isRateLimitError(message: string): boolean { + if (!isOsvError(message)) { + return false; + } + + return isRateLimitCause(finalErrorCause(message)); +} + +export function isServerError(message: string): boolean { + if (!isOsvError(message)) { + return false; + } + + return isServerCause(finalErrorCause(message)); +} + export function isLikelyBlockedAdvisoryRequestError(message: string): boolean { - if (!message.includes("OSV")) { + if (!isOsvError(message)) { + return false; + } + + const finalCause = finalErrorCause(message); + if (isRateLimitCause(finalCause) || isServerCause(finalCause)) { return false; } - const normalized = message.toLowerCase(); - const finalCause = normalized.split(":").pop()?.trim() ?? normalized; const blockedIndicators = [ "access denied", "blocked", @@ -85,5 +140,5 @@ export function isLikelyBlockedAdvisoryRequestError(message: string): boolean { return true; } - return /^(401|403|407|408|429|451|502|503|504)\b/.test(finalCause); + return /^(401|403|407|408|451)\b/.test(finalCause); } diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index a0d043b..8183eee 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -84,6 +84,7 @@ jest.unstable_mockModule("../src/output/formatters.js", () => ({ printCacheSummary: printCacheSummaryMock, serializeFinding: serializeFindingMock, sortFindingsForOutput: sortFindingsForOutputMock, + formatAdvisorySourceLine: jest.fn(sourceLabel => sourceLabel), getRecommendedAction: jest.fn(() => "Upgrade to latest"), })); @@ -518,6 +519,44 @@ describe("CLI integration", () => { expect(stderr).toContain("cve-lite advisories sync --output /path/to/advisories.db"); }); + it("prints a retry and offline hint when OSV rate limits the scan", async () => { + loadPackagesMock.mockReturnValue(createScanInput({ + packages: [{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] }], + })); + scanPackagesMock.mockRejectedValue( + new Error("OSV batch query failed for https://api.osv.dev: OSV batch query failed: 429 Too Many Requests"), + ); + + const result = await runIndexModule(); + const stderr = stripAnsi(result.stderr.join("\n")); + + expect(result.exitCode).toBe(1); + expect(stderr).toContain("Error: OSV batch query failed for https://api.osv.dev: OSV batch query failed: 429 Too Many Requests"); + expect(stderr).toContain("Hint: OSV API rate limit reached. Wait a moment and retry, or scan offline:"); + expect(stderr).toContain("cve-lite . --offline"); + expect(stderr).toContain("cve-lite advisories sync"); + expect(stderr).not.toContain("Outbound access to the OSV API may be blocked"); + }); + + it("prints a transient service hint when OSV returns a server error", async () => { + loadPackagesMock.mockReturnValue(createScanInput({ + packages: [{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] }], + })); + scanPackagesMock.mockRejectedValue( + new Error("OSV batch query failed for https://api.osv.dev: OSV batch query failed: 503 Service Unavailable"), + ); + + const result = await runIndexModule(); + const stderr = stripAnsi(result.stderr.join("\n")); + + expect(result.exitCode).toBe(1); + expect(stderr).toContain("Error: OSV batch query failed for https://api.osv.dev: OSV batch query failed: 503 Service Unavailable"); + expect(stderr).toContain("Hint: OSV API may be temporarily unavailable. Wait a moment and retry, or scan offline:"); + expect(stderr).toContain("cve-lite . --offline"); + expect(stderr).toContain("cve-lite advisories sync"); + expect(stderr).not.toContain("Outbound access to the OSV API may be blocked"); + }); + it("routes verbose mode through the detailed printer pipeline", async () => { const finding = createFinding({ severity: "medium" }); parseArgsMock.mockReturnValue({ diff --git a/tests/network.test.ts b/tests/network.test.ts index 0864cac..4cc5c4f 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -1,4 +1,4 @@ -import { isLikelyBlockedAdvisoryRequestError, isSslCertificateError, extractErrorMessage } from "../src/utils/network.js"; +import { isLikelyBlockedAdvisoryRequestError, isRateLimitError, isServerError, isSslCertificateError, extractErrorMessage } from "../src/utils/network.js"; describe("extractErrorMessage", () => { it("returns the message of a plain Error", () => { @@ -76,4 +76,58 @@ describe("isLikelyBlockedAdvisoryRequestError", () => { ), ).toBe(false); }); + + it("returns false for OSV rate-limit and server responses", () => { + for (const message of [ + "OSV batch query failed for https://api.osv.dev: OSV batch query failed: 429 Too Many Requests", + "OSV batch query failed for https://api.osv.dev: OSV batch query failed: 503 Service Unavailable", + ]) { + expect(isLikelyBlockedAdvisoryRequestError(message)).toBe(false); + } + }); +}); + +describe("isRateLimitError", () => { + it("returns true for OSV 429 responses", () => { + expect( + isRateLimitError( + "OSV batch query failed for https://api.osv.dev: OSV batch query failed: 429 Too Many Requests", + ), + ).toBe(true); + }); + + it("matches OSV errors case-insensitively", () => { + expect( + isRateLimitError( + "osv batch query failed for https://api.osv.dev: osv batch query failed: 429 Too Many Requests", + ), + ).toBe(true); + }); + + it("returns false for non-OSV 429 responses", () => { + expect(isRateLimitError("API failed: 429 Too Many Requests")).toBe(false); + }); +}); + +describe("isServerError", () => { + it("returns true for OSV 5xx responses", () => { + for (const message of [ + "OSV batch query failed for https://api.osv.dev: OSV batch query failed: 500 Internal Server Error", + "OSV batch query failed for https://api.osv.dev: OSV batch query failed: 503 Service Unavailable", + ]) { + expect(isServerError(message)).toBe(true); + } + }); + + it("returns false for OSV client errors", () => { + expect( + isServerError( + "OSV vuln fetch failed for OSV-404 via https://api.osv.dev: OSV vuln fetch failed for OSV-404: 404 Not Found", + ), + ).toBe(false); + }); + + it("returns false for non-OSV 5xx responses", () => { + expect(isServerError("API failed: 500 Internal Server Error")).toBe(false); + }); });