#!/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()