admin / Synapse-NetscanXi
publicNetwork Scanning, Vulnerability and Compliance Application
Synapse-NetscanXi / NetscanXiVersion13 / app / integrations.py
12349 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 | """ Service-desk integrations (v9.1): Jira, ServiceNow and Zendesk. Native, dependency-free integrations built on urllib so NetscanXi can open a ticket for a vulnerability finding directly in the customer's service desk. Design notes ------------ * Each platform has a small adapter that builds the right REST call, auth header and JSON body, then normalises the response to {ok, key, url, error}. * Everything is best-effort and fully degrades when offline or misconfigured: no call raises to the Flask layer; failures come back as {ok: False, error}. * Auth: - Jira Cloud : Basic auth with email:api_token (auth_user = email) - ServiceNow : Basic auth with user:password (creates incident) - Zendesk : Basic auth with email/token:api_token (auth_user already includes the "/token" suffix when using an API token) * `base_url` is the platform root, e.g. Jira https://acme.atlassian.net ServiceNow https://acme.service-now.com Zendesk https://acme.zendesk.com * `project_key` is reused per platform: Jira -> project key (e.g. SEC) ServiceNow -> table name (default 'incident') Zendesk -> ticket type / group (optional) """ import base64 import hashlib import json import urllib.error import urllib.request HTTP_TIMEOUT = 12 # Synapse Cortex ticket ingest (see app/cortex.py for the standalone feed). CORTEX_INGEST_PATH = "/api/ingest/tickets" CORTEX_DEFAULT_URL = "http://localhost:8000" CORTEX_VALID_PRIORITIES = ("low", "medium", "high", "critical") def _basic_auth_header(user, secret): raw = f"{user or ''}:{secret or ''}".encode("utf-8") return "Basic " + base64.b64encode(raw).decode("ascii") def _request(method, url, headers=None, body=None): """Perform an HTTP request, returning (status, parsed_json_or_text, error).""" data = None headers = dict(headers or {}) if body is not None: data = json.dumps(body).encode("utf-8") headers.setdefault("Content-Type", "application/json") headers.setdefault("Accept", "application/json") headers.setdefault("User-Agent", "netscan-xi/9.1") req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) 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, etc. return 0, None, str(e) def _base(rec): return (rec.get("base_url") or "").rstrip("/") # --------------------------------------------------------------------------- # Jira # --------------------------------------------------------------------------- def _jira_create(rec, summary, description, cve="", asset_id=""): url = _base(rec) + "/rest/api/2/issue" project = rec.get("project_key") or "SEC" body = { "fields": { "project": {"key": project}, "summary": summary[:250], "description": description, "issuetype": {"name": "Bug"}, } } headers = {"Authorization": _basic_auth_header(rec.get("auth_user"), rec.get("auth_secret"))} status, data, err = _request("POST", url, headers=headers, body=body) if err: return {"ok": False, "error": err} key = (data or {}).get("key") if isinstance(data, dict) else None if not key: return {"ok": False, "error": "Jira did not return an issue key"} return {"ok": True, "key": key, "url": _base(rec) + "/browse/" + key} def _jira_test(rec): url = _base(rec) + "/rest/api/2/myself" headers = {"Authorization": _basic_auth_header(rec.get("auth_user"), rec.get("auth_secret"))} status, data, err = _request("GET", url, headers=headers) if err: return {"ok": False, "error": err} who = (data or {}).get("displayName") if isinstance(data, dict) else None return {"ok": True, "detail": f"Authenticated as {who or 'Jira user'}"} # --------------------------------------------------------------------------- # ServiceNow (creates an incident by default) # --------------------------------------------------------------------------- def _snow_create(rec, summary, description, cve="", asset_id=""): table = rec.get("project_key") or "incident" url = _base(rec) + f"/api/now/table/{table}" body = {"short_description": summary[:160], "description": description, "category": "security"} headers = {"Authorization": _basic_auth_header(rec.get("auth_user"), rec.get("auth_secret"))} status, data, err = _request("POST", url, headers=headers, body=body) if err: return {"ok": False, "error": err} result = (data or {}).get("result", {}) if isinstance(data, dict) else {} number = result.get("number") sys_id = result.get("sys_id") if not number: return {"ok": False, "error": "ServiceNow did not return a record number"} ext_url = _base(rec) + f"/nav_to.do?uri={table}.do?sys_id={sys_id}" return {"ok": True, "key": number, "url": ext_url} def _snow_test(rec): table = rec.get("project_key") or "incident" url = _base(rec) + f"/api/now/table/{table}?sysparm_limit=1" headers = {"Authorization": _basic_auth_header(rec.get("auth_user"), rec.get("auth_secret"))} status, data, err = _request("GET", url, headers=headers) if err: return {"ok": False, "error": err} return {"ok": True, "detail": f"Reachable; table '{table}' accessible"} # --------------------------------------------------------------------------- # Zendesk # --------------------------------------------------------------------------- def _zendesk_create(rec, summary, description, cve="", asset_id=""): url = _base(rec) + "/api/v2/tickets.json" body = {"ticket": {"subject": summary[:200], "comment": {"body": description}, "type": "incident", "priority": "high"}} headers = {"Authorization": _basic_auth_header(rec.get("auth_user"), rec.get("auth_secret"))} status, data, err = _request("POST", url, headers=headers, body=body) if err: return {"ok": False, "error": err} ticket = (data or {}).get("ticket", {}) if isinstance(data, dict) else {} tid = ticket.get("id") if not tid: return {"ok": False, "error": "Zendesk did not return a ticket id"} return {"ok": True, "key": f"#{tid}", "url": _base(rec) + f"/agent/tickets/{tid}"} def _zendesk_test(rec): url = _base(rec) + "/api/v2/users/me.json" headers = {"Authorization": _basic_auth_header(rec.get("auth_user"), rec.get("auth_secret"))} status, data, err = _request("GET", url, headers=headers) if err: return {"ok": False, "error": err} who = ((data or {}).get("user", {}) or {}).get("name") if isinstance(data, dict) else None return {"ok": True, "detail": f"Authenticated as {who or 'Zendesk user'}"} # --------------------------------------------------------------------------- # Synapse Cortex (ITSM) - create a ticket via Cortex's ingest API. # # Unlike the other desks, Cortex authenticates with a Bearer API key (issued in # Cortex's Admin -> Integrations page) and its ingest is create-only: a ticket # is keyed by a stable external_ref and re-posting the same ref is reported back # as "skipped", never overwritten. `base_url` is the Cortex origin; NetscanXi # appends /api/ingest/tickets. The 'project_key' field doubles as the default # priority (low/medium/high/critical), falling back to 'high' for a finding. # --------------------------------------------------------------------------- def _cortex_ticket_ref(rec, summary, cve, asset_id): """Stable dedupe key. CVE (per asset) is the natural key; when a finding has no CVE, fall back to a hash of the summary so distinct findings stay distinct.""" tenant = rec.get("tenant") or "default" tail = (cve or "").strip() if not tail: tail = "sig-" + hashlib.sha1((summary or "").encode("utf-8")).hexdigest()[:12] return f"netscanxi:{tenant}:integration:{asset_id or 'na'}:{tail}" def _cortex_priority(rec): prio = (rec.get("project_key") or "").strip().lower() return prio if prio in CORTEX_VALID_PRIORITIES else "high" def _cortex_create(rec, summary, description, cve="", asset_id=""): url = _base(rec) + CORTEX_INGEST_PATH ref = _cortex_ticket_ref(rec, summary, cve, asset_id) ticket = { "external_ref": ref, "title": (summary or f"NetscanXi finding {cve}".strip())[:200], "description": description or None, "priority": _cortex_priority(rec), "status": "new", } if asset_id: ticket["asset_id"] = asset_id headers = {"Authorization": "Bearer " + (rec.get("auth_secret") or "")} status, data, err = _request("POST", url, headers=headers, body={"tickets": [ticket]}) if isinstance(data, dict) and data.get("ok"): # Cortex's ingest returns counts, not a ticket id/URL; surface the stable # external_ref as the key and link to the ticket list. return {"ok": True, "key": ref, "url": _base(rec) + "/tickets"} if err: return {"ok": False, "error": err} return {"ok": False, "error": f"Cortex did not accept the ticket (HTTP {status})"} def _cortex_test(rec): url = _base(rec) + CORTEX_INGEST_PATH headers = {"Authorization": "Bearer " + (rec.get("auth_secret") or "")} # Empty list: Cortex authenticates BEFORE validating the payload, so 401 = # bad key, 422 = key OK but payload empty (success), 200 = OK. status, data, err = _request("POST", url, headers=headers, body={"tickets": []}) if status == 401: return {"ok": False, "error": "Authentication failed (invalid API key)"} if status in (200, 422): return {"ok": True, "detail": "Connected to Synapse Cortex"} 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})"} _CREATORS = {"jira": _jira_create, "servicenow": _snow_create, "zendesk": _zendesk_create, "cortex": _cortex_create} _TESTERS = {"jira": _jira_test, "servicenow": _snow_test, "zendesk": _zendesk_test, "cortex": _cortex_test} def _build_description(description, cve, asset_id): parts = [] if description: parts.append(description) meta = [] if cve: meta.append(f"CVE/Finding: {cve}") if asset_id: meta.append(f"Asset ID: {asset_id}") if meta: parts.append("\n".join(meta)) parts.append("Opened automatically by NetscanXi Version 9.1.") return "\n\n".join(parts) def create_ticket(rec, summary, description="", cve="", asset_id=""): """Open a ticket in the configured platform. Returns {ok, key, url, error}.""" platform = (rec.get("platform") or "").lower() fn = _CREATORS.get(platform) if not fn: return {"ok": False, "error": f"unsupported platform '{platform}'"} desc = _build_description(description, cve, asset_id) try: return fn(rec, summary, desc, cve=cve, asset_id=asset_id) except Exception as e: return {"ok": False, "error": str(e)} def test_connection(rec): """Verify credentials / reachability. Returns {ok, detail, error}.""" platform = (rec.get("platform") or "").lower() fn = _TESTERS.get(platform) if not fn: return {"ok": False, "error": f"unsupported platform '{platform}'"} try: return fn(rec) except Exception as e: return {"ok": False, "error": str(e)} |