"""The CVE page: server-rendered HTML with no script at all.
Every string that reaches the document goes through e(), the single escape path; links additionally
go through safe_url(). The style block is the only inline resource and is allowed by hash.
"""
import base64
import hashlib
import html
import math
from datetime import timedelta
from urllib.parse import parse_qs, urlencode
import report as report_mod
import trivy
from state import parse_ts
REPORT_STALE = timedelta(hours=26)
DB_STALE = timedelta(hours=48)
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
SEV_RANK = {name: i for i, name in enumerate(SEVERITIES)}
STATUS_RANK = {"fix-available": 0, "open": 1, "accepted": 2}
STATE_RANK = {name: i for i, name in enumerate(report_mod.STATES)}
PAGE_SIZES = (25, 50, 100, 200, 500)
DEFAULT_SIZE = 100
_MAX_PARAM = 200
_EXPANSION_LIMIT = 200 # per-target CVE list in the inventory view; the remainder is linked
STYLE = """
body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#fff;color:#1a1a1a}
main{padding:0 16px 32px}
header{padding:12px 16px;background:#f4f4f6;border-bottom:1px solid #ccc}
h1{font-size:18px;margin:0 0 8px}
nav a{margin-right:12px}
.warn{background:#fff0d6;border:2px solid #b35900;padding:8px 12px;margin:8px 0;font-weight:600}
.status{font-weight:700}
.tiles span{display:inline-block;margin:2px 10px 2px 0}
table{border-collapse:collapse;width:100%;margin-top:8px}
th,td{border-bottom:1px solid #ddd;padding:4px 6px;text-align:left;vertical-align:top}
th a{text-decoration:none}
.sev-CRITICAL{color:#a00;font-weight:700}
.sev-HIGH{color:#b35900;font-weight:700}
form{margin:8px 0}
form label{margin-right:8px}
code{word-break:break-all}
@media (prefers-color-scheme:dark){body{background:#16181c;color:#e6e6e6}
header{background:#22252b;border-color:#3a3d44}
th,td{border-color:#33363c}
a{color:#7fb4ff}
.warn{background:#3d2a12;border-color:#c98422}}
"""
STYLE_HASH = base64.b64encode(hashlib.sha256(STYLE.encode("utf-8")).digest()).decode("ascii")
CSP = ("default-src 'none'; style-src 'sha256-" + STYLE_HASH + "'; base-uri 'none'; "
"form-action 'self'; frame-ancestors 'none'")
SECURITY_HEADERS = {
"Content-Security-Policy": CSP,
"X-Content-Type-Options": "nosniff",
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
}
def e(value):
"""The one escape path for anything placed in the document."""
return "" if value is None else html.escape(str(value), quote=True)
def safe_url(value):
return trivy.safe_url(value) if isinstance(value, str) else ""
def _link(url, label):
url = safe_url(url)
return '' + e(label) + " " if url else e(label)
def _parse(query):
raw = parse_qs(query or "", keep_blank_values=False)
params = {k: v[0][:_MAX_PARAM] for k, v in raw.items() if v}
view = params.get("view", "cves")
params["view"] = view if view in ("cves", "workloads", "inventory") else "cves"
try:
params["page"] = max(1, int(params.get("page", "1")))
except ValueError:
params["page"] = 1
try:
size = int(params.get("size", str(DEFAULT_SIZE)))
except ValueError:
size = DEFAULT_SIZE
params["size"] = size if size in PAGE_SIZES else DEFAULT_SIZE
return params
def _qs(params, **overrides):
merged = dict(params)
merged.update(overrides)
pairs = sorted((k, str(v)) for k, v in merged.items() if v not in (None, ""))
return "?" + urlencode(pairs)
def _age(moment, now):
if moment is None:
return "unknown"
seconds = int((now - moment).total_seconds())
if seconds < 0:
return "in the future"
hours, remainder = divmod(seconds, 3600)
minutes = remainder // 60
return str(hours) + "h " + str(minutes) + "m ago"
def _finding_text(finding, target):
parts = [finding["id"], finding["package"], finding["title"], target["id"], target["image"]]
for workload in target["workloads"]:
parts.extend((workload["namespace"], workload["name"], *workload["containers"]))
return " ".join(parts).lower()
def _match_workload(target, params):
namespace, workload = params.get("namespace"), params.get("workload")
if not namespace and not workload:
return True
for item in target["workloads"]:
if namespace and item["namespace"] != namespace:
continue
if workload and workload not in (item["name"], item["kind"] + "/" + item["name"]):
continue
return True
return False
def _match_finding(finding, target, params):
if params.get("severity") and finding["severity"] != params["severity"].upper():
return False
if params.get("status") and finding["status"] != params["status"]:
return False
fix = params.get("fix")
if fix == "yes" and not finding["fixedVersion"]:
return False
if fix == "no" and finding["fixedVersion"]:
return False
if not _match_workload(target, params):
return False
text = params.get("q", "").lower()
return not text or text in _finding_text(finding, target)
def _paginate(rows, params):
total = len(rows)
size = params["size"]
pages = max(1, math.ceil(total / size))
page = min(params["page"], pages)
start = (page - 1) * size
return rows[start:start + size], total, page, pages, start
def _pager(params, total, page, pages, start, shown, noun):
first = start + 1 if total else 0
summary = "Showing " + str(first) + "–" + str(start + shown) + " of " + str(total) + " " + noun \
+ " (page " + str(page) + " of " + str(pages) + ")"
links = []
if page > 1:
links.append('previous ')
if page < pages:
links.append('next ')
return "
" + e(summary) + " " + " ".join(links) + "
"
def _th(params, label, key):
direction = "desc" if params.get("sort") == key and params.get("dir", "asc") == "asc" else "asc"
return '' + e(label) + " "
def _table(head, rows, empty):
if not rows:
return "" + e(empty) + "
"
return "" + "".join(head) + " " + "".join(rows) + "
"
def _sev(severity):
return '' + e(severity) + " "
def _state_label(target):
return "target " + e(target["state"]) + (" (" + e(target["reason"]) + ")" if target.get("reason") else "")
def _status_label(finding):
"""One finding's own status, with its own acceptance: never another image's."""
label = "status " + e(finding["status"])
acceptance = finding.get("acceptance")
if acceptance:
label += " (" + e(acceptance["reason"]) + ", until " + e(acceptance["expires"]) + ")"
return label
def _workload_lines(target, finding=None):
"""One line per workload, each carrying the target state and, in a CVE expansion, the status."""
suffix = " — " + _state_label(target) + (" — " + _status_label(finding) if finding else "")
items = []
for workload in target["workloads"]:
items.append("" + e(workload["namespace"]) + " / " + e(workload["kind"]) + " / " + e(workload["name"])
+ " — containers: " + e(", ".join(workload["containers"]) or "none") + suffix + " ")
return ""
def _status_summary(statuses):
"""The row-level status. A CVE accepted on one image and open on another reads as mixed."""
counts = {}
for status in statuses:
counts[status] = counts.get(status, 0) + 1
if len(counts) == 1:
return e(next(iter(counts)))
return "mixed: " + ", ".join(e(counts[s]) + " " + e(s) for s in sorted(counts, key=STATUS_RANK.get))
def _remediation_cell(images, remediation):
if not images:
return ""
out = []
for image in sorted(images):
entry = remediation.get(image)
if entry is None:
out.append(e(image) + ": no data")
elif entry["state"] == "pr-open" and safe_url(entry.get("url")):
out.append(e(image) + ": " + _link(entry["url"], "pr-open"))
else:
out.append(e(image) + ": " + e(entry["state"]))
return " ".join(out)
# --- views ------------------------------------------------------------------------------------------
def _cves(report, targets, params, remediation):
groups = {}
for finding in report["findings"]:
target = targets[finding["targetId"]]
if not _match_finding(finding, target, params):
continue
group = groups.setdefault(finding["id"], {
"id": finding["id"], "severity": finding["severity"], "cvss": None, "packages": set(), "fixed": set(),
"firstSeen": finding["firstSeenAt"], "statuses": set(), "url": "", "title": "", "items": [],
})
if SEV_RANK[finding["severity"]] < SEV_RANK[group["severity"]]:
group["severity"] = finding["severity"]
if finding["cvss"] is not None and (group["cvss"] is None or finding["cvss"] > group["cvss"]):
group["cvss"] = finding["cvss"]
group["packages"].add(finding["package"])
if finding["fixedVersion"]:
group["fixed"].add(finding["fixedVersion"])
group["firstSeen"] = min(group["firstSeen"], finding["firstSeenAt"])
group["statuses"].add(finding["status"])
group["url"] = group["url"] or finding["url"]
group["title"] = group["title"] or finding["title"]
group["items"].append((finding, target))
rows = []
for group in groups.values():
group["status"] = min(group["statuses"], key=STATUS_RANK.get)
group["imageCount"] = len({t["id"] for _, t in group["items"]})
group["workloadCount"] = len({(w["namespace"], w["kind"], w["name"]) for _, t in group["items"] for w in t["workloads"]})
group["package"] = ", ".join(sorted(group["packages"]))
group["fix"] = ", ".join(sorted(group["fixed"]))
rows.append(group)
sorts = {
"id": lambda r: r["id"], "severity": lambda r: SEV_RANK[r["severity"]], "cvss": lambda r: r["cvss"] if r["cvss"] is not None else -1,
"package": lambda r: r["package"], "fixed": lambda r: r["fix"], "firstSeen": lambda r: r["firstSeen"],
"status": lambda r: STATUS_RANK[r["status"]], "images": lambda r: r["imageCount"], "workloads": lambda r: r["workloadCount"],
}
rows.sort(key=lambda r: r["id"])
rows.sort(key=sorts.get(params.get("sort"), sorts["severity"]), reverse=params.get("dir") == "desc")
page_rows, total, page, pages, start = _paginate(rows, params)
html_rows = []
for row in page_rows:
lines = []
fix_images = set()
for finding, target in row["items"]:
if finding["status"] == "fix-available":
fix_images.add(target["image"])
lines.append("" + e(target["id"]) + " — " + _state_label(target) + " — " + _status_label(finding)
+ " — " + e(finding["package"]) + " " + e(finding["installedVersion"]) + " → "
+ e(finding["fixedVersion"] or "no fix") + _workload_lines(target, finding) + " ")
status_text = _status_summary(f["status"] for f, _ in row["items"])
html_rows.append(
"" + _link(row["url"], row["id"]) + " " + _sev(row["severity"]) + " "
+ e("" if row["cvss"] is None else row["cvss"]) + " " + e(row["package"]) + " " + e(row["fix"])
+ " " + e(row["firstSeen"]) + " " + status_text + " " + _remediation_cell(fix_images, remediation)
+ " " + e(row["imageCount"]) + " images, " + e(row["workloadCount"])
+ " workloads " + e(row["title"]) + "
")
head = [_th(params, "CVE", "id"), _th(params, "Severity", "severity"), _th(params, "CVSS", "cvss"), _th(params, "Package", "package"),
_th(params, "Fixed in", "fixed"), _th(params, "First seen", "firstSeen"), _th(params, "Status", "status"),
"Remediation ", _th(params, "Affected", "images")]
return _pager(params, total, page, pages, start, len(page_rows), "CVEs") \
+ _table(head, html_rows, "No CVE rows match these filters.")
def _workload_key(item):
return item["namespace"], item["kind"], item["name"]
def _workloads(report, targets, params):
by_target = {}
for finding in report["findings"]:
target = targets[finding["targetId"]]
if _match_finding(finding, target, params):
by_target.setdefault(target["id"], []).append(finding)
rows = {}
finding_filter = params.get("severity") or params.get("status") or params.get("fix")
needle = params.get("q", "").lower()
for target in report["targets"]:
matching = by_target.get(target["id"], [])
for workload in target["workloads"]:
key = _workload_key(workload)
scope = {"namespace": params.get("namespace"), "workload": params.get("workload")}
if not _match_workload({"workloads": [workload]}, scope):
continue
text = " ".join((workload["namespace"], workload["name"], *workload["containers"], target["image"])).lower()
if not matching and (finding_filter or (needle and needle not in text)):
continue
row = rows.setdefault(key, {"key": key, "containers": set(), "images": set(), "findings": {}, "counts": dict.fromkeys(SEVERITIES, 0)})
row["containers"].update(workload["containers"])
row["images"].add(target["id"])
for finding in matching:
row["findings"].setdefault((finding["id"], target["id"], finding["package"]), finding)
for row in rows.values():
for finding in row["findings"].values():
row["counts"][finding["severity"]] += 1
items = list(rows.values())
sorts = {
"namespace": lambda r: r["key"][0], "kind": lambda r: r["key"][1], "name": lambda r: r["key"][2],
"images": lambda r: len(r["images"]), "total": lambda r: -len(r["findings"]),
}
for severity in SEVERITIES:
sorts[severity.lower()] = (lambda s: lambda r: -r["counts"][s])(severity)
items.sort(key=lambda r: r["key"])
if params.get("sort") in sorts:
items.sort(key=sorts[params["sort"]], reverse=params.get("dir") == "desc")
else:
items.sort(key=lambda r: tuple(-r["counts"][s] for s in SEVERITIES))
page_rows, total, page, pages, start = _paginate(items, params)
html_rows = []
for row in page_rows:
lines = "".join(
"" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + _status_label(f)
+ " — " + e(f["targetId"]) + " — " + _state_label(targets[f["targetId"]]) + " "
for f in sorted(row["findings"].values(), key=lambda f: (SEV_RANK[f["severity"]], f["id"], f["targetId"])))
counts = "".join("" + e(row["counts"][s]) + " " for s in SEVERITIES)
html_rows.append(
"" + e(row["key"][0]) + " " + e(row["key"][1]) + " " + e(row["key"][2]) + " "
+ e(", ".join(sorted(row["containers"])) or "none") + " " + " ".join(e(i) + " — " + _state_label(targets[i]) for i in sorted(row["images"])) + " "
+ counts + "" + e(len(row["findings"])) + " findings ")
head = [_th(params, "Namespace", "namespace"), _th(params, "Kind", "kind"), _th(params, "Workload", "name"), "Containers ",
_th(params, "Images", "images")] + [_th(params, s, s.lower()) for s in SEVERITIES] + [_th(params, "Findings", "total")]
return _pager(params, total, page, pages, start, len(page_rows), "workloads") \
+ _table(head, html_rows, "No workload rows match these filters.")
def _inventory(report, params):
counts = {}
by_target = {}
for finding in report["findings"]:
counts[finding["targetId"]] = counts.get(finding["targetId"], 0) + 1
by_target.setdefault(finding["targetId"], []).append(finding)
needle = params.get("q", "").lower()
wanted_state = params.get("status")
rows = []
for target in report["targets"]:
if wanted_state and target["state"] != wanted_state:
continue
if not _match_workload(target, params):
continue
if needle:
text = " ".join((target["id"], target["image"], target["state"], *[w["namespace"] + " " + w["name"] for w in target["workloads"]])).lower()
if needle not in text:
continue
rows.append(target)
sorts = {
"id": lambda t: t["id"], "type": lambda t: t["type"], "state": lambda t: STATE_RANK[t["state"]],
"lastScanned": lambda t: t["lastScannedAt"] or "", "findings": lambda t: counts.get(t["id"], 0),
}
rows.sort(key=lambda t: t["id"])
rows.sort(key=sorts.get(params.get("sort"), sorts["state"]), reverse=params.get("dir") == "desc")
page_rows, total, page, pages, start = _paginate(rows, params)
html_rows = []
for target in page_rows:
found = sorted(by_target.get(target["id"], []), key=lambda f: (SEV_RANK[f["severity"]], f["id"]))
listed = "".join("" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + " "
for f in found[:_EXPANSION_LIMIT])
if len(found) > _EXPANSION_LIMIT:
listed += '' + e(len(found) - _EXPANSION_LIMIT) + " more in the CVE view "
html_rows.append(
"" + e(target["id"]) + "" + e(target["type"]) + " " + e(target["state"]) + " "
+ e(target["reason"]) + " " + e(target["lastScannedAt"] or "never") + " " + e(counts.get(target["id"], 0))
+ " " + e(len(target["workloads"])) + " workloads, " + e(len(found)) + " findings "
+ _workload_lines(target) + ("" if listed else "") + " ")
head = [_th(params, "Target", "id"), _th(params, "Type", "type"), _th(params, "State", "state"), "Reason ",
_th(params, "Last scanned", "lastScanned"), _th(params, "Findings", "findings"), "Workloads "]
return _pager(params, total, page, pages, start, len(page_rows), "targets") \
+ _table(head, html_rows, "No targets match these filters.")
# --- header -----------------------------------------------------------------------------------------
def _header(report, now):
generated = parse_ts(report["generatedAt"])
database = parse_ts(report["database"]["updatedAt"])
states = dict.fromkeys(report_mod.STATES, 0)
for target in report["targets"]:
states[target["state"]] += 1
open_counts = dict.fromkeys(SEVERITIES, 0)
accepted = 0
for finding in report["findings"]:
if finding["status"] == "accepted":
accepted += 1
else:
open_counts[finding["severity"]] += 1
warnings = []
report_stale = generated is None or now - generated > REPORT_STALE
if report_stale:
warnings.append("STALE REPORT: generated " + _age(generated, now) + "; the expected refresh is within 26 hours.")
if report["run"]["status"] == "degraded":
warnings.append("DEGRADED RUN: the last run did not complete normally (database, acceptances file or inventory problem). Do not treat these results as current.")
db_epoch = report["database"]["version"] == "unavailable"
if db_epoch:
warnings.append("DATABASE UNAVAILABLE: no vulnerability database has been downloaded.")
elif database is None or now - database > DB_STALE:
warnings.append("DATABASE STALE: the vulnerability database is older than 48 hours.")
total_targets = len(report["targets"])
not_scanned = total_targets - states["scanned"]
if total_targets == 0:
warnings.append("NO TARGETS: the inventory is empty; nothing has been evaluated.")
if not_scanned:
warnings.append("INCOMPLETE COVERAGE: " + str(not_scanned) + " of " + str(total_targets)
+ " targets are not in the scanned state; findings for them are missing or may be out of date.")
open_total = sum(open_counts.values())
if warnings:
status = "Status: ATTENTION NEEDED"
elif open_total or accepted:
status = "Status: CURRENT, findings present"
else:
status = "Status: CURRENT"
tiles = "".join("" + e(name) + ": " + e(states[name]) + " " for name in report_mod.STATES)
sev_tiles = "".join("" + _sev(s) + ": " + e(open_counts[s]) + " " for s in SEVERITIES)
claim = ""
if not warnings and not report["findings"] and total_targets > 0 and not_scanned == 0:
claim = "Clean: all " + e(total_targets) + " targets were evaluated within 24 hours with zero findings.
"
return ("CVE visibility "
"" + e(status) + "
"
+ "".join('' + e(w) + "
" for w in warnings) + claim
+ "Report age: " + e(_age(generated, now)) + " · Database age: "
+ e("unavailable" if db_epoch else _age(database, now)) + " · Run: " + e(report["run"]["status"]) + "
"
+ 'Targets (' + e(total_targets) + "): " + tiles + "
"
+ 'Open findings by severity: ' + sev_tiles + "accepted: " + e(accepted) + "
"
+ " ")
def _nav(params):
links = []
for view, label in (("cves", "CVEs"), ("workloads", "Workloads"), ("inventory", "Inventory")):
marker = ' aria-current="page"' if params["view"] == view else ""
target = {k: v for k, v in params.items() if k not in ("page", "sort", "dir")}
target["view"] = view
links.append('" + e(label) + " ")
return "" + "".join(links) + " "
def _filters(params):
def field(name, label):
return "" + e(label) + ' '
selected = params.get("fix", "")
fix_options = "".join(
'" + e(t) + " "
for v, t in (("", "any"), ("yes", "yes"), ("no", "no")))
return ('')
def _document(body):
return ("CVE visibility "
' "
"" + body + "").encode("utf-8")
def render(report, query, now):
"""(status code, header dict, body bytes) for one page request."""
if report is None:
body = ("CVE visibility NO REPORT YET: nothing has been evaluated. "
"Nothing can be said about this cluster until the first report is published.
")
return 503, dict(SECURITY_HEADERS), _document(body)
params = _parse(query)
targets = {t["id"]: t for t in report["targets"]}
remediation = {r["image"]: r for r in report["remediation"]}
view = params["view"]
if view == "workloads":
content = _workloads(report, targets, params)
elif view == "inventory":
content = _inventory(report, params)
else:
content = _cves(report, targets, params, remediation)
return 200, dict(SECURITY_HEADERS), _document(_header(report, now) + "" + _nav(params) + _filters(params) + content + " ")
def not_found():
return 404, dict(SECURITY_HEADERS), _document("Not found.
")