diff --git a/README.md b/README.md index 8d78333..1d409a7 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,9 @@ cve-lite /path/to/project --offline-db /path/to/advisories.db # Use a custom advisory endpoint cve-lite /path/to/project --osv-url https://security.company.internal/osv +# Allow custom endpoint to resolve to private/reserved IPs (for internal mirrors) +cve-lite /path/to/project --osv-url https://internal-mirror.company.local/osv --allow-private-osv-url + # Show version cve-lite --version diff --git a/src/advisory/osv-advisory-source.ts b/src/advisory/osv-advisory-source.ts index 1195ed2..c775d40 100644 --- a/src/advisory/osv-advisory-source.ts +++ b/src/advisory/osv-advisory-source.ts @@ -46,6 +46,7 @@ export class OsvAdvisorySource implements AdvisorySource { "Content-Type": "application/json", }, body: JSON.stringify(payload), + redirect: "error", }); if (this.debugLog) { this.debugLog("OSV response", { @@ -98,7 +99,7 @@ export class OsvAdvisorySource implements AdvisorySource { headers: {}, }); } - const response = await fetch(requestUrl); + const response = await fetch(requestUrl, { redirect: "error" }); if (this.debugLog) { this.debugLog("OSV response", { method: "GET", diff --git a/src/cli/args.ts b/src/cli/args.ts index 717d5b1..2239dd4 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -253,6 +253,10 @@ export function parseArgs(argv: string[]): { options.osvUrl = arg.slice("--osv-url=".length); continue; } + if (arg === "--allow-private-osv-url") { + options.allowPrivateOsvUrl = true; + continue; + } if (arg === "--output") { options.output = argv[++i]; continue; diff --git a/src/cli/help.ts b/src/cli/help.ts index 69434ad..7059798 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -43,7 +43,8 @@ export function printHelp(): void { " --ratchet Save current findings as baseline or, if baseline exists, only fail on new findings", " --create-pr After --fix, commit changes and open a GitHub pull request (requires gh)", " --base Base branch for --create-pr (default: main)", - " --osv-url Use a custom OSV-compatible advisory endpoint", + " --osv-url Use a custom OSV-compatible advisory endpoint (HTTPS only)", + " --allow-private-osv-url Allow --osv-url to resolve to private/reserved IPs", " --ca-cert Path to a CA certificate file for corporate SSL proxies", " --debug Write verbose runtime/network diagnostics to a timestamped log file", " --verbose Show detailed output with fix plan, paths, and full table", diff --git a/src/cli/validate.ts b/src/cli/validate.ts index fff3da9..787004b 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -1,7 +1,12 @@ import { validateCaCertFile } from "./config.js"; +import { validateOsvUrl } from "../utils/validate-url.js"; import type { ParsedOptions } from "../types.js"; -export function validateOptions(options: ParsedOptions): void { +export async function validateOptions(options: ParsedOptions): Promise { + if (options.allowPrivateOsvUrl && !options.osvUrl) { + throw new Error("--allow-private-osv-url requires --osv-url"); + } + if ((options.offline || options.offlineDb) && options.osvUrl) { throw new Error("--offline/--offline-db cannot be used with --osv-url"); } @@ -10,12 +15,15 @@ export function validateOptions(options: ParsedOptions): void { throw new Error("--no-cache cannot be used with --offline or --offline-db"); } + let ssrfWarning: string | undefined; if (options.osvUrl) { try { new URL(options.osvUrl); } catch { throw new Error(`Invalid value for --osv-url: ${options.osvUrl}`); } + // SSRF defense: validate scheme, IP, and allow-private flag + ssrfWarning = await validateOsvUrl(options.osvUrl, !!options.allowPrivateOsvUrl); } if (options.fix && options.json) { @@ -45,4 +53,6 @@ export function validateOptions(options: ParsedOptions): void { throw new Error(`--ca-cert: ${err instanceof Error ? err.message : String(err)}`); } } + + return ssrfWarning; } diff --git a/src/index.ts b/src/index.ts index 9046083..9953c1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -220,7 +220,8 @@ if (parsedArgs) { process.exit(exitCode); } - validateOptions(options); + const validationWarning = await validateOptions(options); + if (validationWarning) logWarn(validationWarning, options); // Multi-folder mode: if no root lockfile and 2+ nested lockfiles exist, // route to dedicated multi-folder handler instead of single-lockfile scan diff --git a/src/types.ts b/src/types.ts index 7ac4165..1093724 100644 --- a/src/types.ts +++ b/src/types.ts @@ -229,6 +229,8 @@ export type ParsedOptions = { checkNetwork?: boolean; /** --rule - filter `overrides` to a single rule (OA001-OA008). */ rule?: string; + /** --allow-private-osv-url - allow --osv-url to resolve to private/reserved IPs. */ + allowPrivateOsvUrl?: boolean; }; /** diff --git a/src/utils/validate-url.ts b/src/utils/validate-url.ts new file mode 100644 index 0000000..ba1487d --- /dev/null +++ b/src/utils/validate-url.ts @@ -0,0 +1,149 @@ +import dns from "node:dns"; +import { URL } from "node:url"; + +const OFFICIAL_OSV_HOSTS = ["api.osv.dev"]; + +// IPv4 special-purpose CIDRs — numerical match replaces error-prone regex +// https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml +const PRIVATE_IPV4_CIDRS = [ + "0.0.0.0/8", // "this network" + "10.0.0.0/8", // private Class A + "100.64.0.0/10", // CGNAT + "127.0.0.0/8", // loopback + "169.254.0.0/16", // link-local + "172.16.0.0/12", // private Class B + "192.168.0.0/16", // private Class C + "192.0.0.0/24", // IETF Protocol + "192.0.2.0/24", // documentation (TEST-NET-1) + "198.18.0.0/15", // benchmark + "198.51.100.0/24", // documentation (TEST-NET-2) + "203.0.113.0/24", // documentation (TEST-NET-3) + "224.0.0.0/4", // multicast + "240.0.0.0/4", // reserved + "255.255.255.255/32", // broadcast +]; + +function ipv4ToU32(ip: string): number | undefined { + const parts = ip.split("."); + if (parts.length !== 4) return undefined; + let n = 0; + for (const part of parts) { + if (!/^\d+$/.test(part)) return undefined; + const value = Number(part); + if (value < 0 || value > 255) return undefined; + n = (n << 8) + value; + } + return n >>> 0; +} + +function ipv4InCidr(ip: string, cidr: string): boolean { + const [base, bitsText] = cidr.split("/"); + const bits = Number(bitsText); + const ipNum = ipv4ToU32(ip); + const baseNum = ipv4ToU32(base); + if (ipNum === undefined || baseNum === undefined) return false; + if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false; + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (ipNum & mask) === (baseNum & mask); +} + +function isPrivateOrReservedIpv4(ip: string): boolean { + return PRIVATE_IPV4_CIDRS.some(cidr => ipv4InCidr(ip, cidr)); +} + +// IPv6 special-purpose ranges — kept as prefix regex checks to avoid extra dependencies. +const IPV6_PATTERNS = [ + /^::$/, // unspecified (::/128) + /^::1$/, // loopback (::1/128) + /^::ffff:(?:255\.255\.255\.255|0\.0\.0\.0)$/, // IPv4-mapped broadcast/unspecified + /^f[cd][0-9a-f]{2}:/i, // ULA (fc00::/7) + /^fe[89ab][0-9a-f]{0,2}:/i, // link-local (fe80::/10) + /^ff[0-9a-f]{2}:/i, // multicast (ff00::/8) + /^2001:db8:/, // documentation (2001:db8::/32) + /^64:ff9b::/, // NAT64 (64:ff9b::/96) + /^100::/, // discard (100::/64) + /^2001::/, // Teredo (2001::/32) + /^2001:2::/, // benchmark (2001:2::/48) +]; + +// IPv4-mapped IPv6 prefix +const IPV4_MAPPED_PREFIX = "::ffff:"; + +function isIPv4MappedIPv6(ip: string): string | null { + if (ip.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) { + return ip.slice(IPV4_MAPPED_PREFIX.length); + } + return null; +} + +export function isOfficialOsv(url: string): boolean { + try { + const parsed = new URL(url); + return OFFICIAL_OSV_HOSTS.includes(parsed.hostname); + } catch { + return false; + } +} + +export function isPrivateOrReservedIp(ip: string): boolean { + // Check for IPv4-mapped IPv6 address (e.g., ::ffff:127.0.0.1) + const ipv4 = isIPv4MappedIPv6(ip); + const target = ipv4 ?? ip; + + // Fast-path: IPv4 CIDR match + if (ipv4ToU32(target) !== undefined) { + return isPrivateOrReservedIpv4(target); + } + + // IPv6: regex match + return IPV6_PATTERNS.some(pattern => pattern.test(target)); +} + +export async function validateOsvUrl( + url: string, + allowPrivate: boolean, +): Promise { + const parsed = new URL(url); + + // Layer 1: HTTPS only + if (parsed.protocol !== "https:") { + throw new Error( + `--osv-url requires https:// scheme, got ${parsed.protocol}`, + ); + } + + // Skip IP check for official hosts + if (isOfficialOsv(url)) return undefined; + + // Layer 2: Resolve DNS and check ALL addresses + const addresses = await new Promise((resolve, reject) => { + dns.lookup( + parsed.hostname, + { all: true }, + (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => { + if (err) reject(err); + else resolve(addresses); + }, + ); + }); + + // Check ALL resolved addresses - reject if ANY is private/reserved + const privateAddresses = addresses.filter(addr => isPrivateOrReservedIp(addr.address)); + + if (privateAddresses.length > 0) { + const ipList = privateAddresses.map(a => a.address).join(", "); + if (!allowPrivate) { + throw new Error( + `--osv-url resolves to private/reserved IP (${ipList}). ` + + `Use --allow-private-osv-url for internal mirrors.`, + ); + } + // Layer 3: Explicit bypass — return warning for caller to emit + return ( + `Warning: --allow-private-osv-url is set. ` + + `Requests may access internal host (${ipList}).` + ); + } + + return undefined; +} diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts index 940975f..ed79a7e 100644 --- a/tests/create-pr.test.ts +++ b/tests/create-pr.test.ts @@ -141,9 +141,9 @@ describe("create-pr CLI options", () => { expect(result.options.prBase).toBe("develop"); }); - it("requires --fix for --create-pr", () => { - expect(() => validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", createPr: true })).toThrow( - "--create-pr requires --fix", - ); + it("requires --fix for --create-pr", async () => { + await expect( + validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", createPr: true }), + ).rejects.toThrow("--create-pr requires --fix"); }); }); diff --git a/tests/osv-advisory-source.test.ts b/tests/osv-advisory-source.test.ts index 4fe69af..735a13b 100644 --- a/tests/osv-advisory-source.test.ts +++ b/tests/osv-advisory-source.test.ts @@ -57,6 +57,7 @@ describe("OsvAdvisorySource", () => { }, ], }), + redirect: "error", }); expect(results).toEqual([ @@ -111,6 +112,7 @@ describe("OsvAdvisorySource", () => { expect(fetchMock).toHaveBeenCalledWith( "https://example.test/v1/vulns/GHSA-abcd%2F1234", + { redirect: "error" }, ); expect(vuln).toMatchObject({ id: "GHSA-abcd/1234", diff --git a/tests/validate-url.test.ts b/tests/validate-url.test.ts new file mode 100644 index 0000000..a83f057 --- /dev/null +++ b/tests/validate-url.test.ts @@ -0,0 +1,298 @@ +import dns from "node:dns"; +import { isOfficialOsv, isPrivateOrReservedIp, validateOsvUrl } from "../src/utils/validate-url.js"; + +describe("isOfficialOsv", () => { + it("returns true for official OSV URL", () => { + expect(isOfficialOsv("https://api.osv.dev/v1/querybatch")).toBe(true); + }); + + it("returns true for official OSV URL without path", () => { + expect(isOfficialOsv("https://api.osv.dev")).toBe(true); + }); + + it("returns false for non-official URL", () => { + expect(isOfficialOsv("https://evil.com")).toBe(false); + }); + + it("returns false for invalid URL", () => { + expect(isOfficialOsv("not-a-url")).toBe(false); + }); + + it("returns true for HTTP official host (scheme check is in validateOsvUrl)", () => { + // isOfficialOsv only checks hostname, scheme validation is in validateOsvUrl + expect(isOfficialOsv("http://api.osv.dev")).toBe(true); + }); +}); + +describe("isPrivateOrReservedIp", () => { + it.each([ + // 0.0.0.0/8 — "this network" + ["0.0.0.0", true], + ["0.255.255.255", true], + ["1.0.0.0", false], + + // 10.0.0.0/8 — private Class A + ["10.0.0.0", true], + ["10.255.255.255", true], + ["9.255.255.255", false], + ["11.0.0.0", false], + + // 100.64.0.0/10 — CGNAT + ["100.64.0.0", true], + ["100.127.255.255", true], + ["100.63.255.255", false], + ["100.128.0.0", false], + ["100.108.0.1", true], + ["100.109.0.1", true], + ["100.118.0.1", true], + ["100.119.0.1", true], + + // 127.0.0.0/8 — loopback + ["127.0.0.0", true], + ["127.255.255.255", true], + ["128.0.0.0", false], + + // 169.254.0.0/16 — link-local + ["169.254.0.0", true], + ["169.254.255.255", true], + ["169.253.255.255", false], + ["169.255.0.0", false], + + // 172.16.0.0/12 — private Class B + ["172.16.0.0", true], + ["172.31.255.255", true], + ["172.15.255.255", false], + ["172.32.0.0", false], + + // 192.168.0.0/16 — private Class C + ["192.168.0.0", true], + ["192.168.255.255", true], + ["192.167.255.255", false], + ["192.169.0.0", false], + + // 192.0.0.0/24 — IETF Protocol + ["192.0.0.0", true], + ["192.0.0.255", true], + ["192.0.1.0", false], + + // 198.18.0.0/15 — benchmark + ["198.18.0.0", true], + ["198.19.255.255", true], + ["198.17.255.255", false], + ["198.20.0.0", false], + + // 192.0.2.0/24 — documentation (TEST-NET-1) + ["192.0.2.0", true], + ["192.0.2.255", true], + ["192.0.3.0", false], + + // 198.51.100.0/24 — documentation (TEST-NET-2) + ["198.51.100.0", true], + ["198.51.100.255", true], + ["198.51.101.0", false], + + // 203.0.113.0/24 — documentation (TEST-NET-3) + ["203.0.113.0", true], + ["203.0.113.255", true], + ["203.0.114.0", false], + + // 224.0.0.0/4 — multicast + ["224.0.0.0", true], + ["239.255.255.255", true], + ["223.255.255.255", false], + + // 240.0.0.0/4 — reserved + ["240.0.0.0", true], + ["255.255.255.255", true], + + // public IPv4 + ["8.8.8.8", false], + ["1.1.1.1", false], + ["93.184.216.34", false], + ])("%s → %s", (ip, expected) => { + expect(isPrivateOrReservedIp(ip)).toBe(expected); + }); + + it("detects IPv6 loopback", () => { + expect(isPrivateOrReservedIp("::1")).toBe(true); + }); + + it("detects IPv6 unspecified", () => { + expect(isPrivateOrReservedIp("::")).toBe(true); + }); + + it("detects IPv6 ULA", () => { + expect(isPrivateOrReservedIp("fc00::1")).toBe(true); + expect(isPrivateOrReservedIp("fd00::1")).toBe(true); + }); + + it("detects IPv6 link-local", () => { + expect(isPrivateOrReservedIp("fe80::1")).toBe(true); + }); + + it("detects IPv6 multicast", () => { + expect(isPrivateOrReservedIp("ff02::1")).toBe(true); + }); + + it("detects IPv6 documentation", () => { + expect(isPrivateOrReservedIp("2001:db8::1")).toBe(true); + }); + + it("detects IPv4-mapped IPv6 addresses", () => { + expect(isPrivateOrReservedIp("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("::ffff:10.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("::ffff:192.168.1.1")).toBe(true); + expect(isPrivateOrReservedIp("::ffff:8.8.8.8")).toBe(false); + }); + + it("does not detect public IPv6", () => { + expect(isPrivateOrReservedIp("2606:4700::1")).toBe(false); + }); +}); + +describe("validateOsvUrl", () => { + let originalLookup: typeof dns.lookup; + + beforeEach(() => { + originalLookup = dns.lookup; + }); + + afterEach(() => { + dns.lookup = originalLookup; + }); + + function mockDnsLookup(hostname: string, address: string, family: number = 4) { + (dns as any).lookup = ( + host: string, + options: dns.LookupOptions | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void), + callback?: (err: NodeJS.ErrnoException | null, address: string, family: number) => void, + ) => { + // Handle the { all: true } overload + if (typeof options === "object" && options.all) { + const cb = callback!; + if (host === hostname) { + cb(null, [{ address, family }] as any, 0); + } else { + cb(new Error("ENOTFOUND"), [], 0); + } + } else { + const cb = typeof options === "function" ? options : callback!; + if (host === hostname) { + cb(null, address, family); + } else { + cb(new Error("ENOTFOUND"), "", 0); + } + } + }; + } + + function mockDnsLookupAll(hostname: string, addresses: Array<{ address: string; family: number }>) { + (dns as any).lookup = ( + host: string, + options: dns.LookupOptions | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void), + callback?: (err: NodeJS.ErrnoException | null, address: string, family: number) => void, + ) => { + // Handle the { all: true } overload + if (typeof options === "object" && options.all) { + const cb = callback!; + if (host === hostname) { + cb(null, addresses as any, 0); + } else { + cb(new Error("ENOTFOUND"), [], 0); + } + } else { + const cb = typeof options === "function" ? options : callback!; + if (host === hostname && addresses.length > 0) { + cb(null, addresses[0].address, addresses[0].family); + } else { + cb(new Error("ENOTFOUND"), "", 0); + } + } + }; + } + + it("accepts official OSV URL without DNS check", async () => { + await expect( + validateOsvUrl("https://api.osv.dev/v1/querybatch", false), + ).resolves.toBeUndefined(); + }); + + it("rejects non-HTTPS URL", async () => { + await expect( + validateOsvUrl("http://api.osv.dev", false), + ).rejects.toThrow("--osv-url requires https:// scheme"); + }); + + it("rejects ftp URL", async () => { + await expect( + validateOsvUrl("ftp://api.osv.dev", false), + ).rejects.toThrow("--osv-url requires https:// scheme"); + }); + + it("rejects invalid URL", async () => { + await expect( + validateOsvUrl("not-a-url", false), + ).rejects.toThrow(); + }); + + it("rejects custom host resolving to loopback", async () => { + mockDnsLookup("evil.test", "127.0.0.1"); + + await expect( + validateOsvUrl("https://evil.test", false), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects custom host resolving to private IP", async () => { + mockDnsLookup("internal.test", "10.0.0.10"); + + await expect( + validateOsvUrl("https://internal.test", false), + ).rejects.toThrow("private/reserved IP"); + }); + + it("allows private IP only with explicit flag", async () => { + mockDnsLookup("internal.test", "10.0.0.10"); + + const warning = await validateOsvUrl("https://internal.test", true); + expect(warning).toContain("--allow-private-osv-url is set"); + }); + + it("rejects if any resolved address is private (multiple A records)", async () => { + mockDnsLookupAll("mixed.test", [ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]); + + await expect( + validateOsvUrl("https://mixed.test", false), + ).rejects.toThrow("private/reserved IP"); + }); + + it("shows all private addresses in error message", async () => { + mockDnsLookupAll("multi-private.test", [ + { address: "10.0.0.1", family: 4 }, + { address: "192.168.1.1", family: 4 }, + ]); + + await expect( + validateOsvUrl("https://multi-private.test", false), + ).rejects.toThrow("10.0.0.1, 192.168.1.1"); + }); + + it("allows public IPs", async () => { + mockDnsLookup("public.test", "93.184.216.34"); + + await expect( + validateOsvUrl("https://public.test", false), + ).resolves.toBeUndefined(); + }); + + it("allows private IP with warning when flag is set", async () => { + mockDnsLookup("internal.test", "10.0.0.10"); + + const warning = await validateOsvUrl("https://internal.test", true); + expect(warning).toContain("--allow-private-osv-url is set"); + expect(warning).toContain("10.0.0.10"); + }); +}); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 174de57..f153390 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -1,3 +1,4 @@ +import dns from "node:dns"; import { validateOptions } from "../src/cli/validate.js"; import type { ParsedOptions } from "../src/types.js"; @@ -7,90 +8,172 @@ function opts(overrides: Partial = {}): ParsedOptions { describe("validateOptions", () => { describe("--offline / --offline-db with --osv-url", () => { - it("throws when --offline and --osv-url are combined", () => { - expect(() => validateOptions(opts({ offline: true, osvUrl: "https://example.com" }))).toThrow( + it("throws when --offline and --osv-url are combined", async () => { + await expect(validateOptions(opts({ offline: true, osvUrl: "https://api.osv.dev" }))).rejects.toThrow( "--offline/--offline-db cannot be used with --osv-url", ); }); - it("throws when --offline-db and --osv-url are combined", () => { - expect(() => validateOptions(opts({ offlineDb: "/path/to/db", osvUrl: "https://example.com" }))).toThrow( + it("throws when --offline-db and --osv-url are combined", async () => { + await expect(validateOptions(opts({ offlineDb: "/path/to/db", osvUrl: "https://api.osv.dev" }))).rejects.toThrow( "--offline/--offline-db cannot be used with --osv-url", ); }); - it("does not throw when --offline is used without --osv-url", () => { - expect(() => validateOptions(opts({ offline: true }))).not.toThrow(); + it("does not throw when --offline is used without --osv-url", async () => { + await expect(validateOptions(opts({ offline: true }))).resolves.toBeUndefined(); }); }); describe("--no-cache with --offline / --offline-db", () => { - it("throws when --no-cache and --offline are combined", () => { - expect(() => validateOptions(opts({ noCache: true, offline: true }))).toThrow( + it("throws when --no-cache and --offline are combined", async () => { + await expect(validateOptions(opts({ noCache: true, offline: true }))).rejects.toThrow( "--no-cache cannot be used with --offline or --offline-db", ); }); - it("throws when --no-cache and --offline-db are combined", () => { - expect(() => validateOptions(opts({ noCache: true, offlineDb: "/path/to/db" }))).toThrow( + it("throws when --no-cache and --offline-db are combined", async () => { + await expect(validateOptions(opts({ noCache: true, offlineDb: "/path/to/db" }))).rejects.toThrow( "--no-cache cannot be used with --offline or --offline-db", ); }); - it("does not throw when --no-cache is used without offline flags", () => { - expect(() => validateOptions(opts({ noCache: true }))).not.toThrow(); + it("does not throw when --no-cache is used without offline flags", async () => { + await expect(validateOptions(opts({ noCache: true }))).resolves.toBeUndefined(); }); }); describe("--osv-url validation", () => { - it("throws when --osv-url is not a valid URL", () => { - expect(() => validateOptions(opts({ osvUrl: "not-a-url" }))).toThrow( + it("throws when --osv-url is not a valid URL", async () => { + await expect(validateOptions(opts({ osvUrl: "not-a-url" }))).rejects.toThrow( "Invalid value for --osv-url: not-a-url", ); }); - it("does not throw when --osv-url is a valid URL", () => { - expect(() => validateOptions(opts({ osvUrl: "https://custom.osv.example.com" }))).not.toThrow(); + it("does not throw when --osv-url is official OSV URL", async () => { + await expect(validateOptions(opts({ osvUrl: "https://api.osv.dev" }))).resolves.toBeUndefined(); + }); + + it("rejects non-HTTPS --osv-url", async () => { + await expect(validateOptions(opts({ osvUrl: "http://api.osv.dev" }))).rejects.toThrow( + "--osv-url requires https:// scheme", + ); + }); + + it("throws when --allow-private-osv-url is used without --osv-url", async () => { + await expect(validateOptions(opts({ allowPrivateOsvUrl: true }))).rejects.toThrow( + "--allow-private-osv-url requires --osv-url", + ); }); }); describe("--fix with --json", () => { - it("throws when --fix and --json are combined", () => { - expect(() => validateOptions(opts({ fix: true, json: true }))).toThrow( + it("throws when --fix and --json are combined", async () => { + await expect(validateOptions(opts({ fix: true, json: true }))).rejects.toThrow( "--fix cannot be used with --json", ); }); - it("does not throw when --fix is used without --json", () => { - expect(() => validateOptions(opts({ fix: true }))).not.toThrow(); + it("does not throw when --fix is used without --json", async () => { + await expect(validateOptions(opts({ fix: true }))).resolves.toBeUndefined(); }); }); describe("--report with --json", () => { - it("throws when --report and --json are combined", () => { - expect(() => validateOptions(opts({ report: "report.html", json: true }))).toThrow( + it("throws when --report and --json are combined", async () => { + await expect(validateOptions(opts({ report: "report.html", json: true }))).rejects.toThrow( "--report cannot be used with --json", ); }); - it("does not throw when --report is used without --json", () => { - expect(() => validateOptions(opts({ report: "report.html" }))).not.toThrow(); + it("does not throw when --report is used without --json", async () => { + await expect(validateOptions(opts({ report: "report.html" }))).resolves.toBeUndefined(); }); }); describe("--ca-cert validation", () => { - it("throws with a --ca-cert: prefix when the cert file does not exist or is invalid", () => { - expect(() => validateOptions(opts({ caCert: "invalid-fake-cert.pem" }))).toThrow( + it("throws with a --ca-cert: prefix when the cert file does not exist or is invalid", async () => { + await expect(validateOptions(opts({ caCert: "invalid-fake-cert.pem" }))).rejects.toThrow( "--ca-cert:" ); }); - it("does not throw when --ca-cert is not set", () => { - expect(() => validateOptions(opts({}))).not.toThrow(); + it("does not throw when --ca-cert is not set", async () => { + await expect(validateOptions(opts({}))).resolves.toBeUndefined(); }); }); - it("does not throw for a valid set of options", () => { - expect(() => validateOptions(opts({ fix: true, verbose: true }))).not.toThrow(); + it("does not throw for a valid set of options", async () => { + await expect(validateOptions(opts({ fix: true, verbose: true }))).resolves.toBeUndefined(); + }); + + describe("SSRF warning does not short-circuit validation", () => { + let originalLookup: typeof dns.lookup; + + beforeEach(() => { + originalLookup = dns.lookup; + }); + + afterEach(() => { + dns.lookup = originalLookup; + }); + + function mockDnsLookupPrivate(hostname: string) { + (dns as any).lookup = ( + host: string, + options: dns.LookupOptions | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void), + callback?: (err: NodeJS.ErrnoException | null, address: string, family: number) => void, + ) => { + if (typeof options === "object" && options.all) { + const cb = callback!; + if (host === hostname) { + cb(null, [{ address: "10.0.0.1", family: 4 }] as any, 0); + } else { + cb(new Error("ENOTFOUND"), [], 0); + } + } else { + const cb = typeof options === "function" ? options : callback!; + if (host === hostname) { + cb(null, "10.0.0.1", 4); + } else { + cb(new Error("ENOTFOUND"), "", 0); + } + } + }; + } + + it("still throws --fix --json when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, fix: true, json: true })), + ).rejects.toThrow("--fix cannot be used with --json"); + }); + + it("still throws --create-pr without --fix when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, createPr: true })), + ).rejects.toThrow("--create-pr requires --fix"); + }); + + it("still throws --report --json when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, report: "report.html", json: true })), + ).rejects.toThrow("--report cannot be used with --json"); + }); + + it("still throws bad --ca-cert when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, caCert: "nonexistent.pem" })), + ).rejects.toThrow("--ca-cert:"); + }); + + it("returns warning when all options are valid", async () => { + mockDnsLookupPrivate("private.test"); + const warning = await validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true })); + expect(warning).toContain("--allow-private-osv-url is set"); + }); }); }); diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index 3781621..6db6c7f 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -63,7 +63,10 @@ See [Offline Advisory DB](./offline-advisory-db.md) for the full offline workflo | Flag | Default | Description | Example | |---|---|---|---| | `--ca-cert=` | - | Path to a PEM CA certificate file for corporate SSL inspection proxies | `cve-lite . --ca-cert ~/corp-ca.crt` | -| `--osv-url=` | OSV API | Use a custom OSV-compatible endpoint instead of the public API | `cve-lite . --osv-url https://osv.example.com` | +| `--osv-url=` | OSV API | Use a custom OSV-compatible endpoint instead of the public API (HTTPS only, public IPs only by default) | `cve-lite . --osv-url https://osv.example.com` | +| `--allow-private-osv-url` | off | Allow `--osv-url` to resolve to private/reserved IPs (RFC 1918, loopback, link-local) for internal mirrors | `cve-lite . --osv-url https://internal-mirror.local/osv --allow-private-osv-url` | + +**Security note:** The `--osv-url` flag enforces HTTPS and blocks private/reserved IP ranges by default to prevent SSRF attacks. Use `--allow-private-osv-url` only when connecting to trusted internal mirrors. For networks with SSL inspection, save the certificate path once so you do not need to pass the flag on every scan: diff --git a/website/docs/corporate-proxy.md b/website/docs/corporate-proxy.md index 0bb1216..b8943af 100644 --- a/website/docs/corporate-proxy.md +++ b/website/docs/corporate-proxy.md @@ -83,6 +83,34 @@ The certificate file must be in PEM format - a plain text file starting with `-- --- +## Using a custom OSV endpoint + +If your organization hosts an internal OSV-compatible endpoint, you can point CVE Lite CLI to it with `--osv-url`: + +```bash +cve-lite . --osv-url https://internal-osv.company.local +``` + +### SSRF protection + +By default, `--osv-url` enforces: +- **HTTPS only** — HTTP URLs are rejected +- **Public IPs only** — private/reserved IP ranges (RFC 1918, loopback, link-local) are blocked + +This prevents Server-Side Request Forgery (SSRF) attacks where an attacker could redirect requests to internal services. + +### Using internal mirrors + +If your internal OSV endpoint resolves to a private IP address, you must explicitly allow it with `--allow-private-osv-url`: + +```bash +cve-lite . --osv-url https://internal-osv.company.local --allow-private-osv-url +``` + +**Security warning:** Only use `--allow-private-osv-url` with trusted internal mirrors. This bypasses the SSRF protection and allows requests to private network addresses. + +--- + ## Advisory sync over a proxy The same certificate applies when syncing the offline advisory DB: diff --git a/website/docs/offline-advisory-db.md b/website/docs/offline-advisory-db.md index 5dd4651..74ac36d 100644 --- a/website/docs/offline-advisory-db.md +++ b/website/docs/offline-advisory-db.md @@ -88,6 +88,14 @@ Use an internal proxy or mirror for the advisory API: cve-lite /path/to/project --osv-url https://security.company.internal/osv ``` +If your internal endpoint resolves to a private IP address, you must explicitly allow it: + +```bash +cve-lite /path/to/project --osv-url https://security.company.internal/osv --allow-private-osv-url +``` + +**Note:** By default, `--osv-url` enforces HTTPS and blocks private/reserved IP ranges to prevent SSRF attacks. Use `--allow-private-osv-url` only with trusted internal mirrors. + --- ## Advisory DB freshness diff --git a/website/docs/security-assurance-case.md b/website/docs/security-assurance-case.md index 789b139..4465ec3 100644 --- a/website/docs/security-assurance-case.md +++ b/website/docs/security-assurance-case.md @@ -121,7 +121,7 @@ Mapped against the [OWASP Top 10 (2021)](https://owasp.org/Top10/) — the categ | A07: Identification and Authentication Failures | Not applicable | The CLI has no authentication surface. | | A08: Software and Data Integrity Failures | Countered | Release tarballs are signed via Sigstore Artifact Attestations. Git tags are GPG-signed by the project lead. Withdrawn OSV advisories are filtered at ingest. Sync replaces the advisory database atomically rather than partially updating it. | | A09: Security Logging and Monitoring Failures | Partially countered | CodeQL alerts on every push, the self-scan workflow flags new advisories in the project's own dependencies on every push, and the GitHub release workflow emits build provenance. The project does not centrally log scan invocations because none happen on project-controlled infrastructure — scans run on each user's local machine. | -| A10: Server-Side Request Forgery | Countered | The CLI only makes outbound HTTP requests to fixed, hardcoded endpoints: `api.osv.dev`, `storage.googleapis.com/osv-vulnerabilities/...`, and `registry.npmjs.org`. No user-controlled URLs are ever used as request targets. | +| A10: Server-Side Request Forgery | Countered | The CLI makes outbound HTTP requests to its default advisory and registry endpoints and can optionally use a user-supplied OSV-compatible endpoint via `--osv-url`. That flag enforces HTTPS and validates DNS resolution against all private and reserved IP ranges defined by IANA (RFC 1918, loopback, link-local, multicast, CGNAT, etc.) by default to prevent SSRF attacks. Using `--allow-private-osv-url` bypasses this protection and should only be used with trusted internal mirrors. | ## Limitations and explicit non-goals