Files
vulncheck/app/services/exploit_intel_service.py
T
vulncheck dbad9a365e chore(rebrand): VulnCheck → TrueVuln
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).

Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).

Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
  README lines derived from it (git clone target dir, tree listing) — a real
  repo rename is a manual Gitea-side step (Settings → repository name) the
  user would need to do themselves, and existing clones would need
  `git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
  README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
  accounts that still exist under the old name; renaming the text alone
  wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
  vulncheck/...) — tied to GitNexus's own index name for this repo, not our
  branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
  (vulnmanager-*) — explicit user decision: infra naming carries real
  deploy/data risk on an already-running instance and isn't part of the
  product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
  — invisible internal CSS variable name, renaming it would touch ~270
  className occurrences for zero user-visible benefit.

Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
2026-07-07 16:34:40 +02:00

416 lines
15 KiB
Python

"""
Exploit-intel enrichment from third-party catalogs.
Closes the gap noted by tester: "Nutzt Du darüber hinaus auch bereits
alle Infos wie von exploit-db.com? Das ist in der Detailansicht nur
verlinkt — fliessen die Infos in den Score?"
Sources covered by this module:
1. Exploit-DB (Offensive Security)
- Source: gitlab.com/exploit-database/exploitdb (raw CSV)
- File: files_exploits.csv (~50k rows, ~10 MB)
- CVE refs in column `codes` (e.g. "CVE-2023-1234;CVE-2023-5678")
2. PoC-in-GitHub (nomi-sec)
- Source: github.com/nomi-sec/PoC-in-GitHub
- Per-year tree of JSON files (one per CVE)
- We pull the aggregated JSON dump (mirrored daily) when available.
3. Metasploit Framework
- Source: github.com/rapid7/metasploit-framework
- Modules under modules/exploits/ with `References` field in
comments — extracted into a flat CVE→[module-path] map.
All three are best-effort: a fetch failure leaves the column as-is,
never wipes a known-good row. Cached on disk + in the `settings`
table for 24h (matches the existing EPSS/KEV cache pattern).
Each enricher returns `dict[cve_id, list[reference]]`. The bulk
runner (`refresh_all_exploit_intel`) walks all open vulns once and
writes the union into the new columns from migration 025.
"""
from __future__ import annotations
import csv
import io
import json
import logging
import os
import re
import tempfile
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Set
import httpx
from sqlalchemy.orm import Session
from app.models.setting import Setting
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Exploit-DB CSV
# ----------------------------------------------------------------------
_EDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
_EDB_CACHE_PATH = "/tmp/truevuln-exploit-db.csv"
_EDB_CACHE_TTL = 24 * 3600
# Lower-case header → index lookup (the GitLab CSV occasionally
# shuffles columns; resolve by name).
_EDB_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}", re.IGNORECASE)
def _cache_fresh(path: str, ttl_seconds: int) -> bool:
try:
mtime = os.path.getmtime(path)
return (time.time() - mtime) < ttl_seconds
except OSError:
return False
def _download_to(path: str, url: str, timeout: float = 60.0) -> bool:
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
r = client.get(url)
r.raise_for_status()
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(r.content)
os.replace(tmp, path)
logger.info("downloaded %s%s (%d bytes)", url, path, len(r.content))
return True
except Exception as e:
logger.warning("download failed for %s: %s", url, e)
return False
def fetch_exploit_db_cve_map() -> Dict[str, List[str]]:
"""Parse Exploit-DB CSV → {cve_id: [edb_id, ...]}.
Returns {} on fetch / parse failure — caller skips enrichment for
that run rather than wiping previously-stored values.
"""
if not _cache_fresh(_EDB_CACHE_PATH, _EDB_CACHE_TTL):
if not _download_to(_EDB_CACHE_PATH, _EDB_CSV_URL):
return {}
out: Dict[str, List[str]] = {}
try:
with open(_EDB_CACHE_PATH, "r", encoding="utf-8", errors="replace") as f:
reader = csv.DictReader(f)
for row in reader:
edb_id = (row.get("id") or "").strip()
if not edb_id:
continue
# Field name has changed over time — try common spellings.
codes_blob = (
row.get("codes")
or row.get("Codes")
or row.get("cve_ref")
or row.get("references")
or ""
)
for m in _EDB_CVE_RE.findall(codes_blob):
cve = m.upper()
out.setdefault(cve, []).append(edb_id)
except Exception as e:
logger.warning("exploit-db CSV parse failed: %s", e)
return {}
logger.info("Exploit-DB map: %d CVEs with public exploits", len(out))
return out
# ----------------------------------------------------------------------
# PoC-in-GitHub
# ----------------------------------------------------------------------
# nomi-sec maintains the aggregated dump that mirrors the per-year
# folders into a single JSON: https://github.com/nomi-sec/PoC-in-GitHub
# Direct raw URL — heavy but cacheable. Pattern: one folder per year
# under the repo, each holding {CVE-ID}.json.
_POC_RAW_BASE = "https://raw.githubusercontent.com/nomi-sec/PoC-in-GitHub/master"
_POC_CACHE_PATH = "/tmp/truevuln-pocs-github.json"
_POC_CACHE_TTL = 24 * 3600
def fetch_pocs_github_cve_map(
known_cves: Optional[Set[str]] = None,
years: Optional[List[int]] = None,
) -> Dict[str, List[str]]:
"""Per-CVE PoC URLs from PoC-in-GitHub.
Strategy
1. List each year-folder (one cheap HTTP call per year).
2. Filter the listing to filenames matching `known_cves`
(uppercase CVE-id set). If known_cves is empty/None we DO
NOT walk the whole repo — that's 30k+ requests = 100 min and
hits the unauthenticated 60-req/h GitHub rate-limit fast.
Caller MUST pass the CVE-id set from the DB.
3. For each matching CVE, fetch its per-CVE JSON file → repo URLs.
Cached on disk + keyed by the known_cves hash so a repeat call
with the same DB scope is instant.
"""
if not known_cves:
logger.info(
"PoC-in-GitHub skipped — no known_cves set passed in. "
"Walking the full repo would burn the 60 req/h GitHub limit."
)
return {}
if years is None:
current_year = datetime.now().year
years = list(range(current_year - 5, current_year + 1))
# Cache key includes the known-cves digest so we never serve stale
# results scoped to a smaller DB.
import hashlib
digest = hashlib.sha1(
",".join(sorted(known_cves)).encode("utf-8")
).hexdigest()[:12]
cache_path = f"{_POC_CACHE_PATH}.{digest}"
if _cache_fresh(cache_path, _POC_CACHE_TTL):
try:
with open(cache_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
out: Dict[str, List[str]] = {}
try:
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
for year in years:
api = f"https://api.github.com/repos/nomi-sec/PoC-in-GitHub/contents/{year}"
try:
r = client.get(api, headers={"Accept": "application/vnd.github+json"})
if r.status_code != 200:
if r.status_code in (403, 429):
logger.warning(
"PoC-in-GitHub: GitHub rate-limit hit on year %d — stopping.",
year,
)
break
continue
entries = r.json()
except Exception:
continue
if not isinstance(entries, list):
continue
# Filter to CVEs we actually have in the DB.
relevant: list[dict] = []
for entry in entries:
name = entry.get("name") or ""
if not name.endswith(".json"):
continue
cve_id = name[:-5].upper()
if cve_id in known_cves:
relevant.append(entry)
logger.info(
"PoC-in-GitHub year %d: %d / %d entries match DB",
year, len(relevant), len(entries),
)
for entry in relevant:
cve_id = entry["name"][:-5].upper()
download_url = entry.get("download_url")
if not download_url:
continue
try:
rr = client.get(download_url)
if rr.status_code == 403 or rr.status_code == 429:
logger.warning(
"PoC-in-GitHub: rate-limit on %s — stopping.",
cve_id,
)
break
if rr.status_code != 200:
continue
pocs = rr.json()
except Exception:
continue
if isinstance(pocs, list):
urls = [p.get("html_url") for p in pocs if isinstance(p, dict) and p.get("html_url")]
if urls:
out[cve_id] = urls
try:
with open(cache_path, "w", encoding="utf-8") as f:
json.dump(out, f)
except Exception:
pass
except Exception as e:
logger.warning("PoC-in-GitHub fetch failed: %s", e)
return {}
logger.info("PoC-in-GitHub map: %d CVEs with public PoC links", len(out))
return out
# ----------------------------------------------------------------------
# Metasploit Framework modules
# ----------------------------------------------------------------------
# rapid7 ships a `db/modules_metadata_base.json` companion file containing
# parsed metadata for every module. CVE refs live under `references`.
_MSF_META_URL = (
"https://raw.githubusercontent.com/rapid7/metasploit-framework/master/"
"db/modules_metadata_base.json"
)
_MSF_CACHE_PATH = "/tmp/truevuln-msf-modules.json"
_MSF_CACHE_TTL = 24 * 3600
def fetch_metasploit_cve_map() -> Dict[str, List[str]]:
"""Per-CVE Metasploit module paths."""
if not _cache_fresh(_MSF_CACHE_PATH, _MSF_CACHE_TTL):
if not _download_to(_MSF_CACHE_PATH, _MSF_META_URL, timeout=120.0):
return {}
out: Dict[str, List[str]] = {}
try:
with open(_MSF_CACHE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.warning("metasploit metadata parse failed: %s", e)
return {}
if not isinstance(data, dict):
return {}
for path, mod in data.items():
if not isinstance(mod, dict):
continue
refs = mod.get("references") or []
if not isinstance(refs, list):
continue
for ref in refs:
if not isinstance(ref, str):
continue
# References come as "CVE-YYYY-NNNN" plain or "CVE,YYYY-NNNN"
for m in _EDB_CVE_RE.findall(ref) or (
[f"CVE-{ref[4:]}"] if ref.startswith("CVE,") and "-" in ref[4:] else []
):
cve = m.upper().replace("CVE,", "CVE-")
if not cve.startswith("CVE-"):
continue
out.setdefault(cve, []).append(path)
# Dedup per-cve while preserving order
for k in list(out.keys()):
seen: Set[str] = set()
deduped: List[str] = []
for p in out[k]:
if p in seen:
continue
seen.add(p)
deduped.append(p)
out[k] = deduped
logger.info("Metasploit map: %d CVEs with weaponised modules", len(out))
return out
# ----------------------------------------------------------------------
# Bulk apply
# ----------------------------------------------------------------------
def refresh_all_exploit_intel(
db: Session,
*,
only_open: bool = True,
fetch_pocs: bool = True,
fetch_msf: bool = True,
) -> dict:
"""Walk vulnerabilities, write exploit-intel columns.
Stats: {edb_marked, edb_cleared, poc_marked, msf_marked, total}.
"""
# Load vulns first so we can pass the CVE-id set into the PoC
# fetcher — otherwise it walks the whole 30k-entry repo and hits
# GitHub's 60 req/h limit.
q = db.query(Vulnerability)
if only_open:
q = q.filter(Vulnerability.status == VulnerabilityStatus.open)
vulns = q.all()
known_cves = {
(v.cve_id or "").upper() for v in vulns
if v.cve_id and v.cve_id.upper().startswith("CVE-")
}
edb_map = fetch_exploit_db_cve_map()
poc_map = (
fetch_pocs_github_cve_map(known_cves=known_cves)
if fetch_pocs else {}
)
msf_map = fetch_metasploit_cve_map() if fetch_msf else {}
stats = {
"edb_marked": 0, "edb_cleared": 0,
"poc_marked": 0, "poc_cleared": 0,
"msf_marked": 0, "msf_cleared": 0,
"total": 0,
}
if not (edb_map or poc_map or msf_map):
return stats
# `vulns` already loaded above for the known_cves set.
now = datetime.now()
for vuln in vulns:
cve = (vuln.cve_id or "").upper()
if not cve.startswith("CVE-"):
continue
stats["total"] += 1
# Exploit-DB
edb_ids = edb_map.get(cve, [])
if edb_ids:
payload = json.dumps(edb_ids[:50]) # cap for sanity
if vuln.exploit_db_ids != payload:
vuln.exploit_db_ids = payload
vuln.exploit_db_count = len(edb_ids)
stats["edb_marked"] += 1
else:
if vuln.exploit_db_count and vuln.exploit_db_count > 0:
vuln.exploit_db_ids = None
vuln.exploit_db_count = 0
stats["edb_cleared"] += 1
# PoC-in-GitHub
if fetch_pocs:
urls = poc_map.get(cve, [])
if urls:
payload = json.dumps(urls[:50])
if vuln.pocs_github_urls != payload:
vuln.pocs_github_urls = payload
vuln.pocs_github_count = len(urls)
stats["poc_marked"] += 1
else:
if vuln.pocs_github_count and vuln.pocs_github_count > 0:
vuln.pocs_github_urls = None
vuln.pocs_github_count = 0
stats["poc_cleared"] += 1
# Metasploit
if fetch_msf:
mods = msf_map.get(cve, [])
if mods:
payload = json.dumps(mods[:30])
if vuln.metasploit_modules != payload:
vuln.metasploit_modules = payload
vuln.metasploit_module_count = len(mods)
stats["msf_marked"] += 1
else:
if vuln.metasploit_module_count and vuln.metasploit_module_count > 0:
vuln.metasploit_modules = None
vuln.metasploit_module_count = 0
stats["msf_cleared"] += 1
vuln.exploit_intel_updated_at = now
try:
vuln.refresh_scores()
except Exception:
pass
db.commit()
logger.info(
"exploit-intel refresh: total=%d, edb_marked=%d, poc_marked=%d, msf_marked=%d",
stats["total"], stats["edb_marked"], stats["poc_marked"], stats["msf_marked"],
)
return stats