admin / Synapse-NetscanXi
publicNetwork Scanning, Vulnerability and Compliance Application
Synapse-NetscanXi / NetscanXiVersion13 / app / sonar.py
5894 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | """ Synapse Sonar vulnerability feed (v13). Two complementary integration directions share this module: * PUSH - NetscanXi sends findings TO Sonar's ingest endpoint using a key that ORIGINATES FROM SONAR (test_connection / push_findings below). * PULL - Sonar reads findings FROM NetscanXi using a key that ORIGINATES HERE. That endpoint lives in server.py; it reuses build_findings() so both directions emit the exact same finding shape. Sonar's documented finding shape (see app/api/ingest/vulnerabilities): POST {url}/api/ingest/vulnerabilities Authorization: Bearer <API key> { "findings": [ { ip, cveId, cvssScore, title, ... } ] } Design notes ------------ * Dependency-free (urllib only), best-effort: no call raises to the Flask layer; failures come back as {ok: False, error}. * `test_connection` probes the ingest endpoint with an EMPTY findings list. Sonar authenticates BEFORE it validates the payload, so the response distinguishes the two failure modes without ever writing data: - HTTP 401 -> the API key (or URL) is wrong -> Failed to Connect - HTTP 422 -> key accepted, empty payload rejected -> OK Connected - HTTP 200 -> accepted -> OK Connected """ import json import ssl import urllib.error import urllib.request HTTP_TIMEOUT = 15 INGEST_PATH = "/api/ingest/vulnerabilities" USER_AGENT = "netscan-xi/13" def _base(url): return (url or "").strip().rstrip("/") def _ssl_context(url, verify_tls): """Return an SSL context that skips verification when asked (self-signed Sonar deployments are common on internal networks).""" if not url.lower().startswith("https") or verify_tls: return None ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx def _request(url, api_key, body, verify_tls=True): """POST a JSON body with bearer auth. Returns (status, parsed, error).""" data = json.dumps(body).encode("utf-8") headers = { "Content-Type": "application/json", "Accept": "application/json", "Authorization": f"Bearer {api_key or ''}", "User-Agent": USER_AGENT, } req = urllib.request.Request(url, data=data, headers=headers, method="POST") ctx = _ssl_context(url, verify_tls) try: with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp: txt = resp.read().decode("utf-8", "replace") try: return resp.status, json.loads(txt), None except ValueError: return resp.status, txt, None except urllib.error.HTTPError as e: detail = "" try: detail = e.read().decode("utf-8", "replace")[:400] except Exception: pass return e.code, None, f"HTTP {e.code}: {detail or e.reason}" except Exception as e: # URLError, timeout, DNS, bad TLS, etc. return 0, None, str(e) def test_connection(url, api_key, verify_tls=True): """Verify reachability + credentials. Returns {ok, detail, error}. Sends an empty findings list so nothing is ever ingested; a 422 means the key authenticated and only the (deliberately empty) payload was rejected.""" base = _base(url) if not base: return {"ok": False, "error": "Sonar URL is required"} if not api_key: return {"ok": False, "error": "API key is required"} status, data, err = _request(base + INGEST_PATH, api_key, {"findings": []}, verify_tls) if status in (200, 422): return {"ok": True, "detail": "Connected to Synapse Sonar"} if status == 401: return {"ok": False, "error": "Authentication failed (invalid API key)"} if status == 0: return {"ok": False, "error": err or "could not reach Synapse Sonar"} return {"ok": False, "error": err or f"unexpected response (HTTP {status})"} def _finding_from_vuln(host, vuln): """Map a NetscanXi host+vuln into Sonar's documented finding shape. Returns None when the vuln has no usable CVE id (Sonar requires one).""" cve = (vuln.get("cve") or vuln.get("id") or "").strip() ip = (host.get("ip") or "").strip() if not cve or not ip: return None try: score = float(vuln.get("cvss") or 0) except (TypeError, ValueError): score = 0.0 score = max(0.0, min(10.0, score)) finding = {"ip": ip, "cveId": cve, "cvssScore": score, "source": "NetscanXi v13"} if host.get("hostname"): finding["hostname"] = host["hostname"] title = (vuln.get("title") or vuln.get("description") or "").strip() if title: finding["title"] = title[:500] return finding def build_findings(hosts): """Flatten scanned hosts into Sonar findings, skipping non-CVE entries.""" out = [] for h in hosts or []: for v in h.get("vulns", []): f = _finding_from_vuln(h, v) if f: out.append(f) return out def push_findings(url, api_key, findings, verify_tls=True): """Push findings to Sonar. Returns {ok, sent, summary, error}.""" base = _base(url) if not base: return {"ok": False, "error": "Sonar URL is required"} if not api_key: return {"ok": False, "error": "API key is required"} findings = list(findings or []) if not findings: return {"ok": True, "sent": 0, "summary": {}} status, data, err = _request(base + INGEST_PATH, api_key, {"findings": findings[:1000]}, verify_tls) if status == 200 and isinstance(data, dict) and data.get("ok"): return {"ok": True, "sent": min(len(findings), 1000), "summary": data.get("summary", {})} return {"ok": False, "error": err or f"ingest failed (HTTP {status})"} |