Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/lib/ci-failure-ingestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
extractFailureWorkflow,
groupDefaultBranchRuns,
hasOpenIssueForSignature,
isScanFailure,
parseScanFindings,
type CiRun,
type FiledIssue,
} from "./ci-failure-ingestion";
Expand Down Expand Up @@ -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" }));
Expand Down
138 changes: 136 additions & 2 deletions src/lib/ci-failure-ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, keyof ScanFinding> = {
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;
Expand All @@ -325,19 +434,44 @@ 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)}\`)`,
`- Previous: [${previous.html_url}](${previous.html_url}) (\`${previous.head_sha.slice(0, 8)}\`)`,
`- 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(
"",
Expand Down
Loading