Files
cve/scripts/sync_nuclei.py
T
0xMarcio 35cb1e6962 Index nuclei templates as PoCs and take their ratings with them
A nuclei template is a runnable check for one CVE, which makes it a proof of
concept whether or not anyone published a repository for it. Measured against
the index: 3,952 CVEs gain a template link and 858 of those had no PoC at all,
584 of the additions falling in 2024 or later. That last figure is the point.
ExploitDB carries more CVEs overall and Metasploit is better weaponised, but
both skew historical; nuclei is where coverage of a recent CVE actually shows
up first.

Templates also carry the ratings the entries never had. Severity, CVSS with
its vector, EPSS with its percentile and CWE now sit in nuclei.json for the
site to read: 4,193 of 4,418 templates state a CVSS score and 4,274 an EPSS
score. A result can finally say how bad a flaw is and how likely it is to be
used, rather than only that somebody wrote code for it.

Templates are kept in their own section and their own field. They are not
somebody's repository and should not be ranked, starred or credited as one,
but they do lead the PoC list, being the one link that runs as it stands.

The index files are also revalidated rather than reused from cache. They are
rebuilt hourly and a returning visitor was holding whatever it downloaded
first, which cost a stale index here until the copy was thrown away by hand.
2026-09-01 05:11:31 +02:00

154 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Fold ProjectDiscovery's nuclei templates into the index.
A template is a runnable check for one CVE, which makes it a proof of concept
in its own right, and it carries the severity, CVSS, EPSS and CWE the markdown
entries never had. Both are picked up here: the template link joins the PoC
list for its CVE, and the ratings land in nuclei.json for the site to read.
"""
from __future__ import annotations
import argparse
import io
import json
import re
import tarfile
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "nuclei.json"
TARBALL = "https://codeload.github.com/projectdiscovery/nuclei-templates/tar.gz/refs/heads/main"
BLOB = "https://github.com/projectdiscovery/nuclei-templates/blob/main/"
SECTION = "#### Nuclei"
EMPTY = "No nuclei template."
TEMPLATE_NAME = re.compile(r"^CVE-\d{4}-\d{4,7}\.yaml$")
USER_AGENT = "0xMarcio-cve-nuclei"
# The fields wanted are plain scalars on their own line inside info:, so they
# are read directly rather than pulling a YAML parser into a stdlib-only job.
FIELDS = {
"name": re.compile(r"^ name:\s*(.+?)\s*$", re.M),
"severity": re.compile(r"^\s{2,4}severity:\s*([a-z]+)\s*$", re.M),
"cvss": re.compile(r"^\s+cvss-score:\s*([0-9.]+)\s*$", re.M),
"cvss_vector": re.compile(r"^\s+cvss-metrics:\s*(\S+)\s*$", re.M),
"epss": re.compile(r"^\s+epss-score:\s*([0-9.]+)\s*$", re.M),
"epss_pct": re.compile(r"^\s+epss-percentile:\s*([0-9.]+)\s*$", re.M),
"cwe": re.compile(r"^\s+cwe-id:\s*(CWE-\d+)\s*$", re.M | re.I),
}
def download() -> bytes:
request = urllib.request.Request(TARBALL, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=180) as response:
return response.read()
def parse(text: str) -> dict:
"""Ratings for one template, dropping anything the template does not state."""
head = text[:4000]
found: dict[str, object] = {}
for key, pattern in FIELDS.items():
match = pattern.search(head)
if not match:
continue
value = match.group(1).strip().strip('"\'')
if key in ("cvss", "epss", "epss_pct"):
try:
found[key] = round(float(value), 5)
except ValueError:
pass
elif value:
found[key] = value
return found
def collect(archive: bytes) -> dict[str, dict]:
"""Every CVE template in the tarball, keyed by CVE id.
Templates live under several top level directories (http, code, network and
so on); what marks one is a cves/ path segment and a CVE-shaped filename.
"""
templates: dict[str, dict] = {}
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
for member in tar:
if not member.isfile():
continue
parts = Path(member.name).parts
if "cves" not in parts or not TEMPLATE_NAME.match(parts[-1]):
continue
handle = tar.extractfile(member)
if handle is None:
continue
text = handle.read().decode("utf-8", "replace")
cve = parts[-1][:-5].upper()
# Strip the archive's own top level directory from the blob path.
relative = "/".join(parts[1:])
entry = parse(text)
entry["url"] = BLOB + relative
# A CVE with more than one template keeps the richer of the two.
if len(entry) >= len(templates.get(cve, {})):
templates[cve] = entry
return templates
def section_bounds(text: str, header: str) -> tuple[int, int] | None:
start = text.find("\n" + header)
if start < 0:
return None
body = start + 1 + len(header)
nxt = text.find("\n#", body)
return body, len(text) if nxt < 0 else nxt
def apply_to_markdown(cve: str, url: str, dry_run: bool) -> str:
"""Add the template link to one CVE entry. Returns what happened."""
path = ROOT / cve.split("-")[1] / f"{cve}.md"
if not path.exists():
return "absent"
original = path.read_text(encoding="utf-8")
if url in original:
return "unchanged"
block = f"\n{SECTION}\n- {url}\n"
bounds = section_bounds(original, SECTION)
if bounds:
start, end = bounds
updated = original[:start] + f"\n- {url}\n" + original[end:]
else:
# The section sits at the end of the POC block, after the GitHub links.
updated = original.rstrip("\n") + "\n" + block
if not dry_run:
path.write_text(updated, encoding="utf-8")
return "added"
def main() -> int:
parser = argparse.ArgumentParser(description="Sync nuclei templates into the index")
parser.add_argument("--dry-run", action="store_true", help="report without writing")
args = parser.parse_args()
templates = collect(download())
print(f"{len(templates):,} CVE templates in nuclei-templates")
tally = {"added": 0, "unchanged": 0, "absent": 0}
for cve, entry in sorted(templates.items()):
tally[apply_to_markdown(cve, entry["url"], args.dry_run)] += 1
payload = {cve: entry for cve, entry in sorted(templates.items())}
if not args.dry_run:
with OUTPUT.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=0, sort_keys=True)
handle.write("\n")
rated = sum(1 for e in payload.values() if "cvss" in e)
scored = sum(1 for e in payload.values() if "epss" in e)
print(f"markdown: {tally['added']:,} links added, {tally['unchanged']:,} already there, "
f"{tally['absent']:,} for CVEs not in the index")
print(f"{OUTPUT.name}: {len(payload):,} CVEs, {rated:,} with CVSS, {scored:,} with EPSS")
return 0
if __name__ == "__main__":
raise SystemExit(main())