Files
cve/scripts/sync_exploits.py
T
0xMarcio a7449da61b Move the year directories out of the repository root
Twenty-eight year folders sat above the README, so anyone opening the
repository scrolled past 1999 through 2026 before reaching a word about what
it is. The entries move to cves/ and the files the pipeline reads and writes
move to index/, which leaves the root at README, LICENSE and five
directories.

Nothing about the data changes. Every script takes its two roots from the
same place, and all five syncs report zero writes against the moved tree,
which is what a correct repoint looks like.
2026-09-01 14:43:38 +02:00

184 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Fold the two curated exploit archives into the index.
ExploitDB and Metasploit both publish a single machine readable file mapping
their entries to CVE ids. Neither is a GitHub repository, so their links are
kept in their own sections rather than being counted and starred as one.
"""
from __future__ import annotations
import argparse
import csv
import gzip
import io
import json
import re
import urllib.request
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CVES = ROOT / "cves"
INDEX = ROOT / "index"
EDB_CSV = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
MSF_JSON = ("https://raw.githubusercontent.com/rapid7/metasploit-framework/"
"master/db/modules_metadata_base.json")
EDB_ENTRY = "https://www.exploit-db.com/exploits/"
MSF_BLOB = "https://github.com/rapid7/metasploit-framework/blob/master"
CVE = re.compile(r"CVE-\d{4}-\d{4,7}", re.I)
def plausible(cve: str) -> bool:
"""Reject impossible years. Metasploit ships CVE-1012-5358 for CVE-2012-5358."""
return 1999 <= int(cve.split("-")[1]) <= 2100
USER_AGENT = "0xMarcio-cve-exploits"
# One CVE can carry dozens of ExploitDB rows for the same flaw on different
# platforms. Enough to be useful, not enough to bury the repositories below.
PER_CVE = 8
SOURCES = (
("#### ExploitDB", "No ExploitDB entry."),
("#### Metasploit", "No Metasploit module."),
)
def fetch(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=180) as response:
raw = response.read()
return gzip.decompress(raw) if url.endswith(".gz") else raw
def exploitdb() -> dict[str, list[str]]:
"""CVE to ExploitDB entry links, verified entries first, newest first."""
text = fetch(EDB_CSV).decode("utf-8", "replace")
rows: dict[str, list[tuple[int, int]]] = defaultdict(list)
for row in csv.DictReader(io.StringIO(text)):
codes = row.get("codes") or ""
if "CVE-" not in codes.upper():
continue
try:
entry = int(row["id"])
except (KeyError, TypeError, ValueError):
continue
verified = 1 if str(row.get("verified") or "").strip() == "1" else 0
for cve in {c.upper() for c in CVE.findall(codes)}:
rows[cve].append((verified, entry))
return {
cve: [f"{EDB_ENTRY}{entry}" for _, entry in
sorted(found, key=lambda item: (-item[0], -item[1]))[:PER_CVE]]
for cve, found in rows.items()
}
def metasploit() -> dict[str, list[str]]:
"""CVE to module source links, the better ranked module first.
A module is written to be run, so rank is the useful order here: excellent
beats manual, whatever order the database happens to list them in.
"""
data = json.loads(fetch(MSF_JSON).decode("utf-8", "replace"))
rows: dict[str, list[tuple[int, str]]] = defaultdict(list)
for module in data.values():
path = str(module.get("path") or "")
if not path.endswith(".rb"):
continue
rank = int(module.get("rank") or 0)
found: set[str] = set()
for reference in module.get("references") or []:
found |= {c.upper() for c in CVE.findall(str(reference))}
found = {cve for cve in found if plausible(cve)}
# An exploit module is written against the flaws it lists, chains
# included. A scanner is not: "SSL/TLS Version Detection" cites BEAST,
# RC4, POODLE and Logjam because it reports on them, and filing it as a
# proof of concept under each is wrong. Keep auxiliary and post modules
# only where they name a single CVE, which is the shape of an auxiliary
# module that is itself the exploit.
if str(module.get("type") or "") != "exploit" and len(found) > 1:
continue
for cve in found:
rows[cve].append((rank, MSF_BLOB + path))
return {
cve: [url for _, url in
sorted(found, key=lambda item: (-item[0], item[1]))[:PER_CVE]]
for cve, found in rows.items()
}
def section_bounds(text: str, header: str) -> tuple[int, int] | None:
start = text.find("\n" + header)
if start < 0:
return None
body = start + 1 + len(header)
nxt = text.find("\n#", body)
return body, len(text) if nxt < 0 else nxt
def apply_to_markdown(cve: str, header: str, urls: list[str], dry_run: bool) -> str:
path = CVES / cve.split("-")[1] / f"{cve}.md"
if not path.exists():
return "absent"
original = path.read_text(encoding="utf-8")
bounds = section_bounds(original, header)
block = "\n" + "\n".join(f"- {url}" for url in urls) + "\n"
if bounds:
start, end = bounds
if original[start:end] == block:
return "unchanged"
updated = original[:start] + block + original[end:]
else:
updated = original.rstrip("\n") + f"\n\n{header}\n" + block.lstrip("\n")
if not dry_run:
path.write_text(updated, encoding="utf-8")
return "added"
def drop_section(cve: str, header: str, dry_run: bool) -> bool:
"""Remove a section for a CVE the source no longer claims."""
path = CVES / cve.split("-")[1] / f"{cve}.md"
if not path.exists():
return False
original = path.read_text(encoding="utf-8")
bounds = section_bounds(original, header)
if not bounds:
return False
start, _ = bounds
head = original.rfind("\n" + header, 0, start + 1)
end = bounds[1]
updated = (original[:head] + original[end:]).rstrip("\n") + "\n"
if not dry_run:
path.write_text(updated, encoding="utf-8")
return True
def main() -> int:
parser = argparse.ArgumentParser(description="Sync ExploitDB and Metasploit into the index")
parser.add_argument("--dry-run", action="store_true", help="report without writing")
args = parser.parse_args()
for (header, _), collect in zip(SOURCES, (exploitdb, metasploit)):
found = collect()
links = sum(len(urls) for urls in found.values())
tally = {"added": 0, "unchanged": 0, "absent": 0}
for cve, urls in sorted(found.items()):
tally[apply_to_markdown(cve, header, urls, args.dry_run)] += 1
# A CVE the source has stopped claiming is never visited above, so its
# section would otherwise survive every future run.
marker = "\n" + header
stale = 0
for path in CVES.glob("[12][0-9][0-9][0-9]/CVE-*.md"):
if path.stem in found:
continue
if marker in path.read_text(encoding="utf-8", errors="replace"):
stale += drop_section(path.stem, header, args.dry_run)
name = header.replace("#", "").strip()
print(f"{name}: {len(found):,} CVEs, {links:,} links | "
f"{tally['added']:,} entries written, {tally['unchanged']:,} unchanged, "
f"{tally['absent']:,} for CVEs not in the index, {stale:,} stale sections dropped")
return 0
if __name__ == "__main__":
raise SystemExit(main())