Files
cve/scripts/sync_exploits.py
T
0xMarcio a2c376e77d Stop filing Metasploit scanners as proofs of concept
A module's CVE list comes from its own declared references, so an exploit
that chains two flaws should be linked under both. A scanner is different:
"SSL/TLS Version Detection" cites BEAST, RC4, POODLE and Logjam because it
reports on them, and CVE-2014-3566 was carrying it as exploit code. So were
"SSH Username Enumeration" and "JBoss Vulnerability Scanner".

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; exploit modules
keep every CVE they declare. 3,542 links become 3,339, 86 entries lose a
Metasploit section entirely and 51 lose part of one.

Metasploit also ships CVE-1012-5358 for CVE-2012-5358. The regex accepted
it; an impossible year is now rejected before it can invent a 1012/
directory.
2026-09-01 13:44:46 +02:00

182 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]
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 = ROOT / 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 = ROOT / 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 ROOT.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())