Antivirus/action/report.py
dev 0754cf75d0 0.1.8: a GitHub Action, and a light theme that is actually light
**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>
2026-08-21 13:40:35 -05:00

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())