Files
cve/scripts/sync_nuclei.py
T
0xMarcio 1b87e9669d Only index a nuclei template that exercises the bug
Wordfence templates do not exploit anything. They fetch a plugin's
readme.txt, read the version string and report that a vulnerable release is
installed: 80,706 of their 81,526 templates are that and nothing else, and
not one sends a payload. Yesterday's widening filed 35,383 of them as proofs
of concept, which is a claim the files do not support.

Judge the template rather than the repository it came from. A template whose
every request reads readme.txt, style.css or a changelog, and which never
sends a body or a payload, is a version probe and is skipped wherever it
comes from. That keeps the 382 real templates Wordfence does publish and
drops the same shape out of the community sets, which were re-hosting them.

39,973 templated CVEs becomes 4,929 and 19,504 entries give up a nuclei
section they should never have been given. The index returns to 104,184
CVEs with PoCs from an inflated 117,373.
2026-09-01 14:11:21 +02:00

238 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Fold 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"
def source(repo: str, branch: str = "main", *, scoped: bool = False) -> tuple:
"""A template repository: where to fetch it and how to link into it.
ProjectDiscovery keeps templates for everything, so only its cves/ tree
counts. The community repositories below are nothing but CVE templates,
where that scoping would throw the whole repository away.
"""
return (
repo,
f"https://codeload.github.com/{repo}/tar.gz/refs/heads/{branch}",
f"https://github.com/{repo}/blob/{branch}/",
scoped,
)
# Order is precedence. ProjectDiscovery's template is the one to link when it
# exists; the rest are read only for the CVEs it does not cover. Wordfence
# alone carries thousands of WordPress plugin CVEs nobody else templates.
SOURCES = (
source("projectdiscovery/nuclei-templates", scoped=True),
source("topscoder/nuclei-wordfence-cve"),
source("linuxadi/40k-nuclei-templates"),
source("CharanRayudu/Custom-Nuclei-Templates"),
source("geeknik/the-nuclei-templates"),
source("Akokonunes/Private-Nuclei-Templates"),
)
SECTION = "#### Nuclei"
EMPTY = "No nuclei template."
# ProjectDiscovery names a template for its CVE and nothing else. Wordfence
# appends the plugin slug and an advisory uuid, so the id is a prefix there.
TEMPLATE_NAME = re.compile(r"^(CVE-\d{4}-\d{4,7})(?:[-_.][^/]*)?\.ya?ml$", re.IGNORECASE)
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(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=900) as response:
return response.read()
# A template that only fetches a plugin's readme and compares the version it
# finds has not proven anything: it reports that a vulnerable release is
# installed. Wordfence publishes tens of thousands of these, and filing one as
# a proof of concept claims something the file does not do.
PROTOCOL = re.compile(
r"\n(?:requests|http|code|network|javascript|dns|file|headless|ssl|websocket|tcp):"
)
METADATA_PATH = re.compile(r"(readme\.txt|readme\.md|style\.css|changelog\.txt)\s*$", re.I)
REQUEST_PATH = re.compile(r"-\s*\"?\{\{BaseURL\}\}([^\"'\n]*)")
EXERCISES_BUG = re.compile(r"\braw:|\bmethod:\s*(?:POST|PUT|PATCH|DELETE)|payloads:|\bbody:|fuzzing:")
def version_probe_only(text: str) -> bool:
"""True when the template only reads a version string."""
parts = PROTOCOL.split(text, maxsplit=1)
if len(parts) < 2:
return False
body = parts[1]
if EXERCISES_BUG.search(body):
return False
paths = REQUEST_PATH.findall(body)
return bool(paths) and all(METADATA_PATH.search(path) for path in paths)
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, blob: str, scoped: bool) -> dict[str, dict]:
"""Every CVE template in the tarball, keyed by CVE id."""
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
named = TEMPLATE_NAME.match(parts[-1])
if not named:
continue
if scoped and "cves" not in parts:
continue
handle = tar.extractfile(member)
if handle is None:
continue
text = handle.read().decode("utf-8", "replace")
if version_probe_only(text):
continue
cve = named.group(1).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 drop_section(path: Path, dry_run: bool) -> bool:
"""Remove the nuclei section from an entry no template covers any more."""
original = path.read_text(encoding="utf-8")
bounds = section_bounds(original, SECTION)
if not bounds:
return False
head = original.rfind("\n" + SECTION, 0, bounds[0] + 1)
if head < 0:
return False
updated = (original[:head] + original[bounds[1]:]).rstrip("\n") + "\n"
if not dry_run:
path.write_text(updated, encoding="utf-8")
return True
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: dict[str, dict] = {}
for repo, tarball, blob, scoped in SOURCES:
try:
found = collect(download(tarball), blob, scoped)
except Exception as problem:
print(f"{repo}: unavailable ({problem}); skipped")
continue
fresh = {cve: entry for cve, entry in found.items() if cve not in templates}
templates.update(fresh)
print(f"{repo}: {len(found):,} CVE templates, {len(fresh):,} not already covered")
print(f"{len(templates):,} CVEs templated in total")
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
# An entry whose template stopped qualifying is never visited above, so its
# section would otherwise outlive the reason it was written.
stale = 0
for path in ROOT.glob("[12][0-9][0-9][0-9]/CVE-*.md"):
if path.stem in templates:
continue
if SECTION in path.read_text(encoding="utf-8", errors="replace"):
stale += drop_section(path, args.dry_run)
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, {stale:,} sections withdrawn")
print(f"{OUTPUT.name}: {len(payload):,} CVEs, {rated:,} with CVSS, {scored:,} with EPSS")
return 0
if __name__ == "__main__":
raise SystemExit(main())