admin / Synapse-NetscanXi
publicNetwork Scanning, Vulnerability and Compliance Application
Synapse-NetscanXi / NetscanXiVersion13 / app / cortex.py
13450 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 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 | """ Synapse Cortex feed (v13): assets + tickets. PUSH only - NetscanXi sends data TO Cortex's ingest endpoints using a key that ORIGINATES FROM CORTEX (an admin generates it in Cortex's Integrations page and pastes it into the Synapse Cortex modal below). There is no pull/outbound direction for Cortex - it never needs to read data back out of NetscanXi. The same key covers both feeds below. 1. Asset inventory (asset_id, MAC, IP, device type, OS, software): POST {url}/api/ingest/assets Authorization: Bearer <API key> { "assets": [ { asset_id, mac, ip, hostname, device_type, os, software }, ... ] } 2. Tickets, sourced from NetscanXi's own Remediation Tracking module, sent one asset at a time from the Assets tab. An analyst clicks "Cortex Ticket" on a specific asset and NetscanXi pushes THAT asset's Remediation Tracking items as tickets - so ONLY the incidents the user selects are sent, there is no bulk/auto push. This reuses the curated Remediation Tracking data (title, priority, status, linked asset) and the same /api/ingest/tickets endpoint the old bulk send used, rather than inventing ticket content from raw CVSS: POST {url}/api/ingest/tickets Authorization: Bearer <API key> { "tickets": [ { external_ref, title, description, priority, status, asset_id }, ... ] } 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 asset ingest endpoint with an EMPTY assets list. Cortex 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 * Cortex's ticket ingest is create-only: pushing an already-known `external_ref` again is reported back as "skipped", never updated, so an agent's work on that ticket in Cortex is never silently clobbered. """ import json import ssl import urllib.error import urllib.request HTTP_TIMEOUT = 15 INGEST_PATH = "/api/ingest/assets" TICKET_INGEST_PATH = "/api/ingest/tickets" USER_AGENT = "netscan-xi/13" MAX_ASSETS_PER_PUSH = 1000 MAX_TICKETS_PER_PUSH = 1000 # NetscanXi's Remediation Tracking status -> Cortex's ticket status. "blocked" # has no exact Cortex equivalent; "awaiting_user" is the closest available # status representing "not actively being worked, waiting on something". REMEDIATION_STATUS_MAP = { "open": "new", "in_progress": "in_progress", "blocked": "awaiting_user", "resolved": "resolved", } 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 Cortex 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 assets 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": "Cortex URL is required"} if not api_key: return {"ok": False, "error": "API key is required"} status, data, err = _request(base + INGEST_PATH, api_key, {"assets": []}, verify_tls) if status in (200, 422): return {"ok": True, "detail": "Connected to Synapse Cortex"} 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 Cortex"} return {"ok": False, "error": err or f"unexpected response (HTTP {status})"} def _asset_from_host(host): """Map a NetscanXi host dict into Cortex's documented asset shape. Returns None when the host has no usable asset_id (Cortex requires one - it's the field that keeps the two systems' records in sync).""" asset_id = (host.get("asset_id") or "").strip() if not asset_id: return None mac = (host.get("mac") or "").strip() ip = (host.get("ip") or "").strip() asset = { "asset_id": asset_id, "mac": mac if mac and mac != "-" else None, "ip": ip if ip and ip != "-" else None, "device_type": host.get("device_type") or None, "os": host.get("os") if host.get("os") and host.get("os") != "unknown" else None, } hostname = host.get("hostname") if hostname and hostname != "-": asset["hostname"] = hostname software = [] for sw in host.get("software", []) or []: name = sw.get("name") or sw.get("product") if not name: continue software.append({"name": name, "version": sw.get("version") or None}) if software: asset["software"] = software return asset def build_assets(hosts): """Flatten scanned hosts into Cortex assets, skipping hosts with no asset_id.""" out = [] for h in hosts or []: a = _asset_from_host(h) if a: out.append(a) return out def push_assets(url, api_key, assets, verify_tls=True): """Push assets to Cortex. Returns {ok, sent, created, updated, error}.""" base = _base(url) if not base: return {"ok": False, "error": "Cortex URL is required"} if not api_key: return {"ok": False, "error": "API key is required"} assets = list(assets or []) if not assets: return {"ok": True, "sent": 0, "created": 0, "updated": 0} status, data, err = _request(base + INGEST_PATH, api_key, {"assets": assets[:MAX_ASSETS_PER_PUSH]}, verify_tls) if status == 200 and isinstance(data, dict) and data.get("ok"): errs = data.get("errors") or [] errs = errs if isinstance(errs, list) else [] return { "ok": True, "sent": min(len(assets), MAX_ASSETS_PER_PUSH), "created": data.get("created", 0), "updated": data.get("updated", 0), "failed": len(errs), "errors": errs[:5], # first few, for a useful error message } return {"ok": False, "error": err or f"ingest failed (HTTP {status})"} def _ticket_from_remediation(item): """Map a NetscanXi Remediation Tracking item into Cortex's documented ticket shape. Returns None when the item has no id (can't build a stable dedupe key) or no title (Cortex requires one).""" rid = item.get("id") title = (item.get("title") or "").strip() if rid is None or not title: return None tenant = item.get("tenant") or "default" cve = (item.get("cve") or "").strip() full_title = f"{title} ({cve})" if cve else title ticket = { "external_ref": f"netscanxi:{tenant}:remediation:{rid}", # Cortex enforces title <= 200 chars; truncate so a long remediation # title can't get the whole ticket rejected with a 422. "title": full_title[:200], "description": item.get("detail") or None, "priority": item.get("priority") or "medium", "status": REMEDIATION_STATUS_MAP.get(item.get("status"), "new"), } asset_id = (item.get("asset_id") or "").strip() if asset_id: ticket["asset_id"] = asset_id return ticket def build_tickets(remediation_items): """Flatten NetscanXi Remediation Tracking items into Cortex tickets, skipping items with no id/title.""" out = [] for item in remediation_items or []: t = _ticket_from_remediation(item) if t: out.append(t) return out def _cvss_float(v): try: return float(v) except (TypeError, ValueError): return 0.0 def build_vuln_ticket(host, tenant="default", selected=None): """Build a Cortex ticket from an asset's VULNERABILITY findings so Cortex's AI can suggest fixes. `selected`, if given, is the list of CVE ids the user chose to include (from the Assets-tab picker); when omitted, all of the asset's vulns are included. Returns None when the host has no asset_id or no matching vulns. Keyed by a stable per-asset external_ref.""" asset_id = (host.get("asset_id") or "").strip() if not asset_id: return None sel = {str(s).strip().upper() for s in (selected or []) if str(s).strip()} vulns = [] for v in host.get("vulns", []) or []: cve = (v.get("cve") or v.get("id") or "").strip() if not cve: continue if sel and cve.upper() not in sel: continue vulns.append(v) if not vulns: return None ip = (host.get("ip") or "").strip() hostname = host.get("hostname") hostname = hostname if hostname and hostname != "-" else "" label = hostname or ip or asset_id max_cvss = max((_cvss_float(v.get("cvss")) for v in vulns), default=0.0) any_kev = any(v.get("kev") for v in vulns) if any_kev or max_cvss >= 9.0: priority = "critical" elif max_cvss >= 7.0: priority = "high" elif max_cvss >= 4.0: priority = "medium" else: priority = "low" kev_count = sum(1 for v in vulns if v.get("kev")) lines = [f"NetscanXi found {len(vulns)} vulnerability finding(s) on asset " f"{asset_id} ({label})."] if ip: lines.append(f"IP: {ip}") if kev_count: lines.append(f"Known-exploited (KEV): {kev_count}") lines += ["", "Vulnerabilities:"] # Most urgent first: KEV, then highest CVSS. for v in sorted(vulns, key=lambda x: (0 if x.get("kev") else 1, -_cvss_float(x.get("cvss")))): cve = (v.get("cve") or v.get("id") or "").strip() tags = [] if v.get("cvss"): tags.append(f"CVSS {v.get('cvss')}") if v.get("kev"): tags.append("KEV") if v.get("exploit"): tags.append("exploit available") head = cve + (f" - {', '.join(tags)}" if tags else "") extra = [] prod = " ".join(x for x in [v.get("product"), v.get("version")] if x) if prod: extra.append(prod) if v.get("port") and v.get("port") != "host": extra.append(f"port {v.get('port')}") line = f"- {head}" if extra: line += f" ({'; '.join(extra)})" lines.append(line) fix = v.get("mitigation") or v.get("patch") if fix: lines.append(f" Fix: {fix}") if v.get("description"): lines.append(f" {v['description']}") lines += ["", "Please review and recommend remediation."] title = f"Vulnerabilities: {label} - {len(vulns)} finding(s)" return { "external_ref": f"netscanxi:{tenant}:vulns:{asset_id}", "title": title[:200], "description": "\n".join(lines), "priority": priority, "status": "new", "asset_id": asset_id, } def push_tickets(url, api_key, tickets, verify_tls=True): """Push tickets to Cortex. Returns {ok, sent, created, skipped, error}.""" base = _base(url) if not base: return {"ok": False, "error": "Cortex URL is required"} if not api_key: return {"ok": False, "error": "API key is required"} tickets = list(tickets or []) if not tickets: return {"ok": True, "sent": 0, "created": 0, "skipped": 0} status, data, err = _request( base + TICKET_INGEST_PATH, api_key, {"tickets": tickets[:MAX_TICKETS_PER_PUSH]}, verify_tls ) if status == 200 and isinstance(data, dict) and data.get("ok"): skipped = data.get("skipped") or [] return { "ok": True, "sent": min(len(tickets), MAX_TICKETS_PER_PUSH), "created": data.get("created", 0), "skipped": len(skipped) if isinstance(skipped, list) else skipped, } return {"ok": False, "error": err or f"ingest failed (HTTP {status})"} |