From 31d6c8d87c4f50dd63e5fa46959be2146a24920e Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Fri, 22 May 2026 16:59:47 -0400 Subject: [PATCH 1/2] fix: walk error cause chain and add no-hyphen variant for SSL certificate detection --- src/advisory/osv-advisory-source.ts | 5 +++-- src/utils/network.ts | 8 ++++++++ tests/network.test.ts | 20 +++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/advisory/osv-advisory-source.ts b/src/advisory/osv-advisory-source.ts index 3dea914..3c7cc9e 100644 --- a/src/advisory/osv-advisory-source.ts +++ b/src/advisory/osv-advisory-source.ts @@ -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") {} @@ -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}`); } } @@ -51,7 +52,7 @@ export class OsvAdvisorySource implements AdvisorySource { return response.json() as Promise; } 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}`); } } diff --git a/src/utils/network.ts b/src/utils/network.ts index 1a77c8b..5ed3faf 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -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.", @@ -19,6 +26,7 @@ export function blockedAdvisoryRequestHint(): string[] { export function isSslCertificateError(message: string): boolean { const normalized = message.toLowerCase(); return [ + "self signed certificate", "self-signed certificate", "self_signed_cert_in_chain", "cert_untrusted", diff --git a/tests/network.test.ts b/tests/network.test.ts index 2698419..24a4501 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -1,7 +1,25 @@ -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("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); From 93cac906de4a8afe3c67eec307de6432b7c38397 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Fri, 22 May 2026 17:03:29 -0400 Subject: [PATCH 2/2] fix: check error code and cause chain for SSL detection instead of string matching --- src/index.ts | 2 +- src/utils/network.ts | 40 ++++++++++++++++++++++++++-------------- tests/network.test.ts | 38 ++++++++++++++++++++++---------------- 3 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/index.ts b/src/index.ts index a4bd7a0..d01afde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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))); diff --git a/src/utils/network.ts b/src/utils/network.ts index 5ed3faf..69e4e3b 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -23,20 +23,32 @@ export function blockedAdvisoryRequestHint(): string[] { ]; } -export function isSslCertificateError(message: string): boolean { - const normalized = message.toLowerCase(); - return [ - "self signed certificate", - "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 { diff --git a/tests/network.test.ts b/tests/network.test.ts index 24a4501..0864cac 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -18,29 +18,35 @@ describe("extractErrorMessage", () => { }); 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("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 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 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 expired certificate errors", () => { - expect(isSslCertificateError("certificate has expired")).toBe(true); - expect(isSslCertificateError("cert_has_expired")).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("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); }); });