Catalyst / admin/Synapse-NetscanXi 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-NetscanXi

public

Network Scanning, Vulnerability and Compliance Application

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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})"}