2026-09-01 05:11:31 +02:00
|
|
|
#!/usr/bin/env python3
|
2026-09-01 13:50:39 +02:00
|
|
|
"""Fold nuclei templates into the index.
|
2026-09-01 05:11:31 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-10-07 01:23:59 +02:00
|
|
|
import releases
|
2026-09-01 19:01:07 +02:00
|
|
|
from update_cves import ensure_cve_entries
|
|
|
|
|
|
2026-09-01 05:11:31 +02:00
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
2026-09-01 14:43:06 +02:00
|
|
|
CVES = ROOT / "cves"
|
|
|
|
|
INDEX = ROOT / "index"
|
|
|
|
|
OUTPUT = INDEX / "nuclei.json"
|
2026-09-01 13:50:39 +02:00
|
|
|
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"),
|
|
|
|
|
)
|
2026-09-01 05:11:31 +02:00
|
|
|
SECTION = "#### Nuclei"
|
|
|
|
|
EMPTY = "No nuclei template."
|
2026-09-01 13:50:39 +02:00
|
|
|
# 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)
|
2026-09-01 05:11:31 +02:00
|
|
|
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),
|
|
|
|
|
"cwe": re.compile(r"^\s+cwe-id:\s*(CWE-\d+)\s*$", re.M | re.I),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 13:50:39 +02:00
|
|
|
def download(url: str) -> bytes:
|
|
|
|
|
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
|
|
|
with urllib.request.urlopen(request, timeout=900) as response:
|
2026-09-01 05:11:31 +02:00
|
|
|
return response.read()
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 14:11:21 +02:00
|
|
|
# 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)
|
2026-10-06 23:23:48 +02:00
|
|
|
if paths and all(METADATA_PATH.search(path) for path in paths):
|
|
|
|
|
return True
|
|
|
|
|
# WordPress core probes extract versions from landing/install/feed pages.
|
|
|
|
|
# A version comparison alongside an actual exploit request must stay.
|
|
|
|
|
request_paths = re.findall(r'''(?m)^\s*-\s*["']?(?:\{\{[^}]+\}\}|https?://)''', body)
|
|
|
|
|
return len(PROTOCOL.findall(text)) == 1 and len(request_paths) == len(paths) and bool(paths) and all(
|
|
|
|
|
path in {"", "/", "/wp-admin/install.php", "/feed/", "/?feed=rss2"} for path in paths
|
|
|
|
|
) and bool(re.search(r"compare_versions\(\s*version_by_(?:generator|js|css)\b", body)) and bool(
|
|
|
|
|
re.search(r"\bname:\s*version_by_(?:generator|js|css)\b", body)
|
|
|
|
|
)
|
2026-09-01 14:11:21 +02:00
|
|
|
|
|
|
|
|
|
2026-09-01 05:11:31 +02:00
|
|
|
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('"\'')
|
2026-09-04 00:36:11 +02:00
|
|
|
if key == "cvss":
|
2026-09-01 05:11:31 +02:00
|
|
|
try:
|
|
|
|
|
found[key] = round(float(value), 5)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
elif value:
|
|
|
|
|
found[key] = value
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 13:50:39 +02:00
|
|
|
def collect(archive: bytes, blob: str, scoped: bool) -> dict[str, dict]:
|
|
|
|
|
"""Every CVE template in the tarball, keyed by CVE id."""
|
2026-09-01 05:11:31 +02:00
|
|
|
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
|
2026-09-01 13:50:39 +02:00
|
|
|
named = TEMPLATE_NAME.match(parts[-1])
|
|
|
|
|
if not named:
|
|
|
|
|
continue
|
|
|
|
|
if scoped and "cves" not in parts:
|
2026-09-01 05:11:31 +02:00
|
|
|
continue
|
|
|
|
|
handle = tar.extractfile(member)
|
|
|
|
|
if handle is None:
|
|
|
|
|
continue
|
|
|
|
|
text = handle.read().decode("utf-8", "replace")
|
2026-09-01 14:11:21 +02:00
|
|
|
if version_probe_only(text):
|
|
|
|
|
continue
|
2026-09-01 13:50:39 +02:00
|
|
|
cve = named.group(1).upper()
|
2026-09-01 05:11:31 +02:00
|
|
|
# Strip the archive's own top level directory from the blob path.
|
|
|
|
|
relative = "/".join(parts[1:])
|
|
|
|
|
entry = parse(text)
|
2026-09-01 13:50:39 +02:00
|
|
|
entry["url"] = blob + relative
|
2026-09-01 05:11:31 +02:00
|
|
|
# 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."""
|
2026-10-22 13:26:22 +02:00
|
|
|
import link_health
|
|
|
|
|
if link_health.unavailable(url):
|
|
|
|
|
return "unchanged"
|
2026-09-01 14:43:06 +02:00
|
|
|
path = CVES / cve.split("-")[1] / f"{cve}.md"
|
2026-09-01 05:11:31 +02:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 14:11:21 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 05:11:31 +02:00
|
|
|
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")
|
2026-09-01 19:01:07 +02:00
|
|
|
parser.add_argument("--cvelist-dir", type=Path, help="local CVE List V5 root for a large backfill")
|
2026-09-01 05:11:31 +02:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
2026-09-01 13:50:39 +02:00
|
|
|
templates: dict[str, dict] = {}
|
2026-09-01 19:01:07 +02:00
|
|
|
failures: list[str] = []
|
2026-09-01 13:50:39 +02:00
|
|
|
for repo, tarball, blob, scoped in SOURCES:
|
|
|
|
|
try:
|
|
|
|
|
found = collect(download(tarball), blob, scoped)
|
|
|
|
|
except Exception as problem:
|
2026-09-01 19:01:07 +02:00
|
|
|
print(f"{repo}: unavailable ({problem})")
|
|
|
|
|
failures.append(repo)
|
2026-09-01 13:50:39 +02:00
|
|
|
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")
|
2026-09-01 19:01:07 +02:00
|
|
|
if failures:
|
|
|
|
|
print(
|
|
|
|
|
"No files changed because a source was unavailable: " + ", ".join(failures)
|
|
|
|
|
)
|
|
|
|
|
return 1
|
2026-09-01 13:50:39 +02:00
|
|
|
print(f"{len(templates):,} CVEs templated in total")
|
2026-09-01 05:11:31 +02:00
|
|
|
|
2026-09-01 19:01:07 +02:00
|
|
|
created, unavailable = ensure_cve_entries(
|
|
|
|
|
templates,
|
|
|
|
|
dry_run=args.dry_run,
|
|
|
|
|
cvelist_dir=args.cvelist_dir,
|
|
|
|
|
)
|
|
|
|
|
if created or unavailable:
|
|
|
|
|
verb = "would create" if args.dry_run else "created"
|
|
|
|
|
print(f"base entries: {len(created):,} {verb}, {len(unavailable):,} unpublished or unavailable")
|
|
|
|
|
|
2026-09-01 05:11:31 +02:00
|
|
|
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
|
|
|
|
|
|
2026-09-01 14:11:21 +02:00
|
|
|
# An entry whose template stopped qualifying is never visited above, so its
|
|
|
|
|
# section would otherwise outlive the reason it was written.
|
|
|
|
|
stale = 0
|
2026-09-01 14:43:06 +02:00
|
|
|
for path in CVES.glob("[12][0-9][0-9][0-9]/CVE-*.md"):
|
2026-09-01 14:11:21 +02:00
|
|
|
if path.stem in templates:
|
|
|
|
|
continue
|
|
|
|
|
if SECTION in path.read_text(encoding="utf-8", errors="replace"):
|
|
|
|
|
stale += drop_section(path, args.dry_run)
|
|
|
|
|
|
2026-09-01 05:11:31 +02:00
|
|
|
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")
|
|
|
|
|
|
2026-10-07 01:23:59 +02:00
|
|
|
if not args.dry_run:
|
|
|
|
|
releases.save_ledger(releases.reconcile(releases.load_ledger(), releases.published_pairs()))
|
|
|
|
|
|
2026-09-01 05:11:31 +02:00
|
|
|
rated = sum(1 for e in payload.values() if "cvss" in e)
|
|
|
|
|
print(f"markdown: {tally['added']:,} links added, {tally['unchanged']:,} already there, "
|
2026-09-01 14:11:21 +02:00
|
|
|
f"{tally['absent']:,} for CVEs not in the index, {stale:,} sections withdrawn")
|
2026-09-04 00:36:11 +02:00
|
|
|
print(f"{OUTPUT.name}: {len(payload):,} CVEs, {rated:,} with CVSS")
|
2026-09-01 05:11:31 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|