diff --git a/review-enrichment/src/analyzers/eol-check.ts b/review-enrichment/src/analyzers/eol-check.ts index 0968bb005..d6ed3af9c 100644 --- a/review-enrichment/src/analyzers/eol-check.ts +++ b/review-enrichment/src/analyzers/eol-check.ts @@ -21,6 +21,10 @@ interface VersionPin { version: string; } +const MAX_EOL_FILES = 40; +const MAX_EOL_PATCH_LINES = 1_000; +const MAX_EOL_PINS = 80; + // Leading numeric version from a tag/value: "3.8-slim" → "3.8", "18" → "18", "latest" → null. function leadingVersion(value: string): string | null { return /^v?(\d+(?:\.\d+)*)/.exec(value.trim())?.[1] ?? null; @@ -36,10 +40,17 @@ export function extractVersionPins( files: NonNullable, ): VersionPin[] { const pins: VersionPin[] = []; + let filesScanned = 0; + let linesScanned = 0; for (const file of files) { if (!file.patch) continue; + if (filesScanned >= MAX_EOL_FILES) break; + filesScanned += 1; const base = file.path.split("/").pop() ?? file.path; for (const raw of file.patch.split("\n")) { + if (linesScanned >= MAX_EOL_PATCH_LINES || pins.length >= MAX_EOL_PINS) + return pins; + linesScanned += 1; if (raw[0] !== "+" || raw.startsWith("+++")) continue; const line = raw.slice(1).trim(); if (isDockerfile(file.path)) { @@ -115,11 +126,14 @@ export async function scanEol( ): Promise { const findings: EolFinding[] = []; const seen = new Set(); + const cyclesByProduct = new Map(); for (const pin of extractVersionPins(req.files ?? [])) { const key = `${pin.product}:${pin.version}`; if (seen.has(key)) continue; seen.add(key); - const cycles = await fetchCycles(pin.product, fetchImpl); + if (!cyclesByProduct.has(pin.product)) + cyclesByProduct.set(pin.product, await fetchCycles(pin.product, fetchImpl)); + const cycles = cyclesByProduct.get(pin.product); if (!cycles) continue; const cycle = matchCycle(cycles, pin.version); if (!cycle) continue; diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts index 763bd0aef..61405430e 100644 --- a/review-enrichment/test/enrichment.test.ts +++ b/review-enrichment/test/enrichment.test.ts @@ -499,6 +499,66 @@ test("extractVersionPins: Dockerfile FROM + .nvmrc + go.mod; latest skipped", () ); // node:latest skipped }); +test("extractVersionPins: caps attacker-controlled EOL scan input", () => { + const pins = extractVersionPins([ + { + path: "Dockerfile", + patch: + "@@ -1,0 +1,100 @@\n" + + Array.from( + { length: 100 }, + (_, index) => `+FROM node:18.0.${index}`, + ).join("\n"), + }, + ]); + + assert.equal(pins.length, 80); + assert.equal(pins[0].version, "18.0.0"); + assert.equal(pins.at(-1).version, "18.0.79"); +}); + +test("scanEol: caches endoflife.date cycles per product", async () => { + const requested: string[] = []; + const findings = await scanEol( + { + repoFullName: "o/r", + prNumber: 1, + files: [ + { + path: "Dockerfile", + patch: [ + "@@ -1,0 +1,4 @@", + "+FROM node:18.0.0", + "+FROM node:18.0.1", + "+FROM python:3.8", + "+FROM node:20.0.0", + ].join("\n"), + }, + ], + }, + async (url) => { + requested.push(String(url)); + return { + ok: true, + json: async () => + String(url).includes("python") + ? [{ cycle: "3.8", eol: "2024-10-07" }] + : [ + { cycle: "18", eol: "2023-06-01" }, + { cycle: "20", eol: "2026-07-01" }, + ], + }; + }, + NOW, + ); + + assert.deepEqual(requested, [ + "https://endoflife.date/api/nodejs.json", + "https://endoflife.date/api/python.json", + ]); + assert.equal(findings.length, 4); +}); + test("scanEol: flags EOL + EOL-soon, skips current + fetch-fail (injected now)", async () => { const cycles = [ { cycle: "18", eol: "2023-06-01" },