Give the README a banner and cut it back to the useful parts

The README opened on a bare heading and five year tables, said nothing about
what the project is, and never linked the site. Anyone landing on the repo had
to guess. It now leads with a generated banner carrying the live index totals,
a badge row, and tables trimmed to what fits a table cell.

hero.py draws the banner and the social card as self-contained SVG, since
GitHub proxies README images and drops anything the file has to fetch for
itself. It counts the corpus through build_site's own parser rather than a
second guess at what counts as a PoC, so the banner cannot drift from the site
header, and leaves the figures in docs/stats.json for the README build to
quote. If the count fails it falls back to the deployed site and, failing
that, writes nothing at all: a banner reading zero is worse than yesterday's.

Badges describing the index are baked in at build time so they always match
the banner and cannot degrade to "resource not found"; the two that describe
live GitHub state carry a stamp that defeats the image proxy cache.

The sweep now runs hourly instead of every six hours. It takes under two
minutes and the repository shows up in "recently updated" far more often.

Also adds the OpenGraph tags the site never had, so a shared link renders a
card instead of a bare URL.
This commit is contained in:
0xMarcio committed 2026-09-01 04:28:33 +02:00
1 parent 66a8c7d36b
commit 2ed7386028
10 files changed
+729 -142

No files matched your search

