diff --git a/review-enrichment/README.md b/review-enrichment/README.md index 1a009b2678..5804c86f7a 100644 --- a/review-enrichment/README.md +++ b/review-enrichment/README.md @@ -21,6 +21,7 @@ See `src/server.ts` for the `EnrichRequest` / `ReviewBrief` contract. ## Analyzers (added behind the contract) - **#1474** dependency-diff + OSV.dev CVE +- **#1502** lockfile-only transitive vulnerability drift via OSV.dev - **#1475** SPDX license policy - **#1476** gitleaks-grade secret scan (value-redacted) - **#1477** static analysis + complexity (lint/semgrep over the diff) diff --git a/review-enrichment/src/analyzers/lockfile-drift.ts b/review-enrichment/src/analyzers/lockfile-drift.ts new file mode 100644 index 0000000000..2695eaddc8 --- /dev/null +++ b/review-enrichment/src/analyzers/lockfile-drift.ts @@ -0,0 +1,416 @@ +// Lockfile drift + OSV.dev analyzer (#1502). Detects vulnerable package versions introduced only through +// lockfile changes, where the top-level manifest diff does not name the package. This catches transitive pins and +// downgraded resolved versions that the manifest-only dependency analyzer cannot see. +import type { + Cve, + EnrichRequest, + LockfileDriftFinding, +} from "../types.js"; +import { extractDependencyChanges } from "./dependency-scan.js"; + +interface LockfileChange { + file: string; + line: number; + ecosystem: "npm" | "PyPI"; + package: string; + from: string | null; + to: string; +} + +interface ScanLimits { + maxLockfileFiles?: number; + maxPatchLinesPerFile?: number; + maxOsvQueries?: number; +} + +interface ScanOptions { + signal?: AbortSignal; + limits?: ScanLimits; +} + +interface PatchLine { + sign: "+" | "-" | " "; + content: string; + newLine: number; +} + +interface OsvVuln { + id: string; + summary?: string; + details?: string; + severity?: Array<{ type: string; score: string }>; + database_specific?: { severity?: string }; + affected?: Array<{ ranges?: Array<{ events?: Array<{ fixed?: string }> }> }>; +} + +const MAX_LOCKFILE_FILES = 12; +const MAX_PATCH_LINES_PER_FILE = 1200; +const MAX_OSV_QUERIES = 40; +const SUPPORTED_LOCKFILES = new Set(["package-lock.json", "yarn.lock", "poetry.lock"]); +const VERSION_SAFE_RE = /^[0-9][0-9A-Za-z._+-]*$/; +const MAX_PACKAGE_LEN = 200; +const MAX_VERSION_LEN = 100; +const PACKAGE_LOCK_CONTAINER_KEYS = new Set(["", "packages", "dependencies"]); + +function severityOf(vuln: OsvVuln): Cve["severity"] { + const label = vuln.database_specific?.severity?.toLowerCase(); + if ( + label === "critical" || + label === "high" || + label === "medium" || + label === "low" + ) + return label; + const score = Number( + vuln.severity?.find((s) => s.type?.startsWith("CVSS"))?.score, + ); + if (!Number.isFinite(score)) return "unknown"; + return score >= 9 + ? "critical" + : score >= 7 + ? "high" + : score >= 4 + ? "medium" + : "low"; +} + +function fixedOf(vuln: OsvVuln): string | null { + for (const affected of vuln.affected ?? []) { + for (const range of affected.ranges ?? []) { + for (const event of range.events ?? []) { + if (event.fixed) return event.fixed; + } + } + } + return null; +} + +function toCves(vulns: OsvVuln[] | undefined): Cve[] { + return (vulns ?? []).map((vuln) => ({ + id: vuln.id, + severity: severityOf(vuln), + summary: (vuln.summary ?? vuln.details ?? "") + .replace(/\s+/g, " ") + .slice(0, 180), + fixedIn: fixedOf(vuln), + })); +} + +function isSafeQuery(pkg: string, version: string): boolean { + return ( + pkg.length > 0 && + pkg.length <= MAX_PACKAGE_LEN && + version.length > 0 && + version.length <= MAX_VERSION_LEN && + VERSION_SAFE_RE.test(version) + ); +} + +function* patchLines( + patch: string, + maxLines: number, +): Generator { + let newLine = 0; + let seen = 0; + for (const raw of patch.split("\n")) { + seen += 1; + if (seen > maxLines) break; + if (raw.startsWith("+++") || raw.startsWith("---")) continue; + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); + if (hunk) { + newLine = Number(hunk[1]); + continue; + } + const first = raw[0]; + if (first === "+") { + yield { sign: "+", content: raw.slice(1), newLine }; + newLine += 1; + } else if (first === "-") { + yield { sign: "-", content: raw.slice(1), newLine }; + } else { + yield { sign: " ", content: raw.slice(1), newLine }; + newLine += 1; + } + } +} + +function npmPackageFromNodeModulesPath(path: string): string | null { + const marker = "node_modules/"; + const i = path.lastIndexOf(marker); + if (i < 0) return null; + const rest = path.slice(i + marker.length); + if (rest.startsWith("@")) { + const parts = rest.split("/"); + if (parts.length >= 2) return `${parts[0]}/${parts[1]}`; + return null; + } + return rest.split("/")[0] || null; +} + +function parsePackageLock(path: string, patch: string, maxLines: number): LockfileChange[] { + const byKey = new Map(); + let currentPackage: string | null = null; + let sawPackagesEntry = false; + for (const line of patchLines(patch, maxLines)) { + const body = line.content.trim(); + const objectHeader = /^"([^"]+)"\s*:\s*\{/.exec(body); + if (objectHeader) { + const key = objectHeader[1]!; + const packageName = npmPackageFromNodeModulesPath(key); + if (packageName) { + currentPackage = packageName; + sawPackagesEntry = true; + } else if (!sawPackagesEntry && !PACKAGE_LOCK_CONTAINER_KEYS.has(key)) { + currentPackage = key; + } else { + currentPackage = null; + } + continue; + } + if (body === "}" || body.startsWith("},")) currentPackage = null; + if (!currentPackage) continue; + const versionMatch = /^"version"\s*:\s*"([^"]+)"/.exec(body); + if (!versionMatch) continue; + const version = versionMatch[1]!; + const key = `npm::${currentPackage}`; + const entry = + byKey.get(key) ?? + { + file: path, + line: line.newLine, + ecosystem: "npm" as const, + package: currentPackage, + from: null, + to: "", + }; + if (line.sign === "+") { + entry.to = version; + entry.line = line.newLine; + } else if (line.sign === "-") { + entry.from = version; + } + byKey.set(key, entry); + } + return [...byKey.values()].filter((change) => change.to && change.to !== change.from); +} + +function yarnPackageFromDescriptor(descriptor: string): string | null { + const cleaned = descriptor.trim().replace(/^["']|["']$/g, ""); + if (!cleaned) return null; + if (cleaned.startsWith("@")) { + const slash = cleaned.indexOf("/"); + if (slash < 0) return null; + const rangeAt = cleaned.indexOf("@", slash + 1); + return rangeAt < 0 ? cleaned : cleaned.slice(0, rangeAt); + } + const at = cleaned.indexOf("@"); + return at < 0 ? cleaned : cleaned.slice(0, at); +} + +function splitYarnDescriptors(header: string): string[] { + const descriptors: string[] = []; + let current = ""; + let quote: string | null = null; + for (const char of header) { + if ((char === "\"" || char === "'") && quote === null) { + quote = char; + current += char; + continue; + } + if (char === quote) { + quote = null; + current += char; + continue; + } + if (char === "," && quote === null) { + const descriptor = current.trim(); + if (descriptor) descriptors.push(descriptor); + current = ""; + continue; + } + current += char; + } + const descriptor = current.trim(); + if (descriptor) descriptors.push(descriptor); + return descriptors; +} + +function parseYarnLock(path: string, patch: string, maxLines: number): LockfileChange[] { + const byKey = new Map(); + let currentPackages: string[] = []; + for (const line of patchLines(patch, maxLines)) { + if (line.content && !line.content.startsWith("#") && !/^\s/.test(line.content)) { + if (!line.content.trim().endsWith(":")) { + currentPackages = []; + continue; + } + const header = line.content.trim().replace(/:$/, ""); + currentPackages = [ + ...new Set( + splitYarnDescriptors(header) + .map((descriptor) => yarnPackageFromDescriptor(descriptor)) + .filter((pkg): pkg is string => Boolean(pkg)), + ), + ]; + continue; + } + if (!currentPackages.length) continue; + const versionMatch = + /^\s+version\s+"([^"]+)"/.exec(line.content) ?? + /^\s+version:\s*"?([^"\s#]+)"?/.exec(line.content); + if (!versionMatch) continue; + const version = versionMatch[1]!; + for (const currentPackage of currentPackages) { + const key = `npm::${currentPackage}`; + const entry = + byKey.get(key) ?? + { + file: path, + line: line.newLine, + ecosystem: "npm" as const, + package: currentPackage, + from: null, + to: "", + }; + if (line.sign === "+") { + entry.to = version; + entry.line = line.newLine; + } else if (line.sign === "-") { + entry.from = version; + } + byKey.set(key, entry); + } + } + return [...byKey.values()].filter((change) => change.to && change.to !== change.from); +} + +function parsePoetryLock(path: string, patch: string, maxLines: number): LockfileChange[] { + const byKey = new Map(); + let currentPackage: string | null = null; + for (const line of patchLines(patch, maxLines)) { + const body = line.content.trim(); + if (body === "[[package]]") { + currentPackage = null; + continue; + } + const nameMatch = /^name\s*=\s*"([^"]+)"/.exec(body); + if (nameMatch) { + currentPackage = nameMatch[1]!; + continue; + } + if (!currentPackage) continue; + const versionMatch = /^version\s*=\s*"([^"]+)"/.exec(body); + if (!versionMatch) continue; + const version = versionMatch[1]!; + const key = `PyPI::${currentPackage}`; + const entry = + byKey.get(key) ?? + { + file: path, + line: line.newLine, + ecosystem: "PyPI" as const, + package: currentPackage, + from: null, + to: "", + }; + if (line.sign === "+") { + entry.to = version; + entry.line = line.newLine; + } else if (line.sign === "-") { + entry.from = version; + } + byKey.set(key, entry); + } + return [...byKey.values()].filter((change) => change.to && change.to !== change.from); +} + +function parseLockfile(path: string, patch: string, maxLines: number): LockfileChange[] { + const name = path.split("/").pop() ?? path; + if (name === "package-lock.json") return parsePackageLock(path, patch, maxLines); + if (name === "yarn.lock") return parseYarnLock(path, patch, maxLines); + if (name === "poetry.lock") return parsePoetryLock(path, patch, maxLines); + return []; +} + +/** Extract lockfile-only resolved package changes. Top-level manifest changes are excluded as direct deps. */ +export function extractLockfileChanges( + files: NonNullable, + limits: ScanLimits = {}, +): LockfileChange[] { + const direct = new Set( + extractDependencyChanges(files).map((dep) => `${dep.ecosystem}::${dep.package}`), + ); + const maxFiles = limits.maxLockfileFiles ?? MAX_LOCKFILE_FILES; + const maxLines = limits.maxPatchLinesPerFile ?? MAX_PATCH_LINES_PER_FILE; + const changes: LockfileChange[] = []; + let scannedFiles = 0; + for (const file of files) { + const name = file.path.split("/").pop() ?? file.path; + if (!file.patch || !SUPPORTED_LOCKFILES.has(name)) continue; + scannedFiles += 1; + if (scannedFiles > maxFiles) break; + for (const change of parseLockfile(file.path, file.patch, maxLines)) { + if (direct.has(`${change.ecosystem}::${change.package}`)) continue; + if (!isSafeQuery(change.package, change.to)) continue; + changes.push(change); + } + } + return changes; +} + +/** Batch-query OSV.dev for lockfile resolutions. Best-effort: returns empty CVE arrays on any failure. */ +export async function queryOsvBatch( + changes: LockfileChange[], + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, +): Promise> { + const results = new Map(); + if (!changes.length || signal?.aborted) return results; + try { + const response = await fetchImpl("https://api.osv.dev/v1/querybatch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + queries: changes.map((change) => ({ + package: { name: change.package, ecosystem: change.ecosystem }, + version: change.to, + })), + }), + signal, + }); + if (!response.ok) return results; + const data = (await response.json()) as { + results?: Array<{ vulns?: OsvVuln[] }>; + }; + changes.forEach((change, index) => { + results.set(`${change.ecosystem}::${change.package}@${change.to}`, toCves(data.results?.[index]?.vulns)); + }); + } catch { + return results; + } + return results; +} + +/** Analyzer entrypoint: lockfile-only resolved deps → OSV → vulnerable transitive drift findings. */ +export async function scanLockfileDrift( + req: EnrichRequest, + fetchImpl: typeof fetch = fetch, + options: ScanOptions = {}, +): Promise { + const changes = extractLockfileChanges(req.files ?? [], options.limits).slice( + 0, + options.limits?.maxOsvQueries ?? MAX_OSV_QUERIES, + ); + const cvesByKey = await queryOsvBatch(changes, fetchImpl, options.signal); + const findings: LockfileDriftFinding[] = []; + for (const change of changes) { + const cves = cvesByKey.get(`${change.ecosystem}::${change.package}@${change.to}`) ?? []; + if (!cves.length) continue; + findings.push({ + ...change, + direction: change.from ? "change" : "add", + cves, + }); + } + return findings; +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index 6cbf74b70c..f8a9dcf65e 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -8,6 +8,7 @@ import type { AnalyzerStatus, } from "./types.js"; import { scanDependencies } from "./analyzers/dependency-scan.js"; +import { scanLockfileDrift } from "./analyzers/lockfile-drift.js"; import { scanSecrets } from "./analyzers/secret-scan.js"; import { scanLicenses } from "./analyzers/license-check.js"; import { scanInstallScripts } from "./analyzers/install-scripts.js"; @@ -25,6 +26,7 @@ type AnalyzerFn = (req: EnrichRequest, signal: AbortSignal) => Promise; // The analyzer registry. More land behind this same shape: license (#1475), secret (#1476), static (#1477), history (#1478). const ANALYZERS: Record = { dependency: (req, signal) => scanDependencies(req, fetch, { signal }), + lockfileDrift: (req, signal) => scanLockfileDrift(req, fetch, { signal }), secret: (req) => scanSecrets(req), license: (req) => scanLicenses(req), installScript: (req) => scanInstallScripts(req), diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 3008b706f0..ceba3ce5bd 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -58,9 +58,32 @@ export function renderBrief( (SEVERITY_RANK[b.cve.severity] ?? 4), ); for (const { dep, cve } of flat) { - const fix = cve.fixedIn ? ` — fixed in ${cve.fixedIn}` : ""; + const fix = cve.fixedIn + ? ` — fixed in ${safeCodeSpan(cve.fixedIn)}` + : ""; + lines.push( + `- ${safeCodeSpan(`${dep.package}@${dep.to}`)} (${dep.ecosystem}): **${cve.severity}** ${safeCodeSpan(cve.id)} — ${promptText(cve.summary)}${fix}`, + ); + } + } + + const lockfileDrift = findings.lockfileDrift ?? []; + if (lockfileDrift.length) { + lines.push("### Vulnerable lockfile-only dependency drift (OSV.dev)"); + const flat = lockfileDrift + .flatMap((dep) => dep.cves.map((cve) => ({ dep, cve }))) + .sort( + (a, b) => + (SEVERITY_RANK[a.cve.severity] ?? 4) - + (SEVERITY_RANK[b.cve.severity] ?? 4), + ); + for (const { dep, cve } of flat) { + const from = dep.from ? ` from ${safeCodeSpan(dep.from)}` : ""; + const fix = cve.fixedIn + ? ` — fixed in ${safeCodeSpan(cve.fixedIn)}` + : ""; lines.push( - `- \`${dep.package}@${dep.to}\` (${dep.ecosystem}): **${cve.severity}** ${cve.id} — ${cve.summary}${fix}`, + `- ${safeCodeSpan(`${dep.file}:${dep.line}`)} resolves transitive ${safeCodeSpan(`${dep.package}@${dep.to}`)} (${dep.ecosystem})${from}: **${cve.severity}** ${safeCodeSpan(cve.id)} — ${promptText(cve.summary)}${fix}`, ); } } diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index b7de952841..6a09c2451a 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -43,6 +43,19 @@ export interface DependencyFinding { cves: Cve[]; } +/** A vulnerable lockfile-only dependency resolution. The package was not changed in a top-level manifest diff, + * so it is treated as transitive lockfile drift and reported with the lockfile location that introduced it. */ +export interface LockfileDriftFinding { + file: string; + line: number; + ecosystem: "npm" | "PyPI"; + package: string; + from: string | null; + to: string; + direction: "add" | "change"; + cves: Cve[]; +} + /** A potential leaked credential. Value-redacted by construction — only the location + kind are ever reported. */ export interface SecretFinding { file: string; @@ -136,6 +149,7 @@ export interface AssetWeightFinding { /** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */ export interface BriefFindings { dependency?: DependencyFinding[]; + lockfileDrift?: LockfileDriftFinding[]; secret?: SecretFinding[]; license?: LicenseFinding[]; actionPin?: ActionPinFinding[]; diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts index 4d9d65db87..7b00ebca58 100644 --- a/review-enrichment/test/enrichment.test.ts +++ b/review-enrichment/test/enrichment.test.ts @@ -5,6 +5,11 @@ import { queryOsv, scanDependencies, } from "../dist/analyzers/dependency-scan.js"; +import { + extractLockfileChanges, + queryOsvBatch, + scanLockfileDrift, +} from "../dist/analyzers/lockfile-drift.js"; import { renderBrief } from "../dist/render.js"; import { buildBrief } from "../dist/brief.js"; import { scanPatch, scanSecrets } from "../dist/analyzers/secret-scan.js"; @@ -160,6 +165,283 @@ test("scanDependencies: only deps with vulns are returned", async () => { assert.equal(findings[0].cves[0].severity, "critical"); }); +test("extractLockfileChanges: package-lock version drift with file line, skipping direct manifest deps", () => { + const changes = extractLockfileChanges([ + { + path: "package.json", + patch: '+ "direct": "2.0.0",', + }, + { + path: "package-lock.json", + patch: [ + "@@ -10,8 +10,8 @@", + ' "node_modules/direct": {', + '- "version": "1.0.0",', + '+ "version": "2.0.0",', + ' },', + ' "node_modules/minimist": {', + '- "version": "1.2.8",', + '+ "version": "0.0.8",', + ].join("\n"), + }, + ]); + assert.equal(changes.length, 1); + assert.equal(changes[0].package, "minimist"); + assert.equal(changes[0].from, "1.2.8"); + assert.equal(changes[0].to, "0.0.8"); + assert.equal(changes[0].line, 14); +}); + +test("extractLockfileChanges: package-lock root dependency versions do not reuse package context", () => { + const changes = extractLockfileChanges([ + { + path: "package-lock.json", + patch: [ + "@@ -20,13 +20,13 @@", + ' "node_modules/a": {', + '- "version": "1.0.0",', + '+ "version": "1.0.1",', + " },", + ' "dependencies": {', + ' "b": {', + '- "version": "2.0.0",', + '+ "version": "2.0.1"', + " }", + ].join("\n"), + }, + ]); + + assert.deepEqual( + changes.map(({ ecosystem, package: name, from, to }) => ({ + ecosystem, + name, + from, + to, + })), + [{ ecosystem: "npm", name: "a", from: "1.0.0", to: "1.0.1" }], + ); +}); + +test("extractLockfileChanges: package-lock v1 dependency stanzas are scanned", () => { + const changes = extractLockfileChanges([ + { + path: "package-lock.json", + patch: [ + "@@ -30,10 +30,10 @@", + ' "dependencies": {', + ' "minimist": {', + '- "version": "1.2.8",', + '+ "version": "0.0.8",', + " },", + ' "@scope/pkg": {', + '- "version": "2.0.0",', + '+ "version": "2.0.1-beta.1+build.5"', + ].join("\n"), + }, + ]); + + assert.deepEqual( + changes.map(({ ecosystem, package: name, from, to }) => ({ + ecosystem, + name, + from, + to, + })), + [ + { ecosystem: "npm", name: "minimist", from: "1.2.8", to: "0.0.8" }, + { + ecosystem: "npm", + name: "@scope/pkg", + from: "2.0.0", + to: "2.0.1-beta.1+build.5", + }, + ], + ); +}); + +test("extractLockfileChanges: parses yarn.lock and poetry.lock resolved versions", () => { + const changes = extractLockfileChanges([ + { + path: "web/yarn.lock", + patch: [ + "@@ -20,7 +20,7 @@", + ' "@scope/pkg@^1.0.0":', + '- version "1.1.0"', + '+ version "1.0.1"', + ].join("\n"), + }, + { + path: "berry/yarn.lock", + patch: [ + "@@ -30,7 +30,7 @@", + " left-pad@npm:^1.0.0:", + "- version: 1.1.0", + "+ version: 1.0.1", + ].join("\n"), + }, + { + path: "poetry.lock", + patch: [ + "@@ -40,7 +40,7 @@", + " [[package]]", + ' name = "requests"', + '-version = "2.31.0"', + '+version = "2.19.0"', + ].join("\n"), + }, + ]); + assert.deepEqual( + changes.map(({ ecosystem, package: name, from, to }) => ({ + ecosystem, + name, + from, + to, + })), + [ + { ecosystem: "npm", name: "@scope/pkg", from: "1.1.0", to: "1.0.1" }, + { ecosystem: "npm", name: "left-pad", from: "1.1.0", to: "1.0.1" }, + { ecosystem: "PyPI", name: "requests", from: "2.31.0", to: "2.19.0" }, + ], + ); +}); + +test("extractLockfileChanges: Yarn ignores non-stanza top-level lines", () => { + const changes = extractLockfileChanges([ + { + path: "yarn.lock", + patch: [ + "@@ -10,8 +10,8 @@", + " a@^1.0.0:", + "- version: 1.0.0", + "+ version: 1.0.1", + " metadata", + "- version: 2.0.0", + "+ version: 2.0.1", + ].join("\n"), + }, + ]); + + assert.deepEqual( + changes.map(({ package: name, from, to }) => ({ name, from, to })), + [{ name: "a", from: "1.0.0", to: "1.0.1" }], + ); +}); + +test("extractLockfileChanges: Yarn multi-descriptor stanzas preserve transitive packages", () => { + const changes = extractLockfileChanges([ + { + path: "package.json", + patch: '+ "direct": "1.0.0",', + }, + { + path: "yarn.lock", + patch: [ + "@@ -10,7 +10,7 @@", + " direct@^1.0.0, transitive@npm:1.0.0:", + "- version: 1.2.8", + "+ version: 0.0.8", + ].join("\n"), + }, + ]); + + assert.deepEqual( + changes.map(({ ecosystem, package: name, from, to }) => ({ + ecosystem, + name, + from, + to, + })), + [{ ecosystem: "npm", name: "transitive", from: "1.2.8", to: "0.0.8" }], + ); +}); + +test("queryOsvBatch: sends lockfile resolutions to OSV batch and maps indexed results", async () => { + const calls = []; + const cves = await queryOsvBatch( + [ + { + file: "package-lock.json", + line: 3, + ecosystem: "npm", + package: "minimist", + from: "1.2.8", + to: "0.0.8", + }, + ], + async (url, init) => { + calls.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + return { + ok: true, + json: async () => ({ + results: [ + { + vulns: [ + { + id: "GHSA-x", + summary: "Prototype pollution", + database_specific: { severity: "HIGH" }, + affected: [ + { + ranges: [ + { events: [{ introduced: "0" }, { fixed: "1.2.6" }] }, + ], + }, + ], + }, + ], + }, + ], + }), + }; + }, + ); + assert.equal(calls[0].url, "https://api.osv.dev/v1/querybatch"); + assert.deepEqual(calls[0].body.queries[0], { + package: { name: "minimist", ecosystem: "npm" }, + version: "0.0.8", + }); + assert.equal(cves.get("npm::minimist@0.0.8")[0].severity, "high"); + assert.equal(cves.get("npm::minimist@0.0.8")[0].fixedIn, "1.2.6"); +}); + +test("scanLockfileDrift: reports only vulnerable lockfile-only resolutions", async () => { + const findings = await scanLockfileDrift( + { + repoFullName: "o/r", + prNumber: 1, + files: [ + { + path: "package-lock.json", + patch: [ + "@@ -1,4 +1,4 @@", + ' "node_modules/minimist": {', + '- "version": "1.2.8",', + '+ "version": "0.0.8",', + ].join("\n"), + }, + ], + }, + async () => ({ + ok: true, + json: async () => ({ + results: [ + { + vulns: [ + { + id: "GHSA-lock", + database_specific: { severity: "CRITICAL" }, + }, + ], + }, + ], + }), + }), + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].direction, "change"); + assert.equal(findings[0].cves[0].severity, "critical"); +}); + test("renderBrief: sorts by severity, empty when no findings", () => { const empty = renderBrief({}); assert.equal(empty.promptSection, ""); @@ -194,6 +476,127 @@ test("renderBrief: sorts by severity, empty when no findings", () => { assert.match(rendered.systemSuffix, /verified ground truth/); }); +test("renderBrief: renders lockfile drift with sanitized location", () => { + const r = renderBrief({ + lockfileDrift: [ + { + file: "package-lock.json", + line: 12, + ecosystem: "npm", + package: "minimist", + from: "1.2.8", + to: "0.0.8", + direction: "change", + cves: [ + { + id: "GHSA-lock", + severity: "high", + summary: "Prototype pollution", + fixedIn: "1.2.6", + }, + ], + }, + ], + }); + assert.match(r.promptSection, /Vulnerable lockfile-only dependency drift/); + assert.match(r.promptSection, /`package-lock\.json:12` resolves transitive/); + assert.match(r.promptSection, /GHSA-lock/); +}); + +test("renderBrief: sanitizes dependency OSV text", () => { + const r = renderBrief({ + dependency: [ + { + ecosystem: "npm", + package: "minimist", + from: null, + to: "0.0.8", + direction: "add", + cves: [ + { + id: "GHSA-dep`\n### injected", + severity: "high", + summary: "Prototype\n### injected", + fixedIn: "1.2.6`\n### fixed", + }, + ], + }, + ], + }); + assert.doesNotMatch(r.promptSection, /\n### injected/); + assert.doesNotMatch(r.promptSection, /\n### fixed/); + assert.match(r.promptSection, /GHSA-depˋ␤### injected/); +}); + +test("renderBrief: sanitizes lockfile drift OSV text", () => { + const r = renderBrief({ + lockfileDrift: [ + { + file: "package-lock.json", + line: 12, + ecosystem: "npm", + package: "minimist", + from: "1.2.8`\n### forged", + to: "0.0.8", + direction: "change", + cves: [ + { + id: "GHSA-lock`\n### injected", + severity: "high", + summary: "Prototype\n### injected", + fixedIn: "1.2.6`\n### fixed", + }, + ], + }, + ], + }); + assert.doesNotMatch(r.promptSection, /\n### injected/); + assert.doesNotMatch(r.promptSection, /\n### fixed/); + assert.match(r.promptSection, /GHSA-lockˋ␤### injected/); +}); + +test("buildBrief: lockfile-drift analyzer runs and renders OSV findings", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ + results: [ + { + vulns: [ + { + id: "GHSA-lock", + database_specific: { severity: "HIGH" }, + }, + ], + }, + ], + }), + }); + try { + const brief = await buildBrief({ + repoFullName: "o/r", + prNumber: 10, + analyzers: ["lockfileDrift"], + files: [ + { + path: "package-lock.json", + patch: [ + "@@ -1,4 +1,4 @@", + ' "node_modules/minimist": {', + '- "version": "1.2.8",', + '+ "version": "0.0.8",', + ].join("\n"), + }, + ], + }); + assert.equal(brief.analyzerStatus.lockfileDrift, "ok"); + assert.equal(brief.findings.lockfileDrift.length, 1); + assert.match(brief.promptSection, /Vulnerable lockfile-only dependency drift/); + } finally { + globalThis.fetch = realFetch; + } +}); + test("buildBrief: runs dependency analyzer, marks others skipped, partial=false on success", async () => { const realFetch = globalThis.fetch; globalThis.fetch = okFetch([ @@ -2131,6 +2534,7 @@ test("buildBrief: secret-log analyzer runs (pure, no network)", async () => { const brief = await buildBrief({ repoFullName: "o/r", prNumber: 1, + analyzers: ["secretLog"], files: [ { path: "src/a.ts",