mirror of
https://github.com/0xMarcio/cve.git
synced 2026-10-27 16:51:03 +00:00
Track PoC releases independently and expand verified sources
This commit is contained in:
1 parent
ecabad726b
commit
dbe6530b3c
804 files changed
+142287
-1361
No files matched your search
+111
-57
@@ -22,8 +22,10 @@ import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from string import Template
|
||||
from urllib import error, request
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
|
||||
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||||
@@ -36,6 +38,7 @@ GITHUB = "https://github.com/"
|
||||
SEVERITIES = {"NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"}
|
||||
LASTMOD = "page_lastmod.json"
|
||||
PAGE_STATE = "page_state.json"
|
||||
TREND_ROWS = 20
|
||||
|
||||
SOURCES = (
|
||||
("nuclei", "Nuclei templates", "projectdiscovery/nuclei-templates"),
|
||||
@@ -45,6 +48,11 @@ SOURCES = (
|
||||
("collections", "Exploit collections", ""),
|
||||
)
|
||||
|
||||
# As logic.js labels curated paths: by leaf for these (CURATED there), and
|
||||
# below these collection roots for the rest (COLLECTION_SOURCES there).
|
||||
NAMED_BY_LEAF = {"zan8in/afrog", "chaitin/xray", "helloexp/0day", "tzwlhack/vulnerability"}
|
||||
COLLECTION_ROOTS = {"google/security-research": "pocs/", "github/securitylab": "SecurityExploits/"}
|
||||
|
||||
|
||||
def load(name: str) -> dict | list:
|
||||
with open(os.path.join(DOCS, name), encoding="utf-8") as handle:
|
||||
@@ -166,13 +174,25 @@ def repo_of(url: str) -> str | None:
|
||||
return f"{parts[0].lower()}/{re.sub(r'[.]git$', '', parts[1], flags=re.I).lower()}"
|
||||
|
||||
|
||||
def artifact_label(url: str) -> str:
|
||||
"""A curated link's path inside its repository, as logic.js labels it.
|
||||
Empty for a repository root or a link that is not into a repository."""
|
||||
repo = repo_of(url)
|
||||
if not repo:
|
||||
return ""
|
||||
parts = [part for part in urlsplit(url).path.split("/") if part][2:]
|
||||
if parts[:1] in (["tree"], ["blob"]):
|
||||
parts = parts[2:]
|
||||
if repo in NAMED_BY_LEAF:
|
||||
parts = parts[-1:]
|
||||
path = unquote("/".join(parts))
|
||||
root = COLLECTION_ROOTS.get(repo, "")
|
||||
return path[len(root):] if root and path.startswith(root) and len(path) > len(root) else path
|
||||
|
||||
|
||||
def page_lastmod(entry: dict, data: dict) -> str:
|
||||
"""Bootstrap date before a page has a published content fingerprint."""
|
||||
stamps = [entry.get("modified") or "", entry.get("published") or ""]
|
||||
for url in entry.get("poc") or []:
|
||||
meta = data["repo_meta"].get(repo_of(url) or "")
|
||||
if meta and len(meta) > 1 and isinstance(meta[1], str):
|
||||
stamps.append(meta[1][:10])
|
||||
"""Bootstrap date before a page has a published content fingerprint: the record's own."""
|
||||
stamps = (entry.get("modified") or "", entry.get("published") or "")
|
||||
return max((s for s in stamps if len(s) == 10), default=data["today"])
|
||||
|
||||
|
||||
@@ -196,38 +216,46 @@ def previous_page_state() -> dict:
|
||||
return payload["pages"]
|
||||
|
||||
|
||||
def tracked_state(key: str, payload: object, today: str, fallback: str, previous: dict) -> list[str]:
|
||||
fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
def digest(payload: object) -> str:
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def tracked_state(key: str, payload: object, today: str, fallback: str, previous: dict,
|
||||
legacy=None) -> list[str]:
|
||||
fingerprint = digest(payload)
|
||||
old = previous.get(key)
|
||||
if old is not None:
|
||||
if not isinstance(old, list) or len(old) != 2:
|
||||
raise ValueError(f"Invalid published content date for {key}")
|
||||
date.fromisoformat(old[1])
|
||||
lastmod = old[1] if old[0] == fingerprint else today
|
||||
unchanged = old[0] == fingerprint or (legacy is not None and old[0] == digest(legacy()))
|
||||
lastmod = old[1] if unchanged else today
|
||||
else:
|
||||
lastmod = today if previous else fallback
|
||||
return [fingerprint, lastmod]
|
||||
|
||||
|
||||
def content_state(entry: dict, data: dict, previous: dict) -> list[str]:
|
||||
"""Track significant content, including curated links absent from repo_meta.
|
||||
"""Track significant content: the record, every link, CVSS, advisories,
|
||||
Nuclei and KEV.
|
||||
|
||||
Daily EPSS fluctuations, star counts and site-wide totals are deliberately
|
||||
excluded; they are not a new exploit, assessment, or vulnerability record.
|
||||
Daily EPSS fluctuations, star counts, repository pushes and site-wide totals
|
||||
are deliberately excluded; they are not a new exploit, assessment, or
|
||||
vulnerability record.
|
||||
"""
|
||||
cid = entry["cve"]
|
||||
payload = {
|
||||
"entry": entry,
|
||||
"cvss": (data["meta"].get(cid) or {}).get("cvss"),
|
||||
"advisories": (data["meta"].get(cid) or {}).get("advisories"),
|
||||
"nuclei": data["nuclei"].get(cid),
|
||||
"kev": data["kev"].get(cid),
|
||||
"pushed": {
|
||||
repo: meta[1] for url in entry.get("poc") or []
|
||||
if (repo := repo_of(url)) and (meta := data["repo_meta"].get(repo)) and len(meta) > 1
|
||||
},
|
||||
}
|
||||
return tracked_state(cid, payload, data["today"], page_lastmod(entry, data), previous)
|
||||
meta = data["meta"].get(cid) or {}
|
||||
payload = {"entry": entry, "cvss": meta.get("cvss"), "advisories": meta.get("advisories"),
|
||||
"nuclei": data["nuclei"].get(cid), "kev": data["kev"].get(cid)}
|
||||
|
||||
def legacy() -> dict:
|
||||
# Manifests published while pushes were fingerprinted still match, so they keep their dates.
|
||||
repos = data.get("repo_meta") or {}
|
||||
pushed = {repo: stored[1] for url in entry.get("poc") or []
|
||||
if (repo := repo_of(url)) and (stored := repos.get(repo)) and len(stored) > 1}
|
||||
return {**payload, "pushed": pushed}
|
||||
|
||||
return tracked_state(cid, payload, data["today"], page_lastmod(entry, data), previous, legacy)
|
||||
|
||||
|
||||
def build_related(cves: list) -> dict:
|
||||
@@ -269,7 +297,7 @@ def build_related(cves: list) -> dict:
|
||||
return related
|
||||
|
||||
|
||||
def link_rows(urls: list, repo_meta: dict, *, trusted: bool = False) -> str:
|
||||
def link_rows(urls: list, repo_meta: dict, *, trusted: bool = False, collection: bool = False) -> str:
|
||||
"""One row per source link.
|
||||
|
||||
The repository list is crowd-sourced: anyone can publish a repository named
|
||||
@@ -277,13 +305,21 @@ def link_rows(urls: list, repo_meta: dict, *, trusted: bool = False) -> str:
|
||||
Metasploit, Vulhub and the CVE and NVD records are curated destinations the
|
||||
index deliberately vouches for, and Google's guidance is to leave a link
|
||||
unqualified when the association is intended.
|
||||
|
||||
A curated path shows neither its repository's stars nor its push date.
|
||||
Collection paths are labelled as search labels them, with the repository
|
||||
as the note.
|
||||
"""
|
||||
rel = "noopener" if trusted else "nofollow noopener"
|
||||
rows = []
|
||||
for url in urls:
|
||||
label = url[len(GITHUB):] if url.startswith(GITHUB) else url
|
||||
meta = repo_meta.get(repo_of(url) or "")
|
||||
path = artifact_label(url) if trusted else ""
|
||||
meta = None if path else repo_meta.get(repo_of(url) or "")
|
||||
note = f"<span>{meta[0]}★ · {esc(meta[1])}</span>" if meta else ""
|
||||
if path and collection:
|
||||
repository = "/".join(url[len(GITHUB):].split("/")[:2])
|
||||
label, note = path, f"<span>{esc(repository)}</span>"
|
||||
rows.append(
|
||||
f'<li><a href="{esc(url)}" rel="{rel}" target="_blank">'
|
||||
f"{esc(short(label, 90))}</a>{note}</li>"
|
||||
@@ -402,7 +438,7 @@ def page(entry: dict, data: dict) -> str:
|
||||
if urls:
|
||||
blocks.append(
|
||||
f"<h2>{heading} ({len(urls)})</h2>"
|
||||
f'<ul class="cve-links">{link_rows(urls, data["repo_meta"], trusted=True)}</ul>'
|
||||
f'<ul class="cve-links">{link_rows(urls, data["repo_meta"], trusted=True, collection=key == "collections")}</ul>'
|
||||
)
|
||||
|
||||
siblings = "".join(
|
||||
@@ -633,6 +669,52 @@ def hub_pages(cves: list, data: dict) -> dict:
|
||||
return {"pages": pages, "lastmod": lastmod, "states": states}
|
||||
|
||||
|
||||
def utc_day(value: object) -> str:
|
||||
"""UTC date of an ISO date or timestamp, read as logic.js reads it: a time
|
||||
without a zone is UTC. Empty when there is no readable date."""
|
||||
if not isinstance(value, str) or not re.match(r"\d{4}-\d{2}-\d{2}", value):
|
||||
return ""
|
||||
try:
|
||||
moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return ""
|
||||
return (moment.astimezone(timezone.utc) if moment.tzinfo else moment).date().isoformat()
|
||||
|
||||
|
||||
def star_label(stars: int) -> str:
|
||||
"""formatStars in logic.js. toFixed rounds the exact binary value half up,
|
||||
so 1,250 stars is 1.3k there where Python's format would print 1.2k."""
|
||||
if stars < 1000:
|
||||
return str(stars)
|
||||
return f"{Decimal(stars / 1000).quantize(Decimal('0.1'), ROUND_HALF_UP)}k"
|
||||
|
||||
|
||||
def trend_rows(items: object, landed: bool) -> str:
|
||||
"""A front-page list as logic.js renders it, in feed order. Just landed is
|
||||
dated by first release, Trending by latest artifact commit."""
|
||||
rows = []
|
||||
for item in (items if isinstance(items, list) else [])[:TREND_ROWS]:
|
||||
# A path in a collection does not have the collection's stars.
|
||||
stars = None if item.get("artifact") else item.get("stars")
|
||||
star_cell = "" if stars is None else f'{star_label(stars)} <span class="star">★</span>'
|
||||
popular = " is-popular" if stars is not None and stars >= 500 else ""
|
||||
# Off GitHub the last path segment is a bare id, so it keeps its host.
|
||||
source = str(item.get("source") or "").removeprefix("www.")
|
||||
name = (f"{source}/{item.get('name') or ''}" if item.get("artifact") and source and source != "github.com"
|
||||
else item.get("name"))
|
||||
flagged = '<span class="trend-kev" title="CISA known exploited">KEV</span>' if item.get("kev") else ""
|
||||
detail = (f'<a class="trend-detail" href="{esc(item["page"])}">{esc(item.get("cve"))} details</a>'
|
||||
if item.get("page") else "")
|
||||
rows.append(
|
||||
f'<div class="trend-row"><span class="trend-stars{popular}">{star_cell}</span>'
|
||||
f'<span class="trend-age">{utc_day(item.get("released" if landed else "pushed"))}</span>'
|
||||
f'<span class="trend-name-cell">{flagged}<a class="trend-name" href="{esc(item.get("url"))}" '
|
||||
f'target="_blank" rel="nofollow noopener">{esc(name)}</a>{detail}</span>'
|
||||
f'<span class="trend-desc">{esc(item.get("desc"))}</span></div>'
|
||||
)
|
||||
return "".join(rows) or '<div class="empty">No recent PoCs.</div>'
|
||||
|
||||
|
||||
def homepage(cves: list, kev: dict, trending: dict) -> str:
|
||||
"""Publish the same content before JavaScript or a crawler render runs."""
|
||||
indexed = {entry["cve"] for entry in cves}
|
||||
@@ -640,35 +722,6 @@ def homepage(cves: list, kev: dict, trending: dict) -> str:
|
||||
raise ValueError("Homepage counts disagree with the searchable corpus")
|
||||
generated = datetime.fromisoformat(trending["generated"].replace("Z", "+00:00"))
|
||||
|
||||
def rank(item: dict) -> float:
|
||||
try:
|
||||
pushed = datetime.fromisoformat(item["pushed"].replace("Z", "+00:00"))
|
||||
if pushed.tzinfo is None:
|
||||
pushed = pushed.replace(tzinfo=timezone.utc)
|
||||
age = max(0, (generated - pushed).total_seconds() / 3600)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
age = 8760
|
||||
return item["stars"] / (age + 6) ** 0.45
|
||||
|
||||
rows = []
|
||||
for item in sorted(trending["items"], key=rank, reverse=True)[:20]:
|
||||
cid = item.get("cve")
|
||||
stars = item["stars"]
|
||||
star_label = f"{stars / 1000:.1f}k" if stars >= 1000 else str(stars)
|
||||
popular = " is-popular" if stars >= 500 else ""
|
||||
flagged = '<span class="trend-kev" title="CISA known exploited">KEV</span>' if cid in kev else ""
|
||||
detail = (f'<a class="trend-detail" href="/{esc(cid)}">{esc(cid)} details</a>'
|
||||
if cid in indexed else "")
|
||||
rows.append(
|
||||
f'<div class="trend-row"><span class="trend-stars{popular}">{star_label} <span class="star">★</span></span>'
|
||||
f'<span class="trend-age">{esc((item.get("pushed") or "")[:10])}</span>'
|
||||
f'<span class="trend-name-cell">{flagged}<a class="trend-name" href="{esc(item["url"])}" '
|
||||
f'target="_blank" rel="nofollow noopener">{esc(item["name"])}</a>{detail}</span>'
|
||||
f'<span class="trend-desc">{esc(item.get("desc"))}</span></div>'
|
||||
)
|
||||
if not rows:
|
||||
rows.append('<div class="trend-row"><span class="trend-desc">No recent PoCs.</span></div>')
|
||||
|
||||
website = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
@@ -687,7 +740,8 @@ def homepage(cves: list, kev: dict, trending: dict) -> str:
|
||||
description=esc(DESCRIPTION), site=esc(SITE), fonts=FONTS,
|
||||
sources_line=SOURCES_LINE, repo_link=REPO_LINK, website_schema=json_ld(website),
|
||||
total_cves=f"{trending['total_cves']:,}", with_pocs=f"{len(indexed):,}", kev=f"{len(kev):,}",
|
||||
trending_rows="\n".join(rows), generated=esc(generated.strftime("%Y-%m-%d %H:%M UTC")),
|
||||
landed_rows=trend_rows(trending.get("landed"), True),
|
||||
generated=esc(generated.strftime("%Y-%m-%d %H:%M UTC")),
|
||||
year_links=" ".join(f'<a href="/{esc(year)}">{esc(year)}</a>' for year in years),
|
||||
)
|
||||
|
||||
|
||||
+62
-4
@@ -36,6 +36,7 @@ METADATA_OUTPUT = DOCS_DIR / "cve_metadata.json"
|
||||
CVSS_OUTPUT = DOCS_DIR / "cvss.json"
|
||||
ADVISORIES_OUTPUT = DOCS_DIR / "advisories.json"
|
||||
VERIFIED_REFERENCES = INDEX / "reference_pocs.txt"
|
||||
REVIEWED_REFERENCES = INDEX / "reference_poc_verified.json"
|
||||
EPSS_OUTPUT = DOCS_DIR / "epss.json"
|
||||
EPSS_FEED = "https://epss.empiricalsecurity.com/epss_scores-current.csv.gz"
|
||||
GITHUB_NON_REPOS = {
|
||||
@@ -103,11 +104,11 @@ def load_metadata() -> Dict[str, object]:
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_verified_references() -> set[tuple[str, str]]:
|
||||
"""CVE Program references with direct exploit evidence."""
|
||||
"""Independent CVE Program and reviewed-content reference evidence."""
|
||||
try:
|
||||
lines = VERIFIED_REFERENCES.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return set()
|
||||
lines = []
|
||||
verified: set[tuple[str, str]] = set()
|
||||
for line in lines:
|
||||
if " - " not in line:
|
||||
@@ -115,6 +116,11 @@ def load_verified_references() -> set[tuple[str, str]]:
|
||||
cve_id, url = line.split(" - ", 1)
|
||||
if cve_id and url:
|
||||
verified.add((cve_id, link_key(url)))
|
||||
if REVIEWED_REFERENCES.exists():
|
||||
reviewed = json.loads(REVIEWED_REFERENCES.read_text(encoding="utf-8"))
|
||||
if not isinstance(reviewed, dict) or any(not isinstance(urls, list) for urls in reviewed.values()):
|
||||
raise ValueError("Invalid reviewed reference ledger")
|
||||
verified.update((cve_id, link_key(url)) for cve_id, urls in reviewed.items() for url in urls)
|
||||
return verified
|
||||
|
||||
|
||||
@@ -189,7 +195,8 @@ def repo_from_url(url: str) -> str:
|
||||
except Exception:
|
||||
path = url
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) < 2 or parts[0].lower() in GITHUB_NON_REPOS:
|
||||
if (len(parts) < 2 or parts[0].lower() in GITHUB_NON_REPOS
|
||||
or [part.lower() for part in parts[2:4]] == ["security", "advisories"]):
|
||||
return ""
|
||||
owner, repo = parts[0].lower(), re.sub(r"\.git$", "", parts[1], flags=re.I).lower()
|
||||
return f"{owner}/{repo}" if repo else ""
|
||||
@@ -299,8 +306,12 @@ def reference_is_verified_poc(
|
||||
verified: Collection[tuple[str, str]],
|
||||
) -> bool:
|
||||
key = link_key(url)
|
||||
if (cve_id, key) in load_reference_exclusions():
|
||||
return False
|
||||
if (cve_id, key) in verified:
|
||||
return True
|
||||
if (cve_id, key) in load_managed_references():
|
||||
return False
|
||||
for row in (metadata.get(cve_id) or {}).get("advisories", []):
|
||||
if not isinstance(row, list) or not row:
|
||||
continue
|
||||
@@ -310,6 +321,18 @@ def reference_is_verified_poc(
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_reference_exclusions() -> set[tuple[str, str]]:
|
||||
import sync_reference_pocs
|
||||
return sync_reference_pocs.load_exclusions()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_managed_references() -> set[tuple[str, str]]:
|
||||
import sync_reference_pocs
|
||||
return {(row["cve"], link_key(row["url"])) for row in sync_reference_pocs.load_reviews()}
|
||||
|
||||
|
||||
def collect_links(block: str, *, blacklist: Optional[Collection[str]] = None) -> List[str]:
|
||||
links: List[str] = []
|
||||
blacklist = blacklist or set()
|
||||
@@ -525,6 +548,35 @@ def build_trending(blacklist: Collection[str]) -> List[Dict[str, object]]:
|
||||
]
|
||||
|
||||
|
||||
def build_landed(cve_payload: list[dict], repo_meta: dict, kev: dict,
|
||||
ledger: dict | None = None, *, now: str | None = None, candidates: list[dict] | None = None) -> list[dict]:
|
||||
import releases
|
||||
ledger = releases.load_ledger() if ledger is None else ledger
|
||||
entries = {entry["cve"]: entry for entry in cve_payload}
|
||||
published = {releases.key(cve, url) for cve, url in releases.published_pairs(cve_payload)}
|
||||
candidates = {releases.key(row["cve"], row["url"]): row for row in candidates or []}
|
||||
rows = []
|
||||
for artifact in releases.landed(ledger, now=now):
|
||||
cve, url = artifact["cve"], artifact["url"]
|
||||
if releases.key(cve, url) not in published:
|
||||
continue
|
||||
parsed = urlparse(url)
|
||||
parts = unquote(parsed.path).strip("/").split("/")
|
||||
repository = repo_from_url(url)
|
||||
root_link = parsed.hostname == "github.com" and len(parts) == 2
|
||||
meta = repo_meta.get(repository or "") if root_link else None
|
||||
current = candidates.get(releases.key(cve, url), {})
|
||||
stars = current.get("stars", meta[0] if meta else None) if root_link else None
|
||||
rows.append({
|
||||
"year": int(cve.split("-")[1]), "cve": cve, "url": url,
|
||||
"name": parts[-1] or cve, "stars": stars,
|
||||
"desc": entries[cve].get("desc", ""), "released": artifact["released"],
|
||||
"basis": artifact["basis"], "kev": cve in kev, "page": f"/{cve}",
|
||||
"landed": True, "source": parsed.hostname, "artifact": not root_link,
|
||||
})
|
||||
return rows[:100]
|
||||
|
||||
|
||||
def write_json(path: Path, data, *, indent: Optional[int] = None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
@@ -558,8 +610,12 @@ def main() -> int:
|
||||
epss = build_epss(cve_payload)
|
||||
write_json(EPSS_OUTPUT, epss)
|
||||
|
||||
trending_items = build_trending(blacklist)
|
||||
indexed = {entry["cve"] for entry in cve_payload}
|
||||
frontpage_candidates = build_trending(blacklist)
|
||||
trending_items = [row for row in frontpage_candidates
|
||||
if row.get("trending", True) and row.get("cve") in indexed]
|
||||
trending_items.sort(key=lambda row: row.get("score", 0), reverse=True)
|
||||
landed_items = build_landed(cve_payload, repo_meta, kev, candidates=frontpage_candidates)
|
||||
for item in trending_items:
|
||||
# Only link to detail pages that this build will actually publish.
|
||||
item["page"] = f"/{item['cve']}" if item.get("cve") in indexed else None
|
||||
@@ -570,6 +626,8 @@ def main() -> int:
|
||||
"total_cves": total_cves,
|
||||
"with_pocs": len(cve_payload),
|
||||
"items": trending_items,
|
||||
"landed": landed_items,
|
||||
"ranking": "stars_weighted_by_artifact_release_age",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable release evidence for published CVE/artifact pairs.
|
||||
|
||||
Observing a link is not evidence of its publication date. Imports remain
|
||||
undated until source history supplies evidence; removed links stay recorded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlencode, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import source_artifacts
|
||||
from update_cves import CODE_FILE_RE, INDEX, POC_RE, poc_link_key
|
||||
|
||||
DIRECTORY = INDEX / "releases"
|
||||
VERSION = 1
|
||||
DATE_ORDER = {"commit": 1, "source": 2, "created": 3, "public": 4, "merged": 5, "seen": 6}
|
||||
EXCLUDED_DIRS = {".git", ".github", "docs", "doc", "images", "img", "assets", "screenshots", "media",
|
||||
"tests", "test", "node_modules", "vendor", "third_party", "__pycache__"}
|
||||
BUILD_FILES = {"setup.py", "conftest.py", "__init__.py", "requirements.txt", "package.json", "package-lock.json",
|
||||
"go.mod", "go.sum", "cargo.toml", "cargo.lock", "makefile", "cmakelists.txt", "dockerfile",
|
||||
"docker-compose.yml", "pyproject.toml"}
|
||||
DATA_SUFFIXES = {".json", ".yml", ".yaml", ".toml", ".lock", ".txt", ".csv", ".xml", ".ini", ".cfg", ".md",
|
||||
".rst", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".gz", ".zip", ".xz", ".bz2",
|
||||
".tar", ".7z", ".o", ".so", ".a", ".dll", ".exe"}
|
||||
PAPERWORK = re.compile(r"^(?:readme|license|licence|security|contributing|changelog|code.of.conduct)(?:\.|$)", re.I)
|
||||
SUPPORT_FILE = re.compile(r"^(?:setup|install|example|demo|test|conftest|helper|util|common)(?:[_.-]|$)", re.I)
|
||||
HISTORY_VERSION = 3
|
||||
HISTORY_FIELDS = {"paths", "rev", "blobs", "commit", "sha", "commit_verified", "history_paths",
|
||||
"merged", "merged_verified", "history_version", "bulk"}
|
||||
|
||||
|
||||
class Ledger(dict):
|
||||
"""Keep identity comparisons local to one CVE during large backfills."""
|
||||
def __init__(self, values=()):
|
||||
super().__init__()
|
||||
self.by_cve = defaultdict(set)
|
||||
for identity, row in dict(values).items():
|
||||
self[identity] = row
|
||||
|
||||
def __setitem__(self, identity, row):
|
||||
super().__setitem__(identity, row)
|
||||
self.by_cve[row["cve"]].add(identity)
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return Ledger((identity, copy.deepcopy(row, memo)) for identity, row in self.items())
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def timestamp(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def key(cve: str, url: str) -> str:
|
||||
if not re.fullmatch(r"CVE-\d{4}-\d{4,}", cve):
|
||||
raise ValueError(f"Invalid CVE: {cve}")
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError("Invalid artifact URL")
|
||||
return f"{cve} {poc_link_key(url)}"
|
||||
|
||||
|
||||
def artifact_paths(entries: list[dict], cve: str, *, readme_path: str | None = None,
|
||||
readme_qualified: bool = False, limit: int = 12) -> list[str]:
|
||||
"""Select paths in an already qualified repository, before applying a cap.
|
||||
|
||||
A selected directory still needs its qualifying files checked for dating.
|
||||
README fallback is explicit: an advisory/claim alone is not an artifact.
|
||||
"""
|
||||
ranked = []
|
||||
for entry in entries:
|
||||
path = str(entry.get("path") or entry.get("name") or "")
|
||||
parts = path.split("/")
|
||||
name = parts[-1].lower()
|
||||
if (not path or any(part.lower() in EXCLUDED_DIRS for part in parts)
|
||||
or name in BUILD_FILES or PAPERWORK.match(name) or SUPPORT_FILE.match(name)):
|
||||
continue
|
||||
kind = str(entry.get("type") or "blob").lower()
|
||||
code = bool(CODE_FILE_RE.search(name)) or name.endswith((".html", ".asm", ".lua"))
|
||||
if not code and Path(name).suffix in DATA_SUFFIXES:
|
||||
continue
|
||||
rank = (0 if cve.lower() in name else 1 if code else 2 if POC_RE.search(name)
|
||||
else 3 if kind == "tree" else 4)
|
||||
ranked.append((rank, path))
|
||||
selected = [path for _, path in sorted(set(ranked))[:limit]]
|
||||
return selected or ([readme_path] if readme_qualified and readme_path else [])
|
||||
|
||||
|
||||
def artifact_identity(entries: list[dict], paths: list[str]) -> dict:
|
||||
"""Fingerprint selected content, independently of path moves or paperwork."""
|
||||
selected = {entry.get("path") or entry.get("name"): entry for entry in entries}
|
||||
objects, blobs = [], []
|
||||
for path in paths:
|
||||
entry = selected.get(path) or {}
|
||||
oid = entry.get("sha256") or entry.get("oid") or entry.get("sha")
|
||||
if not isinstance(oid, str) or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", oid):
|
||||
continue
|
||||
kind = str(entry.get("type") or "blob").lower()
|
||||
objects.append((kind, oid))
|
||||
size = entry.get("size", entry.get("byteSize", 0))
|
||||
if kind == "blob" and isinstance(size, int) and size >= 256:
|
||||
blobs.append(oid)
|
||||
result = {"paths": sorted(paths)}
|
||||
if objects:
|
||||
result["rev"] = hashlib.sha256(json.dumps(sorted(set(objects)), separators=(",", ":")).encode()).hexdigest()
|
||||
if blobs:
|
||||
result["blobs"] = sorted(set(blobs))
|
||||
return result
|
||||
|
||||
|
||||
def date_release(evidence: dict, *, now: str | None = None) -> tuple[str | None, str]:
|
||||
"""Latest evidenced lower bound; client commit clocks have strict ceilings."""
|
||||
if evidence.get("pending"):
|
||||
return None, "pending"
|
||||
current = timestamp(now or utcnow())
|
||||
ceiling = current
|
||||
for name in ("seen", "pushed_at"):
|
||||
if parsed := timestamp(evidence.get(name)):
|
||||
ceiling = min(ceiling, parsed)
|
||||
bounds = []
|
||||
for name in ("created", "public", "commit", "merged", "source"):
|
||||
value = evidence.get(name)
|
||||
moment = timestamp(value)
|
||||
if moment is None or moment > ceiling:
|
||||
continue
|
||||
if name in {"commit", "merged"} and evidence.get(f"{name}_verified") is not True:
|
||||
continue
|
||||
bounds.append((moment, DATE_ORDER[name], value, name))
|
||||
chosen = max(bounds) if bounds else None
|
||||
absent, seen = timestamp(evidence.get("absent")), timestamp(evidence.get("seen"))
|
||||
# Parser/gate changes and import baselines never establish absence.
|
||||
if (not evidence.get("imported") and evidence.get("absence_verified") is True
|
||||
and evidence.get("previous_head") and absent and seen and absent < seen <= current
|
||||
and (chosen is None or chosen[0] <= absent)):
|
||||
return evidence["seen"], "seen"
|
||||
return (chosen[2], chosen[3]) if chosen else (None, "unknown")
|
||||
|
||||
|
||||
def load_ledger(directory: Path | None = None) -> dict[str, dict]:
|
||||
result = Ledger()
|
||||
for path in sorted((directory or DIRECTORY).glob("[12][0-9][0-9][0-9].json")):
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if payload.get("version") != VERSION or not isinstance(payload.get("entries"), dict):
|
||||
raise ValueError(f"Invalid release shard: {path}")
|
||||
for identity, row in payload["entries"].items():
|
||||
if (not isinstance(row, dict) or identity != key(row.get("cve", ""), row.get("url", ""))
|
||||
or row["cve"].split("-")[1] != path.stem or identity in result):
|
||||
raise ValueError(f"Invalid release entry: {path}")
|
||||
result[identity] = row
|
||||
return result
|
||||
|
||||
|
||||
def save_ledger(ledger: dict[str, dict], directory: Path | None = None) -> None:
|
||||
directory = directory or DIRECTORY
|
||||
previous = load_ledger(directory)
|
||||
if not previous.keys() <= ledger.keys():
|
||||
raise ValueError("Release history cannot shrink; retain removed entries as tombstones")
|
||||
for identity, old in previous.items():
|
||||
if old.get("seen") != ledger[identity].get("seen") or old.get("imported") != ledger[identity].get("imported"):
|
||||
raise ValueError("First release observation and import provenance are immutable")
|
||||
shards = defaultdict(dict)
|
||||
for identity, row in sorted(ledger.items()):
|
||||
if identity != key(row["cve"], row["url"]):
|
||||
raise ValueError("Release key differs from its artifact")
|
||||
shards[row["cve"].split("-")[1]][identity] = row
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
for year, entries in shards.items():
|
||||
# One sorted entry per line keeps backfills and hourly diffs reviewable.
|
||||
body = '{"version":1,"entries":{\n' + ",\n".join(
|
||||
json.dumps(identity) + ":" + json.dumps(row, sort_keys=True, separators=(",", ":"))
|
||||
for identity, row in entries.items()) + "\n}}\n"
|
||||
path = directory / f"{year}.json"
|
||||
if path.exists() and path.read_text(encoding="utf-8") == body:
|
||||
continue
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(body, encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _repository_artifact(url: str) -> tuple[str, str] | None:
|
||||
parsed = urlsplit(url)
|
||||
parts = unquote(parsed.path).strip("/").split("/")
|
||||
if parsed.hostname not in {"github.com", "www.github.com"} or len(parts) < 2:
|
||||
return None
|
||||
if len(parts) == 2:
|
||||
return "/".join(parts[:2]).lower(), ""
|
||||
if len(parts) >= 5 and parts[2] in {"blob", "tree"}:
|
||||
return "/".join(parts[:2]).lower(), "/".join(parts[4:])
|
||||
return None
|
||||
|
||||
|
||||
def _same_artifact(row: dict, candidate: dict) -> bool:
|
||||
left, right = _repository_artifact(row["url"]), _repository_artifact(candidate["url"])
|
||||
if (left and right and row.get("rev") and row.get("rev") == candidate.get("rev")
|
||||
and (left[0] == right[0] or row.get("repo") and row.get("repo") == candidate.get("repo"))):
|
||||
return True
|
||||
# Repository IDs identify transfers, but never collapse different variants.
|
||||
return bool(left and right and row.get("repo") and row.get("repo") == candidate.get("repo")
|
||||
and left[1] == right[1] and left[0] != right[0])
|
||||
|
||||
|
||||
def record(ledger: dict[str, dict], cve: str, url: str, evidence: dict | None = None, *,
|
||||
observed_at: str | None = None, import_mode: bool = False) -> dict:
|
||||
observed_at = observed_at or utcnow()
|
||||
identity = key(cve, url)
|
||||
old = ledger.get(identity)
|
||||
row = copy.deepcopy(old) if old else {"cve": cve, "url": url, "basis": "unknown", "released": None}
|
||||
row.pop("gone", None)
|
||||
if not old:
|
||||
if import_mode:
|
||||
row["imported"] = True
|
||||
else:
|
||||
row["seen"] = observed_at
|
||||
evidence = evidence or {}
|
||||
if evidence.get("error") and row.get("released"):
|
||||
# A failed refresh may advance retry bookkeeping, never valid evidence.
|
||||
evidence = {name: value for name, value in evidence.items() if name in {"error", "checked", "head"}}
|
||||
method = evidence.get("history_version", 0)
|
||||
method = method if type(method) is int and method > 0 else 0
|
||||
old_method = row.get("history_version", 0)
|
||||
old_method = old_method if type(old_method) is int and old_method > 0 else 0
|
||||
moment = timestamp(evidence.get("commit"))
|
||||
ceiling = min(timestamp(value) for value in (
|
||||
observed_at, row.get("seen"), evidence.get("pushed_at", row.get("pushed_at"))
|
||||
) if timestamp(value))
|
||||
verified_history = bool(not evidence.get("error") and evidence.get("commit_verified") is True
|
||||
and moment and moment <= ceiling)
|
||||
correction = bool(old and verified_history and method > old_method)
|
||||
if old_method and evidence.get("commit_verified") and method < old_method:
|
||||
evidence = {name: value for name, value in evidence.items() if name not in HISTORY_FIELDS}
|
||||
if correction:
|
||||
for name in HISTORY_FIELDS | {"copy", "absence_verified"}:
|
||||
row.pop(name, None)
|
||||
row.update(released=None, basis="unknown")
|
||||
for name in ("created", "public", "repo", "paths", "rev", "blobs", "source", "absent", "previous_head",
|
||||
"absence_verified", "commit", "sha", "commit_verified", "merged", "merged_verified", "pushed_at", "bulk", "checked", "head", "history_paths"):
|
||||
if name not in evidence:
|
||||
continue
|
||||
value = evidence[name]
|
||||
if name in {"paths", "rev", "blobs", "absent", "previous_head", "absence_verified"} and name in row:
|
||||
continue
|
||||
if name in {"commit", "merged", "source"} and timestamp(row.get(name)):
|
||||
if not timestamp(value) or timestamp(value) >= timestamp(row[name]):
|
||||
continue
|
||||
if name == "sha" and row.get("commit") != evidence.get("commit"):
|
||||
continue
|
||||
row[name] = value
|
||||
if verified_history and method >= old_method and method:
|
||||
row["history_version"] = method
|
||||
if evidence.get("error"):
|
||||
if not row.get("released"):
|
||||
row.update(basis="pending", pending=True)
|
||||
else:
|
||||
if evidence.get("commit_verified") or evidence.get("source") or evidence.get("absence_verified"):
|
||||
row.pop("pending", None)
|
||||
released, basis = date_release(row, now=observed_at)
|
||||
if released:
|
||||
# Re-adds and subsequent observations are not another release.
|
||||
if row.get("basis") != "seen" or not row.get("released"):
|
||||
row.update(released=released, basis=basis)
|
||||
if (not old or correction or not any(old.get(field) for field in ("repo", "rev", "blobs"))) and any(row.get(field) for field in ("repo", "rev", "blobs")):
|
||||
identities = ledger.by_cve.get(cve, ()) if isinstance(ledger, Ledger) else ledger.keys()
|
||||
candidates = [(other_key, ledger[other_key]) for other_key in identities
|
||||
if other_key != identity and ledger[other_key]["cve"] == cve]
|
||||
matches = [(other_key, other) for other_key, other in candidates if _same_artifact(row, other)]
|
||||
copies = [(other_key, other) for other_key, other in candidates
|
||||
if row.get("blobs") and set(row["blobs"]) == set(other.get("blobs", []))]
|
||||
if matches or copies:
|
||||
other_key, other = min(matches or copies, key=lambda item: (item[1].get("released") or "9999", item[0]))
|
||||
row["copy"] = other.get("copy", other_key)
|
||||
row["released"], row["basis"] = other.get("released"), other.get("basis", "unknown")
|
||||
elif row.get("copy") in ledger:
|
||||
original = ledger[row["copy"]]
|
||||
row["released"], row["basis"] = original.get("released"), original.get("basis", "unknown")
|
||||
ledger[identity] = row
|
||||
return row
|
||||
|
||||
|
||||
def mark_bulk(ledger: dict[str, dict]) -> None:
|
||||
groups = defaultdict(list)
|
||||
for identity, row in ledger.items():
|
||||
source = row.get("repo") or (_repository_artifact(row["url"]) or (row["url"],))[0]
|
||||
if row.get("sha") and row.get("commit_verified"):
|
||||
groups[(str(source), row["sha"], "commit")].append(identity)
|
||||
elif row.get("basis") == "seen":
|
||||
groups[(str(source), row["seen"], "seen")].append(identity)
|
||||
for (*_, basis), identities in groups.items():
|
||||
count = len({ledger[identity]["cve"] for identity in identities}) if basis == "commit" else len(identities)
|
||||
if count > (10 if basis == "commit" else 25):
|
||||
for identity in identities:
|
||||
ledger[identity]["bulk"] = True
|
||||
|
||||
|
||||
def resolve_copies(ledger: dict[str, dict], *, now: str | None = None) -> None:
|
||||
"""Resolve identities after a batch, independent of enrichment order.
|
||||
|
||||
Entire selected nontrivial blob sets must match. One shared helper cannot
|
||||
collapse distinct variants. Tombstones still supply original provenance.
|
||||
"""
|
||||
groups, parents = defaultdict(list), {}
|
||||
for identity, row in ledger.items():
|
||||
location = _repository_artifact(row["url"])
|
||||
if blobs := row.get("blobs"):
|
||||
groups[(row["cve"], "content", tuple(sorted(set(blobs))))].append(identity)
|
||||
if row.get("rev") and location:
|
||||
groups[(row["cve"], "revision", str(row.get("repo") or location[0]), row["rev"])].append(identity)
|
||||
if row.get("repo") and location:
|
||||
groups[(row["cve"], "transfer", str(row["repo"]), location[1])].append(identity)
|
||||
|
||||
def root(identity):
|
||||
parents.setdefault(identity, identity)
|
||||
if parents[identity] != identity:
|
||||
parents[identity] = root(parents[identity])
|
||||
return parents[identity]
|
||||
|
||||
for signature, identities in groups.items():
|
||||
if len(identities) < 2:
|
||||
continue
|
||||
if signature[1] == "transfer" and len({_repository_artifact(ledger[item]["url"])[0] for item in identities}) < 2:
|
||||
continue
|
||||
first = min(identities)
|
||||
for identity in identities:
|
||||
parents[root(identity)] = root(first)
|
||||
components = defaultdict(list)
|
||||
for identity in parents:
|
||||
components[root(identity)].append(identity)
|
||||
for identities in components.values():
|
||||
own_dates = {identity: date_release(ledger[identity], now=now) for identity in identities}
|
||||
original = min(identities, key=lambda identity: (
|
||||
timestamp(own_dates[identity][0]) or datetime.max.replace(tzinfo=timezone.utc), identity))
|
||||
released, basis = own_dates[original]
|
||||
for identity in identities:
|
||||
row = ledger[identity]
|
||||
row["released"], row["basis"] = released, basis
|
||||
if identity == original:
|
||||
row.pop("copy", None)
|
||||
else:
|
||||
row["copy"] = original
|
||||
for identity, row in ledger.items():
|
||||
if row.get("copy") and identity not in parents:
|
||||
row.pop("copy", None)
|
||||
row["released"], row["basis"] = date_release(row, now=now)
|
||||
|
||||
|
||||
def reconcile(ledger: dict[str, dict], pairs: list[tuple[str, str]], *, evidence: dict[str, dict] | None = None,
|
||||
observed_at: str | None = None, import_mode: bool = False, complete: bool = True) -> dict[str, dict]:
|
||||
observed_at = observed_at or utcnow()
|
||||
result = copy.deepcopy(ledger) if isinstance(ledger, Ledger) else Ledger(copy.deepcopy(ledger))
|
||||
present = set()
|
||||
for cve, url in sorted(set(pairs)):
|
||||
identity = key(cve, url)
|
||||
present.add(identity)
|
||||
record(result, cve, url, (evidence or {}).get(identity), observed_at=observed_at, import_mode=import_mode)
|
||||
if complete:
|
||||
for identity, row in result.items():
|
||||
if identity not in present:
|
||||
row.setdefault("gone", observed_at)
|
||||
resolve_copies(result, now=observed_at)
|
||||
mark_bulk(result)
|
||||
return result
|
||||
|
||||
|
||||
def landed(ledger: dict[str, dict], *, now: str | None = None, days: int = 10) -> list[dict]:
|
||||
moment = timestamp(now or utcnow())
|
||||
found = [(identity, row) for identity, row in ledger.items()
|
||||
if not any(row.get(flag) for flag in ("gone", "copy", "bulk"))
|
||||
and row.get("basis") not in {"unknown", "pending"}
|
||||
and timestamp(row.get("released")) is not None
|
||||
and moment - timedelta(days=days) <= timestamp(row["released"]) <= moment]
|
||||
found.sort(key=lambda item: item[0])
|
||||
found.sort(key=lambda item: timestamp(item[1]["released"]), reverse=True)
|
||||
return [dict(row) for _, row in found]
|
||||
|
||||
|
||||
def published_pairs(entries: list[dict] | None = None) -> list[tuple[str, str]]:
|
||||
if entries is None:
|
||||
# Lazy import also lets the site builder consume this ledger.
|
||||
from build_site import build_cve_list, load_blacklist
|
||||
entries, _ = build_cve_list(load_blacklist())
|
||||
return sorted({(row["cve"], url) for row in entries
|
||||
for field in ("poc", "nuclei", "edb", "msf", "vulhub", "collections")
|
||||
for url in row.get(field, [])})
|
||||
|
||||
|
||||
def qualifying_content(repository: str, cve: str, path: str, source: bytes, reviews: list[dict]) -> bool:
|
||||
"""Confirm that a historical file contains reproduction material, not a stub.
|
||||
|
||||
This only dates already accepted links. It is not a discovery/approval gate.
|
||||
Ambiguous historical content stays undated until reviewed.
|
||||
"""
|
||||
if cve in source_artifacts.approved_cves_for_artifact(repository, path, source, reviews=reviews):
|
||||
return True
|
||||
if source_artifacts.identity_cves(path) - {cve}:
|
||||
return False
|
||||
if len(source.strip()) < 40:
|
||||
return False
|
||||
text = source.decode("utf-8", "replace")
|
||||
name = Path(path).name.lower()
|
||||
if repository.lower() == "github/securitylab" and name in {"bug.pdf", "fuzzer-poc.djvu", "poc.bin"}:
|
||||
return bool(source)
|
||||
if repository.lower() == "github/securitylab" and name.endswith(".cue"):
|
||||
return bool(re.search(r"(?m)^\s*(?:FILE|TRACK|INDEX)\b", text))
|
||||
if name.endswith((".md", ".rst")) or name == "readme":
|
||||
context = ""
|
||||
for chunk in re.split(r"(```[^\n]*\n.*?```)", text, flags=re.S):
|
||||
if not chunk.startswith("```"):
|
||||
context = chunk[-1000:]
|
||||
continue
|
||||
if not POC_RE.search(context) or re.search(r"(?im)^#+\s*(?:install|setup|requirements|mitigation)", context):
|
||||
continue
|
||||
if re.search(r"(?m)^\s*(?:curl\s+(?!.*(?:install|update)\.).*(?:https?://|--url)|(?:GET|POST|PUT)\s+/|.*(?:requests\.(?:get|post)|\.sendall)\s*\()", chunk):
|
||||
return True
|
||||
return False
|
||||
if repository in {"tenable/poc", "pedrib/PoC"}:
|
||||
from sync_collections import author_artifact_ids
|
||||
if cve not in author_artifact_ids(repository, path, text):
|
||||
return False
|
||||
if name in {"diff.txt", "stroke_patch.txt"} and repository.lower() == "github/securitylab":
|
||||
return bool(re.search(r"(?m)^@@ .*@@", text) and re.search(r"(?m)^\+[^+]", text))
|
||||
if not (CODE_FILE_RE.search(name) or name.endswith((".html", ".asm", ".lua"))):
|
||||
return False
|
||||
# Imports/comments alone and placeholder text do not establish introduction.
|
||||
executable = "\n".join(line for line in text.splitlines()
|
||||
if line.strip() and not re.match(r"\s*(?:#|//|/\*|\*|import\b|from\s+\S+\s+import\b)", line))
|
||||
# Function/class declarations, pass/return stubs and arbitrary method calls
|
||||
# are not historical exploit evidence. Require an actual action/payload.
|
||||
return bool(re.search(r"(?:\b(?:socket|send|sendto|sendmsg|sendmmsg|write|ioctl|syscall|mmap|mprotect|ptrace|"
|
||||
r"prctl|setsockopt|msgsnd|msgrcv|setxattr|execve|system|memcpy|strcpy|gets|"
|
||||
r"remote|process|fetch|eval|unserialize)\s*\(|"
|
||||
r"\b(?:requests|urllib|http|axios|Net::HTTP)\W.{0,40}\b(?:get|post|request|open|start)\s*\(|"
|
||||
r"\.(?:send|sendall|send_request_cgi|write|Write|writeObject|Dial|NewRequest|"
|
||||
r"connect|Connect|exec|execute|communicate)\s*\(|"
|
||||
r"<(?:form|script)\b|\bcurl\s+(?:.*(?:https?://|--url)))", executable))
|
||||
|
||||
|
||||
class GitHubHistory:
|
||||
"""Pinned, bounded history reads. Failed requests never become absence."""
|
||||
def __init__(self, headers: dict, *, fetch=None):
|
||||
self.headers, self.fetch, self.cache = headers, fetch, {}
|
||||
self.reviews = source_artifacts.load_reviews()
|
||||
self.budget = threading.local()
|
||||
|
||||
def begin(self, *, budget_seconds: float = 45, max_requests: int = 24) -> None:
|
||||
self.budget.deadline = time.monotonic() + budget_seconds
|
||||
self.budget.remaining = max_requests
|
||||
|
||||
def get(self, path: str):
|
||||
if path not in self.cache:
|
||||
if not hasattr(self.budget, "deadline"):
|
||||
self.begin()
|
||||
remaining = self.budget.deadline - time.monotonic()
|
||||
if remaining <= 0 or self.budget.remaining <= 0:
|
||||
raise TimeoutError("Artifact history inspection budget exhausted")
|
||||
self.budget.remaining -= 1
|
||||
url = "https://api.github.com" + path
|
||||
if self.fetch is not None:
|
||||
payload = self.fetch(url, headers=self.headers, timeout=min(15, remaining))
|
||||
else:
|
||||
request = Request(url, headers={"User-Agent": "pocindex-release-evidence", **self.headers})
|
||||
with urlopen(request, timeout=min(15, remaining)) as response:
|
||||
payload = json.load(response)
|
||||
self.cache[path] = payload
|
||||
return self.cache[path]
|
||||
|
||||
def tree(self, repository: str, revision: str, scope: str = "") -> list[dict]:
|
||||
expression = revision + (":" + scope if scope else "")
|
||||
payload = self.get(f"/repos/{repository}/git/trees/{quote(expression, safe='')}?recursive=1")
|
||||
if not isinstance(payload, dict) or payload.get("truncated") is not False or not isinstance(payload.get("tree"), list):
|
||||
raise ValueError("Incomplete artifact tree")
|
||||
result = []
|
||||
for item in payload["tree"]:
|
||||
if not isinstance(item, dict) or not source_artifacts.valid_path(str(item.get("path") or "")):
|
||||
raise ValueError("Invalid artifact tree entry")
|
||||
if item.get("type") == "blob" and item.get("mode") in {"100644", "100755"}:
|
||||
result.append({**item, "path": (scope + "/" if scope else "") + item["path"]})
|
||||
return result
|
||||
|
||||
def blob(self, repository: str, entry: dict) -> bytes:
|
||||
if not isinstance(entry.get("size"), int) or not 0 < entry["size"] <= 2_000_000:
|
||||
raise ValueError("Artifact blob is empty or exceeds the history inspection bound")
|
||||
payload = self.get(f"/repos/{repository}/git/blobs/{entry['sha']}")
|
||||
if payload.get("encoding") != "base64" or not isinstance(payload.get("content"), str):
|
||||
raise ValueError("Missing artifact blob")
|
||||
source = base64.b64decode("".join(payload["content"].split()), validate=True)
|
||||
digest = hashlib.sha1(f"blob {len(source)}\0".encode() + source).hexdigest()
|
||||
if len(source) != entry["size"] or digest != entry["sha"]:
|
||||
raise ValueError("Artifact blob differs from its pinned tree")
|
||||
return source
|
||||
|
||||
def qualifying(self, repository: str, revision: str, paths: list[str], cve: str) -> list[dict]:
|
||||
# Read each parent tree once; a requested directory is expanded into its
|
||||
# real files so its initial README/config commit cannot date the PoC.
|
||||
found = {}
|
||||
for path in paths:
|
||||
parent = path.rsplit("/", 1)[0] if "/" in path else ""
|
||||
rows = self.tree(repository, revision, parent)
|
||||
candidates = [r for r in rows if r["path"] == path or r["path"].startswith(path.rstrip("/") + "/")]
|
||||
explicit = [r for r in candidates if r["path"] == path]
|
||||
selected = {r["path"] for r in explicit} or set(artifact_paths(candidates, cve))
|
||||
if repository.lower() == "github/securitylab":
|
||||
selected.update(r["path"] for r in candidates if Path(r["path"]).name.lower() in {
|
||||
"bug.pdf", "fuzzer-poc.djvu", "poc.bin", "diff.txt", "stroke_patch.txt"} or r["path"].endswith(".cue"))
|
||||
for entry in candidates:
|
||||
size = entry.get("size")
|
||||
if not isinstance(size, int) or not 0 < size <= 2_000_000:
|
||||
continue
|
||||
if entry["path"] in selected and qualifying_content(repository, cve, entry["path"], self.blob(repository, entry), self.reviews):
|
||||
found[entry["path"]] = entry
|
||||
return list(found.values())
|
||||
|
||||
def history(self, repository: str, revision: str, path: str) -> list[dict]:
|
||||
result = []
|
||||
for page in range(1, 21):
|
||||
query = urlencode({"sha": revision, "path": path, "per_page": 100, "page": page})
|
||||
rows = self.get(f"/repos/{repository}/commits?{query}")
|
||||
if not isinstance(rows, list) or any(not isinstance(row, dict) or not re.fullmatch(r"[a-f0-9]{40}", str(row.get("sha") or "")) for row in rows):
|
||||
raise ValueError("Incomplete artifact history")
|
||||
result.extend(rows)
|
||||
if len(rows) < 100:
|
||||
return list(reversed(result))
|
||||
raise ValueError("Artifact history exceeds the bounded scan; use the local backfill")
|
||||
|
||||
def introduction(self, repository: str, revision: str, path: str, cve: str, *, depth: int = 0) -> dict:
|
||||
if depth > 8:
|
||||
raise ValueError("Artifact rename history exceeds inspection bound")
|
||||
for commit in self.history(repository, revision, path):
|
||||
# First path existence alone is insufficient: inspect those bytes.
|
||||
entries = self.qualifying(repository, commit["sha"], [path], cve)
|
||||
if not entries:
|
||||
continue
|
||||
detail = self.get(f"/repos/{repository}/commits/{commit['sha']}")
|
||||
files = detail.get("files")
|
||||
if not isinstance(files, list) or not any(item.get("filename") == path for item in files):
|
||||
raise ValueError("Missing introduction diff")
|
||||
renamed = next((item.get("previous_filename") for item in files
|
||||
if item.get("filename") == path and item.get("status") == "renamed"), None)
|
||||
if renamed and detail.get("parents"):
|
||||
parent = detail["parents"][0]["sha"]
|
||||
if self.qualifying(repository, parent, [renamed], cve):
|
||||
return self.introduction(repository, parent, renamed, cve, depth=depth + 1)
|
||||
result = {"commit": commit["commit"]["committer"]["date"], "sha": commit["sha"],
|
||||
"commit_verified": True, "history_paths": [entry["path"] for entry in entries]}
|
||||
introduced = {cve for item in files if item.get("status") == "added"
|
||||
for cve in source_artifacts.identity_cves(item.get("filename", ""))}
|
||||
if len(introduced) > 10:
|
||||
result["bulk"] = True
|
||||
return result
|
||||
raise ValueError("No verified qualifying artifact introduction in source history")
|
||||
|
||||
def absent(self, repository: str, revision: str, paths: list[str]) -> bool:
|
||||
# A complete known commit tree is stronger than an ambiguous 404 from
|
||||
# a contents endpoint (which may mean the commit/repository is missing).
|
||||
entries = self.tree(repository, revision)
|
||||
present = {entry["path"] for entry in entries}
|
||||
return all(path not in present for path in paths)
|
||||
|
||||
|
||||
def current_repository_evidence(repo: dict, paths: list[str], headers: dict, *, cve: str | None = None,
|
||||
cache_dir: Path | None = None, observed_at: str | None = None,
|
||||
history: GitHubHistory | None = None, budget_seconds: float = 45,
|
||||
max_requests: int = 24) -> dict:
|
||||
"""Verify first qualifying file history for an already accepted repository.
|
||||
|
||||
The supplied HEAD must be captured by the caller. Complete results are
|
||||
cached by HEAD, paths and review rules; errors return pending evidence.
|
||||
"""
|
||||
name = str(repo.get("full_name") or repo.get("nameWithOwner") or "")
|
||||
revision = str(repo.get("revision") or repo.get("head") or
|
||||
((repo.get("defaultBranchRef") or {}).get("target") or {}).get("oid") or "")
|
||||
cve = cve or repo.get("cve")
|
||||
if not cve:
|
||||
cves = source_artifacts.identity_cves(name)
|
||||
cve = next(iter(cves)) if len(cves) == 1 else None
|
||||
evidence = {"repo": repo.get("id", repo.get("databaseId")),
|
||||
"created": repo.get("created_at", repo.get("createdAt")),
|
||||
"pushed_at": repo.get("pushed_at", repo.get("pushedAt")),
|
||||
"checked": observed_at or utcnow(), "head": revision}
|
||||
evidence = {k: v for k, v in evidence.items() if v is not None}
|
||||
if not cve or not re.fullmatch(r"[0-9a-f]{40}", revision) or not paths:
|
||||
return {**evidence, "error": "Missing CVE, pinned HEAD or qualifying artifact paths"}
|
||||
history = history or GitHubHistory(headers)
|
||||
history.begin(budget_seconds=budget_seconds, max_requests=max_requests)
|
||||
rules = [(r["cve"], r["path"], r["sha256"]) for r in history.reviews if r["repository"].lower() == name.lower()]
|
||||
cache_key = hashlib.sha256(json.dumps([HISTORY_VERSION, name.lower(), revision, cve, sorted(paths), sorted(rules)]).encode()).hexdigest()
|
||||
cache_dir = cache_dir or Path(tempfile.gettempdir()) / "pocindex-release-cache"
|
||||
cache_path = cache_dir / f"{cache_key}.json"
|
||||
try:
|
||||
if cache_path.exists():
|
||||
cached = json.loads(cache_path.read_text())
|
||||
if cached.get("history_version") == HISTORY_VERSION and cached.get("commit_verified") is True:
|
||||
return {**cached, **evidence}
|
||||
entries = history.qualifying(name, revision, paths, cve)
|
||||
if not entries:
|
||||
raise ValueError("Selected paths contain no verified reproduction material")
|
||||
entrypoints = [entry for entry in entries if source_artifacts.PAYLOAD_RE.search(Path(entry["path"]).name)
|
||||
or cve.lower() in Path(entry["path"]).name.lower()]
|
||||
if entrypoints:
|
||||
entries = entrypoints
|
||||
identity = artifact_identity(entries, [entry["path"] for entry in entries])
|
||||
introductions = [history.introduction(name, revision, entry["path"], cve) for entry in entries]
|
||||
first = min(introductions, key=lambda row: timestamp(row["commit"]))
|
||||
result = {**evidence, **identity, **first, "history_version": HISTORY_VERSION}
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = cache_path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(result, sort_keys=True) + "\n")
|
||||
temporary.replace(cache_path)
|
||||
return result
|
||||
except Exception as problem:
|
||||
return {**evidence, "error": str(problem)}
|
||||
|
||||
|
||||
def record_collection_snapshot(previous: dict, current: dict, pairs: list[tuple[str, str]], *,
|
||||
directory: Path | None = None, observed_at: str | None = None,
|
||||
history: GitHubHistory | None = None, limit: int = 8) -> None:
|
||||
"""Record only applied source links, after every collection completed.
|
||||
|
||||
Existing undated imports belong to the historical backfill. New/pending
|
||||
pairs get bounded evidence reads; skipped work remains pending for retry.
|
||||
"""
|
||||
directory = directory or DIRECTORY
|
||||
if not directory.exists():
|
||||
return # Establish the explicit historical baseline before enabling hooks.
|
||||
observed_at = observed_at or utcnow()
|
||||
ledger = load_ledger(directory)
|
||||
old_sources, sources = previous.get("sources", {}), current.get("sources", {})
|
||||
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||
history = history or GitHubHistory({"Authorization": f"Bearer {token}"} if token else {})
|
||||
present, attempted = set(), 0
|
||||
ordered = sorted(set(pairs), key=lambda pair: (
|
||||
timestamp(ledger.get(key(*pair), {}).get("checked")) or datetime.min.replace(tzinfo=timezone.utc), pair))
|
||||
for cve, url in ordered:
|
||||
identity = key(cve, url)
|
||||
present.add(identity)
|
||||
location = _repository_artifact(url)
|
||||
if not location:
|
||||
continue
|
||||
repository = next((name for name in sources if name.lower() == location[0]), None)
|
||||
if repository is None:
|
||||
continue
|
||||
snapshot, prior = sources[repository], old_sources.get(repository, {})
|
||||
old = ledger.get(identity) or {}
|
||||
# A newly indexed historical source or newly published CVE record is
|
||||
# not evidence of an artifact appearing at this observation.
|
||||
already_in_source = url in (prior.get("links", {}).get(cve) or [])
|
||||
import_mode = bool(old.get("imported")) if old else not prior.get("observed_at") or already_in_source
|
||||
evidence = None
|
||||
if not old or old.get("basis") == "pending" or old.get("basis") == "unknown" and not old.get("imported"):
|
||||
previous_head = old.get("previous_head") or prior.get("revision")
|
||||
absent_at = old.get("absent") or prior.get("observed_at")
|
||||
evidence = {"error": "Deferred by collection history budget"}
|
||||
if previous_head and absent_at:
|
||||
evidence.update(previous_head=previous_head, absent=absent_at)
|
||||
if attempted < limit:
|
||||
attempted += 1
|
||||
evidence.update(checked=observed_at, head=snapshot["revision"])
|
||||
try:
|
||||
history.begin(budget_seconds=15, max_requests=3)
|
||||
repo = history.get(f"/repos/{repository}")
|
||||
# The parser and these reads use exactly the same captured HEAD.
|
||||
path = unquote(url.split(f"/{snapshot['branch']}/", 1)[1])
|
||||
verified = current_repository_evidence({**repo, "revision": snapshot["revision"]}, [path], {},
|
||||
cve=cve, history=history, observed_at=observed_at, budget_seconds=30, max_requests=20)
|
||||
evidence.update(verified)
|
||||
if "error" not in verified:
|
||||
evidence.pop("error", None)
|
||||
if previous_head and absent_at and not import_mode:
|
||||
evidence["absence_verified"] = history.absent(repository, previous_head, verified["paths"])
|
||||
except Exception as problem:
|
||||
evidence["error"] = str(problem)
|
||||
record(ledger, cve, url, evidence, observed_at=observed_at, import_mode=import_mode)
|
||||
previously_owned = {key(cve, url) for snapshot in old_sources.values()
|
||||
for cve, urls in snapshot.get("links", {}).items() for url in urls}
|
||||
removed = previously_owned.intersection(ledger) - present
|
||||
if removed:
|
||||
published = {key(*pair) for pair in published_pairs()}
|
||||
for identity in removed:
|
||||
if identity in published:
|
||||
ledger[identity].pop("gone", None)
|
||||
else:
|
||||
ledger[identity].setdefault("gone", observed_at)
|
||||
resolve_copies(ledger, now=observed_at)
|
||||
mark_bulk(ledger)
|
||||
save_ledger(ledger, directory)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--seed", action="store_true", help="historical import; no observation or release dates")
|
||||
mode.add_argument("--record", action="store_true", help="reconcile the complete published artifact set")
|
||||
parser.add_argument("--directory", type=Path, default=DIRECTORY)
|
||||
parser.add_argument("--input", type=Path, help="published CVE_list.json; otherwise parse the current corpus")
|
||||
parser.add_argument("--evidence", type=Path, help="JSON mapping of artifact keys to verified evidence")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
entries = json.loads(args.input.read_text()) if args.input else None
|
||||
evidence = json.loads(args.evidence.read_text()) if args.evidence else {}
|
||||
ledger = reconcile(load_ledger(args.directory), published_pairs(entries), evidence=evidence, import_mode=args.seed)
|
||||
if not args.dry_run:
|
||||
save_ledger(ledger, args.directory)
|
||||
print(f"releases: {len(ledger):,} pairs, {sum(bool(row.get('released')) for row in ledger.values()):,} dated")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Pinned source evidence for PoCs that the repository metadata gate misses.
|
||||
|
||||
Only locally reviewed bytes authorize a new link. Remote names and README text
|
||||
select files to inspect, never assert that those files have been reviewed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from urllib.error import HTTPError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REVIEWS = ROOT / "index/source_artifact_reviews.json"
|
||||
CANDIDATES = ROOT / "index/source_artifact_candidates.json"
|
||||
MAX_FILES = 8
|
||||
MAX_BYTES = 500_000
|
||||
MAX_TREE = 10_000
|
||||
CVE_RE = re.compile(r"(?<![A-Z0-9])CVE[-_ ](\d{4})[-_ ](\d{4,})(?![A-Z0-9])", re.I)
|
||||
SOURCE_RE = re.compile(r"\.(?:c|cc|cpp|cs|go|java|js|mjs|nse|php|pl|ps1|py|rb|rs|sh|ts|asm|s|swift|lua|cna)$", re.I)
|
||||
PAYLOAD_RE = re.compile(r"(?:^|[/_. -])(?:poc|pocs|exploit|exploits|expl|reproduce|reproducer|payload|trigger)(?:$|[/_. -])", re.I)
|
||||
SUPPORT = {".git", "node_modules", "vendor", "third_party", "__pycache__"}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArtifactEvidence:
|
||||
cve: str
|
||||
repository: str
|
||||
path: str
|
||||
revision: str
|
||||
sha256: str
|
||||
review_id: str
|
||||
released_at: str | None = None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"https://github.com/{self.repository}/blob/{self.revision}/{quote(self.path, safe='/')}"
|
||||
|
||||
|
||||
def identity_cves(repository: str) -> set[str]:
|
||||
# Treat underscores as separators, including adjacent CVE identifiers.
|
||||
return {f"CVE-{year}-{number}" for year, number in CVE_RE.findall(repository.replace("_", " "))}
|
||||
|
||||
|
||||
def valid_path(path: str) -> bool:
|
||||
return bool(path) and not path.startswith("/") and all(p not in {"", ".", ".."} for p in path.split("/"))
|
||||
|
||||
|
||||
def load_reviews(path: Path | None = None) -> list[dict]:
|
||||
payload = json.loads((path or REVIEWS).read_text(encoding="utf-8"))
|
||||
if payload.get("version") != 1 or not isinstance(payload.get("reviews"), list):
|
||||
raise ValueError("Invalid source artifact review registry")
|
||||
for row in payload["reviews"]:
|
||||
if (not re.fullmatch(r"CVE-\d{4}-\d{4,}", str(row.get("cve", "")))
|
||||
or not re.fullmatch(r"[^/]+/[^/]+", str(row.get("repository", "")))
|
||||
or not valid_path(str(row.get("path", "")))
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", str(row.get("revision", "")))
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", str(row.get("sha256", "")))
|
||||
or row.get("official_state") != "PUBLISHED"
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", str(row.get("official_record_revision", "")))
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", str(row.get("official_record_sha256", "")))
|
||||
or not row.get("review_basis") or not row.get("id")):
|
||||
raise ValueError("Incomplete source artifact review")
|
||||
return payload["reviews"]
|
||||
|
||||
|
||||
|
||||
def reviewed_cves(repository: str) -> set[str]:
|
||||
return {row["cve"] for row in load_reviews() if row["repository"].lower() == repository.lower()}
|
||||
|
||||
|
||||
def excluded_cves(repository: str) -> set[str]:
|
||||
payload = json.loads(REVIEWS.read_text(encoding="utf-8"))
|
||||
rows = payload.get("exclusions", [])
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("Invalid source artifact exclusions")
|
||||
for row in rows:
|
||||
if (not isinstance(row, dict) or not re.fullmatch(r"CVE-\d{4}-\d{4,}", str(row.get("cve", "")))
|
||||
or not row.get("repository") or not row.get("reason")
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", str(row.get("revision", "")))):
|
||||
raise ValueError("Incomplete source artifact exclusion")
|
||||
return {row["cve"] for row in rows if row["repository"].lower() == repository.lower()}
|
||||
|
||||
|
||||
def approved_cves_for_artifact(repository: str, path: str, source: bytes, *,
|
||||
reviews: list[dict] | None = None) -> set[str]:
|
||||
"""Exact author/path/content exceptions for strict collection attribution."""
|
||||
digest = hashlib.sha256(source).hexdigest()
|
||||
return {row["cve"] for row in (load_reviews() if reviews is None else reviews)
|
||||
if row["repository"].lower() == repository.lower() and row["path"] == path
|
||||
and row["sha256"] == digest}
|
||||
|
||||
|
||||
def _evidence(repository: str, revision: str, artifacts: list[dict], cves: set[str], reviews: list[dict]) -> tuple[ArtifactEvidence, ...]:
|
||||
approved: dict[tuple[str, str], list[dict]] = {}
|
||||
for row in reviews:
|
||||
if row["repository"].lower() == repository.lower():
|
||||
approved.setdefault((row["cve"], row["sha256"]), []).append(row)
|
||||
result = []
|
||||
for artifact in artifacts:
|
||||
if not valid_path(str(artifact.get("path", ""))):
|
||||
continue
|
||||
for cve in sorted(cves):
|
||||
matches = approved.get((cve, artifact.get("sha256")), [])
|
||||
if matches:
|
||||
review = next((r for r in matches if r["repository"].lower() == repository.lower()
|
||||
and r["path"] == artifact["path"]), matches[0])
|
||||
# Preserve the reviewed revision and its sibling context. A
|
||||
# copied harness alone is not evidence for another repository.
|
||||
result.append(ArtifactEvidence(cve, review["repository"], review["path"], review["revision"],
|
||||
artifact["sha256"], review["id"], review.get("released_at")))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def reviewed_artifacts(repo: dict) -> tuple[ArtifactEvidence, ...]:
|
||||
"""Only the local collector can attach typed evidence; JSON cannot forge it."""
|
||||
name = str(repo.get("nameWithOwner") or "")
|
||||
return tuple(item for item in (repo.get("_source_artifacts") or ())
|
||||
if isinstance(item, ArtifactEvidence) and item.repository.lower() == name.lower()
|
||||
and item.cve not in excluded_cves(name))
|
||||
|
||||
|
||||
def approved_artifact_links(repo: dict, cve: str) -> list[str]:
|
||||
return sorted({item.url for item in reviewed_artifacts(repo) if item.cve == cve})
|
||||
|
||||
|
||||
def _load_candidates(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {"version": 1, "repositories": {}}
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if payload.get("version") != 1 or not isinstance(payload.get("repositories"), dict):
|
||||
raise ValueError("Invalid source artifact candidate state")
|
||||
return payload
|
||||
|
||||
|
||||
def _save_candidate(path: Path, name: str, row: dict) -> None:
|
||||
with _LOCK:
|
||||
payload = _load_candidates(path)
|
||||
payload["repositories"][name.lower()] = row
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
class InspectionDeferred(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def pending_reviewed_names(limit: int = 16, *, cves: set[str] | None = None) -> list[str]:
|
||||
known = {row["repository"].lower() for row in load_reviews()
|
||||
if cves is None or row["cve"] in cves}
|
||||
with _LOCK:
|
||||
rows = _load_candidates(CANDIDATES)["repositories"]
|
||||
pending = [(row.get("last_checked_at") or row.get("last_attempt_at") or "", name) for name, row in rows.items()
|
||||
if name in known and row.get("scan_complete") is False]
|
||||
return [name for _, name in sorted(pending)[:limit]]
|
||||
|
||||
|
||||
def mark_pending_checked(names: list[str]) -> None:
|
||||
"""Rotate inaccessible replay entries without pretending their scans finished."""
|
||||
with _LOCK:
|
||||
rows = _load_candidates(CANDIDATES)["repositories"]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for name in names:
|
||||
row = rows.get(name.lower())
|
||||
if row and row.get("scan_complete") is False:
|
||||
_save_candidate(CANDIDATES, name, {**row, "last_checked_at": now})
|
||||
|
||||
|
||||
class InspectionBudget:
|
||||
def __init__(self, requests: int = 64, seconds: float = 120):
|
||||
self.remaining = requests
|
||||
self.exhausted = False
|
||||
self.deadline = time.monotonic() + seconds
|
||||
|
||||
def fetch(self, fetch_json, name: str, url: str, **kwargs):
|
||||
remaining_time = self.deadline - time.monotonic()
|
||||
if self.exhausted or self.remaining <= 0 or remaining_time <= 0:
|
||||
raise InspectionDeferred("budget_deferred")
|
||||
self.remaining -= 1
|
||||
kwargs["timeout"] = min(15, remaining_time)
|
||||
try:
|
||||
return fetch_json(url, **kwargs)
|
||||
except HTTPError as exc:
|
||||
if exc.code in {403, 429} and str((exc.headers or {}).get("X-RateLimit-Remaining")) == "0":
|
||||
self.exhausted = True
|
||||
raise InspectionDeferred("rate_limited") from exc
|
||||
raise
|
||||
|
||||
|
||||
_AUTOMATIC_BUDGET: InspectionBudget | None = None
|
||||
|
||||
|
||||
def automatic_budget() -> InspectionBudget:
|
||||
global _AUTOMATIC_BUDGET
|
||||
if _AUTOMATIC_BUDGET is None:
|
||||
_AUTOMATIC_BUDGET = InspectionBudget()
|
||||
return _AUTOMATIC_BUDGET
|
||||
|
||||
|
||||
def inspect_repository(repo: dict, cves: set[str], readme: str, fetch_json, headers: dict,
|
||||
*, reviews_path: Path | None = None, candidates_path: Path | None = None,
|
||||
persist: bool = True, budget: InspectionBudget | None = None) -> tuple[ArtifactEvidence, ...]:
|
||||
"""Verify reviewed paths first; retain bounded deferred work without rejecting it."""
|
||||
name = str(repo.get("nameWithOwner") or "")
|
||||
reviews = [r for r in load_reviews(reviews_path) if r["repository"].lower() == name.lower()]
|
||||
known = {row["cve"] for row in reviews}
|
||||
cves = set(cves) & (identity_cves(name) | known)
|
||||
if not cves or repo.get("isFork") or ("defaultBranchRef" in repo and repo["defaultBranchRef"] is None):
|
||||
return ()
|
||||
reviews = [r for r in reviews if r["cve"] in cves]
|
||||
state_path = candidates_path or CANDIDATES
|
||||
with _LOCK:
|
||||
cached = _load_candidates(state_path)["repositories"].get(name.lower()) or {}
|
||||
revision = str(((repo.get("defaultBranchRef") or {}).get("target") or {}).get("oid") or "")
|
||||
review_set = hashlib.sha256(json.dumps(reviews, sort_keys=True).encode()).hexdigest()
|
||||
artifacts, skipped_paths, known_checked = [], set(), set()
|
||||
attempted = False
|
||||
status, complete = "needs_review", False
|
||||
|
||||
def read(url, *, allow_not_found=False):
|
||||
nonlocal attempted
|
||||
prior = budget.remaining if budget else None
|
||||
try:
|
||||
if budget:
|
||||
return budget.fetch(fetch_json, name, url, headers=headers, allow_not_found=allow_not_found)
|
||||
attempted = True
|
||||
return fetch_json(url, headers=headers, allow_not_found=allow_not_found)
|
||||
finally:
|
||||
if budget and budget.remaining != prior:
|
||||
attempted = True
|
||||
|
||||
def accept_blob(path, size, oid, blob):
|
||||
if (not isinstance(size, int) or not 0 < size <= MAX_BYTES
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", str(oid or ""))):
|
||||
raise RuntimeError(f"Invalid blob identity for {name}/{path}")
|
||||
if blob.get("encoding") != "base64" or not isinstance(blob.get("content"), str):
|
||||
raise RuntimeError(f"Incomplete source blob for {name}/{path}")
|
||||
raw = base64.b64decode("".join(blob["content"].split()), validate=True)
|
||||
if len(raw) != size:
|
||||
raise RuntimeError(f"Source blob size mismatch for {name}/{path}")
|
||||
if hashlib.sha1(f"blob {len(raw)}\0".encode() + raw).hexdigest() != oid:
|
||||
raise RuntimeError(f"Source blob identity mismatch for {name}/{path}")
|
||||
if b"\0" in raw:
|
||||
skipped_paths.add(path)
|
||||
else:
|
||||
artifacts.append({"path": path, "sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw)})
|
||||
|
||||
try:
|
||||
if not revision:
|
||||
try:
|
||||
commit = read(f"https://api.github.com/repos/{name}/commits/HEAD", allow_not_found=True)
|
||||
except HTTPError as exc:
|
||||
if exc.code != 409:
|
||||
raise
|
||||
commit = None
|
||||
if commit is None:
|
||||
return ()
|
||||
revision = str(commit.get("sha") or "")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
||||
raise RuntimeError(f"Invalid artifact revision for {name}")
|
||||
same_revision = cached.get("revision") == revision
|
||||
if same_revision:
|
||||
artifacts = list(cached.get("artifacts", []))
|
||||
skipped_paths = set(cached.get("skipped_paths", []))
|
||||
known_checked = set(cached.get("known_checked", []))
|
||||
same_rules = cached.get("review_set_sha256") == review_set
|
||||
if same_revision and same_rules and cves <= set(cached.get("cves", [])):
|
||||
if cached.get("scan_complete", True) or cached.get("status") == "tree_limited":
|
||||
if persist and cached.get("status") == "tree_limited":
|
||||
_save_candidate(state_path, name, {**cached, "last_checked_at": datetime.now(timezone.utc).isoformat()})
|
||||
return _evidence(name, revision, artifacts, cves, reviews)
|
||||
known_paths = {r["path"] for r in reviews}
|
||||
inspected = {a["path"] for a in artifacts} | skipped_paths
|
||||
pending_paths = sorted(known_paths - known_checked - inspected)
|
||||
for path in pending_paths[:MAX_FILES]:
|
||||
blob = read(f"https://api.github.com/repos/{name}/contents/{quote(path, safe='/')}?ref={revision}", allow_not_found=True)
|
||||
if blob is not None and not isinstance(blob, (dict, list)):
|
||||
raise RuntimeError(f"Incomplete source contents for {name}/{path}")
|
||||
if isinstance(blob, dict) and blob.get("type") not in {"file", "dir", "symlink", "submodule"}:
|
||||
raise RuntimeError(f"Incomplete source contents for {name}/{path}")
|
||||
if isinstance(blob, dict) and blob.get("type") == "file":
|
||||
size = blob.get("size")
|
||||
if isinstance(size, int) and 0 < size <= MAX_BYTES:
|
||||
accept_blob(path, size, blob.get("sha"), blob)
|
||||
known_checked.add(path)
|
||||
if len(pending_paths) > MAX_FILES:
|
||||
raise InspectionDeferred("file_limited")
|
||||
expected = {(r["cve"], r["sha256"]) for r in reviews}
|
||||
matched = {(item.cve, item.sha256) for item in _evidence(name, revision, artifacts, cves, reviews)}
|
||||
if expected and expected <= matched:
|
||||
complete = True
|
||||
else:
|
||||
tree = read(f"https://api.github.com/repos/{name}/git/trees/{revision}?recursive=1")
|
||||
if not isinstance(tree, dict) or not isinstance(tree.get("tree"), list) or not isinstance(tree.get("truncated"), bool):
|
||||
raise RuntimeError(f"Incomplete artifact tree for {name}")
|
||||
if tree["truncated"] or len(tree["tree"]) > MAX_TREE:
|
||||
raise InspectionDeferred("tree_limited")
|
||||
eligible = []
|
||||
for entry in tree["tree"]:
|
||||
path = str(entry.get("path") or "")
|
||||
if entry.get("type") != "blob" or entry.get("mode") not in {"100644", "100755"} or not valid_path(path):
|
||||
continue
|
||||
if any(part.lower() in SUPPORT for part in path.split("/")[:-1]):
|
||||
continue
|
||||
size = entry.get("size")
|
||||
if not isinstance(size, int) or not 0 < size <= MAX_BYTES:
|
||||
continue
|
||||
named = bool(PAYLOAD_RE.search(path)) or bool(identity_cves(path) & cves)
|
||||
mentioned = path.lower() in readme.lower()
|
||||
if path not in known_paths and not ((SOURCE_RE.search(path) or (named and not Path(path).suffix)) and (named or mentioned)):
|
||||
continue
|
||||
eligible.append((path not in known_paths, not mentioned, not named, path.count("/"), path, entry))
|
||||
inspected = {a["path"] for a in artifacts} | skipped_paths
|
||||
eligible = [item for item in eligible if item[-2] not in inspected]
|
||||
for *_, path, entry in sorted(eligible)[:MAX_FILES]:
|
||||
blob = read(f"https://api.github.com/repos/{name}/git/blobs/{entry.get('sha')}")
|
||||
accept_blob(path, entry["size"], entry.get("sha"), blob)
|
||||
complete = len(eligible) <= MAX_FILES
|
||||
if not complete:
|
||||
status = "file_limited"
|
||||
except InspectionDeferred as deferred:
|
||||
status = str(deferred)
|
||||
evidence = _evidence(name, revision, artifacts, cves, reviews)
|
||||
if complete:
|
||||
status = "reviewed" if evidence else "needs_review" if artifacts else "no_candidate_artifact"
|
||||
observed = datetime.now(timezone.utc).isoformat()
|
||||
row = {"revision": revision, "cves": sorted(cves), "artifacts": artifacts,
|
||||
"skipped_paths": sorted(skipped_paths), "known_checked": sorted(known_checked), "review_set_sha256": review_set,
|
||||
"status": status, "first_observed_at": cached.get("first_observed_at") or observed,
|
||||
"revision_observed_at": observed, "last_attempt_at": observed if attempted else cached.get("last_attempt_at"),
|
||||
"last_checked_at": observed if attempted else cached.get("last_checked_at"),
|
||||
"scan_limited": not complete, "scan_complete": complete}
|
||||
if persist:
|
||||
_save_candidate(state_path, name, row)
|
||||
return evidence
|
||||
+173
-20
@@ -4,7 +4,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
@@ -13,10 +15,15 @@ from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from update_cves import CVES, ensure_cve_entries, http_json
|
||||
import source_artifacts
|
||||
import releases
|
||||
from update_cves import CVES, INDEX, ensure_cve_entries, http_json
|
||||
|
||||
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 = 2
|
||||
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"
|
||||
@@ -27,12 +34,19 @@ SOURCES = (
|
||||
("vulnerability", "tzwlhack/Vulnerability", "main"),
|
||||
("0day", "helloexp/0day", "master"),
|
||||
("xray", "chaitin/xray", "master"),
|
||||
("tenable", "tenable/poc", "master"),
|
||||
("pedrib", "pedrib/PoC", "master"),
|
||||
)
|
||||
TREE_SOURCES = (
|
||||
("google-research", "google/security-research", "master", "pocs/"),
|
||||
("github-securitylab", "github/securitylab", "main", "SecurityExploits/"),
|
||||
)
|
||||
CODE_SUFFIXES = {".c", ".cc", ".cpp", ".py", ".js", ".html", ".sh", ".rb", ".go", ".rs"}
|
||||
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.
|
||||
@@ -52,6 +66,59 @@ def download_tree(repo: str, branch: str) -> dict:
|
||||
)
|
||||
|
||||
|
||||
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 two research collections."""
|
||||
if not isinstance(payload, dict) or payload.get("truncated") is not False or not isinstance(payload.get("tree"), list):
|
||||
@@ -84,8 +151,8 @@ def collect_research_tree(repo: str, branch: str, prefix: str, payload: dict) ->
|
||||
return {cve: sorted(urls) for cve, urls in rows.items()}
|
||||
|
||||
|
||||
def download(repo: str, branch: str) -> bytes:
|
||||
url = f"https://codeload.github.com/{repo}/tar.gz/refs/heads/{branch}"
|
||||
def download(repo: str, revision: str) -> bytes:
|
||||
url = f"https://codeload.github.com/{repo}/tar.gz/{quote(revision, safe='')}"
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=900) as response:
|
||||
return response.read()
|
||||
@@ -104,8 +171,55 @@ def github_link(repo: str, branch: str, kind: str, path: str) -> str:
|
||||
return f"https://github.com/{repo}/{kind}/{branch}/{encoded}"
|
||||
|
||||
|
||||
def collect(repo: str, branch: str, archive: bytes) -> dict[str, list[str]]:
|
||||
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]]:
|
||||
rows: dict[str, set[str]] = defaultdict(set)
|
||||
if reviews is None and repo in {"tenable/poc", "pedrib/PoC"}:
|
||||
reviews = source_artifacts.load_reviews()
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile():
|
||||
@@ -116,7 +230,17 @@ def collect(repo: str, branch: str, archive: bytes) -> dict[str, list[str]]:
|
||||
relative = "/".join(parts[1:])
|
||||
suffix = parts[-1].lower()
|
||||
|
||||
if repo == "zan8in/afrog":
|
||||
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":
|
||||
if "pocs/afrog-pocs/CVE/" not in relative or not suffix.endswith((".yaml", ".yml")):
|
||||
continue
|
||||
handle = tar.extractfile(member)
|
||||
@@ -202,28 +326,48 @@ 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")
|
||||
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()
|
||||
combined: dict[str, set[str]] = defaultdict(set)
|
||||
for name, repo, branch in SOURCES:
|
||||
sources = [(name, repo, branch, None) for name, repo, branch in SOURCES] + list(TREE_SOURCES)
|
||||
for name, repo, branch, prefix in sources:
|
||||
try:
|
||||
found = collect(repo, branch, download(repo, branch))
|
||||
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")
|
||||
|
||||
for name, repo, branch, prefix in TREE_SOURCES:
|
||||
try:
|
||||
found = collect_research_tree(repo, branch, prefix, download_tree(repo, branch))
|
||||
except Exception as problem:
|
||||
print(f"{repo}: unavailable ({problem})")
|
||||
return 1
|
||||
for cve, urls in found.items():
|
||||
combined[cve].update(urls)
|
||||
print(f"{name}: {len(found):,} CVEs, {sum(map(len, found.values())):,} path links")
|
||||
print(f"{name}: {len(found):,} CVEs, {sum(map(len, found.values())):,} path links" + (" (cached)" if reusable else ""))
|
||||
|
||||
links = {cve: sorted(urls) for cve, urls in combined.items()}
|
||||
created, unavailable = ensure_cve_entries(
|
||||
@@ -236,16 +380,25 @@ def main() -> int:
|
||||
print(f"base entries: {len(created):,} {verb}, {len(unavailable):,} unpublished or unavailable")
|
||||
|
||||
tally = {"written": 0, "unchanged": 0, "absent": 0}
|
||||
published = []
|
||||
for cve, urls in sorted(links.items()):
|
||||
tally[apply_links(cve, urls, dry_run=args.dry_run)] += 1
|
||||
outcome = apply_links(cve, urls, dry_run=args.dry_run)
|
||||
tally[outcome] += 1
|
||||
if outcome != "absent":
|
||||
published.extend((cve, url) for url in urls)
|
||||
|
||||
stale = 0
|
||||
for path in CVES.glob("[12][0-9][0-9][0-9]/CVE-*.md"):
|
||||
paths = CVES.glob("[12][0-9][0-9][0-9]/CVE-*.md") if next_state != previous else ()
|
||||
for path in paths:
|
||||
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)
|
||||
|
||||
print(
|
||||
f"collections: {len(links):,} CVEs | {tally['written']:,} written, "
|
||||
f"{tally['unchanged']:,} unchanged, {tally['absent']:,} absent, {stale:,} stale removed"
|
||||
|
||||
@@ -16,8 +16,11 @@ import json
|
||||
import re
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import releases
|
||||
from update_cves import ensure_cve_entries
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -56,18 +59,31 @@ def fetch(url: str) -> bytes:
|
||||
return gzip.decompress(raw) if url.endswith(".gz") else raw
|
||||
|
||||
|
||||
def exploitdb() -> dict[str, list[str]]:
|
||||
def publication_date(value: str, *, today: date | None = None) -> str | None:
|
||||
"""ExploitDB supplies a calendar publication date, never an observation date."""
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||
return None
|
||||
try:
|
||||
published = date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if published <= (today or datetime.now(timezone.utc).date()) else None
|
||||
|
||||
|
||||
def exploitdb(publication_dates: dict[str, str] | None = None) -> 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
|
||||
if publication_dates is not None and (published := publication_date(str(row.get("date_published") or "").strip())):
|
||||
publication_dates[f"{EDB_ENTRY}{entry}"] = published
|
||||
if "CVE-" not in codes.upper():
|
||||
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))
|
||||
@@ -181,13 +197,41 @@ def drop_section(cve: str, header: str, dry_run: bool) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def artifact_publication_date(url: str, publication_dates: dict[str, str]) -> str | None:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.hostname not in {"exploit-db.com", "www.exploit-db.com"}:
|
||||
return None
|
||||
match = re.fullmatch(r"/exploits/(\d+)/?", parsed.path)
|
||||
return publication_dates.get(EDB_ENTRY + match[1]) if match else None
|
||||
|
||||
|
||||
def sync_release_dates(publication_dates: dict[str, str], *, import_mode: bool = False) -> dict:
|
||||
pairs = releases.published_pairs()
|
||||
evidence = {releases.key(cve, url): {"source": published}
|
||||
for cve, url in pairs if (published := artifact_publication_date(url, publication_dates))}
|
||||
before = releases.load_ledger()
|
||||
ledger = releases.reconcile(before, pairs, evidence=evidence, import_mode=import_mode)
|
||||
releases.save_ledger(ledger)
|
||||
dated = [ledger[identity] for identity in evidence if ledger[identity].get("released")]
|
||||
return {"published_pairs": len(pairs), "edb_pairs_with_valid_source_date": len(evidence),
|
||||
"dated_edb_pairs": len(dated), "dated_edb_cves": len({row["cve"] for row in dated}),
|
||||
"new_release_rows": len(ledger) - len(before), "import_mode": import_mode}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Sync ExploitDB, Metasploit and Vulhub into the index")
|
||||
parser.add_argument("--dry-run", action="store_true", help="report without writing")
|
||||
parser.add_argument("--backfill-release-dates", action="store_true", help="Date existing ExploitDB links only; preserve historical import provenance")
|
||||
parser.add_argument("--cvelist-dir", type=Path, help="local CVE List V5 root for a large backfill")
|
||||
args = parser.parse_args()
|
||||
|
||||
collected = [collect() for collect in (exploitdb, metasploit, vulhub)]
|
||||
publication_dates: dict[str, str] = {}
|
||||
edb = exploitdb(publication_dates)
|
||||
if args.backfill_release_dates:
|
||||
if not args.dry_run:
|
||||
print(json.dumps(sync_release_dates(publication_dates, import_mode=True), sort_keys=True))
|
||||
return 0
|
||||
collected = [edb, metasploit(), vulhub()]
|
||||
created, unavailable = ensure_cve_entries(
|
||||
{cve for found in collected for cve in found},
|
||||
dry_run=args.dry_run,
|
||||
@@ -215,6 +259,8 @@ def main() -> int:
|
||||
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")
|
||||
if not args.dry_run:
|
||||
print(json.dumps(sync_release_dates(publication_dates), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import tarfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import releases
|
||||
from update_cves import ensure_cve_entries
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -253,6 +254,9 @@ def main() -> int:
|
||||
json.dump(payload, handle, indent=0, sort_keys=True)
|
||||
handle.write("\n")
|
||||
|
||||
if not args.dry_run:
|
||||
releases.save_ledger(releases.reconcile(releases.load_ledger(), releases.published_pairs()))
|
||||
|
||||
rated = sum(1 for e in payload.values() if "cvss" in e)
|
||||
print(f"markdown: {tally['added']:,} links added, {tally['unchanged']:,} already there, "
|
||||
f"{tally['absent']:,} for CVEs not in the index, {stale:,} sections withdrawn")
|
||||
|
||||
@@ -26,6 +26,8 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import source_artifacts
|
||||
|
||||
from update_cves import (
|
||||
GITHUB_LIST,
|
||||
GITHUB_GRAPHQL_URL,
|
||||
@@ -38,6 +40,7 @@ from update_cves import (
|
||||
load_blacklist,
|
||||
ensure_cve_entries,
|
||||
qualifying_repo_cves,
|
||||
attach_source_artifacts,
|
||||
reconcile_inventory,
|
||||
replace_section,
|
||||
section_links,
|
||||
@@ -51,6 +54,7 @@ BATCH = 10
|
||||
USER_AGENT = "0xMarcio-cve-poc-in-github"
|
||||
|
||||
FIELDS = """
|
||||
defaultBranchRef { target { oid } }
|
||||
nameWithOwner url description isFork isArchived
|
||||
repositoryTopics(first: 20) { nodes { topic { name } } }
|
||||
readmeMd: object(expression: "HEAD:README.md") { ... on Blob { text } }
|
||||
@@ -185,10 +189,12 @@ def main() -> int:
|
||||
repo = described.get(full_name)
|
||||
if repo is None:
|
||||
continue
|
||||
attach_source_artifacts(repo, client.headers, blacklist, cves=pending[full_name], persist=not args.dry_run)
|
||||
for cve in pending[full_name]:
|
||||
year = int(cve.split("-")[1])
|
||||
if cve in qualifying_repo_cves(repo, year, blacklist):
|
||||
accepted.setdefault(cve, set()).add(str(repo.get("url") or "").rstrip("/"))
|
||||
links = source_artifacts.approved_artifact_links(repo, cve) or [str(repo.get("url") or "").rstrip("/")]
|
||||
accepted.setdefault(cve, set()).update(links)
|
||||
kept += 1
|
||||
if judged % 2000 < BATCH:
|
||||
print(f" judged {judged:,} of {len(names):,}")
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover NVD reference candidates and publish only reviewed reproductions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib import error, parse, request
|
||||
|
||||
import update_cves
|
||||
|
||||
INDEX = update_cves.INDEX
|
||||
REVIEWS = INDEX / "reference_poc_reviews.json"
|
||||
QUEUE = INDEX / "reference_poc_queue.json"
|
||||
VERIFIED = INDEX / "reference_poc_verified.json"
|
||||
CACHE = update_cves.ROOT / "data" / "reference-pocs"
|
||||
FEED_ROOT = "https://nvd.nist.gov/feeds/json/cve/2.0"
|
||||
MAX_BODY = 2_000_000
|
||||
|
||||
|
||||
class BudgetExpired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Budget:
|
||||
def __init__(self, seconds: float):
|
||||
self.deadline = time.monotonic() + seconds
|
||||
self.failures = 0
|
||||
|
||||
def remaining(self) -> float:
|
||||
seconds = self.deadline - time.monotonic()
|
||||
if seconds <= 0:
|
||||
raise BudgetExpired
|
||||
return seconds
|
||||
|
||||
def read(self, req, *, opener=None, limit=None) -> bytes:
|
||||
remaining = self.remaining()
|
||||
timeout = min(10, remaining)
|
||||
|
||||
def expired(signum, frame):
|
||||
if remaining <= 10:
|
||||
raise BudgetExpired
|
||||
self.remaining()
|
||||
raise TimeoutError("Reference request exceeded 10 seconds")
|
||||
|
||||
handler = signal.signal(signal.SIGALRM, expired)
|
||||
signal.setitimer(signal.ITIMER_REAL, timeout)
|
||||
try:
|
||||
open_url = opener.open if opener is not None else request.urlopen
|
||||
with open_url(req, timeout=timeout) as response:
|
||||
body = response.read() if limit is None else response.read(limit)
|
||||
self.remaining()
|
||||
return body
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
signal.signal(signal.SIGALRM, handler)
|
||||
|
||||
|
||||
def supported(url: str) -> bool:
|
||||
parsed = parse.urlsplit(url)
|
||||
host = (parsed.hostname or "").lower().removeprefix("www.")
|
||||
if parsed.scheme not in {"http", "https"} or parsed.username or parsed.password:
|
||||
return False
|
||||
if host == "gist.github.com":
|
||||
return bool(re.fullmatch(r"/(?:[^/]+/)?[a-fA-F0-9]{20,40}/?", parsed.path))
|
||||
if host == "openwall.com":
|
||||
return bool(re.fullmatch(r"/lists/(?:oss-security|fulldisclosure)/\d{4}/\d{2}/\d{2}/\d+/?", parsed.path))
|
||||
if host == "seclists.org":
|
||||
return bool(re.fullmatch(r"/(?:(?:bugtraq|fulldisclosure)/\d{4}/[A-Za-z]+|oss-sec/\d{4}/q[1-4])/\d+(?:\.html)?/?", parsed.path))
|
||||
return False
|
||||
|
||||
|
||||
class Preformatted(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.blocks: list[str] = []
|
||||
self.parts: list[str] | None = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "pre":
|
||||
self.parts = []
|
||||
elif tag == "br" and self.parts is not None:
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.parts is not None:
|
||||
self.parts.append(data)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "pre" and self.parts is not None:
|
||||
self.blocks.append("".join(self.parts).replace("\r\n", "\n").strip())
|
||||
self.parts = None
|
||||
|
||||
|
||||
def html_artifacts(body: bytes) -> dict[str, str]:
|
||||
parser = Preformatted()
|
||||
parser.feed(body.decode("utf-8", "replace"))
|
||||
return {f"pre:{index}": text for index, text in enumerate(parser.blocks)}
|
||||
|
||||
|
||||
class MailText(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts = []
|
||||
|
||||
def handle_data(self, data):
|
||||
self.parts.append(data)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in {"pre", "tt", "p", "div"}:
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "br":
|
||||
self.parts.append("\n")
|
||||
|
||||
|
||||
def mail_artifacts(url: str, body: bytes) -> dict[str, str]:
|
||||
artifacts = html_artifacts(body)
|
||||
host = parse.urlsplit(url).hostname.removeprefix("www.")
|
||||
if host == "openwall.com":
|
||||
complete = any(all(re.search(rf"^{field}: .+", text, re.M) for field in ("Date", "From", "Subject")) for text in artifacts.values())
|
||||
else:
|
||||
complete = all(marker in body for marker in (b"<!--X-Body-of-Message-->", b"<!--X-Body-of-Message-End-->"))
|
||||
if not complete or not any(artifacts.values()):
|
||||
raise ValueError("Unrecognized or incomplete mail archive response")
|
||||
if host == "seclists.org":
|
||||
message = body.split(b"<!--X-Body-of-Message-->", 1)[1].split(b"<!--X-Body-of-Message-End-->", 1)[0]
|
||||
if not any(html_artifacts(message).values()):
|
||||
raise ValueError("Missing mail archive message body")
|
||||
parser = MailText()
|
||||
parser.feed(message.decode("utf-8", "replace"))
|
||||
artifacts["mail:body"] = "".join(parser.parts).replace("\r\n", "\n").strip()
|
||||
return artifacts
|
||||
|
||||
|
||||
def gist_artifacts(payload: dict) -> dict[str, str]:
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("files"), dict) or payload.get("truncated"):
|
||||
raise ValueError("Incomplete Gist response")
|
||||
artifacts = {}
|
||||
for name, file in payload["files"].items():
|
||||
if not isinstance(file, dict) or file.get("truncated") or not isinstance(file.get("content"), str):
|
||||
raise ValueError("Incomplete Gist file")
|
||||
artifacts[f"file:{name}"] = file["content"]
|
||||
return artifacts
|
||||
|
||||
|
||||
class SourceRedirect(request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if not supported(newurl) or parse.urlsplit(newurl).hostname.removeprefix("www.") != parse.urlsplit(req.full_url).hostname.removeprefix("www."):
|
||||
raise ValueError("Unexpected reference redirect")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def fetch_artifacts(url: str, *, budget: Budget | None = None) -> dict[str, str]:
|
||||
if not supported(url):
|
||||
raise ValueError("Unsupported reference host/path")
|
||||
headers = {"User-Agent": "pocindex-reference-pocs"}
|
||||
is_gist = parse.urlsplit(url).hostname == "gist.github.com"
|
||||
if is_gist:
|
||||
gist = parse.urlsplit(url).path.rstrip("/").split("/")[-1]
|
||||
endpoint = "https://api.github.com/gists/" + gist
|
||||
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
else:
|
||||
endpoint = re.sub(r"^http:", "https:", url)
|
||||
opener = request.build_opener(SourceRedirect())
|
||||
budget = budget or Budget(10)
|
||||
body = budget.read(request.Request(endpoint, headers=headers), opener=opener, limit=MAX_BODY + 1)
|
||||
if len(body) > MAX_BODY:
|
||||
raise ValueError("Reference response exceeds content limit")
|
||||
return gist_artifacts(json.loads(body)) if is_gist else mail_artifacts(url, body)
|
||||
|
||||
|
||||
def load_json(path: Path, default):
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default
|
||||
|
||||
|
||||
def save_json(path: Path, value) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def feed_candidates(payload: dict) -> dict[str, list[str]]:
|
||||
rows = payload.get("vulnerabilities") if isinstance(payload, dict) else None
|
||||
if not isinstance(payload, dict) or payload.get("format") != "NVD_CVE" or payload.get("version") != "2.0" or not isinstance(rows, list) or payload.get("totalResults") != len(rows):
|
||||
raise ValueError("Incomplete NVD feed")
|
||||
found = {}
|
||||
for wrapper in rows:
|
||||
cve = wrapper.get("cve") or {}
|
||||
cid = str(cve.get("id") or "")
|
||||
if not update_cves.is_valid_cve(cid) or cid in found or not isinstance(cve.get("references", []), list):
|
||||
raise ValueError("Invalid NVD CVE entry")
|
||||
found[cid] = []
|
||||
if cve.get("vulnStatus") == "Rejected":
|
||||
continue
|
||||
for reference in cve.get("references", []):
|
||||
url = str(reference.get("url") or "")
|
||||
if "Exploit" in reference.get("tags", []) and supported(url):
|
||||
found[cid].append(url)
|
||||
found[cid] = sorted(set(found[cid]))
|
||||
return found
|
||||
|
||||
|
||||
def load_feeds(directory: Path | None, *, budget: Budget | None = None) -> dict[str, list[str]]:
|
||||
found = {}
|
||||
budget = budget or Budget(180)
|
||||
if directory is not None:
|
||||
paths = sorted(directory.glob("nvd-*.json.gz"))
|
||||
if not paths:
|
||||
raise ValueError("No local NVD feeds")
|
||||
for path in paths:
|
||||
budget.remaining()
|
||||
with gzip.open(path, "rt", encoding="utf-8") as handle:
|
||||
found.update(feed_candidates(json.load(handle)))
|
||||
else:
|
||||
for name in ("recent", "modified"):
|
||||
req = request.Request(f"{FEED_ROOT}/nvdcve-2.0-{name}.json.gz", headers={"User-Agent": "pocindex-reference-pocs"})
|
||||
found.update(feed_candidates(json.loads(gzip.decompress(budget.read(req)))))
|
||||
return found
|
||||
|
||||
|
||||
def load_reviews(path: Path = REVIEWS) -> list[dict]:
|
||||
payload = load_json(path, {})
|
||||
rows = payload.get("reviews") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list) or payload.get("version") != 1:
|
||||
raise ValueError("Invalid reviewed reference manifest")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not update_cves.is_valid_cve(row.get("cve", "")) or not supported(row.get("url", "")) or not re.fullmatch(r"[a-f0-9]{64}", row.get("sha256", "")) or not row.get("artifact") or not row.get("evidence"):
|
||||
raise ValueError("Invalid reviewed reference evidence")
|
||||
return rows
|
||||
|
||||
|
||||
def load_exclusions(path: Path | None = None) -> set[tuple[str, str]]:
|
||||
payload = load_json(path or REVIEWS, {})
|
||||
rows = payload.get("exclusions") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list) or payload.get("version") != 1:
|
||||
raise ValueError("Invalid reference exclusion registry")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not update_cves.is_valid_cve(row.get("cve", "")) or not supported(row.get("url", "")) or not row.get("reason"):
|
||||
raise ValueError("Invalid reference exclusion")
|
||||
return {(row["cve"], update_cves.poc_link_key(row["url"])) for row in rows}
|
||||
|
||||
|
||||
def approvals(reviews: list[dict], candidates: dict, checks: dict, previous: dict) -> dict[str, list[str]]:
|
||||
found: dict[str, set[str]] = {}
|
||||
for row in reviews:
|
||||
cve, url = row["cve"], row["url"]
|
||||
if url not in candidates.get(cve, []):
|
||||
continue
|
||||
check = checks.get(url, {})
|
||||
if check.get("error"):
|
||||
accepted = url in previous.get(cve, [])
|
||||
else:
|
||||
accepted = check.get("digests", {}).get(row["artifact"]) == row["sha256"]
|
||||
if accepted:
|
||||
found.setdefault(cve, set()).add(url)
|
||||
return {cve: sorted(urls) for cve, urls in found.items()}
|
||||
|
||||
|
||||
def refresh_checks(state: dict, reviews: list[dict], *, limit: int, days: int, cache: Path, dry_run: bool, budget: Budget | None = None) -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
today = now.date().isoformat()
|
||||
cutoff = (now.date() - timedelta(days=days)).isoformat()
|
||||
urls = {url for values in state["candidates"].values() for url in values}
|
||||
reviewed = {row["url"] for row in reviews}
|
||||
checks = state["checks"]
|
||||
for url in set(checks) - urls:
|
||||
del checks[url]
|
||||
due = [url for url in urls if checks.get(url, {}).get("checked", "") <= (today if checks.get(url, {}).get("error") else cutoff)]
|
||||
due.sort(key=lambda url: (url not in reviewed, checks.get(url, {}).get("checked", ""), url))
|
||||
failures = 0
|
||||
for url in due[:limit]:
|
||||
try:
|
||||
if budget is not None:
|
||||
budget.remaining()
|
||||
artifacts = fetch_artifacts(url, budget=budget) if budget is not None else fetch_artifacts(url)
|
||||
digests = {name: hashlib.sha256(text.encode()).hexdigest() for name, text in artifacts.items()}
|
||||
if not dry_run:
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
for name, text in artifacts.items():
|
||||
(cache / (digests[name] + ".txt")).write_text(text, encoding="utf-8")
|
||||
checks[url] = {"checked": today, "digests": digests}
|
||||
except error.HTTPError as problem:
|
||||
if problem.code in {404, 410}:
|
||||
checks[url] = {"checked": today, "digests": {}, "missing": problem.code}
|
||||
else:
|
||||
failures += 1
|
||||
checks[url] = {**checks.get(url, {}), "checked": today, "error": f"HTTP {problem.code}"}
|
||||
except (OSError, ValueError, http.client.HTTPException) as problem:
|
||||
failures += 1
|
||||
checks[url] = {**checks.get(url, {}), "checked": today, "error": str(problem)[:160]}
|
||||
if budget is not None:
|
||||
budget.failures = failures
|
||||
if not dry_run:
|
||||
save_json(QUEUE, state)
|
||||
return failures
|
||||
|
||||
|
||||
def retain_previous(accepted: dict, previous: dict, *, dry_run: bool) -> None:
|
||||
if not dry_run:
|
||||
retained = {cve: [url for url in urls if url in accepted.get(cve, [])] for cve, urls in previous.items()}
|
||||
save_json(VERIFIED, {cve: urls for cve, urls in retained.items() if urls})
|
||||
|
||||
|
||||
def published_entries(accepted: dict, previous: dict, args, budget: Budget) -> dict:
|
||||
statuses = load_json(args.record_states, {}) if args.record_states else {}
|
||||
if args.record_states and (not isinstance(statuses, dict) or any(cve not in statuses for cve in accepted)):
|
||||
raise ValueError("Incomplete official CVE state snapshot")
|
||||
with tempfile.TemporaryDirectory(prefix="pocindex-reference-records-") as directory:
|
||||
local = Path(directory)
|
||||
for cve in accepted:
|
||||
budget.remaining()
|
||||
path = update_cves.CVES / cve.split("-")[1] / f"{cve}.md"
|
||||
if not args.record_states and cve in previous and path.exists():
|
||||
# Previously admitted records retain their official publication
|
||||
# evidence; metadata sync handles later rejections.
|
||||
statuses[cve] = "PUBLISHED"
|
||||
if statuses.get(cve) in {"RESERVED", "REJECTED"} or (cve in statuses and path.exists()):
|
||||
continue
|
||||
record = None
|
||||
if args.cvelist_dir:
|
||||
record = update_cves.read_record(update_cves.cvelist_record_path(args.cvelist_dir, cve))
|
||||
if record is None:
|
||||
req = request.Request(update_cves.CVE_API_URL.format(cve_id=cve), headers={"User-Agent": "pocindex-reference-pocs"})
|
||||
try:
|
||||
record = json.loads(budget.read(req))
|
||||
except error.HTTPError as problem:
|
||||
if problem.code != 404:
|
||||
raise
|
||||
statuses[cve] = "UNAVAILABLE"
|
||||
continue
|
||||
status = (record.get("cveMetadata") or {}).get("state")
|
||||
if update_cves.record_cve_id(record) != cve or status not in {"PUBLISHED", "RESERVED", "REJECTED"} or (status == "PUBLISHED" and update_cves.details_from_record(record) is None):
|
||||
raise ValueError(f"Incomplete or mismatched official record: {cve}")
|
||||
statuses[cve] = status
|
||||
if not path.exists():
|
||||
save_json(update_cves.cvelist_record_path(local, cve), record)
|
||||
accepted = {cve: urls for cve, urls in accepted.items() if statuses.get(cve) == "PUBLISHED"}
|
||||
# Every missing record is now local: the shared helper cannot start
|
||||
# unbounded network retries after this command's budget expires.
|
||||
_, unavailable = update_cves.ensure_cve_entries(accepted, dry_run=args.dry_run, cvelist_dir=local)
|
||||
return {cve: urls for cve, urls in accepted.items() if cve not in unavailable}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--feed-dir", type=Path, help="Local complete nvd-YEAR.json.gz feeds")
|
||||
parser.add_argument("--record-states", type=Path, help="Local official CVE ID to record-state snapshot")
|
||||
parser.add_argument("--cvelist-dir", type=Path)
|
||||
parser.add_argument("--cache-dir", type=Path, default=CACHE)
|
||||
parser.add_argument("--limit", type=int, default=100)
|
||||
parser.add_argument("--recheck-days", type=int, default=7)
|
||||
parser.add_argument("--max-seconds", type=float, default=180)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.limit < 1 or args.recheck_days < 1 or args.max_seconds <= 0:
|
||||
parser.error("Limits must be positive")
|
||||
budget = Budget(args.max_seconds)
|
||||
excluded = load_exclusions()
|
||||
reviews = [row for row in load_reviews() if (row["cve"], update_cves.poc_link_key(row["url"])) not in excluded]
|
||||
state = load_json(QUEUE, {"candidates": {}, "checks": {}})
|
||||
previous = load_json(VERIFIED, {})
|
||||
try:
|
||||
candidates = load_feeds(args.feed_dir, budget=budget)
|
||||
for cve, urls in candidates.items():
|
||||
if urls:
|
||||
state["candidates"][cve] = urls
|
||||
else:
|
||||
state["candidates"].pop(cve, None)
|
||||
failures = refresh_checks(state, reviews, limit=args.limit, days=args.recheck_days,
|
||||
cache=args.cache_dir, dry_run=args.dry_run, budget=budget)
|
||||
accepted = approvals(reviews, state["candidates"], state["checks"], previous)
|
||||
retain_previous(accepted, previous, dry_run=args.dry_run)
|
||||
accepted = published_entries(accepted, previous, args, budget)
|
||||
for cve, urls in accepted.items():
|
||||
path = update_cves.CVES / cve.split("-")[1] / f"{cve}.md"
|
||||
if path.exists():
|
||||
update_cves.update_existing_markdown(path, [], urls, dry_run=args.dry_run)
|
||||
update_cves.append_inventory(update_cves.REFERENCE_LIST, accepted, dry_run=args.dry_run)
|
||||
if not args.dry_run:
|
||||
save_json(QUEUE, state)
|
||||
save_json(VERIFIED, accepted)
|
||||
pairs = sum(len(urls) for urls in accepted.values())
|
||||
print(f"Reference PoCs: {pairs} reviewed links for {len(accepted)} published CVEs; {len(state['checks'])} URLs checked")
|
||||
if failures:
|
||||
raise RuntimeError(f"{failures} reference fetches failed; prior approvals and retry work preserved")
|
||||
except BudgetExpired:
|
||||
if not args.dry_run:
|
||||
save_json(QUEUE, state)
|
||||
retain_previous(approvals(reviews, state["candidates"], state["checks"], previous), previous, dry_run=False)
|
||||
print(f"Reference PoCs: time budget reached; {len(state['checks'])} URLs checked, remaining work pending")
|
||||
if budget.failures:
|
||||
raise RuntimeError(f"{budget.failures} reference fetches failed before the budget expired") from None
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+322
-35
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
@@ -19,6 +21,8 @@ from typing import Any, Callable, Collection, Iterable, Iterator
|
||||
from urllib import error, request
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import source_artifacts
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CVES = ROOT / "cves"
|
||||
INDEX = ROOT / "index"
|
||||
@@ -31,6 +35,7 @@ STATE_FILE = ROOT / ".github" / "cve_sync_state.json"
|
||||
KEV_FILE = INDEX / "kev.json"
|
||||
DATES_FILE = INDEX / "cve_dates.json"
|
||||
RECORD_STATE_FILE = INDEX / "cve_record_state.json"
|
||||
DISCOVERY_STATE_FILE = INDEX / "github_discovery_state.json"
|
||||
RECORD_RETRY_DAYS = 1
|
||||
|
||||
GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"
|
||||
@@ -87,6 +92,7 @@ query($query: String!, $cursor: String) {
|
||||
isArchived
|
||||
isFork
|
||||
pushedAt
|
||||
defaultBranchRef { target { oid } }
|
||||
repositoryTopics(first: 20) { nodes { topic { name } } }
|
||||
}
|
||||
}
|
||||
@@ -378,8 +384,10 @@ def qualifying_repo_cves(
|
||||
description = str(repo.get("description") or "")
|
||||
topics = " ".join(topic_names(repo))
|
||||
readme = readme_text(repo)
|
||||
reviewed = {item.cve for item in source_artifacts.reviewed_artifacts(repo)
|
||||
if int(item.cve.split("-")[1]) == year}
|
||||
if repo_is_bare(repo, readme):
|
||||
return set()
|
||||
return reviewed
|
||||
all_name_cves = extract_cves(full_name) | extract_cves(normalise_identity(full_name))
|
||||
all_identity_cves = (
|
||||
all_name_cves
|
||||
@@ -407,10 +415,10 @@ def qualifying_repo_cves(
|
||||
identity_is_non_poc = bool(NON_POC_RE.search(identity_text))
|
||||
has_code = root_has_code(repo)
|
||||
if identity_is_non_poc and not (name_cves and identity_has_poc and has_code):
|
||||
return set()
|
||||
return reviewed
|
||||
if len(readme_cves) > 5 and not identity_cves:
|
||||
readme_cves = set()
|
||||
accepted: set[str] = set()
|
||||
accepted: set[str] = set(reviewed)
|
||||
for cve_id in name_cves:
|
||||
has_context = readme_has_poc_context(readme, cve_id, full_name)
|
||||
has_artifact = root_has_poc_artifact(repo, cve_id)
|
||||
@@ -446,7 +454,7 @@ def qualifying_repo_cves(
|
||||
and (has_artifact or identity_has_poc)
|
||||
):
|
||||
accepted.add(cve_id)
|
||||
return accepted
|
||||
return accepted - source_artifacts.excluded_cves(full_name)
|
||||
|
||||
|
||||
def http_json(
|
||||
@@ -526,6 +534,15 @@ class GitHubClient:
|
||||
if payload.get("errors") or not search:
|
||||
messages = "; ".join(str(item.get("message")) for item in payload.get("errors") or [])
|
||||
raise RuntimeError(f"GitHub search failed: {messages or 'missing response data'}")
|
||||
page = search.get("pageInfo") or {}
|
||||
if (
|
||||
not isinstance(search.get("repositoryCount"), int)
|
||||
or not isinstance(search.get("nodes"), list)
|
||||
or not isinstance(page.get("hasNextPage"), bool)
|
||||
or (page["hasNextPage"] and not page.get("endCursor"))
|
||||
or any(not isinstance(node, dict) for node in search["nodes"])
|
||||
):
|
||||
raise RuntimeError("GitHub search returned incomplete response data")
|
||||
rate = data.get("rateLimit") or {}
|
||||
if int(rate.get("remaining") or 0) < 5:
|
||||
raise RuntimeError(f"GitHub GraphQL quota is nearly exhausted; resets at {rate.get('resetAt')}")
|
||||
@@ -618,14 +635,23 @@ def search_range(
|
||||
|
||||
print(f"GitHub {qualifier} {start}..{end}: {count} repositories")
|
||||
page = first_page
|
||||
received = 0
|
||||
cursors: set[str] = set()
|
||||
while True:
|
||||
for repo in page.get("nodes") or []:
|
||||
if repo:
|
||||
received += 1
|
||||
yield repo
|
||||
page_info = page.get("pageInfo") or {}
|
||||
if not page_info.get("hasNextPage"):
|
||||
break
|
||||
page = client.search_page(query_text, str(page_info.get("endCursor")))
|
||||
cursor = str(page_info.get("endCursor") or "")
|
||||
if not cursor or cursor in cursors:
|
||||
raise RuntimeError("GitHub search returned an invalid pagination cursor")
|
||||
cursors.add(cursor)
|
||||
page = client.search_page(query_text, cursor)
|
||||
if received < count:
|
||||
raise RuntimeError(f"GitHub search returned only {received} of {count} repositories")
|
||||
|
||||
|
||||
def search_without_range(client: GitHubClient, search_terms: str) -> Iterator[dict[str, Any]]:
|
||||
@@ -651,6 +677,9 @@ def discover_github_pocs(
|
||||
lookback_days: int,
|
||||
backfill: bool,
|
||||
cve_filter: set[str],
|
||||
window_end: date | None = None,
|
||||
checkpoints: dict[str, str] | None = None,
|
||||
artifact_cache_write: bool = True,
|
||||
) -> dict[str, list[str]]:
|
||||
client = GitHubClient(token)
|
||||
blacklist = load_blacklist()
|
||||
@@ -672,8 +701,10 @@ def discover_github_pocs(
|
||||
for cve_id in qualifying_repo_cves(repo, year, blacklist):
|
||||
if cve_filter and cve_id not in cve_filter:
|
||||
continue
|
||||
if url not in discovered[cve_id]:
|
||||
discovered[cve_id].append(url)
|
||||
links = source_artifacts.approved_artifact_links(repo, cve_id) or [url]
|
||||
for link in links:
|
||||
if link not in discovered[cve_id]:
|
||||
discovered[cve_id].append(link)
|
||||
|
||||
def search_terms(identifier: str) -> list[str]:
|
||||
return [
|
||||
@@ -690,18 +721,45 @@ def discover_github_pocs(
|
||||
for repo in search_without_range(client, terms):
|
||||
remember(repo)
|
||||
else:
|
||||
end = datetime.now(timezone.utc).date()
|
||||
end = window_end or datetime.now(timezone.utc).date()
|
||||
for year in target_years:
|
||||
if backfill:
|
||||
qualifier = "created"
|
||||
start = date(2008, 1, 1)
|
||||
else:
|
||||
qualifier = "pushed"
|
||||
start = end - timedelta(days=max(1, lookback_days))
|
||||
previous = (checkpoints or {}).get(str(year))
|
||||
anchor = min(end, date.fromisoformat(previous)) if previous else end
|
||||
start = anchor - timedelta(days=max(1, lookback_days))
|
||||
for terms in search_terms(f"CVE-{year}"):
|
||||
for repo in search_range(client, terms, qualifier, start, end):
|
||||
remember(repo)
|
||||
|
||||
# Deferred reviewed work survives the moving search window. Refresh its HEAD
|
||||
# before resuming so a stale candidate cache cannot approve changed bytes.
|
||||
pending_cves = {row["cve"] for row in source_artifacts.load_reviews()
|
||||
if int(row["cve"].split("-")[1]) in target_years
|
||||
and (not cve_filter or row["cve"] in cve_filter)}
|
||||
pending_names = source_artifacts.pending_reviewed_names(cves=pending_cves)
|
||||
missing = [name for name in pending_names if name.lower() not in {n.lower() for n in repositories}
|
||||
and any(int(c.split("-")[1]) in target_years and (not cve_filter or c in cve_filter)
|
||||
for c in source_artifacts.reviewed_cves(name))
|
||||
and not is_blacklisted_repo(name, blacklist)]
|
||||
if missing:
|
||||
aliases = []
|
||||
for index, full_name in enumerate(missing):
|
||||
owner, name = full_name.split("/", 1)
|
||||
aliases.append(f"repo{index}: repository(owner: {json.dumps(owner)}, name: {json.dumps(name)}) "
|
||||
"{ nameWithOwner url description isFork defaultBranchRef { target { oid } } }")
|
||||
payload = http_json(GITHUB_GRAPHQL_URL, headers=client.headers,
|
||||
data={"query": "query { " + " ".join(aliases) + " rateLimit { remaining resetAt } }"})
|
||||
replay = github_repository_data(payload, {f"repo{index}" for index in range(len(missing))})
|
||||
for index in range(len(missing)):
|
||||
if replay.get(f"repo{index}") is not None:
|
||||
remember(replay[f"repo{index}"])
|
||||
if artifact_cache_write:
|
||||
source_artifacts.mark_pending_checked(missing)
|
||||
|
||||
names = sorted(repositories)
|
||||
batches = [names[offset : offset + 10] for offset in range(0, len(names), 10)]
|
||||
completed = 0
|
||||
@@ -731,12 +789,42 @@ def discover_github_pocs(
|
||||
if removed:
|
||||
print(f"Discarded {len(removed)} repositories removed during discovery")
|
||||
|
||||
for repo in repositories.values():
|
||||
pending_order = {name: index for index, name in enumerate(pending_names)}
|
||||
for repo in sorted(repositories.values(), key=lambda r: pending_order.get(str(r.get("nameWithOwner", "")).lower(), len(pending_order))):
|
||||
wanted = {c for c in (source_artifacts.identity_cves(str(repo.get("nameWithOwner") or ""))
|
||||
| source_artifacts.reviewed_cves(str(repo.get("nameWithOwner") or "")))
|
||||
if int(c.split("-")[1]) in target_years and (not cve_filter or c in cve_filter)}
|
||||
attach_source_artifacts(repo, client.headers, blacklist, cves=wanted, persist=artifact_cache_write)
|
||||
for year in target_years:
|
||||
collect(repo, year)
|
||||
return discovered
|
||||
|
||||
|
||||
|
||||
def attach_source_artifacts(
|
||||
repo: dict[str, Any], headers: dict[str, str], blacklist: Collection[str],
|
||||
*, cves: set[str] | None = None, persist: bool = True,
|
||||
) -> None:
|
||||
"""Add locally verified nested evidence without weakening the ordinary gate."""
|
||||
name = str(repo.get("nameWithOwner") or "")
|
||||
if not name or repo.get("isFork") or is_blacklisted_repo(name, blacklist):
|
||||
return
|
||||
if "defaultBranchRef" in repo and repo["defaultBranchRef"] is None:
|
||||
return
|
||||
known = source_artifacts.reviewed_cves(name)
|
||||
# Unreviewed bytes cannot pass the same-repository approval registry.
|
||||
# Do not spend the Actions REST quota harvesting candidates here.
|
||||
wanted = known if cves is None else known & cves
|
||||
if not wanted:
|
||||
return
|
||||
repo["_source_artifacts"] = source_artifacts.inspect_repository(
|
||||
# A caller selecting one CVE (such as Trending) must not overwrite
|
||||
# unfinished inspection of the other reviewed CVEs in this repository.
|
||||
repo, known, readme_text(repo), http_json, headers, persist=persist,
|
||||
budget=source_artifacts.automatic_budget(),
|
||||
)
|
||||
|
||||
|
||||
def cna_container(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return (record.get("containers") or {}).get("cna") or {}
|
||||
|
||||
@@ -788,9 +876,11 @@ def details_from_record(record: dict[str, Any]) -> CVEDetails | None:
|
||||
vendor = str(affected.get("vendor") or "").strip()
|
||||
if vendor and vendor.lower() not in ("n/a", "unknown"):
|
||||
vendors.append(vendor)
|
||||
product = str(affected.get("product") or "").strip()
|
||||
if product:
|
||||
products.append(product)
|
||||
for field in ("product", "packageName"):
|
||||
product = str(affected.get(field) or "").strip()
|
||||
if product and product.lower() not in {"n/a", "unknown"}:
|
||||
products.append(product)
|
||||
break
|
||||
for version in version_rows:
|
||||
status = str(version.get("status") or "affected").lower()
|
||||
value = str(version.get("version") or "").strip()
|
||||
@@ -959,28 +1049,55 @@ def load_backfill_records(
|
||||
return records, references, paths
|
||||
|
||||
|
||||
def load_state(path: Path = STATE_FILE) -> str | None:
|
||||
def load_state(path: Path | None = None, *, allow_missing: bool = False) -> str | None:
|
||||
path = path or STATE_FILE
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
value = data.get("cvelist_fetch_time")
|
||||
return str(value) if value else None
|
||||
except FileNotFoundError as exc:
|
||||
if allow_missing:
|
||||
return None
|
||||
raise RuntimeError("Missing CVE delta checkpoint; use --bootstrap-delta explicitly to start from retained history") from exc
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(f"Unreadable or corrupt CVE delta checkpoint: {path}") from exc
|
||||
value = data.get("cvelist_fetch_time") if isinstance(data, dict) else None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RuntimeError(f"Invalid CVE delta checkpoint: {path}")
|
||||
parse_delta_time(value)
|
||||
return value
|
||||
|
||||
|
||||
def delta_changes(last_fetch_time: str | None) -> tuple[dict[str, str], str]:
|
||||
def parse_delta_time(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid CVE delta timestamp: {value!r}") from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def delta_changes(last_fetch_time: str | None, *, bootstrap: bool = False) -> tuple[dict[str, str], str]:
|
||||
if not last_fetch_time and not bootstrap:
|
||||
raise RuntimeError("CVE delta discovery requires a checkpoint or explicit bootstrap")
|
||||
log = http_json(CVELIST_DELTA_LOG_URL)
|
||||
if not isinstance(log, list) or not log:
|
||||
raise RuntimeError("CVE List V5 delta log is empty")
|
||||
times = [parse_delta_time(str(batch.get("fetchTime") or "")) for batch in log]
|
||||
if times != sorted(times, reverse=True):
|
||||
raise RuntimeError("CVE List V5 delta log is not ordered newest first")
|
||||
if last_fetch_time and parse_delta_time(last_fetch_time) < times[-1]:
|
||||
raise RuntimeError(
|
||||
f"CVE List V5 delta retention gap: checkpoint {last_fetch_time} precedes "
|
||||
f"oldest retained batch {log[-1]['fetchTime']}. "
|
||||
"Recover with --catch-up-dir pointing to a complete pinned CVE List V5 snapshot."
|
||||
)
|
||||
newest = str(log[0].get("fetchTime") or "")
|
||||
selected: list[dict[str, Any]] = []
|
||||
for batch in log:
|
||||
fetch_time = str(batch.get("fetchTime") or "")
|
||||
if last_fetch_time and fetch_time <= last_fetch_time:
|
||||
if last_fetch_time and parse_delta_time(fetch_time) <= parse_delta_time(last_fetch_time):
|
||||
break
|
||||
selected.append(batch)
|
||||
if not last_fetch_time:
|
||||
break
|
||||
|
||||
changes: dict[str, str] = {}
|
||||
for batch in reversed(selected):
|
||||
@@ -994,13 +1111,111 @@ def delta_changes(last_fetch_time: str | None) -> tuple[dict[str, str], str]:
|
||||
return changes, newest
|
||||
|
||||
|
||||
def catch_up_snapshot_files(snapshot: Path) -> tuple[str, dict[str, str], str]:
|
||||
"""Use the committed tree as the completeness manifest, never a file count."""
|
||||
def git(*arguments: str) -> bytes:
|
||||
try:
|
||||
return subprocess.run(["git", "-C", str(snapshot), *arguments], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
raise RuntimeError("Catch-up requires a pinned CVE List V5 Git checkout; extracted directories lack a trusted completeness manifest") from exc
|
||||
|
||||
if Path(git("rev-parse", "--show-toplevel").decode().strip()).resolve() != snapshot.resolve():
|
||||
raise RuntimeError("--catch-up-dir must point to the CVE List V5 Git checkout root")
|
||||
revision = git("rev-parse", "--verify", "HEAD^{commit}").decode().strip()
|
||||
algorithm = git("rev-parse", "--show-object-format").decode().strip()
|
||||
if algorithm not in {"sha1", "sha256"}:
|
||||
raise RuntimeError("Unsupported CVE snapshot object format")
|
||||
files = {}
|
||||
for entry in git("ls-tree", "-rz", "--full-tree", revision, "--", "cves").split(b"\0"):
|
||||
if not entry:
|
||||
continue
|
||||
metadata, raw_path = entry.split(b"\t", 1)
|
||||
path = raw_path.decode("utf-8")
|
||||
if path != "cves/deltaLog.json" and not re.fullmatch(r"cves/[12][0-9]{3}/[^/]+/CVE-[0-9]{4}-[0-9]{4,}\.json", path):
|
||||
continue
|
||||
mode, kind, oid = metadata.decode().split()
|
||||
if kind != "blob" or mode not in {"100644", "100755"}:
|
||||
raise RuntimeError(f"Invalid catch-up snapshot entry: {path}")
|
||||
files[path] = oid
|
||||
if "cves/deltaLog.json" not in files or len(files) < 2:
|
||||
raise RuntimeError("CVE List V5 HEAD must track deltaLog.json and CVE records")
|
||||
return revision, files, algorithm
|
||||
|
||||
|
||||
def read_snapshot_json(path: Path, oid: str, algorithm: str) -> dict | list:
|
||||
try:
|
||||
if path.is_symlink():
|
||||
raise ValueError("symlink")
|
||||
raw = path.read_bytes()
|
||||
digest = hashlib.new(algorithm, f"blob {len(raw)}\0".encode() + raw).hexdigest()
|
||||
if digest != oid:
|
||||
raise ValueError("blob differs from pinned HEAD")
|
||||
return json.loads(raw)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise RuntimeError(f"Invalid catch-up snapshot file (missing, dirty or sparse): {path}") from exc
|
||||
|
||||
|
||||
def load_catch_up_records(snapshot: Path) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]], str]:
|
||||
"""Recover a retention gap from verified files in a pinned CVE List checkout."""
|
||||
revision, files, algorithm = catch_up_snapshot_files(snapshot)
|
||||
log = read_snapshot_json(snapshot / "cves/deltaLog.json", files["cves/deltaLog.json"], algorithm)
|
||||
if not isinstance(log, list) or not log:
|
||||
raise RuntimeError("Snapshot CVE delta log is empty")
|
||||
checkpoint = str(log[0].get("fetchTime") or "")
|
||||
snapshot_time = parse_delta_time(checkpoint)
|
||||
previous = load_state()
|
||||
if not previous:
|
||||
raise RuntimeError("Catch-up requires an existing CVE delta checkpoint")
|
||||
previous_time = parse_delta_time(previous)
|
||||
if snapshot_time <= previous_time:
|
||||
raise RuntimeError("Catch-up snapshot does not advance the existing CVE delta checkpoint")
|
||||
records: dict[str, dict[str, Any]] = {}
|
||||
references: dict[str, list[str]] = {}
|
||||
blacklist = load_blacklist()
|
||||
scanned = 0
|
||||
for relative, oid in files.items():
|
||||
if relative == "cves/deltaLog.json":
|
||||
continue
|
||||
path = snapshot / relative
|
||||
record = read_snapshot_json(path, oid, algorithm)
|
||||
if not isinstance(record, dict) or record_cve_id(record) != path.stem:
|
||||
raise RuntimeError(f"Invalid catch-up CVE record: {path}")
|
||||
scanned += 1
|
||||
meta = record.get("cveMetadata") or {}
|
||||
modified = str(meta.get("dateUpdated") or meta.get("datePublished") or "")
|
||||
if modified and parse_delta_time(modified) <= previous_time:
|
||||
continue
|
||||
state = str(meta.get("state") or "").upper()
|
||||
if state not in {"PUBLISHED", "REJECTED", "RESERVED"} or (state == "PUBLISHED" and details_from_record(record) is None):
|
||||
raise RuntimeError(f"Incomplete catch-up CVE record: {path}")
|
||||
cve_id = record_cve_id(record)
|
||||
records[cve_id] = record
|
||||
if selected := poc_references(record, blacklist):
|
||||
references[cve_id] = selected
|
||||
if not scanned:
|
||||
raise RuntimeError("Catch-up snapshot contains no CVE records")
|
||||
print(f"CVE List V5 catch-up ({revision[:12]}): {scanned} records scanned, {len(records)} changed since {previous}")
|
||||
return records, references, checkpoint
|
||||
|
||||
|
||||
def fetch_records(urls: dict[str, str], *, allow_not_found: bool) -> dict[str, dict[str, Any]]:
|
||||
records: dict[str, dict[str, Any]] = {}
|
||||
failures: list[str] = []
|
||||
|
||||
def fetch(cve_id: str, url: str) -> tuple[str, dict[str, Any] | None]:
|
||||
result = http_json(url, allow_not_found=allow_not_found)
|
||||
return cve_id, result if isinstance(result, dict) else None
|
||||
if result is None and allow_not_found:
|
||||
return cve_id, None
|
||||
if (
|
||||
not isinstance(result, dict)
|
||||
or record_cve_id(result) != cve_id
|
||||
or str((result.get("cveMetadata") or {}).get("state") or "").upper()
|
||||
not in {"PUBLISHED", "RESERVED", "REJECTED"}
|
||||
or (record_is_published(result) and details_from_record(result) is None)
|
||||
):
|
||||
raise RuntimeError("Incomplete or mismatched CVE record")
|
||||
return cve_id, result
|
||||
|
||||
with ThreadPoolExecutor(max_workers=12) as executor:
|
||||
futures = {executor.submit(fetch, cve_id, url): cve_id for cve_id, url in urls.items()}
|
||||
@@ -1014,17 +1229,19 @@ def fetch_records(urls: dict[str, str], *, allow_not_found: bool) -> dict[str, d
|
||||
if record:
|
||||
records[fetched_cve] = record
|
||||
|
||||
if failures and not allow_not_found:
|
||||
raise RuntimeError("Failed to fetch CVE records: " + "; ".join(failures[:10]))
|
||||
if failures:
|
||||
print(f"Skipped {len(failures)} CVE API failures", file=sys.stderr)
|
||||
raise RuntimeError("Failed to fetch CVE records: " + "; ".join(failures[:10]))
|
||||
return records
|
||||
|
||||
|
||||
def load_incremental_records(
|
||||
cve_filter: set[str],
|
||||
*, bootstrap: bool = False,
|
||||
) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]], str]:
|
||||
changes, newest = delta_changes(load_state())
|
||||
checkpoint = load_state(allow_missing=bootstrap)
|
||||
if bootstrap and checkpoint:
|
||||
raise RuntimeError("--bootstrap-delta requires a missing checkpoint; existing state cannot be reset")
|
||||
changes, newest = delta_changes(checkpoint, bootstrap=bootstrap)
|
||||
if cve_filter:
|
||||
changes = {cve_id: url for cve_id, url in changes.items() if cve_id in cve_filter}
|
||||
records = fetch_records(changes, allow_not_found=False) if changes else {}
|
||||
@@ -1139,7 +1356,7 @@ def ensure_cve_entries(
|
||||
if cvelist_dir is not None and not cvelist_dir.is_dir():
|
||||
raise ValueError(f"CVE List directory does not exist: {cvelist_dir}")
|
||||
|
||||
today = date.today()
|
||||
today = datetime.now(timezone.utc).date()
|
||||
due = wanted if cvelist_dir is not None else {
|
||||
cve_id for cve_id in wanted if retry_record(state.get(cve_id, {}), today)
|
||||
}
|
||||
@@ -1414,8 +1631,10 @@ def sync_markdown(
|
||||
for cve_id in cve_ids:
|
||||
year = cve_id.split("-")[1]
|
||||
path = CVES / year / f"{cve_id}.md"
|
||||
github_links = stable_unique(github.get(cve_id, []))
|
||||
reference_links = stable_unique(references.get(cve_id, []))
|
||||
github_links = [url for url in stable_unique(github.get(cve_id, []))
|
||||
if cve_id not in source_artifacts.excluded_cves(github_repo_from_url(url))]
|
||||
reference_links = [url for url in stable_unique(references.get(cve_id, []))
|
||||
if cve_id not in source_artifacts.excluded_cves(github_repo_from_url(url))]
|
||||
record = records.get(cve_id)
|
||||
|
||||
if record and not record_is_published(record):
|
||||
@@ -1514,6 +1733,32 @@ def write_state(fetch_time: str, *, dry_run: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
def load_discovery_state() -> dict[str, Any]:
|
||||
if not DISCOVERY_STATE_FILE.exists():
|
||||
return {"years": {}, "pending": {}}
|
||||
state = json.loads(DISCOVERY_STATE_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(state, dict) or not all(isinstance(state.get(key), dict) for key in ("years", "pending")):
|
||||
raise ValueError("Invalid GitHub discovery state")
|
||||
for year, endpoint in state["years"].items():
|
||||
if not re.fullmatch(r"\d{4}", year):
|
||||
raise ValueError("Invalid GitHub discovery year")
|
||||
date.fromisoformat(endpoint)
|
||||
for cve_id, entry in state["pending"].items():
|
||||
if not is_valid_cve(cve_id) or not isinstance(entry, dict) or any(
|
||||
not isinstance(entry.get(key), list) or any(not isinstance(url, str) for url in entry[key])
|
||||
for key in ("github", "references")
|
||||
):
|
||||
raise ValueError("Invalid pending GitHub discovery")
|
||||
return state
|
||||
|
||||
|
||||
def write_discovery_state(state: dict[str, Any]) -> None:
|
||||
DISCOVERY_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = DISCOVERY_STATE_FILE.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(state, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(DISCOVERY_STATE_FILE)
|
||||
|
||||
|
||||
def newest_delta_time() -> str:
|
||||
log = http_json(CVELIST_DELTA_LOG_URL)
|
||||
if not isinstance(log, list) or not log:
|
||||
@@ -1523,6 +1768,8 @@ def newest_delta_time() -> str:
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Discover and ingest CVE PoC links")
|
||||
parser.add_argument("--bootstrap-delta", action="store_true", help="Explicitly initialize a missing checkpoint from all retained CVE delta batches; earlier history needs a separate backfill")
|
||||
parser.add_argument("--catch-up-dir", type=Path, help="CVE List V5 Git checkout root; verifies every tracked CVE JSON against pinned HEAD before recovery")
|
||||
parser.add_argument("--backfill-dir", type=Path, help="Local CVE List V5 year directory")
|
||||
parser.add_argument("--year", type=int, help="Year to backfill")
|
||||
parser.add_argument("--lookback-days", type=int, default=3, help="GitHub pushed-date overlap")
|
||||
@@ -1539,8 +1786,12 @@ def parse_args() -> argparse.Namespace:
|
||||
help="Rewrite the title and badges of every record from --cvelist-dir, and do nothing else",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.bootstrap_delta and (args.catch_up_dir or args.backfill_dir or args.cve or args.skip_cvelist or args.refresh_headers):
|
||||
parser.error("--bootstrap-delta cannot be filtered, skipped, or combined with catch-up/backfill/header refresh")
|
||||
if bool(args.backfill_dir) != bool(args.year):
|
||||
parser.error("--backfill-dir and --year must be used together")
|
||||
if args.catch_up_dir and (args.backfill_dir or args.cve or args.skip_cvelist or args.refresh_headers):
|
||||
parser.error("--catch-up-dir cannot be filtered, skipped, or combined with backfill/header refresh")
|
||||
if args.lookback_days < 1 or args.years < 1:
|
||||
parser.error("--lookback-days and --years must be positive")
|
||||
return args
|
||||
@@ -1564,18 +1815,23 @@ def main() -> int:
|
||||
references: dict[str, list[str]] = {}
|
||||
record_paths: dict[str, Path] = {}
|
||||
checkpoint = ""
|
||||
discovery_end = datetime.now(timezone.utc).date()
|
||||
discovery_state = load_discovery_state() if not args.skip_github and not cve_filter else None
|
||||
years: list[int] = []
|
||||
|
||||
if not args.skip_cvelist:
|
||||
if backfill:
|
||||
if getattr(args, "catch_up_dir", None):
|
||||
records, references, checkpoint = load_catch_up_records(args.catch_up_dir)
|
||||
references = filter_live_references(references)
|
||||
elif backfill:
|
||||
records, references, record_paths = load_backfill_records(
|
||||
args.backfill_dir,
|
||||
args.year,
|
||||
cve_filter,
|
||||
)
|
||||
checkpoint = newest_delta_time()
|
||||
print(f"CVE List V5 backfill: {len(references)} CVEs with PoC references")
|
||||
else:
|
||||
records, references, checkpoint = load_incremental_records(cve_filter)
|
||||
records, references, checkpoint = load_incremental_records(cve_filter, bootstrap=getattr(args, "bootstrap_delta", False))
|
||||
references = filter_live_references(references)
|
||||
|
||||
github: dict[str, list[str]] = {}
|
||||
@@ -1594,6 +1850,9 @@ def main() -> int:
|
||||
lookback_days=args.lookback_days,
|
||||
backfill=backfill,
|
||||
cve_filter=cve_filter,
|
||||
window_end=discovery_end,
|
||||
checkpoints=discovery_state["years"] if discovery_state else None,
|
||||
artifact_cache_write=not args.dry_run,
|
||||
)
|
||||
print(f"GitHub discovery: {sum(len(urls) for urls in github.values())} links for {len(github)} CVEs")
|
||||
|
||||
@@ -1602,7 +1861,18 @@ def main() -> int:
|
||||
path = record_paths.get(cve_id)
|
||||
if path and (record := read_record(path)):
|
||||
records[cve_id] = record
|
||||
fetch_missing_records(set(github) | set(references), records)
|
||||
deferred: set[str] = set()
|
||||
if discovery_state is not None:
|
||||
for cve_id, pending in discovery_state["pending"].items():
|
||||
if backfill and int(cve_id.split("-")[1]) not in years:
|
||||
continue
|
||||
for mappings, field in ((github, "github"), (references, "references")):
|
||||
if pending[field]:
|
||||
mappings[cve_id] = stable_unique([*mappings.get(cve_id, []), *pending[field]])
|
||||
if not retry_record(pending, discovery_end):
|
||||
deferred.add(cve_id)
|
||||
candidates = set(github) | set(references)
|
||||
fetch_missing_records(candidates - deferred, records)
|
||||
|
||||
stats, accepted_github, accepted_references = sync_markdown(
|
||||
records,
|
||||
@@ -1632,7 +1902,24 @@ def main() -> int:
|
||||
# list; the ones fetched this run are refreshed in place.
|
||||
refreshed = refresh_record_headers(records, dry_run=args.dry_run)
|
||||
dated = record_dates(records, dry_run=args.dry_run)
|
||||
write_state(checkpoint, dry_run=args.dry_run)
|
||||
if not backfill and not cve_filter:
|
||||
write_state(checkpoint, dry_run=args.dry_run)
|
||||
if discovery_state is not None and not args.dry_run:
|
||||
for cve_id in candidates:
|
||||
record = records.get(cve_id) or {}
|
||||
status = str((record.get("cveMetadata") or {}).get("state") or "UNAVAILABLE").upper()
|
||||
if cve_id in accepted_github or cve_id in accepted_references or status == "REJECTED":
|
||||
discovery_state["pending"].pop(cve_id, None)
|
||||
else:
|
||||
previous = discovery_state["pending"].get(cve_id, {})
|
||||
discovery_state["pending"][cve_id] = {
|
||||
"github": github.get(cve_id, []),
|
||||
"references": references.get(cve_id, []),
|
||||
"status": previous.get("status", status) if cve_id in deferred else status,
|
||||
"checked": previous["checked"] if cve_id in deferred else discovery_end.isoformat(),
|
||||
}
|
||||
discovery_state["years"].update({str(year): discovery_end.isoformat() for year in years})
|
||||
write_discovery_state(discovery_state)
|
||||
|
||||
print(
|
||||
f"Created: {len(stats.created)} | Updated: {len(stats.updated)} | "
|
||||
|
||||
Reference in new issue
Block a user