diff --git a/.changeset/osv-supply-chain-replacement.md b/.changeset/osv-supply-chain-replacement.md new file mode 100644 index 0000000000..1d25dc76c0 --- /dev/null +++ b/.changeset/osv-supply-chain-replacement.md @@ -0,0 +1,6 @@ +--- +"@react-doctor/core": patch +"react-doctor": patch +--- + +Replace the Socket.dev supply-chain check with OSV across the core checker, CLI wiring, docs, schema, and tests. The supply-chain config now uses `failOn` for severity gating instead of `minScore`, and diagnostics now report OSV vulnerability IDs and severities. diff --git a/packages/core/src/check-supply-chain.ts b/packages/core/src/check-supply-chain.ts index e36efcd258..fded59c2f1 100644 --- a/packages/core/src/check-supply-chain.ts +++ b/packages/core/src/check-supply-chain.ts @@ -3,23 +3,21 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; import * as semver from "semver"; import { CACHE_FILENAME_HASH_LENGTH_CHARS, FETCH_TIMEOUT_MS, - SOCKET_FREE_PURL_API_BASE, - SOCKET_FREE_USER_AGENT, - SOCKET_PACKAGE_PAGE_BASE, - SOCKET_SCORE_SCALE, - SUPPLY_CHAIN_ALERT_NOTE_MAX_CHARS, + OSV_API_BASE, + OSV_VULN_PAGE_BASE, SUPPLY_CHAIN_CACHE_SUBDIR, SUPPLY_CHAIN_CACHE_TTL_MS, SUPPLY_CHAIN_CATEGORY, - SUPPLY_CHAIN_DEFAULT_MIN_SCORE, + SUPPLY_CHAIN_DEFAULT_FAIL_ON, SUPPLY_CHAIN_FETCH_CONCURRENCY, + SUPPLY_CHAIN_FETCH_MAX_RETRIES, + SUPPLY_CHAIN_FETCH_RETRY_BASE_MS, SUPPLY_CHAIN_IGNORED_PACKAGES, - SUPPLY_CHAIN_MAX_ALERTS_SHOWN, SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS, SUPPLY_CHAIN_PLUGIN, SUPPLY_CHAIN_RULE, @@ -32,214 +30,238 @@ import { sanitizeTerminalText } from "./utils/sanitize-terminal-text.js"; export interface SupplyChainCheckInput { readonly rootDirectory: string; readonly userConfig: ReactDoctorConfig | null; - /** Whole-check wall-clock cap; a many-socket pileup that ignores the per-fetch abort trips this and the check fails open ([]). Defaults to SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS (the same budget the orchestrator's fork-level `SupplyChainOverlapTimeoutMs` ref defaults to — one source of truth). */ readonly totalTimeoutMs?: number; } interface ResolvedSupplyChainOptions { - readonly minScore: number; readonly severity: "error" | "warning"; readonly includeDevDependencies: boolean; + readonly failOn: OsvSeverity; } interface DependencyToScore { readonly name: string; - /** Concrete version queried against Socket (resolved from the spec). */ readonly version: string; - /** The range/spec exactly as declared in package.json (e.g. `^16.2.4`). */ readonly spec: string; - /** 1-based line of the dependency's key in package.json; `0` if not located. */ readonly line: number; - /** 1-based column of the dependency's key in package.json; `0` if not located. */ readonly column: number; } -// The Socket score, all axes in the 0..1 range. Each artifact line carries -// many other fields (id, author, license, …) that `Schema.Struct` ignores; -// an unknown package/version comes back as a `synthetic:notFound:*` artifact -// with `score` absent, which the `optional` lets us skip. -const SocketScoreSchema = Schema.Struct({ - overall: Schema.Number, - license: Schema.Number, - maintenance: Schema.Number, - quality: Schema.Number, - supplyChain: Schema.Number, - vulnerability: Schema.Number, -}); - -// A single Socket alert: the concrete "why" behind a low score (e.g. a -// `critical` `malware` alert in a named file with a human `note`). The free -// endpoint only attaches these for the highest-signal supply-chain threats; -// metric-driven dips (CVE-only scores, sparse maintenance) arrive with an -// empty `alerts` array. Optional fields are `NullOr` because the JSON endpoint -// sends an explicit `null` (not an absent key) for values it lacks, and -// `Schema.optional` alone rejects `null` — which would fail the whole decode. -const SocketAlertSchema = Schema.Struct({ - type: Schema.String, - severity: Schema.String, - file: Schema.optional(Schema.NullOr(Schema.String)), - props: Schema.optional( - Schema.NullOr(Schema.Struct({ note: Schema.optional(Schema.NullOr(Schema.String)) })), - ), -}); - -// The score-bearing artifact line. Alerts are decoded SEPARATELY (see -// `extractAlerts`) rather than as a field here so that a single malformed or -// unknown-variant alert can never fail the artifact decode — which would treat -// the package as unscored and silently drop a real low-score finding. -const SocketArtifactSchema = Schema.Struct({ - score: Schema.optional(SocketScoreSchema), -}); +interface OsvSeverityEntry { + readonly type?: string; + readonly score?: string; +} -// The raw alert list, kept as `Unknown` elements so one unparseable alert -// can't sink the array decode; each element is decoded resiliently below. -const RawAlertsSchema = Schema.Struct({ - alerts: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), -}); +interface OsvDatabaseSpecific { + readonly severity?: string; +} -type SocketScore = Schema.Schema.Type; -type SocketAlert = Schema.Schema.Type; +interface OsvVulnerabilityRecord { + readonly id?: string; + readonly summary?: string; + readonly details?: string; + readonly severity?: ReadonlyArray; + readonly database_specific?: OsvDatabaseSpecific | null; +} -// A resolved artifact: a `score` is guaranteed (callers skip unscored -// packages) and `alerts` is normalized to a (possibly empty) array. -interface SocketArtifact { - readonly score: SocketScore; - readonly alerts: ReadonlyArray; +interface CachedOsvVulnerability { + readonly id: string; + readonly severity: OsvSeverity; + readonly summary: string; + readonly pageUrl: string; } -const decodeArtifact = Schema.decodeUnknownOption(SocketArtifactSchema); -const decodeRawAlerts = Schema.decodeUnknownOption(RawAlertsSchema); -const decodeAlert = Schema.decodeUnknownOption(SocketAlertSchema); - -// Decodes each alert independently, dropping any that don't parse (an unknown -// variant or a malformed entry) rather than discarding the whole artifact — -// and with it the score that gates the check. -const extractAlerts = (parsed: unknown): ReadonlyArray => { - const rawAlerts = Option.getOrNull(decodeRawAlerts(parsed))?.alerts; - if (!rawAlerts) return []; - const alerts: SocketAlert[] = []; - for (const candidate of rawAlerts) { - const alert = Option.getOrNull(decodeAlert(candidate)); - if (alert !== null) alerts.push(alert); - } - return alerts; -}; +type OsvSeverity = "low" | "moderate" | "high" | "critical"; -interface AxisGuidance { - /** - * Plain-English meaning of a low score on this axis, woven into the message - * when Socket returns no explicit alerts to name (the common, metric-driven - * case on the free endpoint). - */ - readonly meaning: string; - /** Axis-specific remediation phrase, woven into the diagnostic's help. */ - readonly remediation: string; -} +const OSV_SEVERITY_ORDER: Record = { + low: 0, + moderate: 1, + high: 2, + critical: 3, +}; -// A security axis that gates the check. Its guidance powers the failing-axis -// message's "why" and the help's remediation. -interface GatedAxis { - readonly key: keyof SocketScore; - readonly label: string; - readonly guidance: AxisGuidance; -} +const OSV_CVSS_AV_VALUES: Record = { + N: 0.85, + A: 0.62, + L: 0.55, + P: 0.2, +}; -// A non-security axis: reported in the diagnostic's breakdown as context, but -// never gates the check. -interface ScoreAxis { - readonly key: keyof SocketScore; - readonly label: string; -} +const OSV_CVSS_AC_VALUES: Record = { + L: 0.77, + H: 0.44, +}; -// Only the security axes decide the gate. Socket's `overall` is its lowest -// axis, so gating on it let a pure quality/maintenance dip fail this -// Security-category check — e.g. `@types/bun@1.3.14` scores quality 48 with -// every security axis at 100 (issue #770). `supplyChain` covers typosquats / -// install scripts / compromised maintainers; `vulnerability` covers known -// CVEs (what flags the compromised `event-stream@3.3.6`). -const GATED_AXES: ReadonlyArray = [ - { - key: "supplyChain", - label: "supply chain", - guidance: { - meaning: - "risky install-time behavior — install scripts, obfuscated or native code, network/filesystem/shell access, or typosquatting", - remediation: - "Confirm this is the package you meant to install, and prefer a more established, audited alternative", - }, +const OSV_CVSS_PR_VALUES: Record<"U" | "C", Record> = { + U: { + N: 0.85, + L: 0.62, + H: 0.27, }, - { - key: "vulnerability", - label: "vulnerability", - guidance: { - meaning: "known security vulnerabilities (CVEs) affecting this version", - remediation: - "Upgrade to a version with no known advisories (run `npm audit` to find one), or replace it", - }, + C: { + N: 0.85, + L: 0.68, + H: 0.5, }, -]; - -// The non-gating axes, reported in the breakdown as context so a developer -// sees which dimension dragged the score down. -const CONTEXT_AXES: ReadonlyArray = [ - { key: "maintenance", label: "maintenance" }, - { key: "quality", label: "quality" }, - { key: "license", label: "license" }, -]; - -// Every axis in display order (gated first), for the per-axis score breakdown -// and the fetch span attributes. -const SCORE_AXES: ReadonlyArray = [...GATED_AXES, ...CONTEXT_AXES]; - -// The axis that decides the gate for one score: the lowest of the gated -// axes. A tie keeps `supplyChain`, matching the rule's name. -const worstGatedAxis = (score: SocketScore): GatedAxis => { - let worst = GATED_AXES[0]; - for (const axis of GATED_AXES) { - if (score[axis.key] < score[worst.key]) worst = axis; +}; + +const OSV_CVSS_UI_VALUES: Record = { + N: 0.85, + R: 0.62, +}; + +const OSV_CVSS_IMPACT_VALUES: Record = { + N: 0, + L: 0.22, + H: 0.56, +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const collapseWhitespace = (value: string): string => value.replace(/\s+/g, " ").trim(); + +const sanitizeSummaryText = (value: string): string => + sanitizeTerminalText(collapseWhitespace(value)); + +const normalizeSeverity = (value: string | undefined): OsvSeverity | null => { + if (value === undefined) return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "low") return "low"; + if (normalized === "moderate" || normalized === "medium") return "moderate"; + if (normalized === "high") return "high"; + if (normalized === "critical") return "critical"; + return null; +}; + +const severityRank = (severity: OsvSeverity): number => OSV_SEVERITY_ORDER[severity]; + +const isMalwareAdvisory = (vulnerability: OsvVulnerabilityRecord): boolean => { + const summaryText = `${vulnerability.summary ?? ""} ${vulnerability.details ?? ""}`.toLowerCase(); + return ( + (typeof vulnerability.id === "string" && vulnerability.id.toUpperCase().startsWith("MAL-")) || + summaryText.includes("malicious package") + ); +}; + +const roundUpToOneDecimal = (value: number): number => Math.ceil(value * 10) / 10; + +const parseCvssV3BaseScore = (vectorOrScore: string): number | null => { + const trimmed = vectorOrScore.trim(); + if (trimmed.length === 0) return null; + + const numericScore = Number(trimmed); + if (Number.isFinite(numericScore)) return numericScore; + if (!trimmed.startsWith("CVSS:3.")) return null; + + const metrics = new Map(); + for (const segment of trimmed.split("/").slice(1)) { + const separatorIndex = segment.indexOf(":"); + if (separatorIndex <= 0) return null; + const metric = segment.slice(0, separatorIndex); + const metricValue = segment.slice(separatorIndex + 1); + if (metric.length === 0 || metricValue.length === 0) return null; + metrics.set(metric, metricValue); + } + + const attackVector = OSV_CVSS_AV_VALUES[metrics.get("AV") ?? ""]; + const attackComplexity = OSV_CVSS_AC_VALUES[metrics.get("AC") ?? ""]; + const privilegesRequiredValue = metrics.get("PR"); + const userInteraction = OSV_CVSS_UI_VALUES[metrics.get("UI") ?? ""]; + const scopeValue = metrics.get("S"); + const confidentialityImpact = OSV_CVSS_IMPACT_VALUES[metrics.get("C") ?? ""]; + const integrityImpact = OSV_CVSS_IMPACT_VALUES[metrics.get("I") ?? ""]; + const availabilityImpact = OSV_CVSS_IMPACT_VALUES[metrics.get("A") ?? ""]; + + if ( + attackVector === undefined || + attackComplexity === undefined || + privilegesRequiredValue === undefined || + userInteraction === undefined || + scopeValue === undefined || + confidentialityImpact === undefined || + integrityImpact === undefined || + availabilityImpact === undefined + ) { + return null; } - return worst; + + const scope = scopeValue === "C" ? "C" : scopeValue === "U" ? "U" : null; + if (scope === null) return null; + + const privilegesRequired = OSV_CVSS_PR_VALUES[scope][privilegesRequiredValue]; + if (privilegesRequired === undefined) return null; + + const impactSubScore = + 1 - (1 - confidentialityImpact) * (1 - integrityImpact) * (1 - availabilityImpact); + if (impactSubScore <= 0) return 0; + + const impactScore = + scope === "U" + ? 6.42 * impactSubScore + : 7.52 * (impactSubScore - 0.029) - 3.25 * Math.pow(impactSubScore - 0.02, 15); + const exploitabilityScore = + 8.22 * attackVector * attackComplexity * privilegesRequired * userInteraction; + const rawScore = + scope === "U" ? impactScore + exploitabilityScore : 1.08 * (impactScore + exploitabilityScore); + return Math.min(roundUpToOneDecimal(rawScore), 10); }; -const clampScore = (value: number): number => { - if (!Number.isFinite(value)) return SUPPLY_CHAIN_DEFAULT_MIN_SCORE; - return Math.min(Math.max(value, 0), SOCKET_SCORE_SCALE); +const bucketCvssBaseScore = (baseScore: number): OsvSeverity => { + if (baseScore >= 9) return "critical"; + if (baseScore >= 7) return "high"; + if (baseScore >= 4) return "moderate"; + return "low"; }; -// Socket scores arrive normalized 0..1; present them on the familiar 0..100 -// scale used everywhere else (diagnostics, span attributes). -const toHundred = (normalizedScore: number): number => - Math.round(clampScore(normalizedScore * SOCKET_SCORE_SCALE)); +const parseOsvSeverityEntries = ( + severityEntries: ReadonlyArray, +): ReadonlyArray => { + const parsedEntries: OsvSeverityEntry[] = []; + for (const severityEntry of severityEntries) { + if (!isRecord(severityEntry)) continue; + const score = typeof severityEntry["score"] === "string" ? severityEntry["score"] : undefined; + const type = typeof severityEntry["type"] === "string" ? severityEntry["type"] : undefined; + if (score === undefined && type === undefined) continue; + parsedEntries.push({ score, type }); + } + return parsedEntries; +}; -const resolveOptions = (config: ReactDoctorConfig | null): ResolvedSupplyChainOptions => { - const supplyChain = config?.supplyChain ?? {}; - return { - minScore: - typeof supplyChain.minScore === "number" - ? clampScore(supplyChain.minScore) - : SUPPLY_CHAIN_DEFAULT_MIN_SCORE, - // Coerce anything that isn't exactly `"warning"` (e.g. a JSON config - // that wrote `"warn"`) to the stricter `"error"` default. - severity: supplyChain.severity === "warning" ? "warning" : "error", - includeDevDependencies: supplyChain.includeDevDependencies !== false, - }; +const resolveVulnerabilitySeverity = (vulnerability: OsvVulnerabilityRecord): OsvSeverity => { + if (isMalwareAdvisory(vulnerability)) return "critical"; + + const databaseSpecificSeverity = normalizeSeverity(vulnerability.database_specific?.severity); + if (databaseSpecificSeverity !== null) return databaseSpecificSeverity; + + let highestSeverity: OsvSeverity | null = null; + for (const severityEntry of vulnerability.severity ?? []) { + const parsedBaseScore = parseCvssV3BaseScore(severityEntry.score ?? ""); + if (parsedBaseScore === null) continue; + const parsedSeverity = bucketCvssBaseScore(parsedBaseScore); + if (highestSeverity === null || severityRank(parsedSeverity) > severityRank(highestSeverity)) { + highestSeverity = parsedSeverity; + } + } + + return highestSeverity ?? "moderate"; }; -// package.json declares ranges (`^4.17.21`, `~1.2.0`, `>=2 <3`), but the -// Socket lookup needs one concrete version. Score the floor of the range — the -// lowest version it permits, a real published version — via `semver.minVersion`, -// which resolves caret/tilde/OR/upper-bound ranges correctly (the old -// "first semver token" scan mis-scored `<2.0.0 >=1.5.0` and `2.0.0 || 1.0.0`). -// Specs with no parseable floor (`latest`, `*`, a URL) or a non-registry -// protocol (`workspace:`, `file:`, `link:`, `npm:`, `git+…`) are skipped: -// nothing to score. +const SUPPLY_CHAIN_FETCH_RETRY_SCHEDULE = Schedule.exponential( + SUPPLY_CHAIN_FETCH_RETRY_BASE_MS, +).pipe(Schedule.take(SUPPLY_CHAIN_FETCH_MAX_RETRIES)); + +const resolveOptions = (config: ReactDoctorConfig | null): ResolvedSupplyChainOptions => ({ + severity: config?.supplyChain?.severity === "warning" ? "warning" : "error", + includeDevDependencies: config?.supplyChain?.includeDevDependencies !== false, + failOn: normalizeSeverity(config?.supplyChain?.failOn) ?? SUPPLY_CHAIN_DEFAULT_FAIL_ON, +}); + const resolveConcreteVersion = (spec: string): string | null => { const trimmed = spec.trim(); if (trimmed.length === 0) return null; if (trimmed.includes(":")) return null; - // `semver.minVersion` *throws* on a bare dist-tag (`latest`, `next`) rather - // than returning null, so validate first. `validRange` collapses a pure - // wildcard to `"*"`, whose only floor is a synthetic `0.0.0` — skip it too. + const range = semver.validRange(trimmed); if (range === null || range === "*") return null; return semver.minVersion(trimmed)?.version ?? null; @@ -247,13 +269,6 @@ const resolveConcreteVersion = (spec: string): string | null => { type DependencySection = "dependencies" | "devDependencies"; -// Locates the 1-based line/column of a dependency's key *within its declaring -// section* in the raw package.json text, so the diagnostic anchors to the -// exact entry the user must edit rather than the top of the file — and never -// to a same-named key under `overrides` / `resolutions` / `pnpm.overrides`. -// Scopes by the `"
": {` header and tracks brace depth so the match -// stays inside that object; the literal `"name"` + colon means `react` never -// matches `react-dom`. const locateDependencyKey = ( packageJsonText: string, section: DependencySection, @@ -286,6 +301,7 @@ const locateDependencyKey = ( } if (depth <= 0) return null; } + return null; }; @@ -319,12 +335,10 @@ const collectDependenciesToScore = ( column: location?.column ?? 0, }); } + return dependencies; }; -// Reads the package.json text for line-location; tolerates a missing / -// unreadable file (the parsed object is read separately and resiliently by -// `readPackageJson`, which returns `{}` on the same failures). const readPackageJsonText = (packageJsonPath: string): string => { try { return fs.readFileSync(packageJsonPath, "utf-8"); @@ -336,83 +350,155 @@ const readPackageJsonText = (packageJsonPath: string): string => { const toPurl = (dependency: DependencyToScore): string => `pkg:npm/${dependency.name}@${dependency.version}`; -// The endpoint streams newline-delimited JSON (one artifact per line); take -// the first line that decodes to an artifact carrying a score. Alerts are -// decoded separately and resiliently (`extractAlerts`) so a malformed alert -// can never discard the score that gates the check. -const parseArtifactFromBody = (body: string): SocketArtifact | null => { - for (const line of body.split("\n")) { - if (line.trim().length === 0) continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - continue; - } - const artifact = Option.getOrNull(decodeArtifact(parsed)); - if (artifact?.score) return { score: artifact.score, alerts: extractAlerts(parsed) }; +const parseOsvQueryBatchResult = (result: unknown): ReadonlyArray => { + if (!isRecord(result)) return []; + const vulns = result["vulns"]; + if (!Array.isArray(vulns)) return []; + + const ids: string[] = []; + for (const vuln of vulns) { + if (!isRecord(vuln)) continue; + const vulnId = vuln["id"]; + if (typeof vulnId === "string" && vulnId.trim().length > 0) ids.push(vulnId); } - return null; + return ids; }; -// Per-PURL on-disk Socket cache (TTL-bounded), so unchanged dependencies skip -// the network on a repeated scan (the recurring CI win + faster local re-scans). -// Disabled by the global `REACT_DOCTOR_NO_CACHE` off-switch. -const isSupplyChainCacheDisabled = (): boolean => { - const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? ""; - return noCache === "1" || noCache === "true"; +const parseOsvQueryBatchResponse = ( + payload: unknown, + dependencyCount: number, +): ReadonlyArray> | null => { + if (!isRecord(payload)) return null; + const results = payload["results"]; + if (!Array.isArray(results)) return null; + + return Array.from({ length: dependencyCount }, (_, index) => + parseOsvQueryBatchResult(results[index]), + ); }; -const supplyChainCacheFile = (cacheDirectory: string, dependency: DependencyToScore): string => { - const purlHash = crypto - .createHash("sha256") - .update(toPurl(dependency)) - .digest("hex") - .slice(0, CACHE_FILENAME_HASH_LENGTH_CHARS); - return path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, `${purlHash}.json`); +const parseOsvQueryResponse = (payload: unknown): ReadonlyArray | null => { + if (!isRecord(payload)) return null; + const vulns = payload["vulns"]; + if (!Array.isArray(vulns)) return null; + + const parsedVulnerabilities: CachedOsvVulnerability[] = []; + for (const vuln of vulns) { + if (!isRecord(vuln) || typeof vuln["id"] !== "string" || vuln["id"].trim().length === 0) { + return null; + } + const parsedVulnerability = parseOsvVulnerabilityRecord(vuln["id"], vuln); + if (parsedVulnerability === null) return null; + parsedVulnerabilities.push(parsedVulnerability); + } + + return parsedVulnerabilities; +}; + +const buildCachedVulnerability = ( + id: string, + severity: OsvSeverity, + summary: string, +): CachedOsvVulnerability => ({ + id, + severity, + summary, + pageUrl: `${OSV_VULN_PAGE_BASE}/${encodeURIComponent(id)}`, +}); + +const parseOsvVulnerabilityRecord = ( + requestedId: string, + payload: unknown, +): CachedOsvVulnerability | null => { + if (!isRecord(payload)) return null; + + const payloadId = + typeof payload["id"] === "string" && payload["id"].trim().length > 0 + ? payload["id"] + : requestedId; + const databaseSpecific = isRecord(payload["database_specific"]) + ? payload["database_specific"] + : null; + const summaryValue = + typeof payload["summary"] === "string" && payload["summary"].trim().length > 0 + ? payload["summary"] + : typeof payload["details"] === "string" && payload["details"].trim().length > 0 + ? payload["details"] + : payloadId; + const severity = resolveVulnerabilitySeverity({ + id: payloadId, + summary: typeof payload["summary"] === "string" ? payload["summary"] : undefined, + details: typeof payload["details"] === "string" ? payload["details"] : undefined, + severity: Array.isArray(payload["severity"]) + ? parseOsvSeverityEntries(payload["severity"]) + : undefined, + database_specific: + databaseSpecific !== null + ? { + severity: + typeof databaseSpecific["severity"] === "string" + ? databaseSpecific["severity"] + : undefined, + } + : undefined, + }); + + return buildCachedVulnerability(payloadId, severity, sanitizeSummaryText(summaryValue)); }; -// Returns the cached raw response body when present and within the TTL, else -// null. Fail-open: a missing / malformed / expired entry reads as a miss. We -// cache the raw body (not the parsed artifact) and re-parse on a hit, so the -// cached and live paths produce byte-identical artifacts through one parser. -const readCachedSocketBody = (cacheFile: string): string | null => { +const readCachedOsvVulns = (cacheFile: string): ReadonlyArray | null => { try { const entry: unknown = JSON.parse(fs.readFileSync(cacheFile, "utf-8")); if ( - typeof entry === "object" && - entry !== null && - "fetchedAtMs" in entry && - "body" in entry && - typeof entry.fetchedAtMs === "number" && - typeof entry.body === "string" && - Date.now() - entry.fetchedAtMs <= SUPPLY_CHAIN_CACHE_TTL_MS + isRecord(entry) && + typeof entry["fetchedAtMs"] === "number" && + Array.isArray(entry["vulns"]) && + Date.now() - entry["fetchedAtMs"] <= SUPPLY_CHAIN_CACHE_TTL_MS ) { - return entry.body; + const vulns: CachedOsvVulnerability[] = []; + for (const vuln of entry["vulns"]) { + if (!isRecord(vuln)) return null; + if ( + typeof vuln["id"] !== "string" || + vuln["id"].trim().length === 0 || + typeof vuln["summary"] !== "string" || + typeof vuln["pageUrl"] !== "string" + ) { + return null; + } + const severity = normalizeSeverity( + typeof vuln["severity"] === "string" ? vuln["severity"] : undefined, + ); + if (severity === null) return null; + vulns.push({ + id: vuln["id"], + severity, + summary: vuln["summary"], + pageUrl: vuln["pageUrl"], + }); + } + return vulns; } } catch { - // unreadable / malformed → treat as a miss + // unreadable or malformed entries are treated as a miss. } + return null; }; -const writeCachedSocketBody = (cacheFile: string, body: string): void => { +const writeCachedOsvVulns = ( + cacheFile: string, + vulns: ReadonlyArray, +): void => { try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); - fs.writeFileSync(cacheFile, JSON.stringify({ fetchedAtMs: Date.now(), body })); + fs.writeFileSync(cacheFile, JSON.stringify({ fetchedAtMs: Date.now(), vulns })); } catch { // A cache write failure must never sink the scan. } }; -// Drops cache files past the TTL. A live dependency's expired entry would -// re-fetch anyway; without this, entries for purls that stop being looked up -// (version bumps, removed dependencies) accumulate forever — a slow monotonic -// leak in CI, where the whole cache directory is persisted and restored across -// runs. File mtime stands in for `fetchedAtMs` (same clock: the file is -// written when the entry is fetched, and both local disks and the CI cache's -// tar round-trip preserve it), so pruning stats instead of parsing every file. -const pruneExpiredSocketCache = (cacheDirectory: string): void => { +const pruneExpiredOsvCache = (cacheDirectory: string): void => { try { const supplyChainCacheDirectory = path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR); const expiryThresholdMs = Date.now() - SUPPLY_CHAIN_CACHE_TTL_MS; @@ -429,261 +515,147 @@ const pruneExpiredSocketCache = (cacheDirectory: string): void => { } }; -// Fetches the free, keyless Socket artifact (score + alerts) for one -// dependency — the same `firewall-api.socket.dev/purl/` endpoint -// Socket Firewall's free tier hits. `Effect.tryPromise` hands `fetch` an -// `AbortSignal` that `Effect.timeout` trips on the deadline (cancelling the -// request), and `Effect.orElseSucceed` makes the lookup fail-open: an unscored -// / unknown package, a timeout, or any network/parse failure yields `null` -// (skip) rather than sinking the scan. Each lookup is its own -// `SupplyChain.fetchScore` span: the package identity rides the initial -// attributes, and the resolved axis scores (overall + each SCORE_AXES -// dimension, 0..100) plus the alert count are annotated once the lookup -// settles. Dotted `socket.*` namespacing per the observability conventions, so -// a trace backend can group by package or query score / alert distributions -// across a scan. No-op without a tracer. -const fetchSocketArtifact = ( - dependency: DependencyToScore, - cacheDirectory: string | null, -): Effect.Effect => - Effect.tryPromise(async (signal) => { - const cacheFile = - cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency); - if (cacheFile !== null) { - const cachedBody = readCachedSocketBody(cacheFile); - if (cachedBody !== null) { - const cachedArtifact = parseArtifactFromBody(cachedBody); - // An unparseable cached body (Socket schema drift / a corrupted restore) - // is a MISS, not a null result — fall through to the network rather than - // silently skipping the advisory until the TTL expires. - if (cachedArtifact !== null) return cachedArtifact; - } - } - const requestUrl = `${SOCKET_FREE_PURL_API_BASE}/${encodeURIComponent(toPurl(dependency))}`; - const response = await fetch(requestUrl, { - headers: { "User-Agent": SOCKET_FREE_USER_AGENT }, - signal, - }); - if (!response.ok) return null; - const body = await response.text(); - const artifact = parseArtifactFromBody(body); - // Cache only a genuine hit — a null (unknown/unscored package) re-checks next - // run rather than pinning a stale negative for the whole TTL. - if (artifact !== null && cacheFile !== null) writeCachedSocketBody(cacheFile, body); - return artifact; - }).pipe( - Effect.timeout(FETCH_TIMEOUT_MS), - Effect.orElseSucceed(() => null), - Effect.tap((artifact) => { - const scoreAttributes: Record = {}; - if (artifact !== null) { - scoreAttributes["socket.score.overall"] = toHundred(artifact.score.overall); - for (const axis of SCORE_AXES) { - scoreAttributes[`socket.score.${axis.key}`] = toHundred(artifact.score[axis.key]); - } - scoreAttributes["socket.alert.count"] = artifact.alerts.length; - } - return Effect.annotateCurrentSpan({ - "socket.scored": artifact !== null, - ...scoreAttributes, - }); - }), - Effect.withSpan("SupplyChain.fetchScore", { - attributes: { - "socket.package": dependency.name, - "socket.version": dependency.version, - "socket.purl": toPurl(dependency), - }, - }), - ); - -// The non-failing axes (the failing one already leads the message), e.g. -// "supply chain 100, maintenance 86, quality 100, license 100". -const formatOtherAxisScores = (score: SocketScore, failingKey: keyof SocketScore): string => - SCORE_AXES.filter((axis) => axis.key !== failingKey) - .map((axis) => `${axis.label} ${toHundred(score[axis.key])}`) - .join(", "); - -// Socket alert severities, most to least severe. "middle" is Socket's wire -// spelling for the docs' "medium" band; both map to the same rank. -const ALERT_SEVERITY_RANK: Record = { - critical: 4, - high: 3, - middle: 2, - medium: 2, - low: 1, -}; - -// `Object.hasOwn`, not bare index access: `severity` / `type` come off the -// wire, so `"constructor"` would read an inherited `Object.prototype` member -// (#920's rule-key crash class). -const severityRank = (severity: string): number => { - const normalized = severity.toLowerCase(); - return Object.hasOwn(ALERT_SEVERITY_RANK, normalized) ? ALERT_SEVERITY_RANK[normalized] : 0; +const supplyChainCacheFile = (cacheDirectory: string, dependency: DependencyToScore): string => { + const purlHash = crypto + .createHash("sha256") + .update(toPurl(dependency)) + .digest("hex") + .slice(0, CACHE_FILENAME_HASH_LENGTH_CHARS); + return path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, `${purlHash}.json`); }; -// Display spelling for a severity: normalize Socket's "middle" to "medium", -// otherwise lowercase the (remote, sanitized) wire value. -const displaySeverity = (severity: string): string => { - const normalized = sanitizeTerminalText(severity.toLowerCase()); - return normalized === "middle" ? "medium" : normalized; +const isSupplyChainCacheDisabled = (): boolean => { + const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? ""; + return noCache === "1" || noCache === "true"; }; -// Labels for the alert types whose friendly name differs from the humanized -// identifier. Everything else (`installScript` -> "install script", -// `networkAccess` -> "network access", …) is left to the camelCase fallback, -// which also keeps a brand-new alert type readable. -const ALERT_TYPE_LABELS: Record = { - malware: "known malware", - gptMalware: "AI-detected malware", - gptSecurity: "AI-detected security risk", - gptAnomaly: "AI-detected code anomaly", - envVars: "environment-variable access", - usesEval: "use of eval", - troll: "protestware", - didYouMean: "possible typosquat", - typosquat: "possible typosquat", -}; +const fetchJson = (url: string, init?: RequestInit): Effect.Effect => + Effect.retry( + Effect.tryPromise(async (signal) => { + const response = await fetch(url, { ...init, signal }); + if (!response.ok) { + throw new Error(`OSV request failed with status ${response.status}`); + } -const humanizeAlertType = (type: string): string => - type - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[_-]+/g, " ") - .toLowerCase() - .trim(); - -const friendlyAlertType = (type: string): string => - Object.hasOwn(ALERT_TYPE_LABELS, type) - ? ALERT_TYPE_LABELS[type] - : sanitizeTerminalText(humanizeAlertType(type)); - -// First sentence of a Socket alert note, whitespace-collapsed and capped so a -// paragraph-long malware description doesn't blow out the diagnostic line. -const summarizeAlertNote = (note: string): string => { - // Collapse whitespace first (so legitimate newlines/tabs become spaces), - // then strip remaining control chars/backticks from the remote note. - const collapsed = sanitizeTerminalText(note.replace(/\s+/g, " ").trim()); - const firstSentence = collapsed.split(/(?<=\.)\s/)[0] || collapsed; - if (firstSentence.length <= SUPPLY_CHAIN_ALERT_NOTE_MAX_CHARS) { - return firstSentence.replace(/\.$/, ""); - } - return `${firstSentence.slice(0, SUPPLY_CHAIN_ALERT_NOTE_MAX_CHARS).trimEnd()}…`; -}; + return response.json(); + }).pipe(Effect.timeout(FETCH_TIMEOUT_MS)), + SUPPLY_CHAIN_FETCH_RETRY_SCHEDULE, + ).pipe(Effect.orElseSucceed(() => null)); + +const fetchOsvQueryBatch = ( + dependencies: ReadonlyArray, +): Effect.Effect> | null> => + fetchJson(`${OSV_API_BASE}/v1/querybatch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + queries: dependencies.map((dependency) => ({ + version: dependency.version, + package: { + name: dependency.name, + ecosystem: "npm", + }, + })), + }), + }).pipe(Effect.map((payload) => parseOsvQueryBatchResponse(payload, dependencies.length))); -// The most severe alerts first (stable within a severity), capped so a noisy -// package doesn't flood the message. -const selectTopAlerts = (alerts: ReadonlyArray): ReadonlyArray => - [...alerts] - .sort((left, right) => severityRank(right.severity) - severityRank(left.severity)) - .slice(0, SUPPLY_CHAIN_MAX_ALERTS_SHOWN); - -// The message's "why" clause when Socket returned concrete alerts: one alert -// gets its file + note spelled out; several collapse to a labelled list with -// the worst severity and a "+N more" tail. -const formatAlertReason = (topAlerts: ReadonlyArray, totalCount: number): string => { - if (topAlerts.length === 1) { - const [alert] = topAlerts; - const location = alert.file ? ` in \`${sanitizeTerminalText(alert.file)}\`` : ""; - const note = alert.props?.note ? summarizeAlertNote(alert.props.note) : null; - const detail = note ? `: "${note}"` : ""; - return `Socket flagged a ${displaySeverity(alert.severity)} ${friendlyAlertType(alert.type)} alert${location}${detail}.`; - } - const labels = topAlerts.map((alert) => friendlyAlertType(alert.type)).join(", "); - const more = totalCount > topAlerts.length ? ` (+${totalCount - topAlerts.length} more)` : ""; - return `Socket flagged ${totalCount} alerts (${labels}${more}); most severe: ${displaySeverity(topAlerts[0].severity)}.`; +const fetchOsvQuery = ( + dependency: DependencyToScore, +): Effect.Effect | null> => + fetchJson(`${OSV_API_BASE}/v1/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + version: dependency.version, + package: { + name: dependency.name, + ecosystem: "npm", + }, + }), + }).pipe(Effect.map((payload) => parseOsvQueryResponse(payload))); + +const selectMatchingVulnerabilities = ( + vulnerabilities: ReadonlyArray, + failOn: OsvSeverity, +): ReadonlyArray => { + const matching = vulnerabilities.filter( + (vulnerability) => severityRank(vulnerability.severity) >= severityRank(failOn), + ); + return [...matching].sort((left, right) => { + const severityDelta = severityRank(right.severity) - severityRank(left.severity); + if (severityDelta !== 0) return severityDelta; + return left.id.localeCompare(right.id); + }); }; -// "react@18.2.0" for an exact pin; for a range, names the scored version and -// makes clear it's the floor the range allows — we score the lowest permitted -// version, which may differ from what's installed. `semver.valid` is the -// exact-pin test: it returns non-null only for a single concrete version, so a -// `v`-prefixed pin like `v1.2.3` reads as a pin instead of a mislabeled range. const formatDependencyIdentity = (dependency: DependencyToScore): string => semver.valid(dependency.spec) !== null ? `${dependency.name}@${dependency.version}` : `${dependency.name}@${dependency.version} (lowest version "${dependency.spec}" allows)`; -// Axis-aware remediation. A critical alert (active malware) overrides the -// axis's generic advice with "treat as compromised"; otherwise the failing -// axis's own remediation drives the action, and the escape hatch is the -// gentler "raise the threshold / downgrade to a warning". const buildSupplyChainHelp = ( dependency: DependencyToScore, - failingAxis: GatedAxis, - topAlerts: ReadonlyArray, - packagePageUrl: string, + worstVulnerability: CachedOsvVulnerability, options: ResolvedSupplyChainOptions, + hasMalware: boolean, ): string => { - const hasCriticalAlert = topAlerts.some((alert) => alert.severity.toLowerCase() === "critical"); - const entry = `\`"${dependency.name}": "${dependency.spec}"\``; - - const action = hasCriticalAlert - ? `Treat ${dependency.name} as compromised — do not ship it. Remove ${entry} from package.json and your lockfile, then audit anything it ran.` - : `${failingAxis.guidance.remediation}; update ${entry} in package.json.`; - - const escapeHatch = hasCriticalAlert - ? `Only if you've confirmed this is a false positive, set \`supplyChain.enabled: false\`.` - : `If you've reviewed and accepted this package, raise \`supplyChain.minScore\` (currently ${options.minScore}) or set \`supplyChain.severity: "warning"\`.`; + const entry = `"${dependency.name}": "${dependency.spec}"`; + if (hasMalware) { + return `Treat ${dependency.name} as compromised — do not ship it. Remove ${entry} from package.json and your lockfile, then audit anything it ran. Full report: ${worstVulnerability.pageUrl}. Only if you've confirmed this is a false positive, set \`supplyChain.enabled: false\`.`; + } - return `${action} Full report: ${packagePageUrl}. ${escapeHatch}`; + return `Upgrade ${entry} in package.json, or remove it if you don't need it. Full report: ${worstVulnerability.pageUrl}. If you've reviewed and accepted this package, raise \`supplyChain.failOn\` (currently ${options.failOn}) or set \`supplyChain.severity: "warning"\`.`; }; -const buildLowScoreDiagnostic = ( +const buildOsvDiagnostic = ( dependency: DependencyToScore, - artifact: SocketArtifact, - failingAxis: GatedAxis, + matchingVulnerabilities: ReadonlyArray, options: ResolvedSupplyChainOptions, ): Diagnostic => { - const packagePageUrl = `${SOCKET_PACKAGE_PAGE_BASE}/${dependency.name}/overview/${dependency.version}`; - const failingScore = toHundred(artifact.score[failingAxis.key]); - const topAlerts = selectTopAlerts(artifact.alerts); - - // The "why": name Socket's concrete alerts when it returned any, otherwise - // fall back to the plain-English meaning of the failing axis — the free - // endpoint omits alerts for metric-driven dips (e.g. CVE-only vulnerability - // scores), so the number alone would leave the user guessing. - const reason = - topAlerts.length > 0 - ? formatAlertReason(topAlerts, artifact.alerts.length) - : `This points to ${failingAxis.guidance.meaning}.`; - - // Lead with the exact axis that failed so the number matches what the user - // sees on the socket.dev package page (issue #770: calling `overall` a - // "supply-chain score" read as a false positive when the supplyChain axis - // itself was 100); the remaining axes follow as context. - const headline = `\`${formatDependencyIdentity(dependency)}\` scored ${failingScore}/${SOCKET_SCORE_SCALE} on Socket's ${failingAxis.label} axis (minimum ${options.minScore}).`; - const otherAxes = `Other axes — ${formatOtherAxisScores(artifact.score, failingAxis.key)}.`; + const worstVulnerability = matchingVulnerabilities[0]; + const worstSeverity = worstVulnerability.severity; + const hasMalware = matchingVulnerabilities.some( + (vulnerability) => + vulnerability.id.toUpperCase().startsWith("MAL-") || + vulnerability.summary.toLowerCase().includes("malicious package"), + ); + const issueLabel = hasMalware + ? matchingVulnerabilities.length === 1 + ? "known malicious package advisory" + : "known malicious package advisories" + : matchingVulnerabilities.length === 1 + ? "known vulnerability" + : "known vulnerabilities"; + const advisoryIds = matchingVulnerabilities + .map((vulnerability) => sanitizeTerminalText(vulnerability.id)) + .join(", "); return { filePath: "package.json", plugin: SUPPLY_CHAIN_PLUGIN, rule: SUPPLY_CHAIN_RULE, severity: options.severity, - message: `${headline} ${reason} ${otherAxes}`, - help: buildSupplyChainHelp(dependency, failingAxis, topAlerts, packagePageUrl, options), - url: packagePageUrl, - // Anchor to the dependency's declaration so the CLI / editor points at the - // exact entry to change rather than the top of the file. + message: `\`${formatDependencyIdentity(dependency)}\` has ${matchingVulnerabilities.length} ${worstSeverity}-severity ${issueLabel}: ${advisoryIds}.`, + help: buildSupplyChainHelp(dependency, worstVulnerability, options, hasMalware), + url: worstVulnerability.pageUrl, line: dependency.line, column: dependency.column, category: SUPPLY_CHAIN_CATEGORY, }; }; -/** - * Scores every direct dependency in the project's `package.json` against - * Socket.dev's free PURL endpoint (the same one Socket Firewall's free tier - * uses — no API key) and returns a diagnostic for each dependency whose - * worst Socket *security* axis — supply chain or vulnerability — is below - * the configured `minScore`. The quality / maintenance / license axes are - * reported as context but never gate (see GATED_AXES). - * - * Lookups run with bounded concurrency via `Effect.forEach`. The check is - * total/fail-open: each per-package lookup already recovers to `null` - * (skip) on timeout or network/parse failure, so a flaky Socket API never - * sinks the scan. Diagnostics default to `"error"` severity, so a low score - * fails the run at the standard `blocking` gate. - */ +const getVulnerabilityIdsForDependency = ( + batchResults: ReadonlyArray>, + dependencyIndex: number, +): ReadonlyArray => { + const ids = batchResults[dependencyIndex] ?? []; + return [...new Set(ids)]; +}; + export const checkSupplyChain = (input: SupplyChainCheckInput): Effect.Effect => Effect.gen(function* () { const options = resolveOptions(input.userConfig); @@ -696,31 +668,95 @@ export const checkSupplyChain = (input: SupplyChainCheckInput): Effect.Effect fetchSocketArtifact(dependency, cacheDirectory), - { concurrency: SUPPLY_CHAIN_FETCH_CONCURRENCY }, - ).pipe( - // A many-socket pileup (sockets that ignore the per-fetch abort) trips the - // whole-check cap; recover to "no artifacts scored" — identical fail-open - // contract to the per-fetch `orElseSucceed(() => null)`. - Effect.timeoutOption(input.totalTimeoutMs ?? SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS), - Effect.map((maybeArtifacts) => Option.getOrElse(maybeArtifacts, () => [])), - ); + if (cacheDirectory !== null) pruneExpiredOsvCache(cacheDirectory); + + const vulnerabilitiesByPurl = new Map>(); + const cacheEntriesByPurl = new Map(); + const missedDependencies: DependencyToScore[] = []; + const queryableMissedDependencies: Array<{ + readonly dependency: DependencyToScore; + readonly cacheFile?: string; + }> = []; + + for (const dependency of dependencies) { + const purl = toPurl(dependency); + if (cacheDirectory !== null) { + const cacheFile = supplyChainCacheFile(cacheDirectory, dependency); + cacheEntriesByPurl.set(purl, cacheFile); + const cachedVulnerabilities = readCachedOsvVulns(cacheFile); + if (cachedVulnerabilities !== null) { + vulnerabilitiesByPurl.set(purl, cachedVulnerabilities); + continue; + } + } + missedDependencies.push(dependency); + } + + if (missedDependencies.length > 0) { + const batchResults = yield* fetchOsvQueryBatch(missedDependencies); + if (batchResults !== null) { + for ( + let dependencyIndex = 0; + dependencyIndex < missedDependencies.length; + dependencyIndex += 1 + ) { + const dependency = missedDependencies[dependencyIndex]; + const purl = toPurl(dependency); + const vulnerabilityIds = getVulnerabilityIdsForDependency(batchResults, dependencyIndex); + if (vulnerabilityIds.length === 0) { + vulnerabilitiesByPurl.set(purl, []); + const cacheFile = cacheEntriesByPurl.get(purl); + if (cacheFile !== undefined) writeCachedOsvVulns(cacheFile, []); + continue; + } + + queryableMissedDependencies.push({ + dependency, + cacheFile: cacheEntriesByPurl.get(purl), + }); + } + } + } + + if (queryableMissedDependencies.length > 0) { + const queryResults = yield* Effect.forEach( + queryableMissedDependencies, + ({ dependency }) => fetchOsvQuery(dependency), + { concurrency: SUPPLY_CHAIN_FETCH_CONCURRENCY }, + ); + + for ( + let dependencyIndex = 0; + dependencyIndex < queryableMissedDependencies.length; + dependencyIndex += 1 + ) { + const queryResult = queryResults[dependencyIndex]; + if (queryResult === null) continue; + + const { dependency, cacheFile } = queryableMissedDependencies[dependencyIndex]; + const purl = toPurl(dependency); + vulnerabilitiesByPurl.set(purl, queryResult); + if (cacheFile !== undefined) writeCachedOsvVulns(cacheFile, queryResult); + } + } const diagnostics: Diagnostic[] = []; - for (let index = 0; index < dependencies.length; index += 1) { - const artifact = artifacts[index]; - if (!artifact) continue; - const worstAxis = worstGatedAxis(artifact.score); - if (toHundred(artifact.score[worstAxis.key]) >= options.minScore) continue; - diagnostics.push(buildLowScoreDiagnostic(dependencies[index], artifact, worstAxis, options)); + for (const dependency of dependencies) { + const purl = toPurl(dependency); + const vulnerabilities = vulnerabilitiesByPurl.get(purl) ?? []; + const matchingVulnerabilities = selectMatchingVulnerabilities( + vulnerabilities, + options.failOn, + ); + if (matchingVulnerabilities.length === 0) continue; + diagnostics.push(buildOsvDiagnostic(dependency, matchingVulnerabilities, options)); } + return diagnostics; - }); + }).pipe( + Effect.timeoutOption(input.totalTimeoutMs ?? SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS), + Effect.map((maybeDiagnostics) => Option.getOrElse(maybeDiagnostics, () => [])), + ); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 81389263d1..ae440b134a 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -601,24 +601,12 @@ export const OXLINT_PARTIAL_FAILURE_PREVIEW_COUNT = 3; // updates smoothly instead of jumping by the batch size on completion. export const PROGRESS_TICK_INTERVAL_MS = 50; -// Socket.dev package-score check (the `SupplyChain` service). Mirrors how -// Socket Firewall's free tier (`sfw`) talks to Socket: the keyless, -// no-API-token endpoint `GET /{encodeURIComponent(purl)}`, where the -// PURL is `pkg:npm/@` (scope kept inline, e.g. -// `pkg:npm/@vue/reactivity@3.4.0`). The response is newline-delimited JSON, -// one Socket artifact per line, each carrying a `score` object with an -// `overall` plus per-category values in the 0..1 range. Unknown -// package/version pairs come back as a `synthetic:notFound:*` artifact with -// no `score`, which the check skips. -export const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl"; - -// Public socket.dev package page, linked from each diagnostic's `help`/`url` -// so a developer can see the full alert + score breakdown for the version. -export const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package"; - -// Sent as the `User-Agent` on the free score lookups, matching how `sfw` -// identifies itself to the same endpoint. -export const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain"; +// OSV's free, unauthenticated API (the `SupplyChain` service). Direct +// dependency PURLs are queried against `api.osv.dev` as +// `pkg:npm/@`, and matching advisories are expanded to their +// canonical OSV pages. +export const OSV_API_BASE = "https://api.osv.dev"; +export const OSV_VULN_PAGE_BASE = "https://osv.dev/vulnerability"; // Per-file lint cache (`runners/oxlint/file-lint-cache.ts`). Caches the raw // oxlint diagnostics of unchanged files keyed by content hash + ruleset hash, @@ -673,28 +661,21 @@ export const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json"; export const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json"; // Plugin / rule / category identity for the diagnostics the supply-chain -// check emits. `plugin: "socket"` keeps Socket findings visually distinct -// from the `react-doctor` lint surface in the printed list and JSON report. -export const SUPPLY_CHAIN_PLUGIN = "socket"; -export const SUPPLY_CHAIN_RULE = "low-supply-chain-score"; +// check emits. +export const SUPPLY_CHAIN_PLUGIN = "osv"; +export const SUPPLY_CHAIN_RULE = "known-vulnerability"; export const SUPPLY_CHAIN_CATEGORY = "Security"; -// Default minimum acceptable Socket score (0..100), applied to the security -// axes (supply chain, vulnerability) — a dependency whose worst security -// axis scores below this fails the check. Tuned to Socket's own "needs -// review" band — most healthy, widely-used packages sit comfortably above -// it. Overridable per project via `supplyChain.minScore`. -export const SUPPLY_CHAIN_DEFAULT_MIN_SCORE = 50; +export const SUPPLY_CHAIN_DEFAULT_FAIL_ON = "high"; -// Socket scores arrive normalized 0..1; multiply by this to present the -// familiar 0..100 scale users see on socket.dev. -export const SOCKET_SCORE_SCALE = 100; - -// How many free Socket score lookups to keep in flight at once. Bounded so a -// large dependency list doesn't open hundreds of sockets or trip Socket's -// per-route rate limit. +// How many free OSV lookups to keep in flight at once. Bounded so a large +// dependency list doesn't open hundreds of sockets or trip rate limits. export const SUPPLY_CHAIN_FETCH_CONCURRENCY = 8; +export const SUPPLY_CHAIN_FETCH_MAX_RETRIES = 2; + +export const SUPPLY_CHAIN_FETCH_RETRY_BASE_MS = 100; + // Belt-and-suspenders wall-clock cap on the supply-chain check while it runs on // a background fiber overlapping the lint pass. `Effect.timeout` measures from // when the forked effect STARTS (at fork, before lint) — NOT from the join — so @@ -702,30 +683,17 @@ export const SUPPLY_CHAIN_FETCH_CONCURRENCY = 8; // ceil(~45 deps / SUPPLY_CHAIN_FETCH_CONCURRENCY) ≈ 60s) to avoid cutting a // slow-but-working scan, while still bounding a hung undici socket instead of // letting it drag out the join. On expiry the check fails open to no -// diagnostics — the same outcome class as the per-package Socket fail-open. +// diagnostics — the same outcome class as the per-package OSV fail-open. export const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 90_000; -// On-disk TTL for a cached Socket artifact. A dependency's score/alerts are -// stable day-to-day and advisory, so a cached lookup within 24h skips the -// network entirely (the recurring CI win + faster repeated local scans); -// after expiry it re-fetches. Disabled by `REACT_DOCTOR_NO_CACHE`. +// On-disk TTL for a cached OSV vulnerability summary. A dependency's advisories +// are stable day-to-day and advisory, so a cached lookup within 24h skips the +// network entirely (the recurring CI win + faster repeated local scans); after +// expiry it re-fetches. Disabled by `REACT_DOCTOR_NO_CACHE`. export const SUPPLY_CHAIN_CACHE_TTL_MS = 86_400_000; -// Subdirectory of the react-doctor cache dir holding per-PURL Socket responses. +// Subdirectory of the react-doctor cache dir holding per-PURL OSV summaries. export const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain"; -// Most severe Socket alerts to name in one supply-chain diagnostic before -// collapsing the remainder into a "+N more" count, so a noisy package -// doesn't flood the message. -export const SUPPLY_CHAIN_MAX_ALERTS_SHOWN = 3; - -// Cap for the first-sentence Socket alert note woven into a diagnostic, so a -// paragraph-long malware description doesn't blow out the message line. -export const SUPPLY_CHAIN_ALERT_NOTE_MAX_CHARS = 160; - -// Packages excluded from the Socket supply-chain check (the score gate). -// react-doctor already covers these frameworks' specific -// risks through dedicated rules — e.g. Next.js via the server-components / -// Next rule family — so a low Socket score would be redundant noise rather -// than an actionable, distinct supply-chain signal. +// Packages excluded from the OSV supply-chain check. export const SUPPLY_CHAIN_IGNORED_PACKAGES: ReadonlySet = new Set(["next"]); diff --git a/packages/core/src/editor-scan.ts b/packages/core/src/editor-scan.ts index eef1a441f7..cd94bcfeca 100644 --- a/packages/core/src/editor-scan.ts +++ b/packages/core/src/editor-scan.ts @@ -137,7 +137,7 @@ export const runEditorScan = async (input: EditorScanInput): Promise( ).pipe(Effect.withSpan("SecurityScan.run")), ); - // ── Phase: supply-chain score check (Socket.dev, opt-in) ─────── + // ── Phase: supply-chain check (OSV, opt-in) ─────────────────── // Whole-project (package.json) property, so a plain diff/staged scan // skips it like the environment checks above — but a diff that edits // the scanned project's `package.json` (e.g. a PR adding/bumping a @@ -564,7 +564,7 @@ export const runInspect = ( // change is scored where it matters. Enablement is decided by the // provided layer (`SupplyChain.layerOf([])` when disabled). The stream // is fail-open — per-package timeouts / network failures are recovered - // to "skip" inside the check — so a Socket API outage never sinks the scan. + // to "skip" inside the check — so an OSV API outage never sinks the scan. // // The check is ~100% network-bound and the lint pass below is ~100% // CPU/subprocess-bound, so we fork it onto a child fiber here and join it @@ -573,9 +573,9 @@ export const runInspect = ( // error/interrupt in the orchestrator tears this fiber down with it, so it // never leaks. The collect can't fail (the stream has no error channel), so // the only failure is the `Effect.timeout` deadline, which we fold into a - // fail-open `[]` + a `timedOut` marker — the same outcome class as a Socket + // fail-open `[]` + a `timedOut` marker — the same outcome class as an OSV // outage. The deadline is measured FROM FORK (before lint), so it bounds a - // hung undici socket without depending on how long lint takes. (On the rare + // hung undici network request without depending on how long lint takes. (On the rare // timeout, a stateful `Reporter` — only `layerNdjson`, which has no in-tree // consumer — may hold supply-chain emits from before the deadline that the // returned `[]` omits; production `Reporter.layerNoop` makes emit a no-op, @@ -947,7 +947,7 @@ export const runInspect = ( // forked stream has flushed before a stateful reporter (e.g. NDJSON) closes // its sink. Fail-open + the fork-relative timeout are already folded into // the fiber result, so the join never fails; `timedOut` records whether the - // overlap budget fired (the rare hung-socket guard) for telemetry. + // overlap budget fired (the rare hung-network guard) for telemetry. const supplyChainResult = yield* Fiber.join(supplyChainFiber); const supplyChainCollected = supplyChainResult.diagnostics; // Join the forked security scan (it overlapped lint). Its diagnostics are diff --git a/packages/core/src/run-oxlint.ts b/packages/core/src/run-oxlint.ts index 0ed9b86b81..d60735cf70 100644 --- a/packages/core/src/run-oxlint.ts +++ b/packages/core/src/run-oxlint.ts @@ -504,7 +504,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise` endpoint, which returns per-axis - * scores (0–100 once normalized). A dependency whose worst security axis - * (supply chain or vulnerability) scores below `minScore` produces a - * diagnostic; at the default `severity: "error"` it fails the scan + * Mirrors OSV's free, unauthenticated API: each direct dependency's npm + * package name + concrete version is queried against `api.osv.dev`. A + * dependency whose known vulnerabilities meet or exceed `failOn` produces + * a diagnostic; at the default `severity: "error"` it fails the scan * (non-zero CI exit), the same way an error-severity lint finding does. */ export interface SupplyChainConfig { /** - * Whether to run the Socket supply-chain score check. Default: `true`. + * Whether to run the OSV supply-chain vulnerability check. Default: + * `true`. * Set to `false` to opt out — the check performs one network request per * direct dependency. It is always skipped in `--diff` / `--staged` mode * and in editor scans regardless of this setting. */ enabled?: boolean; /** - * Minimum acceptable Socket score on a 0–100 scale. A direct dependency - * whose worst Socket *security* axis — supply chain or vulnerability — is - * below this is flagged; the quality / maintenance / license axes never - * gate. Default: `50`. Values outside `0..100` are clamped. + * Lowest known-vulnerability severity that should be reported/gated. + * Default: `"high"`. A package with only lower-severity advisories is + * skipped; malware advisories are always treated as critical. */ - minScore?: number; + failOn?: "low" | "moderate" | "high" | "critical"; /** * Severity for a below-threshold dependency. `"error"` (default) fails * the scan at the standard `blocking: "error"` gate; `"warning"` keeps @@ -195,10 +193,11 @@ export interface ReactDoctorConfig { ignore?: ReactDoctorIgnoreConfig; lint?: boolean; /** - * Socket.dev supply-chain score gate. Runs by default; set - * `supplyChain: { enabled: false }` to opt out. See {@link SupplyChainConfig}. - * Every direct dependency is scored against Socket's free PURL endpoint and - * a low score fails the scan (at the default `severity: "error"`). + * OSV supply-chain vulnerability gate. Runs by default; set + * `supplyChain: { enabled: false }` to opt out. See + * {@link SupplyChainConfig}. Every direct dependency is checked against + * OSV's free API and a known vulnerability at or above the configured + * threshold fails the scan (at the default `severity: "error"`). */ supplyChain?: SupplyChainConfig; /** diff --git a/packages/core/src/types/inspect.ts b/packages/core/src/types/inspect.ts index 842e47afa0..d011cf745a 100644 --- a/packages/core/src/types/inspect.ts +++ b/packages/core/src/types/inspect.ts @@ -113,7 +113,7 @@ export interface InspectOptions { /** See `ReactDoctorConfig.deadCode`. Ignored in diff / staged mode. */ deadCode?: boolean; /** - * Whether to run the Socket.dev supply-chain scan. Resolves against + * Whether to run the OSV supply-chain scan. Resolves against * `ReactDoctorConfig.supplyChain.enabled` (this wins when set), defaulting * to `true`. Kept as an option — not folded into the config — so it takes * precedence over per-project config on every scan, like `lint`/`deadCode`. @@ -131,7 +131,7 @@ export interface InspectOptions { respectInlineDisables?: boolean; /** * Whether the scanned project's `package.json` changed in this diff / - * staged scan. Forwarded to the orchestrator so the Socket supply-chain + * staged scan. Forwarded to the orchestrator so the OSV supply-chain * check still runs in diff mode when the manifest changed (a PR that * adds or bumps a dependency), instead of being skipped like the other * whole-project checks. Ignored on full scans. Defaults to `false`. diff --git a/packages/core/src/utils/sanitize-terminal-text.ts b/packages/core/src/utils/sanitize-terminal-text.ts index f9eac51274..e9fe6950a7 100644 --- a/packages/core/src/utils/sanitize-terminal-text.ts +++ b/packages/core/src/utils/sanitize-terminal-text.ts @@ -1,6 +1,6 @@ // Neutralizes untrusted, remote text before it is woven into a diagnostic's // `message`/`help`, which the CLI prints to the terminal without stripping -// escape sequences. Socket alert strings (`type`, `file`, `note`) describe a +// escape sequences. OSV alert strings (`type`, `file`, `note`) describe a // potentially malicious package and are attacker-controlled, so a crafted // filename or note could otherwise inject ANSI/OSC terminal escapes (spoofing // or hiding the very warning) or break the diagnostic's `code` / "quote" diff --git a/packages/core/tests/check-supply-chain-cache.test.ts b/packages/core/tests/check-supply-chain-cache.test.ts index 334c1e98d6..2e19d85e90 100644 --- a/packages/core/tests/check-supply-chain-cache.test.ts +++ b/packages/core/tests/check-supply-chain-cache.test.ts @@ -1,134 +1,288 @@ +import * as crypto from "node:crypto"; import * as fs from "node:fs"; -import os from "node:os"; +import * as os from "node:os"; import * as path from "node:path"; import * as Effect from "effect/Effect"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { checkSupplyChain } from "@react-doctor/core"; - -// A low-score artifact so the check emits a diagnostic (lets us assert the -// cached run reproduces it). NDJSON line shape the free Socket endpoint streams. -const lowScoreArtifactBody = (): string => - JSON.stringify({ - id: "test-artifact", - type: "npm", - score: { - supplyChain: 0.1, - vulnerability: 0.1, - maintenance: 0.1, - quality: 0.1, - license: 0.1, - overall: 0.1, - }, - alerts: [], - }); +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CACHE_FILENAME_HASH_LENGTH_CHARS, + SUPPLY_CHAIN_CACHE_SUBDIR, + SUPPLY_CHAIN_CACHE_TTL_MS, +} from "../src/constants.js"; +import { checkSupplyChain } from "../src/check-supply-chain.js"; +import type { ReactDoctorConfig } from "../src/types/index.js"; +import { resolveReactDoctorCacheDir } from "../src/utils/resolve-react-doctor-cache-dir.js"; -// The on-disk cache nests under a per-project hash subdir (and a `supply-chain` -// subdir), so collect every cache `.json` by walking rather than guessing. -const walkCacheFiles = (directory: string): string[] => - fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) return walkCacheFiles(entryPath); - return entry.name.endsWith(".json") ? [entryPath] : []; - }); +interface OsvQueryVulnerability { + readonly id: string; + readonly summary?: string; + readonly database_specific?: { + readonly severity?: "LOW" | "MODERATE" | "HIGH" | "CRITICAL"; + }; +} -let projectDirectory: string; -let cacheDirectory: string; -const originalCacheDirEnv = process.env["REACT_DOCTOR_CACHE_DIR"]; -const originalNoCacheEnv = process.env["REACT_DOCTOR_NO_CACHE"]; +const createProjectDirectory = (): string => + fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-osv-cache-")); -beforeEach(() => { - projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sc-cache-proj-")); - cacheDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sc-cache-dir-")); +const writePackageJson = (rootDirectory: string): void => { fs.writeFileSync( - path.join(projectDirectory, "package.json"), - JSON.stringify({ name: "fixture", version: "1.0.0", dependencies: { "left-pad": "1.3.0" } }), + path.join(rootDirectory, "package.json"), + `${JSON.stringify( + { + name: "test-project", + private: true, + version: "1.0.0", + dependencies: { + "left-pad": "1.0.0", + }, + }, + null, + 2, + )}\n`, ); - // Point the cache at an isolated dir; ensure the cache isn't globally disabled. - process.env["REACT_DOCTOR_CACHE_DIR"] = cacheDirectory; - delete process.env["REACT_DOCTOR_NO_CACHE"]; +}; + +const runCheckSupplyChain = async ( + rootDirectory: string, + userConfig: ReactDoctorConfig | null = null, +) => + Effect.runPromise( + checkSupplyChain({ + rootDirectory, + userConfig, + }), + ); + +const createOsvQueryVulnerability = (input: OsvQueryVulnerability): Record => ({ + id: input.id, + summary: input.summary ?? input.id, + ...(input.database_specific !== undefined ? { database_specific: input.database_specific } : {}), }); -afterEach(() => { - vi.unstubAllGlobals(); - fs.rmSync(projectDirectory, { recursive: true, force: true }); - fs.rmSync(cacheDirectory, { recursive: true, force: true }); - if (originalCacheDirEnv === undefined) delete process.env["REACT_DOCTOR_CACHE_DIR"]; - else process.env["REACT_DOCTOR_CACHE_DIR"] = originalCacheDirEnv; - if (originalNoCacheEnv === undefined) delete process.env["REACT_DOCTOR_NO_CACHE"]; - else process.env["REACT_DOCTOR_NO_CACHE"] = originalNoCacheEnv; +const createOsvQueryResponse = ( + vulnerabilities: ReadonlyArray, +): Record => ({ + vulns: vulnerabilities.map(createOsvQueryVulnerability), }); -const stubSocketFetch = () => { - const fetchMock = vi.fn(async () => new Response(lowScoreArtifactBody(), { status: 200 })); +const stubOsvFetch = ( + queryResponseByPackage: Record>, +): ReturnType => { + const fetchMock = vi.fn(async (requestInput: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = String(requestInput); + if (requestUrl.endsWith("/v1/querybatch")) { + const payload = JSON.parse(String(init?.body ?? "{}")) as Record; + const queries = Array.isArray(payload.queries) + ? (payload.queries as ReadonlyArray>) + : []; + return new Response( + JSON.stringify({ + results: queries.map((query) => { + const packageName = + typeof query.package === "object" && + query.package !== null && + typeof query.package.name === "string" + ? query.package.name + : ""; + return packageName === "left-pad" ? { vulns: [{ id: "GHSA-cache" }] } : {}; + }), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + if (requestUrl.endsWith("/v1/query")) { + const payload = JSON.parse(String(init?.body ?? "{}")) as Record; + const packageName = + typeof payload.package === "object" && + payload.package !== null && + typeof payload.package.name === "string" + ? payload.package.name + : ""; + const vulnerabilities = queryResponseByPackage[packageName] ?? []; + return new Response(JSON.stringify(createOsvQueryResponse(vulnerabilities)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); return fetchMock; }; -const runCheck = () => - Effect.runPromise(checkSupplyChain({ rootDirectory: projectDirectory, userConfig: null })); +const cacheFileFor = (rootDirectory: string): string => { + const purlHash = crypto + .createHash("sha256") + .update("pkg:npm/left-pad@1.0.0") + .digest("hex") + .slice(0, CACHE_FILENAME_HASH_LENGTH_CHARS); + return path.join( + resolveReactDoctorCacheDir(rootDirectory), + SUPPLY_CHAIN_CACHE_SUBDIR, + `${purlHash}.json`, + ); +}; -describe("supply-chain on-disk cache", () => { - it("skips the network on a repeat scan within the TTL (cache hit), reproducing the diagnostic", async () => { - const fetchMock = stubSocketFetch(); - const first = await runCheck(); - const callsAfterFirst = fetchMock.mock.calls.length; - expect(callsAfterFirst).toBeGreaterThan(0); // the first scan fetched - expect(first.length).toBeGreaterThan(0); // low score ⇒ a diagnostic +const writeCacheEntry = (cacheFile: string, entry: unknown): void => { + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + fs.writeFileSync(cacheFile, JSON.stringify(entry)); +}; - const second = await runCheck(); - expect(fetchMock.mock.calls.length).toBe(callsAfterFirst); // no new network calls - expect(second).toEqual(first); // identical diagnostics, served from cache - }); +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env["REACT_DOCTOR_CACHE_DIR"]; + delete process.env["REACT_DOCTOR_NO_CACHE"]; +}); - it("treats an unparseable cached body as a miss and re-fetches (corruption / schema drift)", async () => { - const fetchMock = stubSocketFetch(); - const first = await runCheck(); - const callsAfterFirst = fetchMock.mock.calls.length; - expect(first.length).toBeGreaterThan(0); - - // Corrupt the cached body in place while keeping a fresh, in-TTL envelope — - // the read still returns a string, but it no longer parses to an artifact. - // This is the corrupted-restore / Socket-schema-drift case: it must fall - // through to the network, not silently skip the advisory for the whole TTL. - // The cache nests under a per-project subdir, so walk for the `.json` files. - const cacheFiles = walkCacheFiles(cacheDirectory); - expect(cacheFiles.length).toBeGreaterThan(0); // the first scan populated it - for (const cacheFile of cacheFiles) { - fs.writeFileSync( - cacheFile, - JSON.stringify({ fetchedAtMs: Date.now(), body: "not-valid-json\n{partial" }), - ); +describe("checkSupplyChain cache", () => { + it("reuses the cached vulnerability summary on a repeat scan", async () => { + const rootDirectory = createProjectDirectory(); + const cacheDirectory = path.join(rootDirectory, "cache"); + process.env["REACT_DOCTOR_CACHE_DIR"] = cacheDirectory; + try { + writePackageJson(rootDirectory); + const fetchMock = stubOsvFetch({ + "left-pad": [ + { + id: "GHSA-cache", + database_specific: { severity: "HIGH" }, + }, + ], + }); + + const firstDiagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "low", + }, + }); + expect(firstDiagnostics).toHaveLength(1); + expect(fetchMock).toHaveBeenCalled(); + + fetchMock.mockClear(); + + const secondDiagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "low", + }, + }); + expect(secondDiagnostics).toHaveLength(1); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); } + }); + + it("re-fetches when the cached body is malformed", async () => { + const rootDirectory = createProjectDirectory(); + const cacheDirectory = path.join(rootDirectory, "cache"); + process.env["REACT_DOCTOR_CACHE_DIR"] = cacheDirectory; + try { + writePackageJson(rootDirectory); + const cacheFile = cacheFileFor(rootDirectory); + writeCacheEntry(cacheFile, { + fetchedAtMs: Date.now(), + body: "old OSV cache body", + }); + const fetchMock = stubOsvFetch({ + "left-pad": [ + { + id: "GHSA-cache", + database_specific: { severity: "HIGH" }, + }, + ], + }); - const second = await runCheck(); - expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterFirst); // re-fetched - expect(second).toEqual(first); // advisory still produced from the fresh fetch + const diagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "low", + }, + }); + + expect(diagnostics).toHaveLength(1); + expect(fetchMock).toHaveBeenCalled(); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); - it("prunes cache files past the TTL so stale purls don't accumulate across CI restores", async () => { - stubSocketFetch(); - await runCheck(); - const cacheFiles = walkCacheFiles(cacheDirectory); - expect(cacheFiles.length).toBeGreaterThan(0); - - // Simulate an entry for a purl no run looks up anymore (a bumped or - // removed dependency) whose mtime is past the 24h TTL. - const staleFile = path.join(path.dirname(cacheFiles[0]), "stale-purl.json"); - fs.writeFileSync(staleFile, JSON.stringify({ fetchedAtMs: 0, body: "{}" })); - const expiredDate = new Date(Date.now() - 48 * 60 * 60 * 1_000); - fs.utimesSync(staleFile, expiredDate, expiredDate); - - await runCheck(); - expect(fs.existsSync(staleFile)).toBe(false); - expect(walkCacheFiles(cacheDirectory).length).toBeGreaterThan(0); // live entries survive + it("prunes stale cache files before scanning", async () => { + const rootDirectory = createProjectDirectory(); + const cacheDirectory = path.join(rootDirectory, "cache"); + process.env["REACT_DOCTOR_CACHE_DIR"] = cacheDirectory; + try { + writePackageJson(rootDirectory); + const resolvedCacheDirectory = resolveReactDoctorCacheDir(rootDirectory); + const staleFile = path.join(resolvedCacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, "stale.json"); + writeCacheEntry(staleFile, { + fetchedAtMs: Date.now() - 2 * SUPPLY_CHAIN_CACHE_TTL_MS, + vulns: [], + }); + fs.utimesSync( + staleFile, + new Date(Date.now() - 2 * SUPPLY_CHAIN_CACHE_TTL_MS), + new Date(Date.now() - 2 * SUPPLY_CHAIN_CACHE_TTL_MS), + ); + stubOsvFetch({ + "left-pad": [ + { + id: "GHSA-cache", + database_specific: { severity: "HIGH" }, + }, + ], + }); + + await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "low", + }, + }); + + expect(fs.existsSync(staleFile)).toBe(false); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); - it("re-fetches every run when REACT_DOCTOR_NO_CACHE is set (cache bypassed)", async () => { + it("ignores cache entries when REACT_DOCTOR_NO_CACHE is enabled", async () => { + const rootDirectory = createProjectDirectory(); + const cacheDirectory = path.join(rootDirectory, "cache"); + process.env["REACT_DOCTOR_CACHE_DIR"] = cacheDirectory; process.env["REACT_DOCTOR_NO_CACHE"] = "1"; - const fetchMock = stubSocketFetch(); - await runCheck(); - const callsAfterFirst = fetchMock.mock.calls.length; - await runCheck(); - expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterFirst); + try { + writePackageJson(rootDirectory); + writeCacheEntry(cacheFileFor(rootDirectory), { + fetchedAtMs: Date.now(), + vulns: [ + { + id: "GHSA-cache", + severity: "high", + summary: "cached", + pageUrl: "https://osv.dev/vulnerability/GHSA-cache", + }, + ], + }); + const fetchMock = stubOsvFetch({ + "left-pad": [ + { + id: "GHSA-cache", + database_specific: { severity: "HIGH" }, + }, + ], + }); + + const diagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "low", + }, + }); + + expect(diagnostics).toHaveLength(1); + expect(fetchMock).toHaveBeenCalled(); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); }); diff --git a/packages/core/tests/check-supply-chain.test.ts b/packages/core/tests/check-supply-chain.test.ts index 24fbe1ac56..14774f5875 100644 --- a/packages/core/tests/check-supply-chain.test.ts +++ b/packages/core/tests/check-supply-chain.test.ts @@ -1,433 +1,489 @@ +import * as crypto from "node:crypto"; import * as fs from "node:fs"; -import os from "node:os"; +import * as os from "node:os"; import * as path from "node:path"; import * as Effect from "effect/Effect"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { checkSupplyChain } from "@react-doctor/core"; -import type { Diagnostic } from "@react-doctor/core"; - -// Per-axis Socket scores in the API's normalized 0..1 range. `overall` is -// Socket's lowest axis, mirroring how the real endpoint computes it. -interface AxisScores { - readonly supplyChain: number; - readonly vulnerability: number; - readonly maintenance: number; - readonly quality: number; - readonly license: number; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CACHE_FILENAME_HASH_LENGTH_CHARS, SUPPLY_CHAIN_CACHE_SUBDIR } from "../src/constants.js"; +import { checkSupplyChain } from "../src/check-supply-chain.js"; +import type { ReactDoctorConfig } from "../src/types/index.js"; +import { resolveReactDoctorCacheDir } from "../src/utils/resolve-react-doctor-cache-dir.js"; + +interface OsvQueryVulnerability { + readonly id: string; + readonly summary?: string; + readonly details?: string; + readonly database_specific?: { + readonly severity?: "LOW" | "MODERATE" | "HIGH" | "CRITICAL"; + }; + readonly severity?: ReadonlyArray<{ + readonly type?: string; + readonly score?: string; + }>; } -// A Socket alert in the shape the endpoint attaches to high-signal threats: -// the `note` rides `props.note`, mirroring the real artifact. -interface AlertInput { - readonly type: string; - readonly severity: string; - readonly file?: string; - readonly note?: string; +interface OsvTestFetchResult { + readonly fetchMock: ReturnType; + readonly queryBatchRequests: Array< + ReadonlyArray<{ readonly name: string; readonly version: string }> + >; + readonly queryRequests: string[]; } -const socketArtifactLine = (axes: AxisScores, alerts: ReadonlyArray): string => - JSON.stringify({ - id: "test-artifact", - type: "npm", - score: { ...axes, overall: Math.min(...Object.values(axes)) }, - alerts: alerts.map((alert) => ({ - key: `${alert.type}-key`, - type: alert.type, - severity: alert.severity, - ...(alert.file ? { file: alert.file } : {}), - ...(alert.note ? { props: { note: alert.note } } : {}), - })), - }); +const createProjectDirectory = (): string => + fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-osv-")); -// Stubs the free Socket PURL endpoint with one canned artifact per package -// name (the NDJSON body shape the real endpoint streams). Alerts are optional -// — the free endpoint omits them for metric-driven (CVE-only) low scores. -const stubSocketApi = ( - scoresByPackageName: Record, - alertsByPackageName: Record> = {}, +const writePackageJson = ( + rootDirectory: string, + packageJson: { + readonly dependencies?: Record; + readonly devDependencies?: Record; + }, ): void => { - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const requestUrl = decodeURIComponent(String(input)); - const matched = Object.entries(scoresByPackageName).find(([name]) => - requestUrl.includes(`pkg:npm/${name}@`), - ); - const body = matched - ? socketArtifactLine(matched[1], alertsByPackageName[matched[0]] ?? []) - : ""; - return new Response(body, { status: 200 }); - }), + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + `${JSON.stringify( + { + name: "test-project", + private: true, + version: "1.0.0", + ...packageJson, + }, + null, + 2, + )}\n`, ); }; -let projectDirectory: string; +const runCheckSupplyChain = async ( + rootDirectory: string, + userConfig: ReactDoctorConfig | null = null, + totalTimeoutMs?: number, +) => + Effect.runPromise( + checkSupplyChain({ + rootDirectory, + userConfig, + totalTimeoutMs, + }), + ); -const writePackageJson = (dependencies: Record): void => { - fs.writeFileSync( - path.join(projectDirectory, "package.json"), - `${JSON.stringify({ name: "fixture", version: "1.0.0", dependencies }, null, 2)}\n`, +const cacheFileFor = (rootDirectory: string, name: string, version: string): string => { + const purlHash = crypto + .createHash("sha256") + .update(`pkg:npm/${name}@${version}`) + .digest("hex") + .slice(0, CACHE_FILENAME_HASH_LENGTH_CHARS); + return path.join( + resolveReactDoctorCacheDir(rootDirectory), + SUPPLY_CHAIN_CACHE_SUBDIR, + `${purlHash}.json`, ); }; -const runCheck = async (): Promise => - Effect.runPromise(checkSupplyChain({ rootDirectory: projectDirectory, userConfig: null })); +const createOsvQueryVulnerability = (input: OsvQueryVulnerability): Record => ({ + id: input.id, + summary: input.summary ?? input.id, + ...(input.details !== undefined ? { details: input.details } : {}), + ...(input.database_specific !== undefined ? { database_specific: input.database_specific } : {}), + ...(input.severity !== undefined ? { severity: input.severity } : {}), +}); -describe("checkSupplyChain — security-axis gating", () => { - beforeEach(() => { - projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-supply-chain-")); - }); +const createOsvQueryResponse = ( + vulnerabilities: ReadonlyArray, +): Record => ({ + vulns: vulnerabilities.map(createOsvQueryVulnerability), +}); - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - fs.rmSync(projectDirectory, { recursive: true, force: true }); - }); +const stubOsvFetch = (input: { + readonly queryBatchIdsByPackage?: Record>; + readonly queryResponseByPackage?: Record>; + readonly queryFailurePackages?: ReadonlySet; +}): OsvTestFetchResult => { + const queryBatchRequests: Array< + ReadonlyArray<{ readonly name: string; readonly version: string }> + > = []; + const queryRequests: string[] = []; + + const fetchMock = vi.fn(async (requestInput: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = String(requestInput); + if (requestUrl.endsWith("/v1/querybatch")) { + const payload = JSON.parse(String(init?.body ?? "{}")) as Record; + const queries = Array.isArray(payload.queries) + ? (payload.queries as ReadonlyArray>) + : []; + queryBatchRequests.push( + queries.map((query) => ({ + name: + typeof query.package === "object" && + query.package !== null && + typeof query.package.name === "string" + ? query.package.name + : "", + version: typeof query.version === "string" ? query.version : "", + })), + ); - it("fails open ([]) when sockets ignore the per-fetch abort and the whole-check budget elapses", async () => { - writePackageJson({ "left-pad": "^1.3.0", "is-odd": "^3.0.0" }); - // Sockets that never tear down on abort: every fetch hangs forever, so - // the per-fetch 10s timeout would otherwise keep the whole `forEach` - // pending. The whole-check cap must short-circuit to "no artifacts". - vi.stubGlobal( - "fetch", - vi.fn(() => new Promise(() => {})), - ); - - const diagnostics = await Effect.runPromise( - checkSupplyChain({ rootDirectory: projectDirectory, userConfig: null, totalTimeoutMs: 20 }), - ); - - expect(diagnostics).toEqual([]); + return new Response( + JSON.stringify({ + results: queries.map((query) => { + const packageName = + typeof query.package === "object" && + query.package !== null && + typeof query.package.name === "string" + ? query.package.name + : ""; + const vulnerabilityIds = input.queryBatchIdsByPackage?.[packageName] ?? []; + return vulnerabilityIds.length > 0 + ? { vulns: vulnerabilityIds.map((id) => ({ id })) } + : {}; + }), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + if (requestUrl.endsWith("/v1/query")) { + const payload = JSON.parse(String(init?.body ?? "{}")) as Record; + const packageName = + typeof payload.package === "object" && + payload.package !== null && + typeof payload.package.name === "string" + ? payload.package.name + : ""; + queryRequests.push(packageName); + + if (input.queryFailurePackages?.has(packageName) === true) { + return new Response("temporary failure", { status: 503 }); + } + + const vulnerabilities = input.queryResponseByPackage?.[packageName] ?? []; + return new Response(JSON.stringify(createOsvQueryResponse(vulnerabilities)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response("not found", { status: 404 }); }); - it("does not flag a package whose security axes are healthy but quality drags `overall` below the minimum (issue #770, @types/bun)", async () => { - writePackageJson({ "@types/bun": "^1.3.14" }); - stubSocketApi({ - "@types/bun": { - supplyChain: 1, - vulnerability: 1, - maintenance: 0.92, - quality: 0.48, - license: 1, - }, - }); - - expect(await runCheck()).toEqual([]); - }); + vi.stubGlobal("fetch", fetchMock); + return { + fetchMock, + queryBatchRequests, + queryRequests, + }; +}; - it("flags a vulnerability-driven low score and names the vulnerability axis (event-stream@3.3.6 shape)", async () => { - writePackageJson({ "event-stream": "3.3.6" }); - stubSocketApi({ - "event-stream": { - supplyChain: 1, - vulnerability: 0.25, - maintenance: 1, - quality: 1, - license: 1, - }, - }); - - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].rule).toBe("low-supply-chain-score"); - expect(diagnostics[0].message).toContain("scored 25/100 on Socket's vulnerability axis"); - expect(diagnostics[0].message).not.toContain("supply chain axis"); - // With no alerts, the message explains what the failing axis means. - expect(diagnostics[0].message).toContain("known security vulnerabilities (CVEs)"); - // The remaining axes follow as context (the failing one already leads). - expect(diagnostics[0].message).toContain("Other axes — supply chain 100, maintenance 100"); - // Vulnerability remediation is "upgrade", not the generic "update/replace". - expect(diagnostics[0].help).toContain("npm audit"); - }); +const stubHangingOsvFetch = (): ReturnType => { + const fetchMock = vi.fn(() => new Promise(() => {})); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; - it("flags a supplyChain-driven low score and names the supply chain axis", async () => { - writePackageJson({ "evil-typosquat": "1.0.0" }); - stubSocketApi({ - "evil-typosquat": { - supplyChain: 0.2, - vulnerability: 1, - maintenance: 1, - quality: 1, - license: 1, - }, - }); +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env["REACT_DOCTOR_CACHE_DIR"]; + delete process.env["REACT_DOCTOR_NO_CACHE"]; +}); - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("scored 20/100 on Socket's supply chain axis"); - }); +describe("checkSupplyChain (OSV)", () => { + it.each([ + { threshold: "low", severity: "LOW", expectDiagnostic: true }, + { threshold: "moderate", severity: "LOW", expectDiagnostic: false }, + { threshold: "moderate", severity: "MODERATE", expectDiagnostic: true }, + { threshold: "high", severity: "MODERATE", expectDiagnostic: false }, + { threshold: "high", severity: "HIGH", expectDiagnostic: true }, + { threshold: "critical", severity: "HIGH", expectDiagnostic: false }, + { threshold: "critical", severity: "CRITICAL", expectDiagnostic: true }, + ] as const)( + "gates $severity advisories at failOn=$threshold", + async ({ threshold, severity, expectDiagnostic }) => { + const rootDirectory = createProjectDirectory(); + try { + writePackageJson(rootDirectory, { + dependencies: { + "left-pad": "1.0.0", + }, + }); + stubOsvFetch({ + queryBatchIdsByPackage: { + "left-pad": ["GHSA-test-1"], + }, + queryResponseByPackage: { + "left-pad": [ + createOsvQueryVulnerability({ + id: "GHSA-test-1", + database_specific: { severity }, + }), + ], + }, + }); - it("headlines the worst security axis when both gate below the minimum", async () => { - writePackageJson({ "doubly-bad": "2.0.0" }); - stubSocketApi({ - "doubly-bad": { - supplyChain: 0.4, - vulnerability: 0.1, - maintenance: 1, - quality: 1, - license: 1, - }, - }); + const diagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: threshold, + }, + }); + + expect(diagnostics).toHaveLength(expectDiagnostic ? 1 : 0); + if (expectDiagnostic) { + expect(diagnostics[0].plugin).toBe("osv"); + expect(diagnostics[0].rule).toBe("known-vulnerability"); + expect(diagnostics[0].category).toBe("Security"); + expect(diagnostics[0].filePath).toBe("package.json"); + } + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } + }, + ); - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("scored 10/100 on Socket's vulnerability axis"); - }); + it("names the worst severity and lists every matching advisory id", async () => { + const rootDirectory = createProjectDirectory(); + try { + writePackageJson(rootDirectory, { + dependencies: { + lodash: "4.17.11", + }, + }); + stubOsvFetch({ + queryBatchIdsByPackage: { + lodash: ["GHSA-high", "GHSA-moderate"], + }, + queryResponseByPackage: { + lodash: [ + createOsvQueryVulnerability({ + id: "GHSA-high", + database_specific: { severity: "HIGH" }, + }), + createOsvQueryVulnerability({ + id: "GHSA-moderate", + database_specific: { severity: "MODERATE" }, + }), + ], + }, + }); - it("does not flag a security axis exactly at the minimum score", async () => { - writePackageJson({ "borderline-pkg": "1.0.0" }); - stubSocketApi({ - "borderline-pkg": { - supplyChain: 0.5, - vulnerability: 1, - maintenance: 0.1, - quality: 0.1, - license: 0.1, - }, - }); + const diagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "moderate", + }, + }); - expect(await runCheck()).toEqual([]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain( + "2 high-severity known vulnerabilities: GHSA-high, GHSA-moderate.", + ); + expect(diagnostics[0].url).toBe("https://osv.dev/vulnerability/GHSA-high"); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); - it("names Socket's concrete alert and tells you to remove a malware package", async () => { - writePackageJson({ "evil-pkg": "1.0.0" }); - stubSocketApi( - { - "evil-pkg": { supplyChain: 0, vulnerability: 1, maintenance: 1, quality: 1, license: 1 }, - }, - { - "evil-pkg": [ - { - type: "malware", - severity: "critical", - file: "package/index.js", - note: "Concealed remote-code-execution payload that exfiltrates environment variables.", - }, - ], - }, - ); - - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - // The message names the alert, the offending file, and the note instead of - // leaving the user to guess what a "0/100" means. - expect(diagnostics[0].message).toContain("scored 0/100 on Socket's supply chain axis"); - expect(diagnostics[0].message).toContain("critical known malware alert"); - expect(diagnostics[0].message).toContain("`package/index.js`"); - expect(diagnostics[0].message).toContain( - "Concealed remote-code-execution payload that exfiltrates environment variables", - ); - // A critical alert escalates the help from "update/replace" to "remove". - expect(diagnostics[0].help).toContain("do not ship it"); - expect(diagnostics[0].help).toContain("Remove"); - expect(diagnostics[0].help).toContain("supplyChain.enabled: false"); + it.each([ + { id: "MAL-2025-0001", summary: "A malicious package", label: "MAL prefix" }, + { id: "GHSA-malicious", summary: "Malicious Package", label: "GHSA malicious summary" }, + ] as const)("always flags malware advisories from $label", async ({ id, summary }) => { + const rootDirectory = createProjectDirectory(); + try { + writePackageJson(rootDirectory, { + dependencies: { + "event-stream": "4.0.0", + }, + }); + stubOsvFetch({ + queryBatchIdsByPackage: { + "event-stream": [id], + }, + queryResponseByPackage: { + "event-stream": [ + createOsvQueryVulnerability({ + id, + summary, + }), + ], + }, + }); + + const diagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "critical", + }, + }); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain("known malicious package advisory"); + expect(diagnostics[0].message).toContain(id); + expect(diagnostics[0].help).toContain("package.json and your lockfile"); + expect(diagnostics[0].help).toContain("supplyChain.enabled: false"); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); - it("names the scored version as the floor of a range spec", async () => { - writePackageJson({ "ranged-pkg": "^2.1.0" }); - stubSocketApi({ - "ranged-pkg": { supplyChain: 0.2, vulnerability: 1, maintenance: 1, quality: 1, license: 1 }, - }); + it("parses CVSS vectors when database_specific severity is absent", async () => { + const rootDirectory = createProjectDirectory(); + try { + writePackageJson(rootDirectory, { + dependencies: { + semver: "7.7.4", + }, + }); + stubOsvFetch({ + queryBatchIdsByPackage: { + semver: ["GHSA-cvss"], + }, + queryResponseByPackage: { + semver: [ + createOsvQueryVulnerability({ + id: "GHSA-cvss", + severity: [ + { + type: "CVSS_V3", + score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + }, + ], + }), + ], + }, + }); - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain('`ranged-pkg@2.1.0 (lowest version "^2.1.0" allows)`'); - }); + const diagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "high", + }, + }); - it("names the exact scored version for a `v`-prefixed pin instead of a range", async () => { - writePackageJson({ "v-pinned": "v1.2.3" }); - stubSocketApi({ - "v-pinned": { supplyChain: 0.2, vulnerability: 1, maintenance: 1, quality: 1, license: 1 }, - }); - - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - // `v1.2.3` is an exact pin, so it must not be mislabeled as a range floor. - expect(diagnostics[0].message).toContain("`v-pinned@1.2.3` scored"); - expect(diagnostics[0].message).not.toContain("lowest version"); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain("critical-severity known vulnerability"); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); - // `semver.minVersion` throws (rather than returning null) on a dist-tag like - // `latest`/`next`, and resolves a wildcard to a synthetic `0.0.0`. Each of - // these specs has no concrete floor to score, so it must be skipped — never - // crash the scan (issue #807) and never fetch a fabricated version. - it.each([ - ["a dist-tag", "latest"], - ["the `next` dist-tag", "next"], - ["a bare wildcard", "*"], - ["an `x` wildcard", "x"], - ["a `workspace:` protocol", "workspace:*"], - ["a `file:` protocol", "file:../local"], - ["an `npm:` alias", "npm:other-pkg@1.2.3"], - ["a git URL", "git+https://example.com/owner/repo.git"], - ["a tarball URL", "https://example.com/pkg/foo-1.2.3.tgz"], - ])("skips %s spec without crashing or scoring it", async (_label, spec) => { - writePackageJson({ "unresolvable-pkg": spec }); - // Stub a failing score: if the spec were (mis)resolved to a concrete - // version it would flag here. An empty result proves it was skipped. - stubSocketApi({ - "unresolvable-pkg": { - supplyChain: 0, - vulnerability: 1, - maintenance: 1, - quality: 1, - license: 1, - }, - }); + it("skips dist-tags, wildcards, and protocol specs, and honors includeDevDependencies: false", async () => { + const rootDirectory = createProjectDirectory(); + try { + writePackageJson(rootDirectory, { + dependencies: { + "scored-package": "1.2.3", + "tagged-package": "latest", + "wildcard-package": "*", + "protocol-package": "file:../local", + }, + devDependencies: { + "dev-only": "2.0.0", + }, + }); + const stub = stubOsvFetch({ + queryBatchIdsByPackage: { + "scored-package": ["GHSA-score"], + }, + queryResponseByPackage: { + "scored-package": [ + createOsvQueryVulnerability({ + id: "GHSA-score", + database_specific: { severity: "LOW" }, + }), + ], + }, + }); - expect(await runCheck()).toEqual([]); + const diagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + includeDevDependencies: false, + failOn: "low", + }, + }); + + expect(diagnostics).toHaveLength(1); + expect(stub.queryBatchRequests).toHaveLength(1); + expect(stub.queryBatchRequests[0].map((query) => query.name)).toEqual(["scored-package"]); + expect(stub.queryRequests).toEqual(["scored-package"]); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); - it("does not crash on a dist-tag and still scores resolvable siblings (issue #807, trigger.dev@latest)", async () => { - // The exact regression shape: a real dep beside an npm dist-tag. PR #804 - // made this throw `TypeError: Invalid comparator: latest` on every scan. - writePackageJson({ react: "^19.0.0", "trigger.dev": "latest" }); - stubSocketApi({ - react: { supplyChain: 0.2, vulnerability: 1, maintenance: 1, quality: 1, license: 1 }, - "trigger.dev": { supplyChain: 0, vulnerability: 1, maintenance: 1, quality: 1, license: 1 }, - }); - - const diagnostics = await runCheck(); - // The dist-tag is skipped (not crashed on); the resolvable sibling still scores. - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("`react@19.0.0"); - }); + it("fails open on a package query error without caching a partial result", async () => { + const rootDirectory = createProjectDirectory(); + const cacheDirectory = path.join(rootDirectory, "cache"); + process.env["REACT_DOCTOR_CACHE_DIR"] = cacheDirectory; + try { + writePackageJson(rootDirectory, { + dependencies: { + lodash: "4.17.11", + }, + }); + stubOsvFetch({ + queryBatchIdsByPackage: { + lodash: ["GHSA-jf85-cpcp-j695"], + }, + queryFailurePackages: new Set(["lodash"]), + }); - it("keeps the score-driven diagnostic when alerts are malformed or null (no fail-open)", async () => { - writePackageJson({ "null-alert-pkg": "1.0.0" }); - // A real Socket line where optional alert fields are explicitly `null` - // (JSON APIs send `null`, not an absent key) and one alert is malformed - // (missing `type`). Neither must sink the score that gates the check. - const body = JSON.stringify({ - id: "test-artifact", - type: "npm", - score: { - supplyChain: 0.1, - vulnerability: 1, - maintenance: 1, - quality: 1, - license: 1, - overall: 0.1, - }, - alerts: [ - { key: "a", type: "malware", severity: "critical", file: null, props: null }, - { key: "b", severity: "high" }, - ], - }); - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response(body, { status: 200 })), - ); - - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("scored 10/100 on Socket's supply chain axis"); - // The valid alert (with null fields) is still named; the malformed one is dropped. - expect(diagnostics[0].message).toContain("critical known malware alert"); - }); + const failedDiagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "low", + }, + }); - it("strips terminal escape sequences and backticks from remote alert strings", async () => { - writePackageJson({ "ansi-pkg": "1.0.0" }); - stubSocketApi( - { "ansi-pkg": { supplyChain: 0, vulnerability: 1, maintenance: 1, quality: 1, license: 1 } }, - { - "ansi-pkg": [ - { - type: "malware", - severity: "critical", - file: "pkg/\u001b[31mhidden\u001b[0m`whoami`.js", - note: "Payload \u001b[2Kspoofs output and runs `rm -rf`.", - }, - ], - }, - ); - - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - const { message } = diagnostics[0]; - // No raw ESC reaches terminal-bound output, and backticks in remote strings - // are neutralized so they can't break the `code` / "quote" framing. - expect(message).not.toContain("\u001b"); - expect(message).toContain("pkg/[31mhidden[0m'whoami'.js"); - expect(message).toContain("Payload [2Kspoofs output and runs 'rm -rf'"); - }); + expect(failedDiagnostics).toEqual([]); + expect(fs.existsSync(cacheFileFor(rootDirectory, "lodash", "4.17.11"))).toBe(false); - it("uses the axis remediation and gentler escape hatch for a non-critical alert", async () => { - writePackageJson({ "high-not-critical": "1.0.0" }); - stubSocketApi( - { - "high-not-critical": { - supplyChain: 0.1, - vulnerability: 1, - maintenance: 1, - quality: 1, - license: 1, + vi.unstubAllGlobals(); + stubOsvFetch({ + queryBatchIdsByPackage: { + lodash: ["GHSA-jf85-cpcp-j695"], }, - }, - { - "high-not-critical": [ - { type: "obfuscatedCode", severity: "high", note: "Heavily obfuscated bundle." }, - ], - }, - ); - - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("high obfuscated code alert"); - // A non-critical alert must not escalate to the "remove / do not ship" help. - expect(diagnostics[0].help).not.toContain("do not ship it"); - expect(diagnostics[0].help).toContain("prefer a more established, audited alternative"); - expect(diagnostics[0].help).toContain("supplyChain.minScore"); - }); + queryResponseByPackage: { + lodash: [ + createOsvQueryVulnerability({ + id: "GHSA-jf85-cpcp-j695", + database_specific: { severity: "CRITICAL" }, + }), + ], + }, + }); - it("summarizes multiple alerts with a +N more tail and the worst severity", async () => { - writePackageJson({ "many-alerts": "1.0.0" }); - stubSocketApi( - { - "many-alerts": { - supplyChain: 0.1, - vulnerability: 1, - maintenance: 1, - quality: 1, - license: 1, + const recoveredDiagnostics = await runCheckSupplyChain(rootDirectory, { + supplyChain: { + failOn: "low", }, - }, - { - "many-alerts": [ - { type: "malware", severity: "critical" }, - { type: "installScript", severity: "high" }, - { type: "networkAccess", severity: "medium" }, - { type: "envVars", severity: "low" }, - ], - }, - ); - - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - // Four alerts: the top three are named, the remainder collapses to "+N more". - expect(diagnostics[0].message).toContain("Socket flagged 4 alerts"); - expect(diagnostics[0].message).toContain("(+1 more)"); - expect(diagnostics[0].message).toContain("most severe: critical"); + }); + + expect(recoveredDiagnostics).toHaveLength(1); + expect(recoveredDiagnostics[0].message).toContain("GHSA-jf85-cpcp-j695"); + expect(fs.existsSync(cacheFileFor(rootDirectory, "lodash", "4.17.11"))).toBe(true); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); - it('normalizes Socket\'s "middle" severity to "medium"', async () => { - writePackageJson({ "middle-sev": "1.0.0" }); - stubSocketApi( - { - "middle-sev": { - supplyChain: 0.1, - vulnerability: 1, - maintenance: 1, - quality: 1, - license: 1, + it("fails open when the whole check exceeds its timeout budget", async () => { + const rootDirectory = createProjectDirectory(); + try { + writePackageJson(rootDirectory, { + dependencies: { + lodash: "4.17.11", }, - }, - { "middle-sev": [{ type: "troll", severity: "middle", note: "Protestware." }] }, - ); + }); + stubHangingOsvFetch(); + + const diagnostics = await runCheckSupplyChain( + rootDirectory, + { + supplyChain: { + failOn: "low", + }, + }, + 20, + ); - const diagnostics = await runCheck(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("medium protestware alert"); + expect(diagnostics).toEqual([]); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } }); }); diff --git a/packages/core/tests/run-inspect.test.ts b/packages/core/tests/run-inspect.test.ts index c8b1e2036d..bf54958902 100644 --- a/packages/core/tests/run-inspect.test.ts +++ b/packages/core/tests/run-inspect.test.ts @@ -104,11 +104,11 @@ const baseInput: InspectInput = { const supplyChainDiagnostic: Diagnostic = { filePath: "package.json", - plugin: "socket", - rule: "low-supply-chain-score", + plugin: "osv", + rule: "known-vulnerability", severity: "error", - message: "`event-stream` has a Socket supply-chain score of 25/100.", - help: "Review it on Socket.", + message: "`event-stream` has 1 high-severity known vulnerability: GHSA-event-stream.", + help: "Review it on OSV.", line: 8, column: 5, category: "Security", @@ -146,7 +146,7 @@ const layersOf = (config: { describe("runInspect — phase timeouts & overall deadline", () => { // A never-completing analyzer stream stands in for a wedged phase (a - // pathological file / hung socket); the Effect-level caps must fire. + // pathological file / hung network); the Effect-level caps must fire. const baseTimeoutLayers = (overrides: { linter: Layer.Layer; deadCode: Layer.Layer; @@ -939,7 +939,7 @@ describe("runInspect — supply-chain in diff mode", () => { Effect.provide(layersOf({ supplyChain: [supplyChainDiagnostic] })), ), ); - expect(output.diagnostics.map((d) => d.rule)).toContain("low-supply-chain-score"); + expect(output.diagnostics.map((d) => d.rule)).toContain("known-vulnerability"); }); it("skips supply-chain in a plain diff scan (no manifest change)", async () => { @@ -948,7 +948,7 @@ describe("runInspect — supply-chain in diff mode", () => { Effect.provide(layersOf({ supplyChain: [supplyChainDiagnostic] })), ), ); - expect(output.diagnostics.map((d) => d.rule)).not.toContain("low-supply-chain-score"); + expect(output.diagnostics.map((d) => d.rule)).not.toContain("known-vulnerability"); }); it("runs supply-chain in a diff scan when the manifest changed", async () => { @@ -959,7 +959,7 @@ describe("runInspect — supply-chain in diff mode", () => { supplyChainManifestChanged: true, }).pipe(Effect.provide(layersOf({ supplyChain: [supplyChainDiagnostic] }))), ); - expect(output.diagnostics.map((d) => d.rule)).toContain("low-supply-chain-score"); + expect(output.diagnostics.map((d) => d.rule)).toContain("known-vulnerability"); }); }); @@ -980,10 +980,10 @@ describe("runInspect — supply-chain lint overlap", () => { // `sortDiagnosticsStable`-ordered by (filePath, line, …) — deterministic // regardless of which fiber settled first. filePath order: // "/repo/src/App.tsx" (no-derived-state) < "package.json" - // (low-supply-chain-score) < "src/Unused.tsx" (unused-file). + // (known-vulnerability) < "src/Unused.tsx" (unused-file). expect(output.diagnostics.map((d) => d.rule)).toEqual([ "no-derived-state", - "low-supply-chain-score", + "known-vulnerability", "unused-file", ]); expect(output.supplyChainOverlapTimedOut).toBe(false); @@ -1044,7 +1044,7 @@ describe("runInspect — supply-chain lint overlap", () => { ), ); expect(output.supplyChainOverlapTimedOut).toBe(false); - expect(output.diagnostics.map((d) => d.rule)).toContain("low-supply-chain-score"); + expect(output.diagnostics.map((d) => d.rule)).toContain("known-vulnerability"); }); it("never invokes supply-chain run in a plain diff scan (fork takes the empty branch)", async () => { @@ -1067,7 +1067,7 @@ describe("runInspect — supply-chain lint overlap", () => { ), ); expect(supplyChainRunCount).toBe(0); - expect(output.diagnostics.map((d) => d.rule)).not.toContain("low-supply-chain-score"); + expect(output.diagnostics.map((d) => d.rule)).not.toContain("known-vulnerability"); expect(output.supplyChainOverlapTimedOut).toBe(false); }); @@ -1095,7 +1095,7 @@ describe("runInspect — supply-chain lint overlap", () => { ), ); expect(supplyChainRunCount).toBe(1); - expect(output.diagnostics.map((d) => d.rule)).toContain("low-supply-chain-score"); + expect(output.diagnostics.map((d) => d.rule)).toContain("known-vulnerability"); }); it("fails open on a supply-chain timeout while a folded lint failure nulls the score", async () => { diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 7f596486e7..c90cc0ae5f 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -549,7 +549,7 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro projectScanTarget.userConfig?.plugins === undefined ? scanTarget.configSourceDirectory : projectScanTarget.configSourceDirectory; - // The Socket supply-chain check runs by default; opted out by + // The OSV supply-chain check runs by default; opted out by // `--no-supply-chain` (wins) or per-project config. Off ⇒ a manifest-only // diff change shouldn't pull a project into the scan (nothing to report). const supplyChainEnabled = flags.supplyChain ?? projectConfig?.supplyChain?.enabled !== false; diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index e6a0b0f0b7..8bab5dd8fc 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -148,7 +148,7 @@ const program = new Command() .option("--supply-chain", "enable the dependency supply-chain scan (default)") .option( "--no-supply-chain", - "skip the dependency supply-chain scan (Socket.dev dependency health checks)", + "skip the dependency supply-chain scan (OSV dependency health checks)", ) .option("--verbose", "show every rule and per-file details (default shows top 3 rules)") .option( diff --git a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts index b853b94d0c..14fc54642c 100644 --- a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts +++ b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts @@ -37,7 +37,7 @@ export interface BuildRuntimeLayersInput { readonly shouldSkipLint: boolean; readonly shouldRunDeadCode: boolean; /** - * Whether the Socket.dev supply-chain scan should run. Resolved by + * Whether the OSV supply-chain scan should run. Resolved by * `inspect()` from the `--supply-chain` / `--no-supply-chain` flag over * `supplyChain.enabled` (the flag wins), so a per-project config can't * undo the CLI choice. `false` swaps `SupplyChain.layerNode` for the @@ -112,9 +112,9 @@ export const buildRuntimeLayers = (input: BuildRuntimeLayersInput) => { const linterLayer = input.shouldSkipLint ? Linter.layerOf([]) : Linter.layerOxlint; const deadCodeLayer = input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]); const scoreLayer = input.shouldComputeScore ? Score.layerHttp : Score.layerOf(null); - // Socket.dev supply-chain score gate runs by default (the keyless HTTP - // layer); a no-op empty layer when the user opts out via - // `--no-supply-chain` or `supplyChain.enabled: false`. + // OSV supply-chain gate runs by default (the keyless HTTP layer); a no-op + // empty layer when the user opts out via `--no-supply-chain` or + // `supplyChain.enabled: false`. const supplyChainLayer = input.shouldRunSupplyChain ? SupplyChain.layerNode : SupplyChain.layerOf([]); diff --git a/packages/website/public/schema/config.json b/packages/website/public/schema/config.json index ca593c7093..8113726857 100644 --- a/packages/website/public/schema/config.json +++ b/packages/website/public/schema/config.json @@ -50,7 +50,7 @@ }, "supplyChain": { "$ref": "#/definitions/SupplyChainConfig", - "description": "Socket.dev supply-chain score gate. Runs by default; set `supplyChain: { enabled: false }` to opt out. See {@link SupplyChainConfig } . Every direct dependency is scored against Socket's free PURL endpoint and a low score fails the scan (at the default `severity: \"error\"`)." + "description": "OSV supply-chain vulnerability gate. Runs by default; set `supplyChain: { enabled: false }` to opt out. See {@link SupplyChainConfig } . Every direct dependency is checked against OSV's free API and a known vulnerability at or above the configured threshold fails the scan (at the default `severity: \"error\"`)." }, "deadCode": { "type": "boolean", @@ -216,11 +216,17 @@ "properties": { "enabled": { "type": "boolean", - "description": "Whether to run the Socket supply-chain score check. Default: `true`. Set to `false` to opt out — the check performs one network request per direct dependency. It is always skipped in `--diff` / `--staged` mode and in editor scans regardless of this setting." + "description": "Whether to run the OSV supply-chain vulnerability check. Default: `true`. Set to `false` to opt out — the check performs one network request per direct dependency. It is always skipped in `--diff` / `--staged` mode and in editor scans regardless of this setting." }, - "minScore": { - "type": "number", - "description": "Minimum acceptable Socket score on a 0–100 scale. A direct dependency whose worst Socket *security* axis — supply chain or vulnerability — is below this is flagged; the quality / maintenance / license axes never gate. Default: `50`. Values outside `0..100` are clamped." + "failOn": { + "type": "string", + "enum": [ + "low", + "moderate", + "high", + "critical" + ], + "description": "Lowest known-vulnerability severity that should be reported/gated. Default: `\"high\"`. A package with only lower-severity advisories is skipped; malware advisories are always treated as critical." }, "severity": { "type": "string", @@ -236,7 +242,7 @@ } }, "additionalProperties": false, - "description": "Configuration for the Socket.dev supply-chain score check (the `SupplyChain` service). Runs by default; set `enabled: false` to opt out (it performs one network request per direct dependency).\n\nMirrors how Socket Firewall's free tier (`sfw`) works: each direct dependency's PURL is looked up against Socket's keyless `firewall-api.socket.dev/purl/` endpoint, which returns per-axis scores (0–100 once normalized). A dependency whose worst security axis (supply chain or vulnerability) scores below `minScore` produces a diagnostic; at the default `severity: \"error\"` it fails the scan (non-zero CI exit), the same way an error-severity lint finding does." + "description": "Configuration for the OSV supply-chain vulnerability check (the `SupplyChain` service). Runs by default; set `enabled: false` to opt out (it performs one network request per direct dependency).\n\nMirrors OSV's free, unauthenticated API: each direct dependency's npm package name + concrete version is queried against `api.osv.dev`. A dependency whose known vulnerabilities meet or exceed `failOn` produces a diagnostic; at the default `severity: \"error\"` it fails the scan (non-zero CI exit), the same way an error-severity lint finding does." }, "ScopeValue": { "type": "string",