Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion src/advisory/osv-advisory-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch> Base branch for --create-pr (default: main)",
" --osv-url <url> Use a custom OSV-compatible advisory endpoint",
" --osv-url <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> 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",
Expand Down
12 changes: 11 additions & 1 deletion src/cli/validate.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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");
}
Expand All @@ -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) {
Expand Down Expand Up @@ -45,4 +53,6 @@ export function validateOptions(options: ParsedOptions): void {
throw new Error(`--ca-cert: ${err instanceof Error ? err.message : String(err)}`);
}
}

return ssrfWarning;
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export type ParsedOptions = {
checkNetwork?: boolean;
/** --rule <id> - 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;
};

/**
Expand Down
149 changes: 149 additions & 0 deletions src/utils/validate-url.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<dns.LookupAddress[]>((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;
}
8 changes: 4 additions & 4 deletions tests/create-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
2 changes: 2 additions & 0 deletions tests/osv-advisory-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe("OsvAdvisorySource", () => {
},
],
}),
redirect: "error",
});

expect(results).toEqual([
Expand Down Expand Up @@ -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",
Expand Down
Loading