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
19 changes: 18 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
63 changes: 59 additions & 4 deletions src/utils/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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);
}
39 changes: 39 additions & 0 deletions tests/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ jest.unstable_mockModule("../src/output/formatters.js", () => ({
printCacheSummary: printCacheSummaryMock,
serializeFinding: serializeFindingMock,
sortFindingsForOutput: sortFindingsForOutputMock,
formatAdvisorySourceLine: jest.fn<any>(sourceLabel => sourceLabel),
getRecommendedAction: jest.fn<any>(() => "Upgrade to latest"),
}));

Expand Down Expand Up @@ -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({
Expand Down
56 changes: 55 additions & 1 deletion tests/network.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small symmetry note: isRateLimitError has a test for non-OSV 429 (line 91) returning false, but isServerError doesn't have the equivalent. Would be good to add:

it("returns false for non-OSV 5xx responses", () => {
  expect(isServerError("API failed: 500 Internal Server Error")).toBe(false);
});


it("returns false for non-OSV 5xx responses", () => {
expect(isServerError("API failed: 500 Internal Server Error")).toBe(false);
});
});