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
5 changes: 3 additions & 2 deletions src/advisory/osv-advisory-source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { OsvVuln, PackageRef } from "../types.js";
import { AdvisorySource, AdvisoryResult } from "./advisory-source.js";
import { extractErrorMessage } from "../utils/network.js";

export class OsvAdvisorySource implements AdvisorySource {
constructor(private readonly baseUrl = "https://api.osv.dev") {}
Expand Down Expand Up @@ -34,7 +35,7 @@ export class OsvAdvisorySource implements AdvisorySource {
vulnerabilities: r.vulns || [],
}));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = extractErrorMessage(error);
throw new Error(`OSV batch query failed for ${this.baseUrl}: ${message}`);
}
}
Expand All @@ -51,7 +52,7 @@ export class OsvAdvisorySource implements AdvisorySource {

return response.json() as Promise<OsvVuln>;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = extractErrorMessage(error);
throw new Error(`OSV vuln fetch failed for ${id} via ${this.baseUrl}: ${message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ if (parsedArgs) {
main().catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`Error: ${errorMessage}`));
if (isSslCertificateError(errorMessage)) {
if (isSslCertificateError(error)) {
const [hint, ...rest] = sslCertificateErrorHint();
console.error(chalk.yellow(hint));
rest.forEach(line => console.error(chalk.gray(line)));
Expand Down
46 changes: 33 additions & 13 deletions src/utils/network.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export function extractErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const cause = (error as NodeJS.ErrnoException & { cause?: unknown }).cause;
if (cause instanceof Error) return `${error.message}: ${extractErrorMessage(cause)}`;
return error.message;
}

export function sslCertificateErrorHint(): string[] {
return [
"Hint: SSL certificate error — your network may be using a corporate proxy that intercepts HTTPS traffic.",
Expand All @@ -16,19 +23,32 @@ export function blockedAdvisoryRequestHint(): string[] {
];
}

export function isSslCertificateError(message: string): boolean {
const normalized = message.toLowerCase();
return [
"self-signed certificate",
"self_signed_cert_in_chain",
"cert_untrusted",
"unable_to_verify_leaf_signature",
"depth_zero_self_signed_cert",
"certificate has expired",
"cert_has_expired",
"unable to verify the first certificate",
"certificate chain",
].some(indicator => normalized.includes(indicator));
const SSL_ERROR_CODES = new Set([
"SELF_SIGNED_CERT_IN_CHAIN",
"CERT_UNTRUSTED",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"CERT_HAS_EXPIRED",
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"ERR_TLS_CERT_ALTNAME_INVALID",
]);

const SSL_ERROR_MESSAGE_FRAGMENTS = [
"self signed certificate",
"self-signed certificate",
"certificate chain",
"certificate has expired",
"unable to verify the first certificate",
];

export function isSslCertificateError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = (error as NodeJS.ErrnoException).code;
if (code && SSL_ERROR_CODES.has(code)) return true;
const cause = (error as { cause?: unknown }).cause;
if (cause) return isSslCertificateError(cause);
const normalized = error.message.toLowerCase();
return SSL_ERROR_MESSAGE_FRAGMENTS.some(f => normalized.includes(f));
}

export function isLikelyBlockedAdvisoryRequestError(message: string): boolean {
Expand Down
56 changes: 40 additions & 16 deletions tests/network.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
import { isLikelyBlockedAdvisoryRequestError, isSslCertificateError } from "../src/utils/network.js";
import { isLikelyBlockedAdvisoryRequestError, isSslCertificateError, extractErrorMessage } from "../src/utils/network.js";

describe("extractErrorMessage", () => {
it("returns the message of a plain Error", () => {
expect(extractErrorMessage(new Error("something went wrong"))).toBe("something went wrong");
});

it("walks the cause chain to surface nested SSL errors", () => {
const cause = new Error("self signed certificate in certificate chain");
const outer = new Error("fetch failed");
(outer as any).cause = cause;
expect(extractErrorMessage(outer)).toBe("fetch failed: self signed certificate in certificate chain");
});

it("returns String(error) for non-Error values", () => {
expect(extractErrorMessage("raw string error")).toBe("raw string error");
});
});

describe("isSslCertificateError", () => {
it("returns true for self-signed certificate errors", () => {
expect(isSslCertificateError("OSV batch query failed: self-signed certificate in certificate chain")).toBe(true);
expect(isSslCertificateError("fetch failed: self_signed_cert_in_chain")).toBe(true);
expect(isSslCertificateError("SELF_SIGNED_CERT_IN_CHAIN")).toBe(true);
it("returns true when error code is a known SSL code", () => {
const err = Object.assign(new Error("fetch failed"), { code: "SELF_SIGNED_CERT_IN_CHAIN" });
expect(isSslCertificateError(err)).toBe(true);
});

it("returns true when SSL code is on error.cause", () => {
const cause = Object.assign(new Error("self-signed certificate in certificate chain"), { code: "SELF_SIGNED_CERT_IN_CHAIN" });
const outer = Object.assign(new Error("fetch failed"), { cause });
expect(isSslCertificateError(outer)).toBe(true);
});

it("returns true for untrusted certificate errors", () => {
expect(isSslCertificateError("cert_untrusted")).toBe(true);
expect(isSslCertificateError("unable_to_verify_leaf_signature")).toBe(true);
expect(isSslCertificateError("depth_zero_self_signed_cert")).toBe(true);
expect(isSslCertificateError("unable to verify the first certificate")).toBe(true);
it("returns true for all known SSL error codes", () => {
for (const code of ["CERT_UNTRUSTED", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "DEPTH_ZERO_SELF_SIGNED_CERT", "CERT_HAS_EXPIRED", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"]) {
expect(isSslCertificateError(Object.assign(new Error("x"), { code }))).toBe(true);
}
});

it("returns true for expired certificate errors", () => {
expect(isSslCertificateError("certificate has expired")).toBe(true);
expect(isSslCertificateError("cert_has_expired")).toBe(true);
it("falls back to message matching when no code is present", () => {
expect(isSslCertificateError(new Error("self signed certificate in certificate chain"))).toBe(true);
expect(isSslCertificateError(new Error("self-signed certificate in certificate chain"))).toBe(true);
expect(isSslCertificateError(new Error("certificate has expired"))).toBe(true);
expect(isSslCertificateError(new Error("unable to verify the first certificate"))).toBe(true);
});

it("returns false for unrelated network errors", () => {
expect(isSslCertificateError("ECONNREFUSED")).toBe(false);
expect(isSslCertificateError("fetch failed")).toBe(false);
expect(isSslCertificateError("403 Forbidden")).toBe(false);
expect(isSslCertificateError(Object.assign(new Error("connection refused"), { code: "ECONNREFUSED" }))).toBe(false);
expect(isSslCertificateError(new Error("fetch failed"))).toBe(false);
expect(isSslCertificateError(new Error("403 Forbidden"))).toBe(false);
expect(isSslCertificateError("not an error")).toBe(false);
});
});

Expand Down