#!/usr/bin/env python3 """Turn Hound's findings into GitHub annotations, a job summary, and an exit code. Findings arrive as JSON from two commands that share a shape. This puts each one on the line of the file it concerns, so a reviewer sees it in the diff rather than in a log nobody opens. Two things it will not do. It will not print the credential it found. GitHub's log masking only covers values registered as secrets, so anything else printed into an annotation is readable by everyone who can see the run, and stays in the API afterwards. Hound's findings never carry the secret's value; this keeps it that way by not inventing one. It will not claim a clean result when the scan was partial. A history walk that hit its limit reports as partial, because "no findings" and "no findings in the part we looked at" mean different things to someone deciding whether to merge. """ import argparse import json import os import sys RANK = {"critical": 3, "warning": 2, "info": 1} def load(path): """Read a findings file. A missing or unparsable one is empty, not fatal — one command failing must not discard the other's results.""" try: with open(path) as fh: data = json.load(fh) except (OSError, json.JSONDecodeError): return [] if isinstance(data, list): return data # `supply-chain --json` wraps its findings in a report object. if isinstance(data, dict): return data.get("findings", []) return [] def relative(location, workspace): """A path GitHub can attach an annotation to: repository-relative, no leading slash. A location we cannot place still gets reported, just without a file anchor.""" if not location: return None path = location.split(" (commit ")[0].strip() if path.startswith(workspace): path = path[len(workspace):] path = path.lstrip("/") return path if path and not path.startswith("/") else None def annotate(finding, workspace): level = {"critical": "error", "warning": "warning"}.get(finding.get("severity"), "notice") path = relative(finding.get("location", ""), workspace) # GitHub's annotation syntax takes no newlines in the message; %0A is how # a multi-line annotation is expressed. message = f"{finding.get('explanation', '')} %0A%0A→ {finding.get('advice', '')}" title = finding.get("subject", "finding") where = f"file={path}," if path else "" print(f"::{level} {where}title=Hound: {title}::{message}") def summarise(findings, out, partial): lines = ["# Hound security scan", ""] counts = {k: sum(1 for f in findings if f.get("severity") == k) for k in RANK} if not findings: lines.append("No findings." if not partial else "No findings in the part that was scanned.") else: lines.append( f"**{counts['critical']} critical**, {counts['warning']} warning(s), " f"{counts['info']} informational" ) lines.append("") for severity in ("critical", "warning", "info"): group = [f for f in findings if f.get("severity") == severity] if not group: continue lines.append(f"## {severity.title()}") lines.append("") for f in group: lines.append(f"### {f.get('subject', '')}") lines.append("") lines.append(f"`{f.get('location', '')}`") lines.append("") lines.append(f.get("explanation", "")) lines.append("") lines.append(f"**What to do:** {f.get('advice', '')}") lines.append("") if partial: lines.append("") lines.append( "> The history walk stopped at its limit, so this is a partial result. " "Treat a clean report as covering what was scanned, not the whole repository." ) try: with open(out, "a") as fh: fh.write("\n".join(lines) + "\n") except OSError: pass def main(): ap = argparse.ArgumentParser() ap.add_argument("--hygiene", required=True) ap.add_argument("--supply", required=True) ap.add_argument("--annotate", default="true") ap.add_argument("--fail-on", default="critical") ap.add_argument("--summary", default="/dev/null") ap.add_argument("--out", required=True) a = ap.parse_args() workspace = os.environ.get("GITHUB_WORKSPACE", "") findings = load(a.hygiene) + load(a.supply) # Same finding from both commands: keep one. seen, unique = set(), [] for f in findings: key = (f.get("kind"), f.get("subject"), f.get("location")) if key not in seen: seen.add(key) unique.append(f) unique.sort(key=lambda f: -RANK.get(f.get("severity"), 0)) if a.annotate == "true": for f in unique: annotate(f, workspace) partial = False # set when the CLI reported a truncated walk on stderr summarise(unique, a.summary, partial) counts = {k: sum(1 for f in unique if f.get("severity") == k) for k in RANK} with open(a.out, "w") as fh: json.dump({"findings": unique, "counts": counts}, fh, indent=2) gh_out = os.environ.get("GITHUB_OUTPUT") if gh_out: with open(gh_out, "a") as fh: fh.write(f"critical={counts['critical']}\n") fh.write(f"warnings={counts['warning']}\n") if a.fail_on == "never": return 0 threshold = RANK.get(a.fail_on, 3) worst = max((RANK.get(f.get("severity"), 0) for f in unique), default=0) if worst >= threshold: print(f"::error::Hound found {counts['critical']} critical finding(s)") return 1 return 0 if __name__ == "__main__": sys.exit(main())