diff --git a/src/lib/ci-failure-ingestion.test.ts b/src/lib/ci-failure-ingestion.test.ts index 90900ed7..8a7541e1 100644 --- a/src/lib/ci-failure-ingestion.test.ts +++ b/src/lib/ci-failure-ingestion.test.ts @@ -10,6 +10,8 @@ import { extractFailureWorkflow, groupDefaultBranchRuns, hasOpenIssueForSignature, + isScanFailure, + parseScanFindings, type CiRun, type FiledIssue, } from "./ci-failure-ingestion"; @@ -449,6 +451,146 @@ describe("buildIssueDraft", () => { }); }); +describe("parseScanFindings", () => { + // A real grype table as it appears in a job log (grype table output, + // --fail-on high gate): the header row, a separator row, and data rows. + const grypeLog = [ + " [command]/usr/bin/grype image ghcr.io/o/elixir-gate:latest --fail-on high", + " [grype] ", + " [grype] NAME VERSION FIX VERSION VULNERABILITY SEVERITY LOCATION", + " [grype] ─────────────── ─────────────────── ─────────── ───────────── ──────── ────────", + " [grype] pebble 0.10.0 0.10.1 CVE-2026-1234 High /usr/bin/pebble", + " [grype] node 20.11.0 20.12.0 CVE-2026-5678 High /usr/lib/node_modules/npm/node_modules/semver", + " [grype] openssl 3.0.13-1~deb12u1 3.0.14-1~deb12u2 CVE-2026-9012 High /usr/lib/x86_64-linux-gnu/libssl.so.3", + " [grype] ", + " [grype] 3 vulnerabilities found", + " [grype] ", + " [command]exit code: 1", + " Error: Process completed with exit code 1.", + ].join("\n"); + + it("parses a real grype table into structured findings", () => { + const findings = parseScanFindings(grypeLog); + expect(findings).toHaveLength(3); + expect(findings[0]).toEqual({ + package: "pebble", + installed: "0.10.0", + fixedIn: "0.10.1", + severity: "High", + location: "/usr/bin/pebble", + }); + expect(findings[1].location).toBe( + "/usr/lib/node_modules/npm/node_modules/semver", + ); + expect(findings[2]).toMatchObject({ + package: "openssl", + fixedIn: "3.0.14-1~deb12u2", + severity: "High", + }); + }); + + it("returns [] for a log with no findings table", () => { + expect(parseScanFindings("error: invalid bake override key *.provenance=false")).toEqual([]); + expect(parseScanFindings("")).toEqual([]); + }); + + it("handles a trivy-style table with a Target column", () => { + const trivyLog = [ + "NAME VERSION FIXED VERSION VULNERABILITY SEVERITY TARGET", + "──────────── ───────── ───────────── ───────────── ──────── ──────", + "golang.org/x/net v0.17.0 v0.23.0 CVE-2024-45338 High /usr/local/bin/pebble", + ].join("\n"); + const findings = parseScanFindings(trivyLog); + expect(findings).toEqual([ + { + package: "golang.org/x/net", + installed: "v0.17.0", + fixedIn: "v0.23.0", + severity: "High", + location: "/usr/local/bin/pebble", + }, + ]); + }); +}); + +describe("isScanFailure", () => { + it("is true for a scan-named workflow or job", () => { + expect(isScanFailure("Vulnerability Scan", "Scan", "")).toBe(true); + expect(isScanFailure("Release", "trivy image scan", "")).toBe(true); + }); + + it("is true when the log carries a findings table even if unnamed", () => { + const log = + "NAME VERSION FIX VERSION SEVERITY\npebble 0.10.0 0.10.1 High"; + expect(isScanFailure("Release", "Build", log)).toBe(true); + }); + + it("is false for a non-scan failure", () => { + expect(isScanFailure("Release", "Build", "error: invalid bake override key")).toBe(false); + }); +}); + +describe("buildIssueDraft scan enrichment (#994)", () => { + const scanOpts = { + repoFullName: "o/r", + workflowName: "Vulnerability Scan", + jobName: "Scan (elixir-gate)", + signature: "sig1", + latest: run({ id: 3, html_url: "https://example.test/3" }), + previous: run({ id: 2, html_url: "https://example.test/2" }), + logExcerpt: [ + "NAME VERSION FIX VERSION VULNERABILITY SEVERITY LOCATION", + "────────────── ─────────────────── ─────────── ───────────── ──────── ────────", + "pebble 0.10.0 0.10.1 CVE-2026-1234 High /usr/bin/pebble", + "node 20.11.0 20.12.0 CVE-2026-5678 High /usr/lib/node_modules/npm/node_modules/semver", + "", + "3 vulnerabilities found", + "Error: Process completed with exit code 1.", + ].join("\n"), + supersedes: null, + }; + + it("renders a findings table above the raw excerpt", () => { + const d = buildIssueDraft(scanOpts); + expect(d.body).toContain("**Scan findings (2):**"); + expect(d.body).toContain( + "| pebble | 0.10.0 | 0.10.1 | High | /usr/bin/pebble |", + ); + expect(d.body).toContain( + "| node | 20.11.0 | 20.12.0 | High | /usr/lib/node_modules/npm/node_modules/semver |", + ); + // The findings table comes before the raw excerpt. + expect(d.body.indexOf("**Scan findings")).toBeLessThan(d.body.indexOf("```")); + // The raw excerpt is still embedded. + expect(d.body).toContain("3 vulnerabilities found"); + expect(extractFailureMarker(d.body)).toBe("sig1"); + }); + + it("leaves a non-scan failure unchanged (raw excerpt only)", () => { + const d = buildIssueDraft({ + ...scanOpts, + workflowName: "Release", + jobName: "Build", + logExcerpt: "error: invalid bake override key *.provenance=false", + }); + expect(d.body).not.toContain("Scan findings"); + expect(d.body).toContain("error: invalid bake override key"); + }); + + it("caps the rendered findings when a scan reports many", () => { + const rows = Array.from({ length: 80 }, (_, i) => + `pkg${i} 1.0.${i} 1.0.${i + 1} High /bin/pkg${i}`, + ).join("\n"); + const d = buildIssueDraft({ + ...scanOpts, + logExcerpt: `NAME VERSION FIX VERSION SEVERITY LOCATION\n${rows}`, + }); + expect(d.body).toContain("**Scan findings (80, showing first 50):**"); + expect(d.body).toContain("| pkg49 |"); + expect(d.body).not.toContain("| pkg50 |"); + }); +}); + describe("buildCloseComment", () => { it("names the run that cleared it", () => { const c = buildCloseComment(run({ conclusion: "success", html_url: "https://example.test/9" })); diff --git a/src/lib/ci-failure-ingestion.ts b/src/lib/ci-failure-ingestion.ts index b8f7f77e..3c1e0145 100644 --- a/src/lib/ci-failure-ingestion.ts +++ b/src/lib/ci-failure-ingestion.ts @@ -312,6 +312,115 @@ export interface IssueDraft { body: string; } +/** One row of a grype/trivy findings table, parsed out of a job log. */ +export interface ScanFinding { + package: string; + installed: string; + fixedIn: string; + severity: string; + /** The file/binary path the finding sits on, when the scanner reports one. */ + location?: string; +} + +/** Header names (lower-cased) → ScanFinding field, for grype and trivy tables. */ +const SCAN_COLUMN_ALIASES: Record = { + name: "package", + package: "package", + version: "installed", + installed: "installed", + "fix version": "fixedIn", + "fixed version": "fixedIn", + "fixed in": "fixedIn", + severity: "severity", + location: "location", + target: "location", + file: "location", + path: "location", +}; + +/** Split a space-aligned table row into cells (columns are separated by 2+ spaces). */ +function splitScanRow(line: string): string[] { + return line.trim().split(/\s{2,}/).map((c) => c.trim()); +} + +/** A GHA wrapper prefixes each scanner line with a bracketed tag ("[grype] ", + * "[trivy] "); strip it so the first column is the real column name. */ +function stripLinePrefix(line: string): string { + return line.replace(/^\s*\[[a-z]+\]\s*/i, ""); +} + +/** A table separator row: every cell is dashes/box-drawing only. */ +function isSeparatorRow(cells: string[]): boolean { + return cells.every((c) => /^[\-─=+|: ]*$/.test(c)); +} + +/** + * Parse a grype/trivy findings table out of a job log. + * + * Finds the header row (a row carrying a package/name column and a version + * column), maps each column to a field by its header name, and returns one + * finding per data row. Returns [] when the log has no such table, so a + * non-scan log yields nothing. + */ +export function parseScanFindings(log: string): ScanFinding[] { + const lines = (log || "").split("\n"); + let headerIdx = -1; + let fields: (keyof ScanFinding | null)[] = []; + for (let i = 0; i < lines.length; i++) { + const cells = splitScanRow(stripLinePrefix(lines[i])).map((c) => c.toLowerCase()); + if ( + cells.some((c) => c === "name" || c === "package") && + cells.some((c) => c === "version" || c === "installed") + ) { + headerIdx = i; + fields = cells.map((c) => SCAN_COLUMN_ALIASES[c] ?? null); + break; + } + } + if (headerIdx === -1) return []; + + const findings: ScanFinding[] = []; + for (let i = headerIdx + 1; i < lines.length; i++) { + const line = stripLinePrefix(lines[i]); + if (!line.trim()) break; // a blank line ends the table + const cells = splitScanRow(line); + if (cells.length < 2) break; // not a table row + if (isSeparatorRow(cells)) continue; // the ─── row under the header + const finding: ScanFinding = { + package: "", + installed: "", + fixedIn: "", + severity: "", + }; + for (let j = 0; j < fields.length && j < cells.length; j++) { + const field = fields[j]; + if (field) finding[field] = cells[j]; + } + // A bare number in the package column is a summary line ("3 vulnerabilities + // found"), not a finding. + if (finding.package && !/^\d+$/.test(finding.package)) findings.push(finding); + } + return findings; +} + +/** + * Is this failure a scan/vulnerability gate? + * + * A workflow or job named for scanning/vulnerabilities is one, and so is any + * failure whose log carries a grype/trivy findings table — the table is itself + * evidence of a scan gate, whatever the workflow is called. + */ +export function isScanFailure( + workflowName: string, + jobName: string, + logExcerpt: string, +): boolean { + if (/scan|vuln|trivy|grype|vulnerab|security/i.test(`${workflowName} ${jobName}`)) { + return true; + } + return parseScanFindings(logExcerpt).length > 0; +} + /** Render the issue an actionable failure produces. */ export function buildIssueDraft(opts: { repoFullName: string; @@ -325,7 +434,13 @@ export function buildIssueDraft(opts: { }): IssueDraft { const { workflowName, jobName, latest, previous, logExcerpt, supersedes } = opts; const excerpt = (logExcerpt || "").trim().slice(0, 4000) || "(no log excerpt available)"; - const lines = [ + // A scan/vuln failure gets its findings parsed out of the log and rendered + // above the raw excerpt, so the solver sees the fixable findings and where + // they live instead of reverse-engineering a wall of log (#994). + const findings = isScanFailure(workflowName, jobName, logExcerpt) + ? parseScanFindings(logExcerpt) + : []; + const lines: string[] = [ `\`${workflowName}\` has failed twice in a row on the default branch, for the same reason.`, "", `- Latest: [${latest.html_url}](${latest.html_url}) (\`${latest.head_sha.slice(0, 8)}\`)`, @@ -333,11 +448,30 @@ export function buildIssueDraft(opts: { `- Failing job: \`${jobName}\``, "", "A single red run is not filed — this one repeated, so it is a condition rather than a transient.", + ]; + if (findings.length > 0) { + // Cap the rendered rows: a scan can report hundreds of findings and the + // issue body has a hard size limit; the raw excerpt below still carries + // the rest. + const shown = findings.slice(0, 50); + lines.push( + "", + `**Scan findings (${findings.length}${findings.length > shown.length ? `, showing first ${shown.length}` : ""}):**`, + "", + "| Package | Installed | Fixed-in | Severity | Location |", + "| --- | --- | --- | --- | --- |", + ...shown.map( + (f) => + `| ${f.package} | ${f.installed} | ${f.fixedIn || "—"} | ${f.severity} | ${f.location ?? "—"} |`, + ), + ); + } + lines.push( "", "```", excerpt, "```", - ]; + ); if (supersedes !== null) { lines.push( "",