#!/usr/bin/env python3 """Build CVE triage sheets from AutoRecon artifacts.""" from __future__ import annotations import json import re from collections import defaultdict from datetime import datetime, timezone from pathlib import Path RESULTS_DIR = Path("results") CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}") OPEN_PORT_RE = re.compile(r"^(\d+/(?:tcp|udp))\s+open\s+(.*)$") SCRIPT_URL_RE = re.compile(r"\|\s+(/[^: ]*):\s+(.*CVE-\d{4}-\d{4,7}.*)$") PRECONDITIONS = { "CVE-2011-3192": "Target must run Apache HTTPD with byte-range handling vulnerable to the Apache Range header DoS condition.", "CVE-2009-3733": "Target must expose VMware SDK endpoint (/sdk) and allow traversal into host files.", "CVE-2011-0966": "Target must be Cisco Unified Operations Manager 8.0/8.5 with vulnerable auditLog.do traversal handling.", "CVE-2018-10822": "Target must be affected D-Link router firmware exposing /uir/ traversal path.", "CVE-2018-10824": "Target must be affected D-Link router firmware exposing plaintext credential path under /tmp/csman.", "CVE-2017-9798": "Target must run Apache HTTP Server with mod_http2 and be susceptible to OptionBleed conditions.", "CVE-2005-3299": "Target must run Apache 2.0.x with mod_imap module enabled and vulnerable to cross-site scripting behavior.", "CVE-2003-1418": "Target must expose Apache mod_include with vulnerable SSI execution behavior.", } REQUIRED_REPRO_STEP = ( "Before creating a ticket, reproduce vulnerable behavior directly (e.g., crafted request causing data exposure, traversal read, " "or exploitable crash) and attach request/response proof. Banner or script signature matches alone are insufficient." ) def load_text(path: Path) -> str: try: return path.read_text(encoding="utf-8", errors="ignore") except FileNotFoundError: return "" def parse_service_evidence(nmap_paths: list[Path]) -> dict[str, list[str]]: evidence: dict[str, list[str]] = defaultdict(list) for path in nmap_paths: for line in load_text(path).splitlines(): m = OPEN_PORT_RE.match(line.strip()) if m: endpoint = m.group(1) value = m.group(2).strip() if value and value not in evidence[endpoint]: evidence[endpoint].append(value) return dict(evidence) def parse_nmap_hits(nmap_paths: list[Path]) -> dict[str, list[dict[str, str]]]: hits: dict[str, list[dict[str, str]]] = defaultdict(list) for path in nmap_paths: lines = load_text(path).splitlines() current_endpoint = "unknown" for idx, line in enumerate(lines): stripped = line.strip() m_open = OPEN_PORT_RE.match(stripped) if m_open: current_endpoint = m_open.group(1) for cve in CVE_RE.findall(line): endpoint = current_endpoint detail = stripped m_url = SCRIPT_URL_RE.search(line) if m_url: endpoint = m_url.group(1) detail = m_url.group(2) # include short context for review ctx = "\n".join(lines[max(0, idx - 1): min(len(lines), idx + 2)]) hits[cve].append( { "source_file": str(path), "endpoint": endpoint, "evidence": detail, "context": ctx, } ) return dict(hits) def parse_pattern_hits(pattern_path: Path) -> dict[str, int]: counts: dict[str, int] = defaultdict(int) for line in load_text(pattern_path).splitlines(): for cve in CVE_RE.findall(line): counts[cve] += 1 return dict(counts) def infer_status(cve: str, evidence_blob: str) -> tuple[str, str]: blob = evidence_blob.lower() if "401 unauthorized" in blob: return ( "not-applicable", "Only unauthorized responses were observed; vulnerable behavior was not reproduced and signature is likely a false positive.", ) if cve in {"CVE-2017-9798", "CVE-2003-1418"}: return ( "needs-manual-test", "Signature-based identification only; exploitability depends on module/configuration and requires targeted validation.", ) return ( "needs-manual-test", "Script/banner evidence exists, but no direct proof of exploit impact was captured in scan output.", ) def host_dirs() -> list[Path]: return sorted([p for p in RESULTS_DIR.iterdir() if p.is_dir() and (p / "scans").exists()]) def main() -> None: generated = datetime.now(timezone.utc).isoformat() global_suppressions = [] for host_dir in host_dirs(): scans_dir = host_dir / "scans" pattern_path = scans_dir / "_patterns.log" nmap_paths = sorted(scans_dir.glob("**/*nmap*.txt")) if not pattern_path.exists() and not nmap_paths: continue pattern_counts = parse_pattern_hits(pattern_path) nmap_hits = parse_nmap_hits(nmap_paths) cves = sorted(set(pattern_counts) | set(nmap_hits)) if not cves: continue service_evidence = parse_service_evidence(nmap_paths) host_entries = [] for cve in cves: hit_entries = nmap_hits.get(cve, []) endpoints = sorted({h["endpoint"] for h in hit_entries if h["endpoint"]}) or ["unknown"] endpoint = endpoints[0] product_versions = [] for ep, products in service_evidence.items(): if ep in endpoint or endpoint in ep or endpoint == "unknown": product_versions.extend(products) product_versions = sorted(set(product_versions)) if not product_versions: product_versions = ["No explicit product/version fingerprint in nmap service line."] evidence_blob = "\n".join([h["evidence"] for h in hit_entries]) status, rationale = infer_status(cve, evidence_blob) reproducibility = ( "not-reproduced" if status != "confirmed" else "reproduced" ) sources = [] if pattern_counts.get(cve, 0): sources.append({"file": str(pattern_path), "match_count": pattern_counts[cve]}) for h in hit_entries: sources.append( { "file": h["source_file"], "endpoint": h["endpoint"], "evidence": h["evidence"], } ) entry = { "host": host_dir.name, "cve": cve, "affected_host": host_dir.name, "endpoint": endpoint, "product_version_evidence": product_versions, "exploit_precondition": PRECONDITIONS.get( cve, "Validate affected product/version and exploit path prerequisites before filing.", ), "reproducibility": reproducibility, "disposition": status, "disposition_rationale": rationale, "required_reproduction_step_before_ticket": REQUIRED_REPRO_STEP, "evidence_sources": sources, } host_entries.append(entry) if status == "not-applicable": global_suppressions.append( { "host": host_dir.name, "signature": cve, "reason": rationale, "suppression_scope": "host-specific", "source": "cve-triage", "updated_at": generated, } ) host_entries.sort(key=lambda item: item["cve"]) md_lines = [ f"# CVE triage sheet - {host_dir.name}", "", f"Generated (UTC): {generated}", "", "| CVE | Affected host | Endpoint | Product/version evidence | Exploit precondition | Reproducibility | Disposition |", "|---|---|---|---|---|---|---|", ] for item in host_entries: evidence_short = "; ".join(item["product_version_evidence"][:2]) md_lines.append( f"| {item['cve']} | {item['affected_host']} | `{item['endpoint']}` | {evidence_short} | {item['exploit_precondition']} | {item['reproducibility']} | **{item['disposition']}** |" ) md_lines.append( f"| | | | | | | Required reproduction gate: {item['required_reproduction_step_before_ticket']} |" ) (scans_dir / "_cve_triage.md").write_text("\n".join(md_lines) + "\n", encoding="utf-8") (scans_dir / "_cve_disposition.json").write_text( json.dumps( { "host": host_dir.name, "generated_at": generated, "required_reproduction_step_before_ticket": REQUIRED_REPRO_STEP, "findings": host_entries, }, indent=2, ) + "\n", encoding="utf-8", ) if global_suppressions: (RESULTS_DIR / "_false_positive_suppressions.json").write_text( json.dumps( { "generated_at": generated, "description": "Host-level CVE suppressions derived from triage dispositions.", "suppressions": sorted( global_suppressions, key=lambda item: (item["host"], item["signature"]), ), }, indent=2, ) + "\n", encoding="utf-8", ) if __name__ == "__main__": main()