+180 -14
View File
@@ -33,6 +33,13 @@ USER_AGENT = "0xMarcio-cve-trending"
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
README = os.path.join(ROOT, "README.md")
TRENDING = os.path.join(ROOT, "trending.json")
KEV = os.path.join(ROOT, "kev.json")
STATS = os.path.join(ROOT, "docs", "stats.json")
DESC_LIMIT = 110 # a table cell, not a paragraph
FOLDED_AFTER = 2 # years past the newest two are collapsed behind a summary
CVE_ID = re.compile(r"CVE[-_](\d{4})[-_](\d{4,7})", re.IGNORECASE)
SLUG = "0xMarcio/cve"
HERO_URL = f"https://raw.githubusercontent.com/{SLUG}/main/docs/hero.svg"
def search_year(year: int, since: str) -> tuple[int, list[dict]]:
@@ -178,23 +185,157 @@ def code_pushed(full_names: list[str], token: str) -> dict[str, str]:
def time_ago(timestamp: str) -> str:
moment = datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
delta = datetime.now(timezone.utc) - moment
for amount, unit in ((delta.days, "day"), (delta.seconds // 3600, "hour"), (delta.seconds // 60, "minute")):
for amount, unit in ((delta.days, "d"), (delta.seconds // 3600, "h"), (delta.seconds // 60, "m")):
if amount > 0:
return f"{amount} {unit}{'s' if amount > 1 else ''} ago"
return f"{amount}{unit} ago"
return "just now"
def cell(value: str) -> str:
"""Keep repository text on a single markdown table cell."""
return " ".join(str(value or "").split()).replace("|", "/")
text = " ".join(str(value or "").split()).replace("|", "/")
return text.replace("\u2014", "-").replace("\u2013", "-")
def known_exploited() -> set[str]:
"""CVE ids CISA lists as exploited in the wild."""
try:
with open(KEV, encoding="utf-8") as handle:
return {str(key).upper() for key in json.load(handle)}
except (OSError, ValueError):
return set()
def cve_of(repo: dict) -> str:
match = CVE_ID.search(f"{repo.get('name') or ''} {repo.get('description') or ''}")
return f"CVE-{match.group(1)}-{match.group(2)}" if match else ""
def shorten(text: str) -> str:
text = cell(text)
if len(text) <= DESC_LIMIT:
return text
return text[:DESC_LIMIT].rsplit(" ", 1)[0] + "\u2026"
def figures() -> dict:
"""Index totals, written by hero.py from the same count the banner shows."""
try:
with open(STATS, encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return {}
def shields(text: str) -> str:
"""Escape one field of a shields badge path.
The path is split on single hyphens, so a hyphen inside the text has to be
doubled before it is percent-encoded or the badge loses everything after it.
"""
return parse.quote(text.replace("-", "--").replace("_", "__"), safe="")
def pill(label: str, value: str, colour: str, link: str) -> str:
"""A shields badge whose text is baked in at build time.
Anything describing the index is fixed here rather than resolved when the
page is viewed: the figure is then exactly the one the banner was drawn
from, and it cannot degrade to "resource not found" when a lookup fails.
"""
return (f"[![{label}](https://img.shields.io/badge/"
f"{shields(label)}-{shields(value)}-{colour}"
f"?style=flat-square&labelColor=161b22)]({link})")
def live_pill(path: str, label: str, colour: str, link: str, stamp: str) -> str:
"""A shields badge that has to be resolved at view time.
The stamp is ignored by shields but changes the URL every rebuild, so
GitHub's image proxy fetches a fresh badge instead of replaying a cached one.
"""
return (f"[![{label}](https://img.shields.io/github/{path}"
f"?style=flat-square&label={parse.quote(label)}&color={colour}"
f"&labelColor=161b22&_={stamp})]({link})")
def header(stamp: str, synced: str) -> list[str]:
"""Everything above the tables. The banner carries a cache-busting stamp
because GitHub proxies README images and would otherwise serve a stale copy
of a file that is redrawn whenever the index moves."""
counts = figures()
home = "https://cve.codepwn.win/"
badges = [
pill("last sync", synced, "2f81f7", f"https://github.com/{SLUG}/commits/main"),
live_pill("actions/workflow/status/" + SLUG + "/hot_cves.yml", "sweep", "2f81f7",
f"https://github.com/{SLUG}/actions/workflows/hot_cves.yml", stamp),
]
if counts.get("with_pocs"):
badges.append(pill("CVEs with PoCs", f"{counts['with_pocs']:,}", "2f81f7", home))
if counts.get("kev"):
badges.append(pill("known exploited", f"{counts['kev']:,}", "f85149", home))
badges.append(live_pill("stars/" + SLUG, "stars", "e3b341",
f"https://github.com/{SLUG}/stargazers", stamp))
return [
'<div align="center">',
"",
f'<a href="{home}"><img src="{HERO_URL}?v={stamp}" alt="CVE Radar" width="100%"></a>',
"",
"&nbsp;".join(badges),
"",
"</div>",
"",
]
FOOTER = """
## Data
Every file is plain JSON on the CDN. No key, no rate limit.
```bash
curl -s https://cve.codepwn.win/CVE_list.json | jq '.[] | select(.cve=="CVE-2026-75604")'
curl -s https://cve.codepwn.win/kev.json | jq 'keys | length'
curl -s https://cve.codepwn.win/repo_meta.json | jq '."sfewer-r7/CVE-2026-55040"'
curl -s https://cve.codepwn.win/trending_poc.json | jq '{total_cves, with_pocs}'
```
| Endpoint | Holds |
| --- | --- |
| [`CVE_list.json`](https://cve.codepwn.win/CVE_list.json) | Every indexed CVE, its description and its PoC links |
| [`kev.json`](https://cve.codepwn.win/kev.json) | CISA known exploited, keyed by CVE id |
| [`repo_meta.json`](https://cve.codepwn.win/repo_meta.json) | Stars and last push date per PoC repository |
| [`trending_poc.json`](https://cve.codepwn.win/trending_poc.json) | Trending repositories plus index totals |
| [`2026/CVE-2026-75604.md`](2026/CVE-2026-75604.md) | Markdown copy of one CVE, one directory per year |
## Build
| Job | Cadence | Picks up |
| --- | --- | --- |
| [PoC sweep](.github/workflows/hot_cves.yml) | hourly | New and updated exploit repositories |
| [CVE sync](.github/workflows/sync_cve_pocs.yml) | daily | New CVEs and references from NVD and MITRE |
| [Link audit](.github/workflows/audit_poc_links.yml) | weekly | Repositories that went dead, dropped from the index |
A repository counts as updated only when a commit touches something other than
paperwork, so a README tweak cannot pass a year-old exploit off as recent.
## Contributing
Missing PoC, wrong link, dead repository: open an issue with the CVE id and the
repository URL.
"""
def main() -> int:
now = datetime.now(timezone.utc)
since = (now - timedelta(days=WINDOW_DAYS)).date().isoformat()
current_year = now.year
lines = ['<h1 align="center">Recently updated Proof-of-Concepts</h1>']
kev = known_exploited()
items: list[dict] = []
sections: list[list[str]] = []
flagged = 0
token = github_token()
for year in range(current_year, current_year - YEARS, -1):
@@ -210,18 +351,23 @@ def main() -> int:
repositories.sort(key=lambda repo: repo["_shipped"], reverse=True)
repositories = repositories[:PER_YEAR]
print(f"CVE-{year}: {total} pushed since {since}, "
f"{stale - len([r for r in repositories])} dropped as paperwork-only")
f"{stale - len(repositories)} dropped as paperwork-only")
if not repositories:
continue
lines.append(f"\n\n## {year}\n")
lines.append(f"### Latest {len(repositories)} of {total} Repositories\n")
lines.append("| Stars | Updated | Name | Description |")
lines.append("| --- | --- | --- | --- |")
block = [
f"## {year} &middot; newest {len(repositories)} of {total}",
"",
"| Stars | Updated | Repository | Description |",
"| --- | --- | --- | --- |",
]
for repo in repositories:
# The last commit that touched anything but paperwork. pushed_at counts
# a README tweak, which had the table claiming a year-old exploit was
# updated twenty-one hours ago.
pushed = repo["_shipped"]
cve = cve_of(repo)
exploited = cve in kev
flagged += exploited
items.append({
"year": year,
"stars": int(repo.get("stargazers_count") or 0),
@@ -230,18 +376,38 @@ def main() -> int:
"desc": cell(repo.get("description")),
"pushed": pushed,
"created": str(repo.get("created_at") or ""),
"cve": cve,
"kev": exploited,
})
lines.append(
f"| {repo.get('stargazers_count', 0)}⭐ | {time_ago(pushed)} "
f"| [{cell(repo.get('name'))}]({repo.get('html_url')}) | {cell(repo.get('description'))} |"
block.append(
f"| {repo.get('stargazers_count', 0)}\u2b50 | {time_ago(pushed)} "
f"| {'\U0001f525 ' if exploited else ''}[{cell(repo.get('name'))}]({repo.get('html_url')}) "
f"| {shorten(repo.get('description'))} |"
)
sections.append(block)
stamp = now.strftime("%Y%m%d%H%M")
# No hyphens: they are the field separator in a shields badge path.
synced = now.strftime("%d %b %Y %H:%M UTC")
lines = header(stamp, synced)
lines.append(f"Exploit repositories with a commit in the last {WINDOW_DAYS} days, "
f"newest first. \U0001f525 marks a CVE on the CISA known exploited list.")
lines.append("")
for index, block in enumerate(sections):
if index == FOLDED_AFTER and len(sections) > FOLDED_AFTER:
years = ", ".join(section[0][3:7] for section in sections[FOLDED_AFTER:])
lines.append(f"<details>\n<summary>{years}</summary>\n")
lines.extend(block)
lines.append("")
if len(sections) > FOLDED_AFTER:
lines.append("</details>")
with open(README, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines))
handle.write("\n".join(lines).rstrip() + FOOTER)
with open(TRENDING, "w", encoding="utf-8") as handle:
json.dump(items, handle, ensure_ascii=False, indent=1)
handle.write("\n")
print(f"Wrote {README} and {TRENDING} ({len(items)} repositories)")
print(f"Wrote {README} and {TRENDING} ({len(items)} repositories, {flagged} known-exploited)")
return 0
+196
View File
@@ -0,0 +1,196 @@
#!/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")
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 for every 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 for every 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 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))):
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())
+12 -2
View File
@@ -3,7 +3,7 @@ name: Hot CVEs List
on:
workflow_dispatch:
schedule:
- cron: '0 */6 * * *'
- cron: '7 * * * *'
permissions:
contents: write
@@ -24,6 +24,16 @@ jobs:
with:
python-version: '3.13'
- name: Redraw the banner and recount the index
run: |
# Counts the corpus with the site build's own parser, so the banner can
# never disagree with the site, and drops the figures in docs/stats.json
# for the README badges to quote.
python3 .github/hero.py
# og:image has to be a raster: Twitter, Slack and Discord all ignore SVG
sudo apt-get install -y --no-install-recommends librsvg2-bin >/dev/null
rsvg-convert -w 1280 -h 640 docs/social-card.svg -o docs/social-card.png
- name: Rebuild the trending PoC table
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -33,7 +43,7 @@ jobs:
run: |
git config user.name 'CVE-BOT'
git config user.email '[email protected]'
git add -- README.md trending.json
git add -- README.md trending.json docs/hero.svg docs/social-card.svg docs/social-card.png docs/stats.json
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0