#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Parse `pup cicd pipelines list` output for a docker-parent-images build into structured vulnerability findings. Assumes the input file is already scoped to a single build — i.e. it came from a `pup` pull filtered with `--query='@ci.pipeline.id:"..."'` (see references/finding-build-failures.md step 2). This script does not re-derive or verify the build number; it just parses whatever job/stage events are in the file. Usage: pup cicd pipelines list --pipeline-name="theorchard/docker-parent-images" \\ --query='@ci.pipeline.id:"jenkins-theorchard-docker-parent-images-master-190"' \\ --from="30d" --limit=1000 > build.json uv run scripts/parse_build_findings.py build.json The stdout output is deliberately a LEAN summary, not a full dump — per-image detail for images with nothing urgent (no blocking findings, no near-expiry warnings) is not worth putting in context, so it's written instead to a side file (default: .images.json, override with --images-out). Only open that file if you need to double check a specific image that isn't already covered by blocking_findings/near_expiry_warnings below; don't read the whole thing by default, and don't read the raw pup JSON input file directly either — everything you need for triage is already in this summary. Output (JSON to stdout): { "total_events_in_file": 784, "warnings": ["..."], # anomalies worth surfacing to the user "image_counts": {"total": 25, "blocking": 4, "near_expiry": 0, "comfortable": 21}, "blocking_findings": [ ... flattened, one entry per (image, cve) ... ], "near_expiry_warnings": [ ... non-blocking findings with grace_period_days below --grace-threshold ... ], "images_detail_file": "build.images.json" # full per-image findings, including comfortable ones } """ import argparse import json import sys import re TAG_IN_MESSAGE_RE = re.compile(r"for docker-parent-images:(\S+?)\.\s") IMAGE_TAG_IN_SCRIPT_RE = re.compile(r"IMAGE_TAG=(\S+)") def parse_table(message): """Extract rows from the ASCII pipe-table embedded in a scan error message.""" rows = [] for line in message.split("\n"): line = line.strip() if not line.startswith("|"): continue if "Vulnerability ID" in line or set(line) <= set("|-+ "): continue cols = [c.strip() for c in line.strip("|").split("|")] if len(cols) < 6: continue cve, installed, fixed, path, severity, blocking = (cols + [""] * 6)[:6] grace_raw = cols[6].strip() if len(cols) > 6 else "" grace_period_days = int(grace_raw) if grace_raw.isdigit() else None rows.append({ "cve": cve, "installed": installed, "fixed": fixed if fixed and fixed != "N/A" else None, "path": path, "severity": severity, "blocking": blocking.strip().lower() == "yes", "grace_period_days": grace_period_days, }) return rows def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("input_file", help="JSON file from `pup cicd pipelines list ...` (or '-' for stdin)") parser.add_argument("--grace-threshold", type=int, default=7, help="flag non-blocking findings with grace_period_days below this as near-expiry (default: 7)") parser.add_argument("--images-out", default=None, help="path to write the full per-image findings JSON (default: .images.json, " "or ./build.images.json when reading from stdin)") args = parser.parse_args() raw = sys.stdin.read() if args.input_file == "-" else open(args.input_file).read() doc = json.loads(raw) events = doc.get("data", doc) if isinstance(doc, dict) else doc if isinstance(events, dict): events = events.get("data", []) warnings = [] images = {} seen_job_ids = set() for item in events: attrs = item.get("attributes", {}).get("attributes", {}) ci = attrs.get("ci", {}) job = ci.get("job") if not job: continue if job.get("result") != "error": continue job_id = job.get("id") if job_id in seen_job_ids: continue seen_job_ids.add(job_id) error = attrs.get("error", {}) message = error.get("message", "") tag_match = TAG_IN_MESSAGE_RE.search(message + " ") tag = tag_match.group(1) if tag_match else None if not tag: script_match = IMAGE_TAG_IN_SCRIPT_RE.search(job.get("script", "")) tag = script_match.group(1) if script_match else None if not tag: warnings.append( f"Job {job_id} has an error result but no image tag could be extracted " f"from its message or script — inspect it manually: {job.get('url')}" ) tag = f"UNKNOWN-{job_id}" findings = parse_table(message) outcome = "blocking_failure" if any(f["blocking"] for f in findings) or "failed due to vulnerabilities" in message else "non_blocking_warning" images[tag] = { "job_id": job_id, "job_url": job.get("url"), "outcome": outcome, "findings": findings, } if not images: warnings.append( "No error-result job events found in the input file. Check that the pup pull actually " "matched the build (a --query filter that matched nothing returns an empty data list) " "and that --limit was high enough to reach every parallel branch's job events." ) blocking_findings = [] near_expiry_warnings = [] for tag, info in images.items(): for f in info["findings"]: if f["blocking"]: blocking_findings.append({"image_tag": tag, **f}) elif f["grace_period_days"] is not None and f["grace_period_days"] < args.grace_threshold: near_expiry_warnings.append({"image_tag": tag, **f}) if len(images) > 40: warnings.append( f"{len(images)} distinct image tags reported findings — that's a lot. Confirm this " f"matches the number of parent-image variants in the docker-parent-images repo; a higher " f"count than expected usually means the input file wasn't actually scoped to one build." ) images_out = args.images_out if images_out is None: images_out = "build.images.json" if args.input_file == "-" else f"{args.input_file}.images.json" with open(images_out, "w") as f: json.dump(images, f, indent=2) blocking_tags = {f["image_tag"] for f in blocking_findings} near_expiry_tags = {f["image_tag"] for f in near_expiry_warnings} comfortable_count = len(images) - len(blocking_tags | near_expiry_tags) print(json.dumps({ "total_events_in_file": len(events), "warnings": warnings, "image_counts": { "total": len(images), "blocking": len(blocking_tags), "near_expiry": len(near_expiry_tags - blocking_tags), "comfortable": comfortable_count, }, "blocking_findings": blocking_findings, "near_expiry_warnings": near_expiry_warnings, "images_detail_file": images_out, }, indent=2)) if __name__ == "__main__": main()