add /cve-check and /cve-check-and-upgrade
/cve-check answers 'what are we exposed to that an upgrade would fix?' without running an upgrade: per-recipe, resolve the available window for EVERY image (sidecars included), run the advisory scan over it, adjudicate whatever pass 1 could not decide, publish a report. Read-only — no PRs, no CI, no merges. /cve-check-and-upgrade does that sweep, then runs /recipe-upgrade only on the recipes whose upgrade actually closes a CVE, worst severity first, and reports on both. --min-severity high for just the urgent ones; --dry-run prints the queue and stops. Never merges. Deliberate choices, each written into the skills: - externals are SWEPT but never upgraded here — a security sweep that skipped deployed software would misreport exposure, but we don't maintain them. - an unknown count never justifies an upgrade AND is never treated as clean; it goes to the Addendum. - no upgrade available means 0 CVEs, not '?'. - subagents are told which CVEs justify their upgrade, so the PR says why it exists — a PR naming the RCE it closes gets reviewed sooner. recipe-report.py grows a page kind: 'cve' files as cve-DATE.html so a sweep can't overwrite a weekly edition, while BOTH appear in the same archive index, suffixed 'full report' / 'CVE check'. /help and /cc-ci-status updated to route to them.
This commit is contained in:
+45
-14
@@ -9,7 +9,11 @@ Subcommands (the /recipe-report agent runs them around its own review/classifica
|
||||
survey [DATE] JSON of the run + every recipe's open PRs + CI verdict + per-recipe upgrade
|
||||
notes (breaking-change/CVE analysis), and the /upgrade-all summary.
|
||||
render SPEC.json OUT.html render the agent's report spec -> a self-contained newspaper HTML page
|
||||
publish OUT.html DATE copy to cc-ci:/var/lib/cc-ci-reports/week-DATE.html and regen the archive index
|
||||
publish OUT.html DATE [KIND] copy to cc-ci:/var/lib/cc-ci-reports/<KIND>-DATE.html and regen the
|
||||
archive index. KIND is `week` (default, the weekly /recipe-report) or `cve`
|
||||
(a /cve-check advisory sweep). BOTH kinds appear in the SAME archive index,
|
||||
newest first, each row suffixed "full report" or "CVE check"; the distinct
|
||||
filename prefix just stops a sweep overwriting a weekly edition.
|
||||
|
||||
Page order: short lead → the full wire table (priority-sorted, CVEs column) → Addendum → Security
|
||||
Bulletin → per-recipe "What changed".
|
||||
@@ -44,6 +48,10 @@ LOGDIR = "/srv/cc-ci/.cc-ci-logs"
|
||||
TESTENV = "/srv/cc-ci/.testenv"
|
||||
INFRA = {"cc-ci", "cc-ci-orchestrator", "cc-ci-secrets"}
|
||||
HOST_REPORTS = "/var/lib/cc-ci-reports"
|
||||
# Both kinds live in ONE archive, distinguished by a suffix on a common title.
|
||||
# prefix -> (page title, index label)
|
||||
KINDS = {"week": ("The Recipe Report", "Week of {d} — full report"),
|
||||
"cve": ("The Recipe Report — CVE check", "{d} — CVE check")}
|
||||
|
||||
|
||||
def _env():
|
||||
@@ -215,8 +223,16 @@ def _table(rows, repo_url=None):
|
||||
if repo_url and r.get("recipe") in repo_url:
|
||||
name = f'<a href="{repo_url[r["recipe"]]}">{name}</a>'
|
||||
cve = r.get("cve")
|
||||
cve_cell = (f'<span class="cve">{int(cve)}</span>' if isinstance(cve, (int, float)) and cve
|
||||
else '<span class="muted">none</span>')
|
||||
# "?" = advisory scan absent or had failed sources → count NOT authoritative. Per the
|
||||
# /recipe-report guardrail this must NEVER render as "none" (a blank-that-reads-clean is
|
||||
# exactly how two CVSS-9.8 gitea RCEs were misreported as "none" on 2026-08-07). A positive
|
||||
# int is the confirmed CVE count; 0/omit is a confirmed-clean scan.
|
||||
if isinstance(cve, str) and cve.strip() == "?":
|
||||
cve_cell = '<span class="muted" title="advisory scan incomplete or absent — CVE count unknown">?</span>'
|
||||
elif isinstance(cve, (int, float)) and cve:
|
||||
cve_cell = f'<span class="cve">{int(cve)}</span>'
|
||||
else:
|
||||
cve_cell = '<span class="muted">none</span>'
|
||||
ci = _esc(r.get("ci"))
|
||||
if r.get("ci_url"):
|
||||
ci = f'<a href="{_esc(r["ci_url"])}">{ci}</a>'
|
||||
@@ -270,8 +286,10 @@ def _mast():
|
||||
|
||||
def render(spec_path, out_path):
|
||||
s = json.load(open(spec_path))
|
||||
kind = s.get("kind", "week")
|
||||
title = KINDS.get(kind, KINDS["week"])[0]
|
||||
gen = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
sub = s.get("subtitle", "Week of " + s["date"])
|
||||
sub = s.get("subtitle", ("Week of " if kind == "week" else "CVE check ") + s["date"])
|
||||
lead = s.get("lead", "") or ""
|
||||
# Auto-link recipe-name mentions in the lead to their mirror repos.
|
||||
gitea = _env().get("GITEA_URL", "git.autonomic.zone")
|
||||
@@ -285,7 +303,9 @@ def render(spec_path, out_path):
|
||||
f'<span>report.ci.commoninternet.net</span><span>{gen}</span></div>'
|
||||
f'<div class="lead">{lead}</div>')
|
||||
# 1) the full wire — every recipe, in the agent's recommended priority order (CVEs first); CVEs column.
|
||||
body += f'<h2>The full wire — every recipe, in priority order</h2>{_table(s.get("table"), repo_url)}'
|
||||
wire = ("The full wire — every recipe, in priority order" if kind == "week"
|
||||
else "Advisory sweep — every recipe, worst first")
|
||||
body += f'<h2>{wire}</h2>{_table(s.get("table"), repo_url)}'
|
||||
# 2) addendum — special issues to look into (normal-size header); omitted entirely if there are none.
|
||||
add = [a for a in (s.get("addendum") or []) if str(a).strip()]
|
||||
if add:
|
||||
@@ -298,19 +318,30 @@ def render(spec_path, out_path):
|
||||
# 4) what changed — a short section per recipe that has a PR
|
||||
if s.get("changes"):
|
||||
body += f'<h2>What changed</h2>{_changes(s.get("changes"), repo_url)}'
|
||||
body += (f'<footer>The Recipe Report · generated {gen} · '
|
||||
body += (f'<footer>{title} · generated {gen} · '
|
||||
f'<a href="https://ci.commoninternet.net/">dashboard</a> · <a href="./">archive</a></footer>')
|
||||
open(out_path, "w").write(_page("The Recipe Report — " + s["date"], body))
|
||||
open(out_path, "w").write(_page(f"{title} · " + s["date"], body))
|
||||
print("wrote", out_path)
|
||||
|
||||
|
||||
def publish(html_path, date):
|
||||
page = f"week-{date}.html"
|
||||
def publish(html_path, date, kind="week"):
|
||||
if kind not in KINDS:
|
||||
print(f"unknown kind {kind!r}; expected one of {', '.join(KINDS)}"); sys.exit(2)
|
||||
page = f"{kind}-{date}.html"
|
||||
subprocess.run(["ssh", "cc-ci", f"cat > {HOST_REPORTS}/{page}"], input=open(html_path, "rb").read(), check=True)
|
||||
listing = subprocess.run(["ssh", "cc-ci", f"ls -1 {HOST_REPORTS}/week-*.html 2>/dev/null"],
|
||||
capture_output=True, text=True).stdout.split()
|
||||
dates = sorted({os.path.basename(p)[5:-5] for p in listing}, reverse=True)
|
||||
lis = "\n".join(f'<li><a href="week-{d}.html">Week of {d}</a><span class="d">{d}</span></li>' for d in dates)
|
||||
# One index over BOTH families, newest first, each row labelled by its kind — an operator looking
|
||||
# for "the latest security picture" should not have to know which skill produced which page.
|
||||
entries = []
|
||||
for k in KINDS:
|
||||
listing = subprocess.run(["ssh", "cc-ci", f"ls -1 {HOST_REPORTS}/{k}-*.html 2>/dev/null"],
|
||||
capture_output=True, text=True).stdout.split()
|
||||
for pth in listing:
|
||||
d = os.path.basename(pth)[len(k) + 1:-5]
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d):
|
||||
entries.append((d, k))
|
||||
lis = "\n".join(
|
||||
f'<li><a href="{k}-{d}.html">{KINDS[k][1].format(d=d)}</a><span class="d">{d}</span></li>'
|
||||
for d, k in sorted(set(entries), reverse=True))
|
||||
idx = _page("The Recipe Report — Archive", _mast() +
|
||||
'<div class="dateline"><span>Weekly review of Co-op Cloud recipe upgrades & CI</span>'
|
||||
'<span>report.ci.commoninternet.net</span></div>'
|
||||
@@ -328,7 +359,7 @@ def main():
|
||||
elif cmd == "render":
|
||||
render(a[1], a[2])
|
||||
elif cmd == "publish":
|
||||
publish(a[1], a[2])
|
||||
publish(a[1], a[2], a[3] if len(a) > 3 else "week")
|
||||
else:
|
||||
print(__doc__); sys.exit(2)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user