Files
cve/scripts/sync_collections.py
T

413 lines
18 KiB
Python
Raw Normal View History

2026-09-01 19:01:07 +02:00
#!/usr/bin/env python3
"""Index CVE-specific paths inside curated multi-CVE repositories."""
from __future__ import annotations
import argparse
import hashlib
2026-09-01 19:01:07 +02:00
import io
import json
import os
2026-09-01 19:01:07 +02:00
import re
import tarfile
import urllib.request
from collections import defaultdict
from pathlib import Path
from urllib.parse import quote
import source_artifacts
import releases
from update_cves import CVES, INDEX, ensure_cve_entries, http_json
2026-09-01 19:01:07 +02:00
SECTION = "#### Collections"
USER_AGENT = "0xMarcio-cve-collections"
SOURCE_STATE_FILE = INDEX / "collection_source_state.json"
# Bump when collection selection or the cached mapping format changes.
SOURCE_CACHE_VERSION = 3
2026-09-01 19:01:07 +02:00
CVE = re.compile(r"(?i)CVE[-_](\d{4})[-_](\d{4,7})")
AFROG_PASSIVE = re.compile(
r"(?im)^\s{2}name:\s*.*\b(?:version|protocol|service)\s+detection\b"
)
SOURCES = (
("afrog", "zan8in/afrog", "main"),
("vulnerability", "tzwlhack/Vulnerability", "main"),
("0day", "helloexp/0day", "master"),
("xray", "chaitin/xray", "master"),
("tenable", "tenable/poc", "master"),
("pedrib", "pedrib/PoC", "master"),
2026-09-01 19:01:07 +02:00
)
TREE_SOURCES = (
("google-research", "google/security-research", "master", "pocs/"),
("github-securitylab", "github/securitylab", "main", "SecurityExploits/"),
("rhino-research", "RhinoSecurityLabs/CVEs", "master", ""),
)
CODE_SUFFIXES = {".c", ".cc", ".cpp", ".py", ".js", ".html", ".sh", ".rb", ".go", ".rs", ".ps1"}
CODE_CONTENT = re.compile(
r"(?m)^\s*(?:#include\s*[<\"]|(?:from\s+\S+\s+)?import\s+\S+|require\s+['\"]|"
r"(?:def|class|function)\s+\w+|(?:int|void)\s+\w+\s*\(|<(?:form|script)\b|"
r"(?:curl|python[23]?|ruby|perl|java|msfconsole)\s)"
)
SUPPORT_DIRS = {"docs", "tests", "test", "include", "vendor", "third_party", "images", "fuzzer", "fuzzers"}
SUPPORT_FILE = re.compile(r"^(?:test|build|setup|util|helper|clean|install|init)[-_.a-z0-9]*$", re.I)
# Reviewed patches that turn upstream clients/servers into the reproduction.
SECURITYLAB_PATCHES = {
"SecurityExploits/libssh/pubkey-auth-bypass-CVE-2023-2283/attacker/home/diff.txt",
"SecurityExploits/libssh2/out_of_bounds_read_kex_CVE-2019-13115/server/home/diff.txt",
"SecurityExploits/strongSwan/CVE-2018-5388/stroke_patch.txt",
}
def download_tree(repo: str, branch: str) -> dict:
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
headers = {"Authorization": f"Bearer {token}"} if token else {}
return http_json(
f"https://api.github.com/repos/{repo}/git/trees/{quote(branch, safe='')}?recursive=1",
headers=headers,
)
def source_head(repo: str, branch: str) -> str:
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
headers = {"Authorization": f"Bearer {token}"} if token else {}
payload = http_json(
f"https://api.github.com/repos/{repo}/git/ref/heads/{quote(branch, safe='')}",
headers=headers,
)
obj = payload.get("object") if isinstance(payload, dict) else None
if (
not isinstance(obj, dict) or payload.get("ref") != f"refs/heads/{branch}"
or obj.get("type") != "commit" or not re.fullmatch(r"[0-9a-f]{40}", str(obj.get("sha") or ""))
):
raise ValueError(f"{repo}: missing or invalid source HEAD")
return obj["sha"]
def load_source_state() -> dict:
try:
state = json.loads(SOURCE_STATE_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return state if isinstance(state, dict) else {}
def valid_source_links(links: object, repo: str, branch: str) -> bool:
return isinstance(links, dict) and bool(links) and all(
isinstance(cve, str) and CVE.fullmatch(cve) and cve_ids(cve) == {cve}
and isinstance(urls, list) and bool(urls) and all(
isinstance(url, str) and url.startswith((
f"https://github.com/{repo}/blob/{branch}/",
f"https://github.com/{repo}/tree/{branch}/",
)) for url in urls
) for cve, urls in links.items()
)
def write_source_state(state: dict) -> None:
if state == load_source_state():
return
SOURCE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
temporary = SOURCE_STATE_FILE.with_suffix(".tmp")
temporary.write_text(json.dumps(state, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(SOURCE_STATE_FILE)
def reviewed_rule_fingerprint(repo: str, reviews: list[dict]) -> str | None:
if repo not in {"tenable/poc", "pedrib/PoC"}:
return None
rules = sorted({(row["cve"], row["path"], row["sha256"]) for row in reviews
if row["repository"].lower() == repo.lower()})
return hashlib.sha256(json.dumps(rules, separators=(",", ":")).encode()).hexdigest() if rules else None
def collect_research_tree(repo: str, branch: str, prefix: str, payload: dict) -> dict[str, list[str]]:
"""CVE directories with reproduction artifacts in research collections."""
if not isinstance(payload, dict) or payload.get("truncated") is not False or not isinstance(payload.get("tree"), list):
raise ValueError(f"{repo}: missing or incomplete repository tree")
rows: dict[str, set[str]] = defaultdict(set)
for item in payload["tree"]:
if not isinstance(item, dict) or not isinstance(item.get("path"), str) or item.get("type") not in {"blob", "tree", "commit"}:
raise ValueError(f"{repo}: invalid repository tree entry")
path = item["path"]
if item["type"] != "blob" or item.get("mode") not in {"100644", "100755"} or not item.get("size") or not path.startswith(prefix):
continue
parts = path.split("/")
named = [(i, cve_ids(part)) for i, part in enumerate(parts[:-1]) if cve_ids(part)]
if len(named) != 1 or len(named[0][1]) != 1:
continue
at, ids = named[0]
relative = parts[at + 1:]
name = relative[-1].lower()
if any(part.lower() in SUPPORT_DIRS for part in relative[:-1]) or SUPPORT_FILE.fullmatch(name):
continue
artifact = Path(name).suffix in CODE_SUFFIXES
# These file formats are the reproductions themselves in Security Lab,
# not reports or build inputs (for example libcue's .cue and bug.pdf).
if repo == "github/securitylab":
artifact |= name.endswith(".cue") or name in {"bug.pdf", "fuzzer-poc.djvu", "poc.bin"}
artifact |= path in SECURITYLAB_PATCHES
if artifact:
target = github_link(repo, branch, "tree", "/".join(parts[:at + 1]))
rows[next(iter(ids))].add(target)
return {cve: sorted(urls) for cve, urls in rows.items()}
2026-09-01 19:01:07 +02:00
def download(repo: str, revision: str) -> bytes:
url = f"https://codeload.github.com/{repo}/tar.gz/{quote(revision, safe='')}"
2026-09-01 19:01:07 +02:00
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=900) as response:
return response.read()
def cve_ids(value: str) -> set[str]:
return {
f"CVE-{year}-{number}"
for year, number in CVE.findall(value)
if 1999 <= int(year) <= 2100
}
def github_link(repo: str, branch: str, kind: str, path: str) -> str:
encoded = quote(path, safe="/()[]-_.")
return f"https://github.com/{repo}/{kind}/{branch}/{encoded}"
def author_artifact_ids(repo: str, path: str, text: str) -> set[str]:
named, mentioned = cve_ids(path), cve_ids(text)
parts = Path(path).parts
if not text.strip() or len(named) > 1:
return set()
if any(part.lower() in SUPPORT_DIRS for part in parts[:-1]) or SUPPORT_FILE.fullmatch(parts[-1]):
return set()
if Path(path).suffix.lower() in CODE_SUFFIXES:
if not CODE_CONTENT.search(text) or (repo == "pedrib/PoC" and not path.startswith("exploits/")):
return set()
ids = named or mentioned
return ids if len(ids) == 1 and (not mentioned or mentioned == ids) else set()
if repo != "pedrib/PoC" or not path.startswith("advisories/") or not path.endswith(".md"):
return set()
# Keep the CVE attribution local to an embedded reproducer. A writeup may
# also discuss historical vulnerabilities that its code does not reproduce.
found: set[str] = set()
headings: list[tuple[int, str]] = []
block: list[str] | None = None
for line in text.splitlines():
if line.startswith("```"):
if block is None:
block = []
continue
code = "\n".join(block)
block = None
if not CODE_CONTENT.search(code):
continue
ids = cve_ids(code)
if len(ids) == 1 and re.search(r"\b(?:exploit(?:ing)?|poc|proof.of.concept|reproduc(?:er|tion))\b", code, re.I):
if not named or ids == named:
found.update(ids)
elif not ids and len(mentioned) == 1 and (not named or mentioned == named):
if any(re.search(r"\b(?:exploit(?:ation)?|proof.of.concept|steps to reproduce)\b", title, re.I) for _, title in headings):
found.update(mentioned)
elif block is not None:
block.append(line)
elif heading := re.match(r"^(#{1,6})\s+(.+)", line):
level = len(heading[1])
headings = [(depth, title) for depth, title in headings if depth < level]
headings.append((level, heading[2]))
return found
def collect(repo: str, branch: str, archive: bytes, *, reviews: list[dict] | None = None) -> dict[str, list[str]]:
2026-09-01 19:01:07 +02:00
rows: dict[str, set[str]] = defaultdict(set)
if reviews is None and repo in {"tenable/poc", "pedrib/PoC"}:
reviews = source_artifacts.load_reviews()
2026-09-01 19:01:07 +02:00
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
for member in tar:
if not member.isfile():
continue
parts = Path(member.name).parts
if len(parts) < 2:
continue
relative = "/".join(parts[1:])
suffix = parts[-1].lower()
if repo in {"tenable/poc", "pedrib/PoC"}:
if Path(relative).suffix.lower() not in CODE_SUFFIXES | {".md"}:
continue
handle = tar.extractfile(member)
source = handle.read() if handle else b""
text = source.decode("utf-8", "replace")
ids = source_artifacts.approved_cves_for_artifact(repo, relative, source, reviews=reviews)
if not ids:
ids = author_artifact_ids(repo, relative, text)
target = github_link(repo, branch, "blob", relative)
elif repo == "zan8in/afrog":
2026-09-01 19:01:07 +02:00
if "pocs/afrog-pocs/CVE/" not in relative or not suffix.endswith((".yaml", ".yml")):
continue
handle = tar.extractfile(member)
text = handle.read().decode("utf-8", "replace") if handle else ""
if AFROG_PASSIVE.search(text):
continue
target = github_link(repo, branch, "blob", relative)
ids = cve_ids(parts[-1])
elif repo == "tzwlhack/Vulnerability":
if not suffix.endswith(".md"):
continue
target = github_link(repo, branch, "blob", relative)
ids = cve_ids(parts[-1])
elif repo == "helloexp/0day":
relative_parts = parts[1:]
indexed = next(
(
(index, ids)
for index, part in enumerate(relative_parts)
if (ids := cve_ids(part))
),
None,
)
if indexed is None:
continue
index, ids = indexed
target_path = "/".join(relative_parts[: index + 1])
kind = "tree" if index < len(relative_parts) - 1 else "blob"
target = github_link(repo, branch, kind, target_path)
else:
if not relative.startswith("pocs/") or not suffix.endswith((".yaml", ".yml")):
continue
target = github_link(repo, branch, "blob", relative)
ids = cve_ids(parts[-1])
for cve_id in ids:
rows[cve_id].add(target)
return {cve: sorted(urls) for cve, urls in rows.items()}
def section_bounds(text: str) -> tuple[int, int, int] | None:
marker = "\n" + SECTION
head = text.find(marker)
if head < 0:
return None
body = head + len(marker)
end = text.find("\n#", body)
return head, body, len(text) if end < 0 else end
def apply_links(cve: str, urls: list[str], *, dry_run: bool) -> str:
import link_health
urls = [url for url in urls if not link_health.unavailable(url)]
2026-09-01 19:01:07 +02:00
path = CVES / cve.split("-")[1] / f"{cve}.md"
if not path.exists():
return "absent"
original = path.read_text(encoding="utf-8")
block = "\n" + "\n".join(f"- {url}" for url in urls) + "\n"
bounds = section_bounds(original)
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{SECTION}" + block
if not dry_run:
path.write_text(updated, encoding="utf-8")
return "written"
def drop_section(path: Path, *, dry_run: bool) -> bool:
original = path.read_text(encoding="utf-8")
bounds = section_bounds(original)
if not bounds:
return False
head, _, end = bounds
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 path-level CVE PoC collections")
parser.add_argument("--dry-run", action="store_true", help="report without writing")
parser.add_argument("--cvelist-dir", type=Path, help="local CVE List V5 root for a large backfill")
parser.add_argument("--release-history-limit", type=int, default=8, help="maximum new artifact history checks per run")
2026-09-01 19:01:07 +02:00
args = parser.parse_args()
previous = load_source_state()
cached = previous.get("sources", {}) if previous.get("version") == SOURCE_CACHE_VERSION else {}
if not isinstance(cached, dict):
cached = {}
next_state = {"version": SOURCE_CACHE_VERSION, "sources": {}}
reviews = source_artifacts.load_reviews()
2026-09-01 19:01:07 +02:00
combined: dict[str, set[str]] = defaultdict(set)
sources = [(name, repo, branch, None) for name, repo, branch in SOURCES] + list(TREE_SOURCES)
for name, repo, branch, prefix in sources:
2026-09-01 19:01:07 +02:00
try:
revision = source_head(repo, branch)
review_sha256 = reviewed_rule_fingerprint(repo, reviews)
prior = cached.get(repo) or {}
reusable = (
isinstance(prior, dict) and prior.get("revision") == revision
and prior.get("branch") == branch and prior.get("prefix") == prefix
and prior.get("review_sha256") == review_sha256
and valid_source_links(prior.get("links"), repo, branch)
)
if reusable:
found = prior["links"]
elif prefix is None:
found = collect(repo, branch, download(repo, revision), reviews=reviews)
else:
found = collect_research_tree(repo, branch, prefix, download_tree(repo, revision))
if not valid_source_links(found, repo, branch):
raise ValueError(f"{repo}: empty or invalid collection mapping")
except Exception as problem:
print(f"{repo}: unavailable ({problem})")
return 1
next_state["sources"][repo] = {"revision": revision, "branch": branch, "prefix": prefix, "links": found}
next_state["sources"][repo]["observed_at"] = (
prior.get("observed_at") if prior.get("revision") == revision else None
) or releases.utcnow()
if review_sha256 is not None:
next_state["sources"][repo]["review_sha256"] = review_sha256
for cve, urls in found.items():
combined[cve].update(urls)
print(f"{name}: {len(found):,} CVEs, {sum(map(len, found.values())):,} path links" + (" (cached)" if reusable else ""))
2026-09-01 19:01:07 +02:00
links = {cve: sorted(urls) for cve, urls in combined.items()}
created, unavailable = ensure_cve_entries(
links,
dry_run=args.dry_run,
cvelist_dir=args.cvelist_dir,
)
if created or unavailable:
verb = "would create" if args.dry_run else "created"
print(f"base entries: {len(created):,} {verb}, {len(unavailable):,} unpublished or unavailable")
tally = {"written": 0, "unchanged": 0, "absent": 0}
published = []
2026-09-01 19:01:07 +02:00
for cve, urls in sorted(links.items()):
outcome = apply_links(cve, urls, dry_run=args.dry_run)
tally[outcome] += 1
if outcome != "absent":
published.extend((cve, url) for url in urls)
2026-09-01 19:01:07 +02:00
stale = 0
paths = CVES.glob("[12][0-9][0-9][0-9]/CVE-*.md") if next_state != previous else ()
for path in paths:
2026-09-01 19:01:07 +02:00
if path.stem not in links and ("\n" + SECTION) in path.read_text(
encoding="utf-8", errors="replace"
):
stale += drop_section(path, dry_run=args.dry_run)
if not args.dry_run:
releases.record_collection_snapshot(previous, next_state, published, limit=max(0, args.release_history_limit))
write_source_state(next_state)
2026-09-01 19:01:07 +02:00
print(
f"collections: {len(links):,} CVEs | {tally['written']:,} written, "
f"{tally['unchanged']:,} unchanged, {tally['absent']:,} absent, {stale:,} stale removed"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())