diff --git a/src/index.ts b/src/index.ts index 6af001fd..6eb58b9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,24 +16,14 @@ import { createDebugLogger, type DebugLogger } from "./output/debug.js"; import { buildSuggestedFixCommandPlan } from "./remediation/fix-commands.js"; import { scanProjectForPackageUsage } from "./usage/scanner.js"; import { getCliVersion } from "./utils/version-info.js"; -import { - blockedAdvisoryRequestHint, - fetchErrorCaCertHint, - isLikelyBlockedAdvisoryRequestError, - isRateLimitError, - isServerError, - isSslCertificateError, - offlineDbSyncHint, - rateLimitAdvisoryRequestHint, - serverAdvisoryRequestHint, - sslCertificateErrorHint, -} from "./utils/network.js"; +import { getNetworkErrorHint, offlineDbSyncHint } from "./utils/network.js"; import { formatAdvisoryDbFreshness } from "./utils/time.js"; import { pluralize, nearestCommand } from "./utils/string.js"; import type { ParsedOptions } from "./types.js"; import { EXIT_ERROR } from "./types.js"; import { formatAdvisorySourceLine, + formatHintLines, logInfo, logWarn, printCacheSummary, @@ -765,27 +755,8 @@ if (parsedArgs) { }); auditLogHandle.close(); - if (isSslCertificateError(error)) { - 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)); - rest.forEach(line => console.error(chalk.gray(line))); - } else { - const [hint, ...rest] = fetchErrorCaCertHint(); - console.error(chalk.yellow(hint)); - rest.forEach(line => console.error(chalk.gray(line))); - } + formatHintLines(getNetworkErrorHint(error, errorMessage)) + .forEach(line => console.error(line)); process.exit(1); }); } @@ -863,4 +834,3 @@ async function scanProject(params: { }; } - diff --git a/src/output/formatters.ts b/src/output/formatters.ts index 3a67bb03..131736c0 100644 --- a/src/output/formatters.ts +++ b/src/output/formatters.ts @@ -34,6 +34,15 @@ export function formatAdvisorySourceLine(sourceLabel: string): string { return `${match[1]} (${chalk.cyan(match[2])})`; } +export function formatHintLines(lines: string[]): string[] { + if (lines.length === 0) return []; + const [first, ...rest] = lines; + return [ + chalk.yellow(first), + ...rest.map(line => chalk.gray(line)), + ]; +} + // MAL-* is the OSV prefix for malicious code advisories function isMaliciousAdvisory(finding: Finding): boolean { return finding.vulnerabilities.some(v => v.id.startsWith("MAL-")); @@ -338,4 +347,3 @@ export function sortFindingsForOutput(findings: Finding[]): Finding[] { export function countUniqueAdvisories(findings: Finding[]): number { return new Set(findings.flatMap(f => f.vulnerabilities.map(v => v.id))).size; } - diff --git a/src/utils/network.ts b/src/utils/network.ts index 31bb7f90..dc622f22 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -158,3 +158,11 @@ export function isLikelyBlockedAdvisoryRequestError(message: string): boolean { return /^(401|403|407|408|451)\b/.test(finalCause); } + +export function getNetworkErrorHint(error: unknown, errorMessage: string): string[] { + if (isSslCertificateError(error)) return sslCertificateErrorHint(); + if (isRateLimitError(errorMessage)) return rateLimitAdvisoryRequestHint(); + if (isServerError(errorMessage)) return serverAdvisoryRequestHint(); + if (isLikelyBlockedAdvisoryRequestError(errorMessage)) return blockedAdvisoryRequestHint(); + return fetchErrorCaCertHint(); +} diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index def7728a..196f7b5e 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -84,6 +84,7 @@ jest.unstable_mockModule("../src/advisory/osv-sync.js", () => ({ jest.unstable_mockModule("../src/output/formatters.js", () => ({ formatAdvisorySourceLine: jest.fn((sourceLabel: string) => sourceLabel), + formatHintLines: jest.fn((lines: string[]) => lines), logInfo: logInfoMock, logWarn: logWarnMock, printCacheSummary: printCacheSummaryMock, diff --git a/tests/network.test.ts b/tests/network.test.ts index 4cc5c4f0..094d66f0 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -1,4 +1,16 @@ -import { isLikelyBlockedAdvisoryRequestError, isRateLimitError, isServerError, isSslCertificateError, extractErrorMessage } from "../src/utils/network.js"; +import { + blockedAdvisoryRequestHint, + extractErrorMessage, + fetchErrorCaCertHint, + getNetworkErrorHint, + isLikelyBlockedAdvisoryRequestError, + isRateLimitError, + isServerError, + isSslCertificateError, + rateLimitAdvisoryRequestHint, + serverAdvisoryRequestHint, + sslCertificateErrorHint, +} from "../src/utils/network.js"; describe("extractErrorMessage", () => { it("returns the message of a plain Error", () => { @@ -131,3 +143,35 @@ describe("isServerError", () => { expect(isServerError("API failed: 500 Internal Server Error")).toBe(false); }); }); + +describe("getNetworkErrorHint", () => { + it("returns the SSL certificate hint for SSL errors", () => { + const err = Object.assign(new Error("fetch failed"), { code: "SELF_SIGNED_CERT_IN_CHAIN" }); + + expect(getNetworkErrorHint(err, "fetch failed")).toEqual(sslCertificateErrorHint()); + }); + + it("returns the rate-limit hint for OSV 429 errors", () => { + const message = "OSV batch query failed for https://api.osv.dev: OSV batch query failed: 429 Too Many Requests"; + + expect(getNetworkErrorHint(new Error(message), message)).toEqual(rateLimitAdvisoryRequestHint()); + }); + + it("returns the server-error hint for OSV 5xx errors", () => { + const message = "OSV batch query failed for https://api.osv.dev: OSV batch query failed: 503 Service Unavailable"; + + expect(getNetworkErrorHint(new Error(message), message)).toEqual(serverAdvisoryRequestHint()); + }); + + it("returns the blocked-advisory hint for likely blocked OSV requests", () => { + const message = "OSV batch query failed for https://api.osv.dev: fetch failed"; + + expect(getNetworkErrorHint(new Error(message), message)).toEqual(blockedAdvisoryRequestHint()); + }); + + it("returns the CA certificate fallback hint for unrelated errors", () => { + const message = "Unexpected scan failure"; + + expect(getNetworkErrorHint(new Error(message), message)).toEqual(fetchErrorCaCertHint()); + }); +}); diff --git a/tests/output.test.ts b/tests/output.test.ts index 48127baf..63d8b8b2 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -14,6 +14,7 @@ import { summarizeNextAction, summarizeRisk, formatRelLabel, + formatHintLines, formatFixCommandWithPublishDates, formatFixVersionPublishDate, } from "../src/output/formatters.js"; @@ -159,6 +160,21 @@ describe("output formatters", () => { } }); + it("formats empty hint lines as empty output", () => { + expect(formatHintLines([])).toEqual([]); + }); + + it("formats the first hint line before gray detail lines", () => { + const lines = formatHintLines(["Hint: retry later", "Run: cve-lite . --offline"]); + + expect(lines.map(line => stripAnsi(line))).toEqual([ + "Hint: retry later", + "Run: cve-lite . --offline", + ]); + expect(lines[0]).toContain("Hint: retry later"); + expect(lines[1]).toContain("Run: cve-lite . --offline"); + }); + it("getPrimaryParent returns null for paths shorter than 3 nodes", () => { const shortPath = createFinding({ dependencyPaths: [["project", "lodash"]],