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 / 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})"}