mirror of
https://github.com/0xMarcio/cve.git
synced 2026-10-27 16:51:03 +00:00
215 lines
8.4 KiB
Python
215 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Render the CVE Radar hero banner and social card as self-contained SVG.
|
|
|
|
Both cards are plain SVG with no external font, image or stylesheet reference:
|
|
GitHub proxies README images through camo, which fetches the file on its own and
|
|
would drop anything it has to resolve afterwards.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from urllib import request
|
|
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
|
|
BUILD_SITE = os.path.join(ROOT, "scripts", "build_site.py")
|
|
LIVE_STATS = "https://cve.codepwn.win/trending_poc.json"
|
|
LIVE_KEV = "https://cve.codepwn.win/kev.json"
|
|
HERO = os.path.join(ROOT, "docs", "hero.svg")
|
|
SOCIAL = os.path.join(ROOT, "docs", "social-card.svg")
|
|
STATS = os.path.join(ROOT, "docs", "stats.json")
|
|
KEV_PILL = os.path.join(ROOT, "docs", "kev.svg")
|
|
|
|
CANVAS = "#0d1117"
|
|
SUBTLE = "#161b22"
|
|
BORDER = "#30363d"
|
|
FG = "#e6edf3"
|
|
MUTED = "#8b949e"
|
|
NEUTRAL = "#6e7681"
|
|
ACCENT = "#2f81f7"
|
|
DANGER = "#f85149"
|
|
MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
|
|
|
|
|
|
def corpus_counts() -> dict | None:
|
|
"""Count the index the same way the site build does.
|
|
|
|
The banner and the site header have to agree, so this reuses build_site's
|
|
own parser instead of a second guess at what counts as a PoC. Roughly
|
|
fifteen seconds over the whole corpus.
|
|
"""
|
|
try:
|
|
spec = importlib.util.spec_from_file_location("build_site", BUILD_SITE)
|
|
build_site = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(build_site)
|
|
entries, total = build_site.build_cve_list(build_site.load_blacklist())
|
|
except Exception as problem:
|
|
print(f"corpus count failed: {problem}")
|
|
return None
|
|
if not total:
|
|
return None
|
|
return {
|
|
"total_cves": total,
|
|
"with_pocs": len(entries),
|
|
"kev": len(build_site.build_kev(entries)),
|
|
}
|
|
|
|
|
|
def live_stats() -> dict | None:
|
|
"""What the deployed site is currently serving.
|
|
|
|
Only reached when the corpus itself will not parse, which on a normal
|
|
checkout means something is broken rather than something is missing.
|
|
"""
|
|
try:
|
|
with request.urlopen(LIVE_STATS, timeout=20) as response:
|
|
data = json.load(response)
|
|
total = int(data.get("total_cves") or 0)
|
|
pocs = int(data.get("with_pocs") or 0)
|
|
except Exception as problem:
|
|
print(f"live stats failed: {problem}")
|
|
return None
|
|
if not total or not pocs:
|
|
return None
|
|
try:
|
|
# the site's copy, already narrowed to CVEs the index carries, so this
|
|
# path reports the same figure the corpus count would
|
|
with request.urlopen(LIVE_KEV, timeout=20) as response:
|
|
kev = len(json.load(response))
|
|
except Exception:
|
|
kev = 0
|
|
return {"total_cves": total, "with_pocs": pocs, "kev": kev}
|
|
|
|
|
|
def group(value: int) -> str:
|
|
return f"{value:,}"
|
|
|
|
|
|
def sweep(cx: float, cy: float, radius: float, opacity: float) -> str:
|
|
"""Radar rings with a hand rotating over them once every eight seconds."""
|
|
rings = "".join(
|
|
f'<circle cx="{cx}" cy="{cy}" r="{radius * scale:.1f}" fill="none" '
|
|
f'stroke="{ACCENT}" stroke-width="1" opacity="{opacity * fade:.3f}"/>'
|
|
for scale, fade in ((0.35, 0.9), (0.62, 0.65), (0.85, 0.45), (1.0, 0.3))
|
|
)
|
|
return f"""<g>
|
|
{rings}
|
|
<line x1="{cx}" y1="{cy}" x2="{cx}" y2="{cy - radius:.1f}" stroke="{ACCENT}"
|
|
stroke-width="1.5" stroke-linecap="round" opacity="{opacity * 1.6:.3f}">
|
|
<animateTransform attributeName="transform" type="rotate"
|
|
from="0 {cx} {cy}" to="360 {cx} {cy}"
|
|
dur="8s" repeatCount="indefinite"/>
|
|
</line>
|
|
</g>"""
|
|
|
|
|
|
def stat_block(x: float, y: float, value: str, label: str, colour: str, size: int) -> str:
|
|
return f"""<text x="{x}" y="{y}" font-family="{MONO}" font-size="{size}" font-weight="700"
|
|
fill="{colour}" letter-spacing="-0.5">{value}</text>
|
|
<text x="{x}" y="{y + size * 0.62:.0f}" font-family="{MONO}" font-size="{size * 0.34:.0f}"
|
|
fill="{NEUTRAL}" letter-spacing="1.6">{label}</text>"""
|
|
|
|
|
|
def hero(counts: dict) -> str:
|
|
blocks = "".join(
|
|
stat_block(x, 96, value, label, colour, 34)
|
|
for x, value, label, colour in (
|
|
(620, group(counts["total_cves"]), "CVES INDEXED", FG),
|
|
(840, group(counts["with_pocs"]), "WITH POCS", ACCENT),
|
|
(1010, group(counts["kev"]), "KNOWN EXPLOITED", DANGER),
|
|
)
|
|
)
|
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="180" viewBox="0 0 1200 180"
|
|
role="img" aria-label="CVE Radar, {group(counts['with_pocs'])} CVEs with public proof-of-concept exploits">
|
|
<clipPath id="card"><rect x="1" y="1" width="1198" height="178" rx="12"/></clipPath>
|
|
<rect x="0.5" y="0.5" width="1199" height="179" rx="12" fill="{CANVAS}" stroke="{BORDER}"/>
|
|
<g clip-path="url(#card)" opacity="0.6">{sweep(1090, 90, 160, 0.3)}</g>
|
|
|
|
<text x="56" y="70" font-family="{MONO}" font-size="44" font-weight="700"
|
|
fill="{FG}" letter-spacing="2">CVE RADAR</text>
|
|
<text x="56" y="104" font-family="{MONO}" font-size="15" fill="{MUTED}">
|
|
Public proof-of-concept exploits, indexed by CVE.</text>
|
|
<text x="56" y="132" font-family="{MONO}" font-size="15" fill="{ACCENT}">cve.codepwn.win</text>
|
|
|
|
{blocks}
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def social(counts: dict) -> str:
|
|
"""1280x640 card for the repository social preview and the site og:image."""
|
|
blocks = "".join(
|
|
stat_block(x, 430, value, label, colour, 46)
|
|
for x, value, label, colour in (
|
|
(96, group(counts["total_cves"]), "CVES INDEXED", FG),
|
|
(500, group(counts["with_pocs"]), "WITH POCS", ACCENT),
|
|
(880, group(counts["kev"]), "KNOWN EXPLOITED", DANGER),
|
|
)
|
|
)
|
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640"
|
|
role="img" aria-label="CVE Radar">
|
|
<clipPath id="card"><rect x="56" y="56" width="1168" height="528" rx="16"/></clipPath>
|
|
<rect width="1280" height="640" fill="{CANVAS}"/>
|
|
<g clip-path="url(#card)" opacity="0.6">{sweep(1000, 300, 220, 0.26)}</g>
|
|
<rect x="56" y="56" width="1168" height="528" rx="16" fill="none" stroke="{BORDER}"/>
|
|
|
|
<text x="96" y="215" font-family="{MONO}" font-size="76" font-weight="700"
|
|
fill="{FG}" letter-spacing="3">CVE RADAR</text>
|
|
<text x="96" y="272" font-family="{MONO}" font-size="24" fill="{MUTED}">
|
|
Public proof-of-concept exploits, indexed by CVE.</text>
|
|
|
|
<rect x="96" y="344" width="1088" height="1" fill="{BORDER}"/>
|
|
{blocks}
|
|
|
|
<text x="96" y="532" font-family="{MONO}" font-size="22" fill="{ACCENT}"
|
|
letter-spacing="1">cve.codepwn.win</text>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def kev_pill() -> str:
|
|
"""The KEV marker the README table puts beside a row.
|
|
|
|
Every flagged row points at this one file, so GitHub's image proxy fetches
|
|
it once and the table stays cheap however many rows carry it.
|
|
"""
|
|
return (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="34" height="16" viewBox="0 0 34 16" '
|
|
'role="img" aria-label="CISA known exploited">'
|
|
f'<rect x="0.5" y="0.5" width="33" height="15" rx="3" fill="rgba(248,81,73,0.14)" '
|
|
f'stroke="{DANGER}" stroke-opacity="0.45"/>'
|
|
f'<text x="17" y="11.5" font-family="{MONO}" font-size="9" font-weight="700" '
|
|
f'letter-spacing="0.9" text-anchor="middle" fill="{DANGER}">KEV</text>'
|
|
"</svg>\n"
|
|
)
|
|
|
|
|
|
def render(counts: dict) -> None:
|
|
"""Draw both cards and leave the figures behind for the README build."""
|
|
for path, markup in ((HERO, hero(counts)), (SOCIAL, social(counts)), (KEV_PILL, kev_pill())):
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(markup)
|
|
# No timestamp: this file should only change when the figures do, so the
|
|
# README build can read the same numbers the banner is drawn from.
|
|
with open(STATS, "w", encoding="utf-8") as handle:
|
|
json.dump(counts, handle, indent=1, sort_keys=True)
|
|
handle.write("\n")
|
|
|
|
|
|
def main() -> int:
|
|
counts = corpus_counts() or live_stats()
|
|
if not counts:
|
|
# Drawing zeros would be worse than leaving the previous banner in place.
|
|
print("no usable counts, leaving the existing banner alone")
|
|
return 1
|
|
render(counts)
|
|
print(f"Wrote {HERO}, {SOCIAL} and {STATS}: {group(counts['total_cves'])} CVEs, "
|
|
f"{group(counts['with_pocs'])} with PoCs, {group(counts['kev'])} known exploited")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|