**The action.** `hound hygiene` and `hound supply-chain` only ever saw repositories somebody had already cloned onto a machine with Hound installed. action/ runs them on every push and pull request: it installs the published .deb, verifies it against the same signed checksum the desktop agent uses, and annotates findings on the lines of the files they concern so a reviewer sees them in the diff rather than in a log nobody opens. report.py will not print a credential it found — GitHub masks only values registered as secrets, so anything else in an annotation is readable by everyone who can see the run and stays in the API afterwards. And it will not report a partial scan as clean: a history walk that hits its limit says so, because "no findings" and "no findings in the part we looked at" mean different things to somebody deciding whether to merge. **The background.** A radial gradient was set on `html, body` — both, each 100% tall — so it painted twice and the seam between the two layers appeared as a band across the middle of the page when scrolled. It was also a hardcoded near-black the light theme had no way to override. Three more like it: the active tab, button hover, and the log panel. The ground is a token now, and every colour in the stylesheet comes from one, so no rule can put one theme's text on the other's background. **The palette.** The light theme now uses houndav.com's values exactly — #5A58C8 buttons, #147A3D, #9A6100, #C22222 — so the app and the site are recognisably the same product rather than two guesses at it. Then measured rather than assumed, and found two failures Joe had not mentioned: "faint" text was 2.90:1 in dark and 3.37:1 in light, and the dark button hover was 4.41:1. All three now clear 4.5:1, and all eight text pairs pass WCAG AA in both themes. **And four more places still naming ClamAV**, which has not been the engine for a long time: the auto-update caption said the daemon runs freshclam, the update log said the same, an error suggested `apt install clamav`, and a permissions hint pointed at /var/lib/clamav. The engine swap replaced the code and left the copy describing software this product no longer runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.6 KiB
Python
Executable file
157 lines
5.6 KiB
Python
Executable file
#!/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())
|