Files
cve/.github/getTrending.py
T
0xMarcio 1377e03b6a Make the recent PoC table actually recent again
Switching the age column to the last commit left the job still sorting on
updated_at, which moves when a repository merely gains a star. The table
kept its "most recently touched" ordering while displaying commit dates,
so the front page and the README filled up with entries last worked on
122, 900 and 923 days ago.

Filter the search to repositories pushed in the last 90 days and re-sort
the pool by that same date. The API can only sort by updated_at, but a
push bumps it too, so the newest commits are always near the top of that
order and a pool of 50 per year is enough to find them.

The 2026 table now opens on a PoC pushed two days ago instead of four
months, and no row anywhere is older than the window.
2026-08-30 23:38:27 +02:00

128 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Rebuild README.md with the most recently updated CVE proof-of-concept repositories."""
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timedelta, timezone
from urllib import error, parse, request
SEARCH_URL = "https://api.github.com/search/repositories"
LANGUAGES = (
"Shell", "Go", "ASP", "WebAssembly", "R", "Lua", "Python", "C++", "C",
"JavaScript", "Perl", "PowerShell", "Ruby", "Rust", "Java", "PHP",
)
YEARS = 5
PER_YEAR = 20
POOL = 50 # fetched per year before re-sorting by last commit
WINDOW_DAYS = 90 # a PoC nobody has touched this quarter is not "recent"
MIN_STARS = 2
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")
def search_year(year: int, since: str) -> tuple[int, list[dict]]:
"""Return how many PoC repositories for one CVE year were pushed since a
date, and the most recently pushed of them.
The API can only sort by updated_at, which also moves when a repository
merely gains a star. Since a push always bumps updated_at too, the newest
commits are guaranteed to sit near the top of that order: take a pool from
there and re-sort it by the date the table actually shows.
"""
query = " ".join(
[f'"CVE-{year}" in:name', f"stars:>{MIN_STARS}", f"pushed:>{since}"]
+ [f"language:{language}" for language in LANGUAGES]
)
url = SEARCH_URL + "?" + parse.urlencode(
{"q": query, "s": "updated", "o": "desc", "per_page": POOL}
)
headers = {"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT}
# The token authenticates the search API only; it is never placed in the URL,
# written to disk, or printed, so it cannot leak through logs or the commit.
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
for attempt in range(3):
try:
with request.urlopen(request.Request(url, headers=headers), timeout=30) as response:
payload = json.load(response)
return int(payload.get("total_count") or 0), payload.get("items") or []
except error.HTTPError as exc:
if exc.code not in {403, 429, 500, 502, 503, 504} or attempt == 2:
raise
except (error.URLError, TimeoutError, json.JSONDecodeError):
if attempt == 2:
raise
time.sleep(5 * (attempt + 1))
raise RuntimeError(f"GitHub search failed for CVE-{year}")
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")):
if amount > 0:
return f"{amount} {unit}{'s' if amount > 1 else ''} 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("|", "/")
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>']
items: list[dict] = []
for year in range(current_year, current_year - YEARS, -1):
total, repositories = search_year(year, since)
repositories.sort(key=lambda repo: str(repo.get("pushed_at") or ""), reverse=True)
repositories = repositories[:PER_YEAR]
print(f"CVE-{year}: {total} repositories pushed since {since}")
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("| --- | --- | --- | --- |")
for repo in repositories:
# pushed_at is the last commit. updated_at also moves when a repository
# only gains a star, which is why the table used to claim that a
# four-month-old exploit had been updated three hours ago.
pushed = str(repo.get("pushed_at") or repo.get("updated_at") or "")
items.append({
"year": year,
"stars": int(repo.get("stargazers_count") or 0),
"name": cell(repo.get("name")),
"url": repo.get("html_url") or "",
"desc": cell(repo.get("description")),
"pushed": pushed,
"created": str(repo.get("created_at") or ""),
})
lines.append(
f"| {repo.get('stargazers_count', 0)}⭐ | {time_ago(pushed)} "
f"| [{cell(repo.get('name'))}]({repo.get('html_url')}) | {cell(repo.get('description'))} |"
)
with open(README, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines))
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)")
return 0
if __name__ == "__main__":
raise SystemExit(main())