diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bcc03f8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# Project guidance for Claude + +## Pre-push checklist + +CI (`.github/workflows/pr-check.yml`) runs four gates and **fails the PR if any +gate fails**. Run all four locally before pushing — `deno task check` and +`deno task test` alone are not sufficient. + +```bash +deno fmt --check # CI gate 1 — formatting +deno lint # CI gate 2 — lint +deno check main.ts # CI gate 3 — type check +deno test --allow-net # CI gate 4 — tests +``` + +Or as a single command: + +```bash +deno fmt --check && deno lint && deno check main.ts && deno test --allow-net +``` + +### Formatting (the most common failure) + +`deno fmt --check` is strict about line breaks in import statements, type +unions, and ternaries. The Deno formatter has opinions that don't always match +what an LLM will produce on the first try. **Always** run `deno task fmt` (which +auto-fixes) after writing or editing TypeScript or Markdown files, then verify +with `deno fmt --check`. + +If CI fails on formatting after a push, the fix is: + +```bash +deno task fmt +git add -u && git commit -m "Apply deno fmt formatting" +git push +``` + +## Project conventions + +- **Runtime**: Deno v2.x. No Node.js or npm install — dependencies are declared + in `deno.json` `imports` and resolved via JSR/npm specifiers. +- **No emojis** in code, comments, or commits unless explicitly requested. +- **Commit style**: plain imperative ("Add X", "Fix Y") — not conventional + commits. Subject under 72 chars, explain _why_ in the body when not obvious. +- **No `Co-Authored-By` trailer** on commits unless explicitly requested. + +## Architecture cheat sheet + +- `main.ts` — entry point; registers all MCP tools on stdio transport +- `src/registries/` — one client per package registry (npm, maven, pypi, etc.) +- `src/parsers/` — one parser per dependency file format +- `src/tools/` — MCP tool implementations +- `src/utils/` — shared utilities (cache, http, version parsing, vulnerability) +- `src/config/` — custom registry/auth configuration loader + +When adding a new registry, you typically need: a registry client +(`src/registries/X.ts`), a parser if it has a dependency file format +(`src/parsers/X.ts`), and registration in `src/registries/index.ts` and +`src/parsers/index.ts`. Update the tool input enums in `src/tools/*.ts` and the +registry list in `README.md`. + +## Vulnerability checking + +Two databases are queried in parallel and merged by CVE ID: + +- **OSV** (`api.osv.dev`) — package-native, broad ecosystem coverage +- **NVD** (`services.nvd.nist.gov`) — authoritative CVSS v3.1 + CWE + +Both are cached **per-package** (not per-version) — checking different versions +of the same package reuses the cached API responses. Version filtering happens +client-side via `osvAffectsVersion` and `cveAffectsVersion`. When changing +either, update the tests in `src/utils/vulnerability.test.ts`. + +Set `NVD_API_KEY` env var for higher NVD rate limits (50 vs 5 requests/30s). diff --git a/README.md b/README.md index 82faa2b..3225056 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ multiple package registries. JSR, NuGet, Docker Hub, RubyGems, Packagist, pub.dev, Swift PM, GitHub Actions - **Version lookup**: Get the latest stable (and optionally prerelease) versions - **Version listing**: List all available versions with metadata -- **Vulnerability scanning**: Check packages against the OSV (Open Source - Vulnerabilities) database +- **Vulnerability scanning**: Check packages against OSV and NVD databases with + deduplicated, CVSS-scored results - **Dependency analysis**: Analyze dependency files and check for updates - **Docker support**: Look up image tags and analyze Dockerfile/docker-compose.yml dependencies @@ -399,6 +399,14 @@ List all available versions of a package. Check a package version for known security vulnerabilities. +Queries both OSV and NVD in parallel for comprehensive coverage. Results are +deduplicated by CVE ID — when a vulnerability appears in both databases, NVD's +CVSS v3.1 score is used as the authoritative severity rating. + +Set the `NVD_API_KEY` environment variable for higher NVD rate limits (50 vs 5 +requests per 30 seconds). Request a free key at +https://nvd.nist.gov/developers/request-an-api-key. + **Parameters:** - `registry` (required): Package registry @@ -429,8 +437,11 @@ Check a package version for known security vulnerabilities. "id": "GHSA-29mw-wpgm-hmr9", "summary": "Prototype Pollution in lodash", "severity": "HIGH", + "cvss": 7.2, "cveIds": ["CVE-2021-23337"], - "fixedVersions": ["4.17.21"] + "cweIds": ["CWE-94"], + "fixedVersions": ["4.17.21"], + "source": "osv+nvd" } ], "totalCount": 1, @@ -648,6 +659,7 @@ src/ | Swift | `api.github.com/repos/{owner}/{repo}/tags` | [docs](https://docs.github.com/en/rest/repos/repos) | | GitHub Actions | `api.github.com/repos/{owner}/{repo}/tags` | [docs](https://docs.github.com/en/rest/repos/repos) | | OSV | `api.osv.dev/v1/query` | [docs](https://osv.dev/docs/) | +| NVD | `services.nvd.nist.gov/rest/json/cves/2.0` | [docs](https://nvd.nist.gov/developers/vulnerabilities) | ## License diff --git a/src/registries/types.ts b/src/registries/types.ts index 115c9dd..1916947 100644 --- a/src/registries/types.ts +++ b/src/registries/types.ts @@ -87,7 +87,7 @@ export interface RegistryClient { export type Severity = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; /** - * Vulnerability information from OSV + * Vulnerability information from OSV and/or NVD */ export interface Vulnerability { id: string; @@ -96,10 +96,13 @@ export interface Vulnerability { severity?: Severity; cvss?: number; cveIds?: string[]; + cweIds?: string[]; affectedVersions?: string; fixedVersions?: string[]; publishedAt?: Date; references?: string[]; + /** Which database(s) reported this vulnerability */ + source?: "osv" | "nvd" | "osv+nvd"; } /** diff --git a/src/tools/analyze-dependencies.ts b/src/tools/analyze-dependencies.ts index ab4d6b7..d9e9831 100644 --- a/src/tools/analyze-dependencies.ts +++ b/src/tools/analyze-dependencies.ts @@ -59,7 +59,7 @@ Supported file formats: Note: For Gradle files, use registry='maven'. For GitHub Actions workflow files, use registry='github-actions'. Variable references ($version, libs.xxx) are skipped. -Optionally checks for known vulnerabilities using the OSV database. +Optionally checks for known vulnerabilities using OSV and NVD databases. Returns a list of dependencies with: - Current version diff --git a/src/tools/check-vulnerabilities.ts b/src/tools/check-vulnerabilities.ts index 18ce846..35c29b0 100644 --- a/src/tools/check-vulnerabilities.ts +++ b/src/tools/check-vulnerabilities.ts @@ -44,15 +44,16 @@ export function registerCheckVulnerabilitiesTool(server: McpServer): void { "check_vulnerabilities", `Check a package version for known security vulnerabilities. -Uses the Open Source Vulnerabilities (OSV) database which aggregates vulnerabilities from: -- GitHub Security Advisories -- NVD (National Vulnerability Database) -- PyPI Advisory Database -- RustSec Advisory Database -- Go Vulnerability Database -- And more +Queries two databases in parallel for comprehensive coverage: +- OSV (Open Source Vulnerabilities): aggregates GitHub Security Advisories, PyPI, RustSec, Go, and more +- NVD (National Vulnerability Database): authoritative CVSS v3.1 scores and CWE classifications -Returns CVE IDs, severity ratings, affected version ranges, and available fixes.`, +Results are deduplicated by CVE ID. When a vulnerability appears in both databases, +NVD's CVSS score is used as the authoritative severity rating. + +Set the NVD_API_KEY environment variable for higher NVD rate limits (50 vs 5 requests/30s). + +Returns CVE IDs, CVSS scores, CWE IDs, severity ratings, affected version ranges, and available fixes.`, inputSchema.shape, async ({ registry, package: packageName, version, severityThreshold }) => { try { @@ -73,11 +74,14 @@ Returns CVE IDs, severity ratings, affected version ranges, and available fixes. id: v.id, summary: v.summary, severity: v.severity, + cvss: v.cvss, cveIds: v.cveIds, + cweIds: v.cweIds, affectedVersions: v.affectedVersions, fixedVersions: v.fixedVersions, publishedAt: v.publishedAt?.toISOString(), references: v.references, + source: v.source, })), totalCount: vulns.length, hasVulnerabilities: vulns.length > 0, diff --git a/src/utils/nvd.ts b/src/utils/nvd.ts new file mode 100644 index 0000000..e6bf77f --- /dev/null +++ b/src/utils/nvd.ts @@ -0,0 +1,474 @@ +/** + * NVD (National Vulnerability Database) API v2.0 client + * + * Queries the NVD for CVEs by keyword, then filters results using + * CPE configuration data to verify the package and version are affected. + * + * Raw responses are cached per-package so that checking multiple versions + * of the same package (e.g. lodash@4.17.20 then lodash@4.17.21) only + * requires one API call. + * + * API docs: https://nvd.nist.gov/developers/vulnerabilities + * Rate limits: 5 req/30s without API key, 50 req/30s with API key + */ + +import type { Registry, Severity, Vulnerability } from "../registries/types.ts"; +import { vulnerabilityCache } from "./cache.ts"; +import { fetchWithHeaders } from "./http.ts"; +import { compareVersions } from "./version.ts"; + +const NVD_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"; + +// === NVD API response types === + +interface NvdCvssMetricV31 { + source: string; + type: string; + cvssData: { + version: string; + vectorString: string; + baseScore: number; + baseSeverity: string; + }; +} + +interface NvdCvssMetricV2 { + source: string; + type: string; + cvssData: { + version: string; + vectorString: string; + baseScore: number; + }; +} + +/** @internal Exported for testing. */ +export interface NvdCpeMatch { + vulnerable: boolean; + criteria: string; + versionStartIncluding?: string; + versionStartExcluding?: string; + versionEndIncluding?: string; + versionEndExcluding?: string; + matchCriteriaId: string; +} + +interface NvdCveItem { + cve: { + id: string; + published: string; + lastModified: string; + descriptions: { lang: string; value: string }[]; + metrics?: { + cvssMetricV31?: NvdCvssMetricV31[]; + cvssMetricV2?: NvdCvssMetricV2[]; + }; + weaknesses?: { + source: string; + type: string; + description: { lang: string; value: string }[]; + }[]; + configurations?: { + nodes: { + operator: string; + negate: boolean; + cpeMatch: NvdCpeMatch[]; + }[]; + }[]; + references?: { url: string; source?: string }[]; + }; +} + +interface NvdResponse { + totalResults: number; + vulnerabilities: NvdCveItem[]; +} + +// === Package name to NVD keyword mapping === + +/** + * Extract the search keyword from a package name based on registry. + * Returns a term suitable for NVD keyword search. + */ +function getSearchKeyword( + packageName: string, + registry: Registry, +): string { + switch (registry) { + case "maven": { + // Use artifactId (groupId:artifactId) + const parts = packageName.split(":"); + return parts.length > 1 ? parts[1] : packageName; + } + case "go": { + // Use last path segment (github.com/gin-gonic/gin -> gin) + const segments = packageName.split("/"); + return segments[segments.length - 1]; + } + case "npm": { + // Remove @ scope prefix (@angular/core -> core) + return packageName.replace(/^@[^/]+\//, ""); + } + case "packagist": { + // Use package part (vendor/package -> package) + const parts = packageName.split("/"); + return parts.length > 1 ? parts[1] : packageName; + } + default: + return packageName; + } +} + +// === CPE matching utilities === + +/** + * Normalize a name for CPE comparison: lowercase, strip separators. + */ +function normalizeCpeName(name: string): string { + return name.toLowerCase().replace(/[._-]+/g, ""); +} + +/** + * Check if a CPE criteria string references the given package. + * + * CPE 2.3 format: + * cpe:2.3:part:vendor:product:version:update:edition:language:sw_edition:target_sw:target_hw:other + * Indices: 0 1 2 3 4 5 6 7 8 9 10 11 + * + * @internal Exported for testing. + */ +export function cpeMatchesPackage( + cpeCriteria: string, + packageName: string, + registry: Registry, +): boolean { + const parts = cpeCriteria.split(":"); + if (parts.length < 5) return false; + + const cpeProduct = parts[4]; + if (cpeProduct === "*") return false; // Wildcard product is too broad + + const keyword = getSearchKeyword(packageName, registry); + const normalizedProduct = normalizeCpeName(cpeProduct); + const normalizedKeyword = normalizeCpeName(keyword); + + return ( + normalizedProduct === normalizedKeyword || + normalizedProduct.includes(normalizedKeyword) || + normalizedKeyword.includes(normalizedProduct) + ); +} + +/** + * Check if a specific version falls within a CPE version range. + * + * @internal Exported for testing. + */ +export function versionInRange(version: string, match: NvdCpeMatch): boolean { + const hasRangeConstraint = match.versionStartIncluding !== undefined || + match.versionStartExcluding !== undefined || + match.versionEndIncluding !== undefined || + match.versionEndExcluding !== undefined; + + if (hasRangeConstraint) { + if ( + match.versionStartIncluding && + compareVersions(version, match.versionStartIncluding) < 0 + ) { + return false; + } + if ( + match.versionStartExcluding && + compareVersions(version, match.versionStartExcluding) <= 0 + ) { + return false; + } + if ( + match.versionEndIncluding && + compareVersions(version, match.versionEndIncluding) > 0 + ) { + return false; + } + if ( + match.versionEndExcluding && + compareVersions(version, match.versionEndExcluding) >= 0 + ) { + return false; + } + return true; + } + + // No range constraints — check for exact version in CPE string + const cpeParts = match.criteria.split(":"); + const cpeVersion = cpeParts.length > 5 ? cpeParts[5] : "*"; + + if (cpeVersion !== "*" && cpeVersion !== "-") { + return compareVersions(version, cpeVersion) === 0; + } + + // Wildcard version with no range constraints — all versions affected + return true; +} + +/** + * Check if a CVE affects a specific package and version by examining + * its CPE configuration data. + */ +function cveAffectsVersion( + cve: NvdCveItem, + packageName: string, + version: string, + registry: Registry, +): boolean { + // Require CPE configuration data to avoid false positives + if (!cve.cve.configurations?.length) { + return false; + } + + for (const config of cve.cve.configurations) { + for (const node of config.nodes) { + for (const match of node.cpeMatch) { + if (!match.vulnerable) continue; + if ( + cpeMatchesPackage(match.criteria, packageName, registry) && + versionInRange(version, match) + ) { + return true; + } + } + } + } + + return false; +} + +// === NVD response parsing === + +/** + * Parse severity from NVD CVSS metrics, preferring v3.1 Primary scores. + */ +function parseNvdSeverity( + cve: NvdCveItem, +): { severity: Severity | undefined; cvss: number | undefined } { + // Prefer CVSS v3.1 + if (cve.cve.metrics?.cvssMetricV31?.length) { + const metric = cve.cve.metrics.cvssMetricV31.find((m) => + m.type === "Primary" + ) || + cve.cve.metrics.cvssMetricV31[0]; + + const baseSeverity = metric.cvssData.baseSeverity.toUpperCase(); + const severity: Severity | undefined = baseSeverity === "LOW" || + baseSeverity === "MEDIUM" || + baseSeverity === "HIGH" || + baseSeverity === "CRITICAL" + ? baseSeverity + : undefined; + + return { severity, cvss: metric.cvssData.baseScore }; + } + + // Fall back to CVSS v2 + if (cve.cve.metrics?.cvssMetricV2?.length) { + const metric = cve.cve.metrics.cvssMetricV2.find((m) => + m.type === "Primary" + ) || + cve.cve.metrics.cvssMetricV2[0]; + const score = metric.cvssData.baseScore; + + let severity: Severity; + if (score >= 9.0) severity = "CRITICAL"; + else if (score >= 7.0) severity = "HIGH"; + else if (score >= 4.0) severity = "MEDIUM"; + else severity = "LOW"; + + return { severity, cvss: score }; + } + + return { severity: undefined, cvss: undefined }; +} + +/** + * Parse an NVD CVE item into our Vulnerability type. + */ +function parseNvdVulnerability(cve: NvdCveItem): Vulnerability { + const { severity, cvss } = parseNvdSeverity(cve); + + const description = cve.cve.descriptions.find((d) => d.lang === "en")?.value; + + // Extract CWE IDs + const cweIds: string[] = []; + if (cve.cve.weaknesses) { + for (const weakness of cve.cve.weaknesses) { + for (const desc of weakness.description) { + if (desc.lang === "en" && desc.value.startsWith("CWE-")) { + cweIds.push(desc.value); + } + } + } + } + + // Extract fixed versions and affected ranges from CPE configurations + const fixedVersions: string[] = []; + const rangeParts: string[] = []; + + if (cve.cve.configurations) { + for (const config of cve.cve.configurations) { + for (const node of config.nodes) { + for (const match of node.cpeMatch) { + if (!match.vulnerable) continue; + if (match.versionEndExcluding) { + fixedVersions.push(match.versionEndExcluding); + } + // Build affected range description from first matching entry + if (rangeParts.length === 0) { + if (match.versionStartIncluding) { + rangeParts.push(`>=${match.versionStartIncluding}`); + } + if (match.versionStartExcluding) { + rangeParts.push(`>${match.versionStartExcluding}`); + } + if (match.versionEndIncluding) { + rangeParts.push(`<=${match.versionEndIncluding}`); + } + if (match.versionEndExcluding) { + rangeParts.push(`<${match.versionEndExcluding}`); + } + } + } + } + } + } + + const uniqueFixed = [...new Set(fixedVersions)]; + + return { + id: cve.cve.id, + summary: description + ? description.length > 200 + ? description.substring(0, 200) + "..." + : description + : undefined, + details: description, + severity, + cvss, + cveIds: [cve.cve.id], + fixedVersions: uniqueFixed.length > 0 ? uniqueFixed : undefined, + affectedVersions: rangeParts.length > 0 ? rangeParts.join(", ") : undefined, + publishedAt: cve.cve.published ? new Date(cve.cve.published) : undefined, + references: cve.cve.references?.map((r) => r.url), + source: "nvd", + cweIds: cweIds.length > 0 ? cweIds : undefined, + }; +} + +// === Per-package fetch with caching === + +/** + * Fetch all NVD CVE items for a package (cached per-package). + * The raw response is cached so that checking different versions + * of the same package reuses the same API call. + */ +async function fetchNvdForPackage( + packageName: string, + registry: Registry, +): Promise { + const keyword = getSearchKeyword(packageName, registry); + + // Skip very short keywords that would produce too many false positives + if (keyword.length < 3) { + return []; + } + + const cacheKey = `nvd:pkg:${registry}:${packageName}`; + + return await vulnerabilityCache.getOrSet(cacheKey, async () => { + const params = new URLSearchParams({ + keywordSearch: keyword, + keywordExactMatch: "", + resultsPerPage: "50", + }); + + const url = `${NVD_API_URL}?${params.toString()}`; + + const headers: Record = {}; + try { + const apiKey = Deno.env.get("NVD_API_KEY"); + if (apiKey) { + headers["apiKey"] = apiKey; + } + } catch { + // Env access not available, continue without API key + } + + try { + const response = await fetchWithHeaders(url, { headers }); + if (!response.ok) return []; + const data = (await response.json()) as NvdResponse; + return data.vulnerabilities ?? []; + } catch { + return []; + } + }) as NvdCveItem[]; +} + +// === Public API === + +/** + * Query NVD for vulnerabilities affecting a specific package and version. + * + * Fetches all CVEs for the package (cached per-package), then filters + * by CPE configuration data to find those affecting the given version. + * + * Returns an empty array on API errors (NVD is a supplementary source). + */ +export async function queryNvd( + packageName: string, + version: string, + registry: Registry, +): Promise { + const items = await fetchNvdForPackage(packageName, registry); + + return items + .filter((item) => cveAffectsVersion(item, packageName, version, registry)) + .map(parseNvdVulnerability); +} + +/** + * Look up a specific CVE by ID from NVD. + * Used to enrich OSV results with authoritative CVSS scores. + */ +export async function lookupCve( + cveId: string, +): Promise { + const params = new URLSearchParams({ cveId }); + const url = `${NVD_API_URL}?${params.toString()}`; + + const headers: Record = {}; + try { + const apiKey = Deno.env.get("NVD_API_KEY"); + if (apiKey) { + headers["apiKey"] = apiKey; + } + } catch { + // Continue without API key + } + + try { + const response = await fetchWithHeaders(url, { headers }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as NvdResponse; + + if (!data.vulnerabilities?.length) { + return null; + } + + return parseNvdVulnerability(data.vulnerabilities[0]); + } catch { + return null; + } +} diff --git a/src/utils/vulnerability.test.ts b/src/utils/vulnerability.test.ts new file mode 100644 index 0000000..0f6dacf --- /dev/null +++ b/src/utils/vulnerability.test.ts @@ -0,0 +1,345 @@ +import { assert, assertEquals } from "@std/assert"; +import { osvAffectsVersion, osvVersionInRange } from "./vulnerability.ts"; +import { cpeMatchesPackage, type NvdCpeMatch, versionInRange } from "./nvd.ts"; + +// ============================================================================= +// OSV version range matching +// ============================================================================= + +Deno.test("osvVersionInRange - version in simple introduced..fixed range", () => { + const events = [{ introduced: "0" }, { fixed: "4.17.21" }]; + assert(osvVersionInRange("4.17.20", events)); + assert(osvVersionInRange("1.0.0", events)); + assert(osvVersionInRange("0.0.1", events)); +}); + +Deno.test("osvVersionInRange - version at or after fix is not affected", () => { + const events = [{ introduced: "0" }, { fixed: "4.17.21" }]; + assert(!osvVersionInRange("4.17.21", events)); + assert(!osvVersionInRange("5.0.0", events)); +}); + +Deno.test("osvVersionInRange - version before introduced is not affected", () => { + const events = [{ introduced: "2.0.0" }, { fixed: "2.5.0" }]; + assert(!osvVersionInRange("1.9.9", events)); + assert(osvVersionInRange("2.0.0", events)); + assert(osvVersionInRange("2.4.9", events)); + assert(!osvVersionInRange("2.5.0", events)); +}); + +Deno.test("osvVersionInRange - open range (no fix)", () => { + const events = [{ introduced: "1.0.0" }]; + assert(osvVersionInRange("1.0.0", events)); + assert(osvVersionInRange("99.0.0", events)); + assert(!osvVersionInRange("0.9.9", events)); +}); + +Deno.test("osvVersionInRange - open range from zero (no fix)", () => { + const events = [{ introduced: "0" }]; + assert(osvVersionInRange("0.0.1", events)); + assert(osvVersionInRange("99.0.0", events)); +}); + +Deno.test("osvVersionInRange - multiple ranges (reintroduced after fix)", () => { + const events = [ + { introduced: "1.0.0" }, + { fixed: "1.5.0" }, + { introduced: "2.0.0" }, + { fixed: "2.3.0" }, + ]; + // First range + assert(osvVersionInRange("1.2.0", events)); + assert(!osvVersionInRange("1.5.0", events)); + assert(!osvVersionInRange("1.8.0", events)); + // Second range + assert(osvVersionInRange("2.1.0", events)); + assert(!osvVersionInRange("2.3.0", events)); + assert(!osvVersionInRange("3.0.0", events)); +}); + +Deno.test("osvVersionInRange - last_affected (inclusive upper bound)", () => { + const events = [{ introduced: "1.0.0" }, { last_affected: "1.5.0" }]; + assert(osvVersionInRange("1.0.0", events)); + assert(osvVersionInRange("1.5.0", events)); // inclusive + assert(!osvVersionInRange("1.5.1", events)); +}); + +Deno.test("osvVersionInRange - empty events", () => { + assert(!osvVersionInRange("1.0.0", [])); +}); + +// ============================================================================= +// OSV full vulnerability matching (versions list + ranges) +// ============================================================================= + +Deno.test("osvAffectsVersion - matches explicit version list", () => { + const vuln = { + id: "TEST-001", + affected: [ + { + versions: ["1.0.0", "1.0.1", "1.1.0"], + }, + ], + }; + assert(osvAffectsVersion(vuln, "1.0.0")); + assert(osvAffectsVersion(vuln, "1.1.0")); + assert(!osvAffectsVersion(vuln, "1.2.0")); +}); + +Deno.test("osvAffectsVersion - matches range when no explicit versions", () => { + const vuln = { + id: "TEST-002", + affected: [ + { + ranges: [ + { + type: "SEMVER", + events: [{ introduced: "0" }, { fixed: "2.0.0" }], + }, + ], + }, + ], + }; + assert(osvAffectsVersion(vuln, "1.5.0")); + assert(!osvAffectsVersion(vuln, "2.0.0")); +}); + +Deno.test("osvAffectsVersion - skips GIT ranges", () => { + const vuln = { + id: "TEST-003", + affected: [ + { + ranges: [ + { + type: "GIT", + events: [{ introduced: "abc123" }, { fixed: "def456" }], + }, + ], + }, + ], + }; + assert(!osvAffectsVersion(vuln, "1.0.0")); +}); + +Deno.test("osvAffectsVersion - matches ECOSYSTEM range type", () => { + const vuln = { + id: "TEST-004", + affected: [ + { + ranges: [ + { + type: "ECOSYSTEM", + events: [{ introduced: "1.0.0" }, { fixed: "1.5.0" }], + }, + ], + }, + ], + }; + assert(osvAffectsVersion(vuln, "1.2.0")); + assert(!osvAffectsVersion(vuln, "1.5.0")); +}); + +Deno.test("osvAffectsVersion - no affected data returns false", () => { + assert(!osvAffectsVersion({ id: "TEST-005" }, "1.0.0")); + assert(!osvAffectsVersion({ id: "TEST-006", affected: [] }, "1.0.0")); +}); + +Deno.test("osvAffectsVersion - version list takes priority over range miss", () => { + const vuln = { + id: "TEST-007", + affected: [ + { + versions: ["3.0.0"], + ranges: [ + { + type: "SEMVER", + events: [{ introduced: "1.0.0" }, { fixed: "2.0.0" }], + }, + ], + }, + ], + }; + // 3.0.0 is in explicit list even though it's outside the range + assert(osvAffectsVersion(vuln, "3.0.0")); + // 1.5.0 is in the range + assert(osvAffectsVersion(vuln, "1.5.0")); + // 2.5.0 is in neither + assert(!osvAffectsVersion(vuln, "2.5.0")); +}); + +Deno.test("osvAffectsVersion - multiple affected entries", () => { + const vuln = { + id: "TEST-008", + affected: [ + { + package: { name: "foo", ecosystem: "npm" }, + ranges: [ + { + type: "SEMVER", + events: [{ introduced: "1.0.0" }, { fixed: "1.5.0" }], + }, + ], + }, + { + package: { name: "foo", ecosystem: "npm" }, + ranges: [ + { + type: "SEMVER", + events: [{ introduced: "2.0.0" }, { fixed: "2.3.0" }], + }, + ], + }, + ], + }; + assert(osvAffectsVersion(vuln, "1.2.0")); + assert(!osvAffectsVersion(vuln, "1.7.0")); + assert(osvAffectsVersion(vuln, "2.1.0")); + assert(!osvAffectsVersion(vuln, "2.5.0")); +}); + +// ============================================================================= +// NVD CPE package matching +// ============================================================================= + +Deno.test("cpeMatchesPackage - exact product match", () => { + const cpe = "cpe:2.3:a:lodash:lodash:*:*:*:*:*:node.js:*:*"; + assert(cpeMatchesPackage(cpe, "lodash", "npm")); +}); + +Deno.test("cpeMatchesPackage - case-insensitive match", () => { + const cpe = "cpe:2.3:a:apache:commons-text:*:*:*:*:*:*:*:*"; + assert(cpeMatchesPackage(cpe, "Commons-Text", "maven")); +}); + +Deno.test("cpeMatchesPackage - npm scoped package matches product", () => { + // @angular/core -> keyword "core" + const cpe = "cpe:2.3:a:angular:core:*:*:*:*:*:*:*:*"; + assert(cpeMatchesPackage(cpe, "@angular/core", "npm")); +}); + +Deno.test("cpeMatchesPackage - maven groupId:artifactId extracts artifactId", () => { + const cpe = "cpe:2.3:a:apache:spring-boot:*:*:*:*:*:*:*:*"; + assert(cpeMatchesPackage(cpe, "org.springframework:spring-boot", "maven")); +}); + +Deno.test("cpeMatchesPackage - go module uses last path segment", () => { + const cpe = "cpe:2.3:a:gin-gonic:gin:*:*:*:*:*:*:*:*"; + assert(cpeMatchesPackage(cpe, "github.com/gin-gonic/gin", "go")); +}); + +Deno.test("cpeMatchesPackage - wildcard product returns false", () => { + const cpe = "cpe:2.3:a:vendor:*:*:*:*:*:*:*:*:*"; + assert(!cpeMatchesPackage(cpe, "lodash", "npm")); +}); + +Deno.test("cpeMatchesPackage - malformed CPE returns false", () => { + assert(!cpeMatchesPackage("not:a:cpe", "lodash", "npm")); + assert(!cpeMatchesPackage("cpe:2.3:a", "lodash", "npm")); +}); + +Deno.test("cpeMatchesPackage - separator-normalized matching", () => { + // CPE uses hyphens, package might use different separators + const cpe = "cpe:2.3:a:vendor:my-cool-lib:*:*:*:*:*:*:*:*"; + assert(cpeMatchesPackage(cpe, "my_cool_lib", "pypi")); + assert(cpeMatchesPackage(cpe, "my.cool.lib", "nuget")); +}); + +Deno.test("cpeMatchesPackage - packagist extracts package name", () => { + const cpe = "cpe:2.3:a:vendor:monolog:*:*:*:*:*:*:*:*"; + assert(cpeMatchesPackage(cpe, "monolog/monolog", "packagist")); +}); + +// ============================================================================= +// NVD CPE version range matching +// ============================================================================= + +Deno.test("versionInRange - versionEndExcluding", () => { + const match: NvdCpeMatch = { + vulnerable: true, + criteria: "cpe:2.3:a:lodash:lodash:*:*:*:*:*:node.js:*:*", + versionEndExcluding: "4.17.21", + matchCriteriaId: "test", + }; + assert(versionInRange("4.17.20", match)); + assert(versionInRange("1.0.0", match)); + assert(!versionInRange("4.17.21", match)); + assert(!versionInRange("5.0.0", match)); +}); + +Deno.test("versionInRange - versionStartIncluding + versionEndExcluding", () => { + const match: NvdCpeMatch = { + vulnerable: true, + criteria: "cpe:2.3:a:vendor:pkg:*:*:*:*:*:*:*:*", + versionStartIncluding: "2.0.0", + versionEndExcluding: "2.5.0", + matchCriteriaId: "test", + }; + assert(!versionInRange("1.9.9", match)); + assert(versionInRange("2.0.0", match)); + assert(versionInRange("2.4.9", match)); + assert(!versionInRange("2.5.0", match)); +}); + +Deno.test("versionInRange - versionEndIncluding (inclusive upper bound)", () => { + const match: NvdCpeMatch = { + vulnerable: true, + criteria: "cpe:2.3:a:vendor:pkg:*:*:*:*:*:*:*:*", + versionEndIncluding: "3.0.0", + matchCriteriaId: "test", + }; + assert(versionInRange("2.0.0", match)); + assert(versionInRange("3.0.0", match)); // inclusive + assert(!versionInRange("3.0.1", match)); +}); + +Deno.test("versionInRange - versionStartExcluding (exclusive lower bound)", () => { + const match: NvdCpeMatch = { + vulnerable: true, + criteria: "cpe:2.3:a:vendor:pkg:*:*:*:*:*:*:*:*", + versionStartExcluding: "1.0.0", + versionEndExcluding: "2.0.0", + matchCriteriaId: "test", + }; + assert(!versionInRange("1.0.0", match)); // exclusive + assert(versionInRange("1.0.1", match)); + assert(versionInRange("1.9.9", match)); + assert(!versionInRange("2.0.0", match)); +}); + +Deno.test("versionInRange - exact version in CPE (no range fields)", () => { + const match: NvdCpeMatch = { + vulnerable: true, + criteria: "cpe:2.3:a:vendor:pkg:1.2.3:*:*:*:*:*:*:*", + matchCriteriaId: "test", + }; + assert(versionInRange("1.2.3", match)); + assert(!versionInRange("1.2.4", match)); +}); + +Deno.test("versionInRange - wildcard version with no range (all affected)", () => { + const match: NvdCpeMatch = { + vulnerable: true, + criteria: "cpe:2.3:a:vendor:pkg:*:*:*:*:*:*:*:*", + matchCriteriaId: "test", + }; + assert(versionInRange("0.0.1", match)); + assert(versionInRange("99.99.99", match)); +}); + +// ============================================================================= +// Merge behavior (tested via getVulnerabilitySummary as a smoke test) +// ============================================================================= + +Deno.test("getVulnerabilitySummary - counts severities correctly", async () => { + const { getVulnerabilitySummary } = await import("./vulnerability.ts"); + const vulns = [ + { id: "A", severity: "CRITICAL" as const }, + { id: "B", severity: "HIGH" as const }, + { id: "C", severity: "HIGH" as const }, + { id: "D", severity: "MEDIUM" as const }, + { id: "E", severity: "LOW" as const }, + { id: "F" }, // no severity + ]; + const summary = getVulnerabilitySummary(vulns); + assertEquals(summary, { critical: 1, high: 2, medium: 1, low: 1 }); +}); diff --git a/src/utils/vulnerability.ts b/src/utils/vulnerability.ts index 0e821f1..382ea9e 100644 --- a/src/utils/vulnerability.ts +++ b/src/utils/vulnerability.ts @@ -1,17 +1,29 @@ /** - * Shared vulnerability checking utilities using OSV (Open Source Vulnerabilities) database + * Shared vulnerability checking utilities. + * + * Queries both OSV (Open Source Vulnerabilities) and NVD (National Vulnerability Database) + * in parallel, then merges and deduplicates results by CVE ID. + * + * Raw responses are cached per-package (not per-version) so that checking + * multiple versions of the same package (e.g. lodash@4.17.20 then lodash@4.17.21) + * only requires one round of API calls. Version filtering happens client-side. + * + * - OSV provides package-native lookups with fast, accurate ecosystem coverage. + * - NVD provides authoritative CVSS v3.1 scores and CWE classifications. + * - When a vulnerability appears in both, NVD's CVSS score takes precedence. */ import type { Registry, Severity, Vulnerability } from "../registries/types.ts"; import { vulnerabilityCache } from "./cache.ts"; import { fetchWithHeaders } from "./http.ts"; +import { queryNvd } from "./nvd.ts"; +import { compareVersions } from "./version.ts"; const OSV_API_URL = "https://api.osv.dev/v1/query"; /** - * Map registry names to OSV ecosystem names - * Note: Docker images are not tracked by OSV as they contain packages from multiple ecosystems. - * Container vulnerability scanning requires specialized tools like Trivy, Snyk, or Clair. + * Map registry names to OSV ecosystem names. + * Empty string means OSV does not track that ecosystem. */ export const registryToOsv: Record = { npm: "npm", @@ -29,9 +41,8 @@ export const registryToOsv: Record = { "github-actions": "", // GitHub Actions are repos; no dedicated OSV ecosystem }; -/** - * OSV API response types - */ +// === OSV API types === + interface OsvVulnerability { id: string; summary?: string; @@ -43,9 +54,14 @@ interface OsvVulnerability { }; aliases?: string[]; affected?: { + package?: { name?: string; ecosystem?: string }; ranges?: { type: string; - events?: { introduced?: string; fixed?: string }[]; + events?: { + introduced?: string; + fixed?: string; + last_affected?: string; + }[]; }[]; versions?: string[]; }[]; @@ -57,6 +73,8 @@ interface OsvResponse { vulns?: OsvVulnerability[]; } +// === Severity helpers === + const severityOrder: Record = { LOW: 1, MEDIUM: 2, @@ -64,11 +82,7 @@ const severityOrder: Record = { CRITICAL: 4, }; -/** - * Parse severity from OSV vulnerability data - */ function parseSeverity(vuln: OsvVulnerability): Severity | undefined { - // Try database_specific first if (vuln.database_specific?.severity) { const s = vuln.database_specific.severity.toUpperCase(); if (s in severityOrder) { @@ -76,7 +90,6 @@ function parseSeverity(vuln: OsvVulnerability): Severity | undefined { } } - // Try CVSS score if (vuln.severity) { for (const sev of vuln.severity) { if (sev.type === "CVSS_V3" || sev.type === "CVSS_V2") { @@ -100,14 +113,97 @@ function parseSeverity(vuln: OsvVulnerability): Severity | undefined { return undefined; } +// === OSV version matching === + +/** + * Check if a version falls within an OSV affected range. + * + * OSV events are ordered pairs: introduced → fixed (or last_affected). + * A version is affected if it falls within any [introduced, fixed) range, + * or >= introduced when no fix exists. + * + * @internal Exported for testing. + */ +export function osvVersionInRange( + version: string, + events: { introduced?: string; fixed?: string; last_affected?: string }[], +): boolean { + let introduced: string | undefined; + + for (const event of events) { + if (event.introduced !== undefined) { + introduced = event.introduced; + } else if (event.fixed !== undefined && introduced !== undefined) { + // Range: [introduced, fixed) + const afterIntroduced = introduced === "0" || + compareVersions(version, introduced) >= 0; + const beforeFixed = compareVersions(version, event.fixed) < 0; + if (afterIntroduced && beforeFixed) return true; + introduced = undefined; + } else if ( + event.last_affected !== undefined && introduced !== undefined + ) { + // Range: [introduced, last_affected] + const afterIntroduced = introduced === "0" || + compareVersions(version, introduced) >= 0; + const atOrBeforeLastAffected = + compareVersions(version, event.last_affected) <= 0; + if (afterIntroduced && atOrBeforeLastAffected) return true; + introduced = undefined; + } + } + + // Open range: introduced with no fix + if (introduced !== undefined) { + const afterIntroduced = introduced === "0" || + compareVersions(version, introduced) >= 0; + if (afterIntroduced) return true; + } + + return false; +} + /** - * Parse OSV vulnerability into our Vulnerability type + * Check if a specific version is affected by an OSV vulnerability. + * + * Checks two sources of truth: + * 1. Explicit `affected[].versions[]` list (exact match) + * 2. `affected[].ranges[].events[]` (version range matching) + * + * @internal Exported for testing. */ -function parseVulnerability(vuln: OsvVulnerability): Vulnerability { +export function osvAffectsVersion( + vuln: OsvVulnerability, + version: string, +): boolean { + if (!vuln.affected) return false; + + for (const affected of vuln.affected) { + // Check explicit version list first (most reliable) + if (affected.versions?.includes(version)) { + return true; + } + + // Check version ranges + if (affected.ranges) { + for (const range of affected.ranges) { + // Skip git commit ranges — not useful for version comparison + if (range.type === "GIT") continue; + if (!range.events?.length) continue; + if (osvVersionInRange(version, range.events)) return true; + } + } + } + + return false; +} + +// === OSV parsing === + +function parseOsvVulnerability(vuln: OsvVulnerability): Vulnerability { const severity = parseSeverity(vuln); const cveIds = vuln.aliases?.filter((a) => a.startsWith("CVE-")) || []; - // Extract fixed versions const fixedVersions: string[] = []; if (vuln.affected) { for (const affected of vuln.affected) { @@ -125,7 +221,6 @@ function parseVulnerability(vuln: OsvVulnerability): Vulnerability { } } - // Extract affected versions summary let affectedVersions: string | undefined; if (vuln.affected?.[0]?.ranges?.[0]?.events) { const events = vuln.affected[0].ranges[0].events; @@ -148,60 +243,150 @@ function parseVulnerability(vuln: OsvVulnerability): Vulnerability { affectedVersions, publishedAt: vuln.published ? new Date(vuln.published) : undefined, references: vuln.references?.map((r) => r.url), + source: "osv", }; } -/** - * Options for vulnerability checking - */ -export interface CheckVulnerabilitiesOptions { - /** Minimum severity to include (default: all) */ - severityThreshold?: Severity; -} +// === Per-package OSV fetch with caching === /** - * Check a package version for known vulnerabilities using OSV database - * Results are cached to avoid repeated API calls + * Fetch all OSV vulnerabilities for a package (cached per-package). + * Omits the `version` field so the OSV API returns all known vulns + * for the package. Version filtering is done client-side. */ -export async function checkVulnerabilities( +async function fetchOsvForPackage( packageName: string, - version: string, registry: Registry, - options?: CheckVulnerabilitiesOptions, -): Promise { - const cacheKey = `osv:${registry}:${packageName}:${version}`; - let vulns = vulnerabilityCache.get(cacheKey) as Vulnerability[] | undefined; +): Promise { + const ecosystem = registryToOsv[registry]; + if (!ecosystem) return []; + + const cacheKey = `osv:pkg:${registry}:${packageName}`; - if (!vulns) { + return await vulnerabilityCache.getOrSet(cacheKey, async () => { try { const response = await fetchWithHeaders(OSV_API_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - package: { - name: packageName, - ecosystem: registryToOsv[registry], - }, - version, + package: { name: packageName, ecosystem }, }), }); - if (!response.ok) { - return []; - } + if (!response.ok) return []; const data = (await response.json()) as OsvResponse; - vulns = (data.vulns || []).map(parseVulnerability); - vulnerabilityCache.set(cacheKey, vulns); + return data.vulns ?? []; } catch { return []; } + }) as OsvVulnerability[]; +} + +/** + * Query OSV for vulnerabilities affecting a specific version. + * Fetches all vulns for the package (cached), then filters client-side. + */ +async function queryOsv( + packageName: string, + version: string, + registry: Registry, +): Promise { + const allVulns = await fetchOsvForPackage(packageName, registry); + + return allVulns + .filter((vuln) => osvAffectsVersion(vuln, version)) + .map(parseOsvVulnerability); +} + +// === Merge / deduplicate === + +/** + * Merge vulnerabilities from OSV and NVD, deduplicating by CVE ID. + * When a CVE appears in both sources, NVD's CVSS score and CWE IDs are preferred. + */ +function mergeVulnerabilities( + osvVulns: Vulnerability[], + nvdVulns: Vulnerability[], +): Vulnerability[] { + // Index NVD vulns by their CVE ID (which is also their primary ID) + const nvdByCve = new Map(); + for (const v of nvdVulns) { + nvdByCve.set(v.id, v); + } + + const merged: Vulnerability[] = []; + + // Process OSV vulns, merging with NVD matches + for (const osv of osvVulns) { + let nvdMatch: Vulnerability | undefined; + + // Try to find a matching NVD entry via CVE IDs + if (osv.cveIds) { + for (const cveId of osv.cveIds) { + nvdMatch = nvdByCve.get(cveId); + if (nvdMatch) { + nvdByCve.delete(cveId); + break; + } + } + } + + if (nvdMatch) { + // Merge: prefer NVD's authoritative CVSS score and CWE data + merged.push({ + ...osv, + severity: nvdMatch.severity ?? osv.severity, + cvss: nvdMatch.cvss ?? osv.cvss, + cweIds: nvdMatch.cweIds ?? osv.cweIds, + source: "osv+nvd", + }); + } else { + merged.push(osv); + } + } + + // Add NVD-only vulnerabilities (not found in OSV) + for (const nvd of nvdByCve.values()) { + merged.push(nvd); } + return merged; +} + +// === Public API === + +export interface CheckVulnerabilitiesOptions { + /** Minimum severity to include (default: all) */ + severityThreshold?: Severity; +} + +/** + * Check a package version for known vulnerabilities. + * + * Fetches all vulnerabilities for the package from OSV and NVD in parallel + * (cached per-package), then filters by version client-side and merges results. + * + * Checking multiple versions of the same package reuses cached API responses. + */ +export async function checkVulnerabilities( + packageName: string, + version: string, + registry: Registry, + options?: CheckVulnerabilitiesOptions, +): Promise { + // Query both sources in parallel — each caches per-package internally + const [osvVulns, nvdVulns] = await Promise.all([ + queryOsv(packageName, version, registry), + queryNvd(packageName, version, registry), + ]); + + let vulns = mergeVulnerabilities(osvVulns, nvdVulns); + // Filter by severity threshold if specified if (options?.severityThreshold) { const threshold = severityOrder[options.severityThreshold]; - return vulns.filter((v) => { + vulns = vulns.filter((v) => { if (!v.severity) return false; return severityOrder[v.severity] >= threshold; }); @@ -211,7 +396,7 @@ export async function checkVulnerabilities( } /** - * Get vulnerability summary counts by severity + * Get vulnerability summary counts by severity. */ export function getVulnerabilitySummary(vulns: Vulnerability[]): { critical: number;