#!/usr/bin/env python3
"""Build site/measurements/sm-018.html from out/cve72_summary.json, out/cve72_rows.json and the exhibits folder.
Every number in the tables comes from those files; the prose numbers are checked against them at the end (asserts).
python3 make_page.py"""
import json, os, html, collections, statistics, csv
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "site", "measurements", "sm-018.html")
S = json.load(open(os.path.join(HERE, "out", "cve72_summary.json")))
ROWS = json.load(open(os.path.join(HERE, "out", "cve72_rows.json")))
C = S["counts"]
E = html.escape
def n(x): return f"{x:,}"
def pct(a, b): return f"{100*a/b:.1f}%"
own = [r for r in ROWS if r["own"]]
late = lambda r: r["lag"] > 3
FOUR = ["zdi", "dell", "drupal", "Arista"]
NAMES = {"zdi": "Zero Day Initiative (ZDI)", "dell": "Dell", "drupal": "Drupal", "Arista": "Arista", "VulnCheck": "VulnCheck", "redhat": "Red Hat",
"suse": "SUSE", "Patchstack": "Patchstack", "adobe": "Adobe", "freebsd": "FreeBSD", "XEN": "Xen Project", "GRAFANA": "Grafana", "microsoft": "Microsoft",
"Zoom": "Zoom", "tenable": "Tenable", "HCL": "HCL Software", "palo_alto": "Palo Alto Networks", "f5": "F5", "Deltaww": "Delta Electronics", "AMD": "AMD",
"juniper": "Juniper", "Tanium": "Tanium", "WatchGuard": "WatchGuard", "vmware": "VMware (Broadcom)", "sonicwall": "SonicWall"}
def q(r): return r["published"][:4] + "Q" + str((int(r["published"][5:7]) - 1) // 3 + 1)
QN = {"Q1": "Jan–Mar", "Q2": "Apr–Jun", "Q3": "Jul–Sep", "Q4": "Oct–Dec"}
def summ(rs):
L = [r for r in rs if late(r)]
return dict(scored=len(rs), w1=sum(1 for r in rs if r["lag"] <= 1), sd=sum(1 for r in rs if r["lag"] == 0), late=len(L), over30=sum(1 for r in L if r["lag"] > 30), over90=sum(1 for r in L if r["lag"] > 90),
medlate=statistics.median(r["lag"] for r in L) if L else None, days=len({r["published"] for r in rs}), maxlag=max(r["lag"] for r in rs) if rs else 0)
ROSTER = {e["shortName"]: e["organizationName"] for e in json.load(open(os.path.join(HERE, "exhibits", "roster", "CNAsList.json")))}
SHORT = {"INCIBE": "INCIBE (Spain)", "RTI": "Real-Time Innovations (RTI)", "GV": "GeoVision", "icscert": "CISA ICS-CERT", "Esri": "Esri", "CERT-PL": "CERT Polska", "Rockwell": "Rockwell Automation", "TR-CERT": "TR-CERT (Türkiye)", "runZero": "runZero", "EEF": "Erlang Ecosystem Foundation", "twcert": "TWCERT/CC", "isc": "ISC", "TYPO3": "TYPO3", "DSF": "Django Software Foundation", "Nozomi": "Nozomi Networks", "NETGEAR": "NETGEAR", "CSA": "Cyber Security Agency of Singapore", "INCD": "Israel National Cyber Directorate", "ESET": "ESET", "GEN": "Gen Digital", "Altium": "Altium"}
NAMES.update(SHORT)
def nm(c): return NAMES.get(c) or html.escape(ROSTER.get(c, c))
# ---- quarter table (own-site)
qt = []
for qq in sorted({q(r) for r in own}, reverse=True):
s = summ([r for r in own if q(r) == qq])
qt.append(f'
| {QN[qq[4:]]} {qq[:4]} | {n(s["scored"])} | {n(s["w1"])} | {n(s["late"])} | {pct(s["late"], s["scored"])} | {n(s["over30"])} |
')
# ---- four-CNA table
sp = {"zdi": ("35 + 10", "34 the same day, 1 a day apart; 2025: 8 the same day, 2 a day apart"), "Arista": ("35 + 10", "35 the same day; 2025: 9 the same day, 1 a month off"), "dell": ("35 + 10", "28 the same day in India time, 3 two days after; 3 give a later revision of the advisory; 1 a typo, dropped; 2025: 8 the same day, 2 give a later revision of an older advisory"), "drupal": ("30 + 10", "30 the same minute; 2025: 10 the same day")}
ft = []; FS = {}
for c in FOUR:
s = summ([r for r in own if r["cna"] == c]); FS[c] = s
ft.append(f'| {NAMES[c]} | {sp[c][0]} | {sp[c][1]} | {n(S["published_by_cna"][c])} ({n(S["with_datePublic_by_cna"][c])}) | {n(s["scored"])} | {n(s["w1"])} | {n(s["late"])} | {pct(s["late"], s["scored"])} | {n(s["over30"])} | {s["medlate"]:.1f} | {s["days"]} |
')
F4 = summ([r for r in own if r["cna"] in FOUR])
# ---- all CNAs with >= 30 own-site records
ct = []; CS = {}
by = collections.defaultdict(list)
for r in own: by[r["cna"]].append(r)
for c, rs in sorted(by.items(), key=lambda kv: (-sum(1 for r in kv[1] if late(r)), nm(kv[0]).lower())):
if len(rs) < 30: continue
s = summ(rs); CS[c] = s
ct.append(f'| {nm(c)} | {n(s["scored"])} | {n(s["w1"])} | {n(s["late"])} | {pct(s["late"], s["scored"])} | {n(s["over30"])} | {"–" if s["medlate"] is None else "%.1f" % s["medlate"]} | {s["days"]} | {len(rs)/s["days"]:.1f} |
')
# ---- longest waits
titles = {}
for f in os.listdir(os.path.join(HERE, "exhibits", "records")):
d = json.load(open(os.path.join(HERE, "exhibits", "records", f))); cna = d["containers"]["cna"]
titles[d["cveMetadata"]["cveId"]] = (cna.get("title") or cna.get("descriptions", [{}])[0].get("value", "")).strip()
wt = []
worst = sorted(own, key=lambda r: (-r["lag"], r["id"]))[:15]
for r in worst:
t = titles.get(r["id"], ""); t = t if len(t) <= 90 else t[:87].rstrip() + "…"
wt.append(f'| {nm(r["cna"])} | {r["id"]} | {E(t) if "…" not in t else E(t[:-8]) + "…"} | {r["public"]} | {r["published"]} | {r["lag"]} |
')
# ---- VulnCheck (outside the own-site set)
VCR = [r for r in ROWS if r["cna"] == "VulnCheck"]; VC = summ(VCR)
assert not any(r["own"] for r in VCR)
# ---- prose numbers
withpub = C["published"] - C["no_datePublic"]
O = summ(own); A = summ(ROWS)
own2025 = summ([r for r in own if r["published"].startswith("2025")]); own2026 = summ([r for r in own if r["published"].startswith("2026")])
ncna = len({r["cna"] for r in ROWS}); nown = len(by)
zdays = collections.Counter(r["published"] for r in by["zdi"]).most_common(1)[0]
ddays = collections.Counter(r["published"] for r in by["drupal"]).most_common(1)[0]
byq = json.load(open(os.path.join(HERE, "out", "cve72_summary.json")))["by_cna_own"]
V = {
"FILES": n(C["files_2025_2026"]), "PUB": n(C["published"]), "WITHPUB": n(withpub), "DROPRES": n(C["dropped_public_before_reserved"]), "DROPAFTER": n(C["dropped_public_after_published"]),
"SCORED": n(C["scored"]), "NCNA": str(ncna), "OWN": n(O["scored"]), "NOWN": str(nown), "OWNW1": n(O["w1"]), "OWNW1PCT": pct(O["w1"], O["scored"]), "OWNLATE": n(O["late"]), "OWNLATEPCT": pct(O["late"], O["scored"]),
"OWN30": n(O["over30"]), "OWN90": n(O["over90"]), "OWNMED": f"{O['medlate']:.0f}", "ALLLATE": n(A["late"]), "ALLLATEPCT": pct(A["late"], A["scored"]), "ALL30": n(A["over30"]),
"O25L": n(own2025["late"]), "O25S": n(own2025["scored"]), "O25P": pct(own2025["late"], own2025["scored"]), "O26L": n(own2026["late"]), "O26S": n(own2026["scored"]), "O26P": pct(own2026["late"], own2026["scored"]),
"F4S": n(F4["scored"]), "F4L": n(F4["late"]), "F4P": pct(F4["late"], F4["scored"]), "F4W1": n(F4["w1"]), "F430": n(F4["over30"]), "F490": n(F4["over90"]), "F4MED": f"{F4['medlate']:.0f}", "F4MAX": str(F4["maxlag"]),
"ZS": n(FS["zdi"]["scored"]), "ZL": n(FS["zdi"]["late"]), "ZP": pct(FS["zdi"]["late"], FS["zdi"]["scored"]), "ZD": str(FS["zdi"]["days"]), "ZTOP": str(zdays[1]), "ZTOPD": zdays[0],
"DS": n(FS["dell"]["scored"]), "DL": n(FS["dell"]["late"]), "DP": pct(FS["dell"]["late"], FS["dell"]["scored"]), "DD": str(FS["dell"]["days"]),
"RS": n(FS["drupal"]["scored"]), "RL": n(FS["drupal"]["late"]), "RP": pct(FS["drupal"]["late"], FS["drupal"]["scored"]), "RD": str(FS["drupal"]["days"]), "RTOP": str(ddays[1]), "RTOPD": ddays[0],
"AS": n(FS["Arista"]["scored"]), "AL": n(FS["Arista"]["late"]), "AP": pct(FS["Arista"]["late"], FS["Arista"]["scored"]),
"MS": n(CS["microsoft"]["scored"]), "ML": n(CS["microsoft"]["late"]), "MP": pct(CS["microsoft"]["late"], CS["microsoft"]["scored"]), "ADS": n(CS["adobe"]["scored"]), "ADL": n(CS["adobe"]["late"]), "ADP": pct(CS["adobe"]["late"], CS["adobe"]["scored"]),
"VS": n(VC["scored"]), "VL": n(VC["late"]), "RHS": n(CS["redhat"]["scored"]), "RHL": n(CS["redhat"]["late"]),
"KEPTRES": n(C["kept_public_before_reserved_within_1d"]), "KEPTAFTER": n(C["kept_public_1d_after_published"]), "AFTERANY": n(C["public_after_published_any"]), "MAPPED": str(S["cnas_scored_mapped"]),
"OWNSD": n(O["sd"]), "OWNSDPCT": pct(O["sd"], O["scored"]), "F4PUB": n(sum(S["published_by_cna"][c] for c in FOUR)), "ZEROLATE": str(sum(1 for c in CS if CS[c]["late"] == 0)),
"DELLMOVED": n(S["dell_tz_effect"]["dates_moved"]), "DELLFLIP": str(S["dell_tz_effect"]["late_flipped_to_on_time"]), "DRDRUPAL": str(S["dropped_before_reserved_by_cna"].get("drupal", 0)), "DRZDI": str(S["dropped_before_reserved_by_cna"].get("zdi", 0)), "DRDELL": str(S["dropped_before_reserved_by_cna"].get("dell", 0)), "DRARISTA": str(S["dropped_before_reserved_by_cna"].get("Arista", 0)),
"NONOWN": n(sum(1 for r in ROWS if not r["own"])), "NONOWNLATE": n(sum(1 for r in ROWS if not r["own"] and late(r))),
"DOI": (json.load(open(os.path.join(HERE, "..", "zenodo", "sm-018", "deposit.json")))["metadata"]["prereserve_doi"]["doi"] if os.path.exists(os.path.join(HERE, "..", "zenodo", "sm-018", "deposit.json")) else "pending"),
"TABLE_Q": "\n".join(qt), "TABLE_FOUR": "\n".join(ft), "TABLE_CNA": "\n".join(ct), "TABLE_WORST": "\n".join(wt), "NCT": str(len(ct)),
}
# hard-coded facts checked against the data
assert C["dropped_public_after_published"] == 8018 and C["dropped_public_before_reserved"] == 4607
assert FS["zdi"]["maxlag"] == 108 and FS["dell"]["maxlag"] == 269 and FS["Arista"]["maxlag"] == 199 and FS["drupal"]["maxlag"] == 57
assert [r["id"] for r in worst][:3] == ["CVE-2025-8352", "CVE-2025-27462", "CVE-2025-27463"] and worst[-1]["id"] == "CVE-2025-5088" and worst[-1]["lag"] == 199
assert zdays == ("2025-07-21", 101) and ddays == ("2026-07-10", 46)
assert all(titles.get(r["id"]) for r in worst), "missing record exhibit"
T = open(os.path.join(HERE, "page_template.html")).read()
for k, v in V.items(): T = T.replace("{{" + k + "}}", v)
import re
left = re.findall(r"\{\{[A-Z0-9_]+\}\}", T)
assert not left, "unfilled: " + ", ".join(left)
open(OUT, "w").write(T)
print("wrote", OUT, len(T), "bytes")