mirror of
https://github.com/GH05TCREW/eidolon.git
synced 2026-07-01 11:55:39 -04:00
feat(network): expand Nmap fingerprinting (OS, TLS, traceroute, NSE results)
This commit is contained in:
+400
-48
@@ -4,6 +4,7 @@ import subprocess
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from defusedxml import ElementTree as DefusedET
|
||||
|
||||
@@ -35,6 +36,7 @@ class NetworkCollector(BaseCollector):
|
||||
dns_resolution: bool = True,
|
||||
aggressive: bool = False,
|
||||
nmap_path: str = "nmap",
|
||||
nse_scripts: list[str] | None = None,
|
||||
cancellation_checker: Callable[[], bool] | None = None,
|
||||
progress_callback: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
@@ -47,6 +49,7 @@ class NetworkCollector(BaseCollector):
|
||||
self.dns_resolution = dns_resolution
|
||||
self.aggressive = aggressive
|
||||
self.nmap_path = nmap_path
|
||||
self.nse_scripts = self._default_nse_scripts() if nse_scripts is None else nse_scripts
|
||||
self.cancellation_checker = cancellation_checker
|
||||
self.progress_callback = progress_callback
|
||||
self._active_process: subprocess.Popen | None = None
|
||||
@@ -99,8 +102,14 @@ class NetworkCollector(BaseCollector):
|
||||
port_scan_args = self._with_dns_flag(port_scan_args)
|
||||
port_scan_args = self._with_parallelism(port_scan_args, self.port_scan_workers)
|
||||
if self.aggressive:
|
||||
port_scan_args.extend(["-O", "-sV"])
|
||||
self._send_progress("Using aggressive scan (OS detection + version detection)")
|
||||
port_scan_args.extend(["-O", "-sV", "--version-all", "--traceroute", "--reason"])
|
||||
script_args = self._build_script_args()
|
||||
if script_args:
|
||||
port_scan_args.extend(script_args)
|
||||
self._send_progress(
|
||||
"Using aggressive scan "
|
||||
"(OS detection + version detection + scripts + traceroute)"
|
||||
)
|
||||
|
||||
port_scan_xml = self._run_nmap(port_scan_args, show_output=False)
|
||||
|
||||
@@ -209,27 +218,21 @@ class NetworkCollector(BaseCollector):
|
||||
if not ip:
|
||||
continue
|
||||
|
||||
# Extract hostname if available
|
||||
hostname = None
|
||||
hostnames_elem = host.find("hostnames")
|
||||
if hostnames_elem is not None:
|
||||
hostname_elem = hostnames_elem.find("hostname")
|
||||
if hostname_elem is not None:
|
||||
hostname = hostname_elem.attrib.get("name")
|
||||
|
||||
# Extract MAC address and vendor
|
||||
mac_address = None
|
||||
mac_vendor = None
|
||||
for address_elem in host.findall("address"):
|
||||
addr_type = address_elem.attrib.get("addrtype")
|
||||
if addr_type == "mac":
|
||||
mac_address = address_elem.attrib.get("addr")
|
||||
mac_vendor = address_elem.attrib.get("vendor")
|
||||
break
|
||||
hostname, hostnames = self._parse_hostnames(host)
|
||||
mac_address, mac_vendor = self._parse_mac_address(host)
|
||||
|
||||
host_data = {"ip": ip, "cidr": cidr, "status": "online"}
|
||||
if status is not None:
|
||||
status_reason = status.attrib.get("reason")
|
||||
status_ttl = status.attrib.get("reason_ttl")
|
||||
if status_reason:
|
||||
host_data["status_reason"] = status_reason
|
||||
if status_ttl:
|
||||
host_data["status_ttl"] = status_ttl
|
||||
if hostname:
|
||||
host_data["hostname"] = hostname
|
||||
if hostnames:
|
||||
host_data["hostnames"] = hostnames
|
||||
if mac_address:
|
||||
host_data["mac_address"] = mac_address
|
||||
if mac_vendor:
|
||||
@@ -241,54 +244,97 @@ class NetworkCollector(BaseCollector):
|
||||
results: list[dict] = []
|
||||
root = DefusedET.fromstring(xml_text)
|
||||
for host in root.findall("host"):
|
||||
addr = host.find("address")
|
||||
if addr is None:
|
||||
continue
|
||||
ip = addr.attrib.get("addr")
|
||||
ip = self._parse_ip_address(host)
|
||||
if not ip:
|
||||
continue
|
||||
|
||||
host_data = {"ip": ip}
|
||||
|
||||
# Extract MAC address and vendor
|
||||
mac_address = None
|
||||
mac_vendor = None
|
||||
for address_elem in host.findall("address"):
|
||||
addr_type = address_elem.attrib.get("addrtype")
|
||||
if addr_type == "mac":
|
||||
mac_address = address_elem.attrib.get("addr")
|
||||
mac_vendor = address_elem.attrib.get("vendor")
|
||||
break
|
||||
hostname, hostnames = self._parse_hostnames(host)
|
||||
if hostname:
|
||||
host_data["hostname"] = hostname
|
||||
if hostnames:
|
||||
host_data["hostnames"] = hostnames
|
||||
|
||||
# Extract MAC address and vendor
|
||||
mac_address, mac_vendor = self._parse_mac_address(host)
|
||||
if mac_address:
|
||||
host_data["mac_address"] = mac_address
|
||||
if mac_vendor:
|
||||
host_data["vendor"] = mac_vendor
|
||||
|
||||
# Parse ports with detailed service information
|
||||
ports = []
|
||||
ports: list[dict] = []
|
||||
ports_element = host.find("ports")
|
||||
if ports_element is not None:
|
||||
for port_elem in ports_element.findall("port"):
|
||||
port_id = int(port_elem.attrib.get("portid", "0"))
|
||||
protocol = port_elem.attrib.get("protocol")
|
||||
state_elem = port_elem.find("state")
|
||||
state = state_elem.attrib.get("state") if state_elem is not None else "unknown"
|
||||
reason = state_elem.attrib.get("reason") if state_elem is not None else None
|
||||
reason_ttl = (
|
||||
state_elem.attrib.get("reason_ttl") if state_elem is not None else None
|
||||
)
|
||||
|
||||
# Extract detailed service information
|
||||
service_elem = port_elem.find("service")
|
||||
service_name = None
|
||||
service_product = None
|
||||
service_version = None
|
||||
service_extrainfo = None
|
||||
service_tunnel = None
|
||||
service_method = None
|
||||
service_conf = None
|
||||
service_ostype = None
|
||||
service_hostname = None
|
||||
service_cpes: list[str] = []
|
||||
if service_elem is not None:
|
||||
service_name = service_elem.attrib.get("name")
|
||||
service_product = service_elem.attrib.get("product")
|
||||
service_version = service_elem.attrib.get("version")
|
||||
service_extrainfo = service_elem.attrib.get("extrainfo")
|
||||
service_tunnel = service_elem.attrib.get("tunnel")
|
||||
service_method = service_elem.attrib.get("method")
|
||||
service_conf = service_elem.attrib.get("conf")
|
||||
service_ostype = service_elem.attrib.get("ostype")
|
||||
service_hostname = service_elem.attrib.get("hostname")
|
||||
for cpe_elem in service_elem.findall("cpe"):
|
||||
if cpe_elem.text:
|
||||
service_cpes.append(cpe_elem.text)
|
||||
|
||||
port_data = {"port": port_id, "state": state, "service": service_name}
|
||||
port_data: dict[str, Any] = {
|
||||
"port": port_id,
|
||||
"protocol": protocol,
|
||||
"state": state,
|
||||
"service": service_name,
|
||||
}
|
||||
if reason:
|
||||
port_data["reason"] = reason
|
||||
if reason_ttl:
|
||||
port_data["reason_ttl"] = reason_ttl
|
||||
if service_product:
|
||||
port_data["product"] = service_product
|
||||
if service_version:
|
||||
port_data["version"] = service_version
|
||||
if service_extrainfo:
|
||||
port_data["extrainfo"] = service_extrainfo
|
||||
if service_tunnel:
|
||||
port_data["tunnel"] = service_tunnel
|
||||
if service_method:
|
||||
port_data["method"] = service_method
|
||||
if service_conf:
|
||||
port_data["conf"] = service_conf
|
||||
if service_ostype:
|
||||
port_data["ostype"] = service_ostype
|
||||
if service_hostname:
|
||||
port_data["service_hostname"] = service_hostname
|
||||
if service_cpes:
|
||||
port_data["cpe"] = service_cpes
|
||||
|
||||
scripts = self._parse_scripts(port_elem)
|
||||
if scripts:
|
||||
port_data["scripts"] = scripts
|
||||
|
||||
ports.append(port_data)
|
||||
|
||||
@@ -297,21 +343,13 @@ class NetworkCollector(BaseCollector):
|
||||
# Parse OS detection information
|
||||
os_element = host.find("os")
|
||||
if os_element is not None:
|
||||
osmatch = os_element.find("osmatch")
|
||||
if osmatch is not None:
|
||||
os_name = osmatch.attrib.get("name")
|
||||
os_accuracy = osmatch.attrib.get("accuracy")
|
||||
if os_name:
|
||||
host_data["os"] = os_name
|
||||
if os_accuracy:
|
||||
host_data["os_accuracy"] = f"{os_accuracy}%"
|
||||
host_data.update(self._parse_os(os_element))
|
||||
|
||||
# Get OS class for more general info
|
||||
osclass = os_element.find("osclass")
|
||||
if osclass is not None:
|
||||
os_family = osclass.attrib.get("osfamily")
|
||||
if os_family and "os" not in host_data:
|
||||
host_data["os"] = os_family
|
||||
self._parse_uptime(host, host_data)
|
||||
self._parse_distance(host, host_data)
|
||||
self._parse_timing(host, host_data)
|
||||
self._parse_traceroute(host, host_data)
|
||||
self._parse_host_scripts(host, host_data)
|
||||
|
||||
results.append(host_data)
|
||||
return results
|
||||
@@ -326,3 +364,317 @@ class NetworkCollector(BaseCollector):
|
||||
collected_at=now,
|
||||
confidence=0.8,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _default_nse_scripts() -> list[str]:
|
||||
return [
|
||||
"ssl-cert",
|
||||
"http-title",
|
||||
"http-headers",
|
||||
"http-server-header",
|
||||
"http-methods",
|
||||
"smb-os-discovery",
|
||||
"smb-security-mode",
|
||||
"smb2-time",
|
||||
"clock-skew",
|
||||
]
|
||||
|
||||
def _build_script_args(self) -> list[str]:
|
||||
if not self.nse_scripts:
|
||||
return []
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for script in self.nse_scripts:
|
||||
script_id = script.strip()
|
||||
if not script_id or script_id in seen:
|
||||
continue
|
||||
normalized.append(script_id)
|
||||
seen.add(script_id)
|
||||
if not normalized:
|
||||
return []
|
||||
return ["--script", ",".join(normalized)]
|
||||
|
||||
@staticmethod
|
||||
def _parse_ip_address(host: DefusedET.Element) -> str | None:
|
||||
for address_elem in host.findall("address"):
|
||||
addr_type = address_elem.attrib.get("addrtype")
|
||||
if addr_type in {"ipv4", "ipv6"}:
|
||||
ip = address_elem.attrib.get("addr")
|
||||
if ip:
|
||||
return ip
|
||||
addr = host.find("address")
|
||||
if addr is not None:
|
||||
ip = addr.attrib.get("addr")
|
||||
if ip:
|
||||
return ip
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_hostnames(host: DefusedET.Element) -> tuple[str | None, list[str]]:
|
||||
hostnames: list[str] = []
|
||||
hostnames_elem = host.find("hostnames")
|
||||
if hostnames_elem is None:
|
||||
return None, hostnames
|
||||
for hostname_elem in hostnames_elem.findall("hostname"):
|
||||
name = hostname_elem.attrib.get("name")
|
||||
if name and name not in hostnames:
|
||||
hostnames.append(name)
|
||||
primary = hostnames[0] if hostnames else None
|
||||
return primary, hostnames
|
||||
|
||||
@staticmethod
|
||||
def _parse_mac_address(host: DefusedET.Element) -> tuple[str | None, str | None]:
|
||||
for address_elem in host.findall("address"):
|
||||
addr_type = address_elem.attrib.get("addrtype")
|
||||
if addr_type == "mac":
|
||||
return address_elem.attrib.get("addr"), address_elem.attrib.get("vendor")
|
||||
return None, None
|
||||
|
||||
def _parse_scripts(self, parent: DefusedET.Element) -> list[dict]:
|
||||
scripts: list[dict] = []
|
||||
for script_elem in parent.findall("script"):
|
||||
parsed = self._parse_script(script_elem)
|
||||
if parsed:
|
||||
scripts.append(parsed)
|
||||
return scripts
|
||||
|
||||
def _parse_script(self, script_elem: DefusedET.Element) -> dict[str, Any] | None:
|
||||
script_id = script_elem.attrib.get("id")
|
||||
output = script_elem.attrib.get("output")
|
||||
data = self._parse_script_data(script_elem)
|
||||
if not script_id and not output and data is None:
|
||||
return None
|
||||
payload: dict[str, Any] = {}
|
||||
if script_id:
|
||||
payload["id"] = script_id
|
||||
if output:
|
||||
payload["output"] = output
|
||||
if data is not None:
|
||||
payload["data"] = data
|
||||
return payload
|
||||
|
||||
def _parse_script_data(self, script_elem: DefusedET.Element) -> Any | None:
|
||||
if not list(script_elem):
|
||||
return None
|
||||
data: dict[str, Any] = {}
|
||||
items: list[Any] = []
|
||||
for child in script_elem:
|
||||
if child.tag == "elem":
|
||||
key = child.attrib.get("key")
|
||||
value = (child.text or "").strip()
|
||||
if key:
|
||||
self._merge_script_value(data, key, value)
|
||||
elif value:
|
||||
items.append(value)
|
||||
elif child.tag == "table":
|
||||
table_value = self._parse_script_table(child)
|
||||
key = child.attrib.get("key")
|
||||
if key:
|
||||
self._merge_script_value(data, key, table_value)
|
||||
else:
|
||||
items.append(table_value)
|
||||
if data and items:
|
||||
data["_items"] = items
|
||||
return data
|
||||
if data:
|
||||
return data
|
||||
if items:
|
||||
return items
|
||||
return None
|
||||
|
||||
def _parse_script_table(self, table_elem: DefusedET.Element) -> Any:
|
||||
data: dict[str, Any] = {}
|
||||
items: list[Any] = []
|
||||
for child in table_elem:
|
||||
if child.tag == "elem":
|
||||
key = child.attrib.get("key")
|
||||
value = (child.text or "").strip()
|
||||
if key:
|
||||
self._merge_script_value(data, key, value)
|
||||
elif value:
|
||||
items.append(value)
|
||||
elif child.tag == "table":
|
||||
nested_value = self._parse_script_table(child)
|
||||
key = child.attrib.get("key")
|
||||
if key:
|
||||
self._merge_script_value(data, key, nested_value)
|
||||
else:
|
||||
items.append(nested_value)
|
||||
if data and items:
|
||||
data["_items"] = items
|
||||
return data
|
||||
if data:
|
||||
return data
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _merge_script_value(target: dict[str, Any], key: str, value: Any) -> None:
|
||||
if key in target:
|
||||
existing = target[key]
|
||||
if isinstance(existing, list):
|
||||
existing.append(value)
|
||||
else:
|
||||
target[key] = [existing, value]
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
def _parse_os(self, os_element: DefusedET.Element) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {}
|
||||
os_matches: list[dict[str, Any]] = []
|
||||
best_accuracy = -1
|
||||
best_name: str | None = None
|
||||
for osmatch in os_element.findall("osmatch"):
|
||||
match_data: dict[str, Any] = {}
|
||||
match_name = osmatch.attrib.get("name")
|
||||
if match_name:
|
||||
match_data["name"] = match_name
|
||||
accuracy_raw = osmatch.attrib.get("accuracy")
|
||||
accuracy = None
|
||||
if accuracy_raw and accuracy_raw.isdigit():
|
||||
accuracy = int(accuracy_raw)
|
||||
match_data["accuracy"] = accuracy
|
||||
line = osmatch.attrib.get("line")
|
||||
if line:
|
||||
match_data["line"] = line
|
||||
|
||||
classes: list[dict[str, Any]] = []
|
||||
for osclass in osmatch.findall("osclass"):
|
||||
class_data: dict[str, Any] = {}
|
||||
os_type = osclass.attrib.get("type")
|
||||
vendor = osclass.attrib.get("vendor")
|
||||
family = osclass.attrib.get("osfamily")
|
||||
os_gen = osclass.attrib.get("osgen")
|
||||
class_accuracy = osclass.attrib.get("accuracy")
|
||||
if os_type:
|
||||
class_data["type"] = os_type
|
||||
if vendor:
|
||||
class_data["vendor"] = vendor
|
||||
if family:
|
||||
class_data["family"] = family
|
||||
if os_gen:
|
||||
class_data["gen"] = os_gen
|
||||
if class_accuracy and class_accuracy.isdigit():
|
||||
class_data["accuracy"] = int(class_accuracy)
|
||||
cpes = [cpe.text for cpe in osclass.findall("cpe") if cpe.text]
|
||||
if cpes:
|
||||
class_data["cpe"] = cpes
|
||||
if class_data:
|
||||
classes.append(class_data)
|
||||
if classes:
|
||||
match_data["classes"] = classes
|
||||
|
||||
match_cpes = [cpe.text for cpe in osmatch.findall("cpe") if cpe.text]
|
||||
if match_cpes:
|
||||
match_data["cpe"] = match_cpes
|
||||
|
||||
if match_data:
|
||||
os_matches.append(match_data)
|
||||
|
||||
if match_name and accuracy is not None and accuracy > best_accuracy:
|
||||
best_name = match_name
|
||||
best_accuracy = accuracy
|
||||
|
||||
if os_matches:
|
||||
data["os_matches"] = os_matches
|
||||
if best_name:
|
||||
data["os"] = best_name
|
||||
if best_accuracy >= 0:
|
||||
data["os_accuracy"] = f"{best_accuracy}%"
|
||||
|
||||
if "os" not in data:
|
||||
osclass = os_element.find("osclass")
|
||||
if osclass is not None:
|
||||
os_family = osclass.attrib.get("osfamily")
|
||||
if os_family:
|
||||
data["os"] = os_family
|
||||
os_vendor = osclass.attrib.get("vendor")
|
||||
if os_vendor:
|
||||
data["os_vendor"] = os_vendor
|
||||
os_type = osclass.attrib.get("type")
|
||||
if os_type:
|
||||
data["os_type"] = os_type
|
||||
os_gen = osclass.attrib.get("osgen")
|
||||
if os_gen:
|
||||
data["os_gen"] = os_gen
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _parse_uptime(host: DefusedET.Element, host_data: dict[str, Any]) -> None:
|
||||
uptime_elem = host.find("uptime")
|
||||
if uptime_elem is None:
|
||||
return
|
||||
seconds = uptime_elem.attrib.get("seconds")
|
||||
if seconds and seconds.isdigit():
|
||||
host_data["uptime_seconds"] = int(seconds)
|
||||
lastboot = uptime_elem.attrib.get("lastboot")
|
||||
if lastboot:
|
||||
host_data["uptime_last_boot"] = lastboot
|
||||
|
||||
@staticmethod
|
||||
def _parse_distance(host: DefusedET.Element, host_data: dict[str, Any]) -> None:
|
||||
distance_elem = host.find("distance")
|
||||
if distance_elem is None:
|
||||
return
|
||||
value = distance_elem.attrib.get("value")
|
||||
if value and value.isdigit():
|
||||
host_data["distance"] = int(value)
|
||||
|
||||
@staticmethod
|
||||
def _parse_timing(host: DefusedET.Element, host_data: dict[str, Any]) -> None:
|
||||
times_elem = host.find("times")
|
||||
if times_elem is None:
|
||||
return
|
||||
srtt = times_elem.attrib.get("srtt")
|
||||
rttvar = times_elem.attrib.get("rttvar")
|
||||
timeout = times_elem.attrib.get("to")
|
||||
if srtt and srtt.isdigit():
|
||||
host_data["rtt_srtt_us"] = int(srtt)
|
||||
if rttvar and rttvar.isdigit():
|
||||
host_data["rtt_var_us"] = int(rttvar)
|
||||
if timeout and timeout.isdigit():
|
||||
host_data["rtt_timeout_us"] = int(timeout)
|
||||
|
||||
@staticmethod
|
||||
def _parse_traceroute(host: DefusedET.Element, host_data: dict[str, Any]) -> None:
|
||||
trace_elem = host.find("trace")
|
||||
if trace_elem is None:
|
||||
return
|
||||
hops: list[dict[str, Any]] = []
|
||||
for hop in trace_elem.findall("hop"):
|
||||
hop_data: dict[str, Any] = {}
|
||||
ttl = hop.attrib.get("ttl")
|
||||
rtt = hop.attrib.get("rtt")
|
||||
ipaddr = hop.attrib.get("ipaddr")
|
||||
hostname = hop.attrib.get("host")
|
||||
if ttl and ttl.isdigit():
|
||||
hop_data["ttl"] = int(ttl)
|
||||
if rtt:
|
||||
try:
|
||||
hop_data["rtt_ms"] = float(rtt)
|
||||
except ValueError:
|
||||
hop_data["rtt_ms"] = rtt
|
||||
if ipaddr:
|
||||
hop_data["ip"] = ipaddr
|
||||
if hostname:
|
||||
hop_data["hostname"] = hostname
|
||||
if hop_data:
|
||||
hops.append(hop_data)
|
||||
if not hops:
|
||||
return
|
||||
trace_data: dict[str, Any] = {"hops": hops}
|
||||
proto = trace_elem.attrib.get("proto")
|
||||
port = trace_elem.attrib.get("port")
|
||||
if proto:
|
||||
trace_data["proto"] = proto
|
||||
if port and port.isdigit():
|
||||
trace_data["port"] = int(port)
|
||||
host_data["traceroute"] = trace_data
|
||||
|
||||
def _parse_host_scripts(self, host: DefusedET.Element, host_data: dict[str, Any]) -> None:
|
||||
hostscript_elem = host.find("hostscript")
|
||||
if hostscript_elem is None:
|
||||
return
|
||||
scripts = self._parse_scripts(hostscript_elem)
|
||||
if scripts:
|
||||
host_data["host_scripts"] = scripts
|
||||
|
||||
@@ -169,6 +169,10 @@ class LiteLLMClient:
|
||||
choices = (
|
||||
raw_response["choices"] if isinstance(raw_response, dict) else raw_response.choices
|
||||
)
|
||||
if not choices:
|
||||
raise ValueError(
|
||||
"LLM returned empty choices array - check API status or rate limits"
|
||||
)
|
||||
choice = choices[0]
|
||||
message = choice["message"] if isinstance(choice, dict) else choice.message
|
||||
tool_calls = (
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
AGENT_SYSTEM_PROMPT = """
|
||||
You are Eidolon, a cautious infrastructure co-pilot.
|
||||
- Prefer read/plan/simulate by default.
|
||||
- Never invent evidence; cite graph-backed facts.
|
||||
- Request approvals before execution.
|
||||
"""
|
||||
|
||||
QUERY_PROMPT_TEMPLATE = """
|
||||
You translate user questions into Cypher queries over an evidence-backed graph.
|
||||
Use only these labels: Asset, NetworkContainer, Identity, Policy.
|
||||
|
||||
@@ -194,16 +194,106 @@ RETURN a.node_id, a.metadata
|
||||
```
|
||||
|
||||
**Common Metadata Fields** (populated by collectors):
|
||||
|
||||
*Basic Host Info:*
|
||||
- `ip` - IPv4/IPv6 address
|
||||
- `hostname` - DNS hostname
|
||||
- `hostnames` - Array of all hostnames discovered for the host
|
||||
- `mac_address` - MAC address (from ARP/nmap)
|
||||
- `vendor` - Network interface vendor (from MAC OUI lookup via nmap)
|
||||
- `ports` - Array of port objects:
|
||||
`[{{"port": 22, "state": "open", "service": "ssh", "version": "..."}}]`
|
||||
- `status` - Host status: "online", "offline", "idle"
|
||||
- `os` - Operating system fingerprint (if available from nmap)
|
||||
- `status_reason` - Why nmap marked the host as up/down
|
||||
- `status_reason_ttl` - TTL of the response packet
|
||||
- `cidr` - Network CIDR the host belongs to
|
||||
|
||||
*Port & Service Information:*
|
||||
- `ports` - Array of port objects with rich service details:
|
||||
```
|
||||
[{{
|
||||
"port": 22,
|
||||
"state": "open",
|
||||
"service": "ssh",
|
||||
"product": "OpenSSH",
|
||||
"version": "8.2p1",
|
||||
"extrainfo": "Ubuntu-4ubuntu0.5",
|
||||
"ostype": "Linux",
|
||||
"method": "probed",
|
||||
"conf": "10",
|
||||
"cpe": ["cpe:/o:linux:linux_kernel"],
|
||||
"protocol": "tcp",
|
||||
"reason": "syn-ack",
|
||||
"reason_ttl": "64",
|
||||
"tunnel": "ssl",
|
||||
"service_hostname": "example.com",
|
||||
"scripts": {{"ssl-cert": "...", "http-title": "..."}}
|
||||
}}]
|
||||
```
|
||||
|
||||
*Operating System Fingerprinting:*
|
||||
- `os` - Primary OS guess (e.g., "Linux 5.4")
|
||||
- `os_accuracy` - Confidence percentage for OS match
|
||||
- `os_matches` - Array of OS match objects:
|
||||
```
|
||||
[{{
|
||||
"name": "Linux 5.4",
|
||||
"accuracy": "98",
|
||||
"line": "123",
|
||||
"classes": [{{
|
||||
"type": "general purpose",
|
||||
"vendor": "Linux",
|
||||
"osfamily": "Linux",
|
||||
"osgen": "5.X",
|
||||
"accuracy": "98",
|
||||
"cpe": ["cpe:/o:linux:linux_kernel:5.4"]
|
||||
}}]
|
||||
}}]
|
||||
```
|
||||
|
||||
*Network Timing & Distance:*
|
||||
- `uptime_seconds` - Host uptime in seconds
|
||||
- `uptime_last_boot` - Timestamp of last boot
|
||||
- `distance` - Network hop distance from scanner
|
||||
- `rtt_srtt_us` - Smoothed round-trip time (microseconds)
|
||||
- `rtt_var_us` - RTT variance (microseconds)
|
||||
- `rtt_timeout_us` - Timeout value used (microseconds)
|
||||
- `traceroute` - Array of hop objects:
|
||||
```
|
||||
[{{
|
||||
"ttl": 1,
|
||||
"rtt": "0.50",
|
||||
"ip": "192.168.1.1",
|
||||
"hostname": "gateway.local"
|
||||
}}]
|
||||
```
|
||||
|
||||
*Host-Level Script Results:*
|
||||
- `host_scripts` - Dictionary of NSE script results:
|
||||
```
|
||||
{{
|
||||
"ssl-cert": "Subject: CN=example.com\\nIssuer: CN=Let's Encrypt...",
|
||||
"http-title": "Welcome to nginx!",
|
||||
"http-server-header": "nginx/1.18.0",
|
||||
"smb-os-discovery": "Windows Server 2019...",
|
||||
"ssh-hostkey": "ssh-rsa AAAAB3NzaC1...",
|
||||
"clock-skew": "+2h30m",
|
||||
"uptime": "23 days"
|
||||
}}
|
||||
```
|
||||
|
||||
**Infrastructure Clustering Signals:**
|
||||
|
||||
When analyzing infrastructure relationships, these metadata fields are especially valuable:
|
||||
- **TLS Certificates** (`ssl-cert` in port scripts or host_scripts): Same cert across hosts
|
||||
= likely same operator
|
||||
- **Service Versions** (`product` + `version` in ports): Same outdated version
|
||||
= shared image/template
|
||||
- **HTTP Fingerprints** (`http-title`, `http-server-header` in scripts):
|
||||
Identical titles/headers = same panel/kit
|
||||
- **OS + Uptime** (`os` + `uptime_seconds`): Hosts provisioned together often boot together
|
||||
- **Traceroute Paths** (`traceroute`): Shared routing = same hosting provider/datacenter
|
||||
- **Clock Skew** (`clock-skew` script): Timezone hints, VM detection
|
||||
- **SMB/Windows Info** (`smb-os-discovery` script): Domain names, workgroup patterns
|
||||
|
||||
**Example - Find hosts by vendor:**
|
||||
```cypher
|
||||
MATCH (a:Asset)
|
||||
@@ -211,6 +301,41 @@ WHERE a.metadata CONTAINS '"vendor"'
|
||||
RETURN a.node_id, a.metadata
|
||||
```
|
||||
|
||||
**Querying Numeric Fields in JSON Metadata:**
|
||||
|
||||
Since metadata is a JSON string (not a map), you CANNOT use dot notation or direct comparison.
|
||||
To query numeric fields like `rtt_srtt_us`, `uptime_seconds`, etc., use this pattern:
|
||||
|
||||
```cypher
|
||||
MATCH (a:Asset)
|
||||
WHERE a.metadata CONTAINS '"rtt_srtt_us"'
|
||||
WITH a,
|
||||
toInteger(
|
||||
split(
|
||||
split(a.metadata, '"rtt_srtt_us": ')[1],
|
||||
',')[0]
|
||||
) AS latency
|
||||
WHERE latency IS NOT NULL
|
||||
RETURN a.node_id, a.metadata, latency
|
||||
ORDER BY latency DESC
|
||||
LIMIT 5
|
||||
```
|
||||
|
||||
**Parsing JSON metadata pattern:**
|
||||
1. Use `CONTAINS` to filter hosts that have the field
|
||||
2. Use `split(a.metadata, '"field_name": ')[1]` to get everything after the field
|
||||
3. Use `split(..., ',')[0]` or `split(..., '}}')[0]` to get just the value
|
||||
4. Convert with `toInteger()` or `toFloat()` for numbers
|
||||
5. Always check `WHERE value IS NOT NULL` to handle parsing failures
|
||||
|
||||
**Example - Find hosts with open port 22:**
|
||||
```cypher
|
||||
MATCH (a:Asset)
|
||||
WHERE a.metadata CONTAINS '"port": 22'
|
||||
AND a.metadata CONTAINS '"state": "open"'
|
||||
RETURN a.node_id, a.metadata
|
||||
```
|
||||
|
||||
Note: Metadata is stored as JSON string. To search for specific values, use CONTAINS with flexible
|
||||
patterns:
|
||||
- `WHERE a.metadata CONTAINS 'Samsung'` (case-sensitive substring)
|
||||
|
||||
@@ -80,24 +80,127 @@ const getNodeColor = (label: string): string => {
|
||||
const formatLabel = (label: string): string =>
|
||||
label.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ");
|
||||
|
||||
const formatMetadataValue = (key: string, value: unknown): string => {
|
||||
if (Array.isArray(value)) {
|
||||
if (key === "ports" && value.length > 0 && typeof value[0] === "object") {
|
||||
// Filter to only show open ports
|
||||
const openPorts = value.filter((p: any) => p.state === "open");
|
||||
if (openPorts.length === 0) {
|
||||
return "No open ports";
|
||||
}
|
||||
return openPorts
|
||||
.map((p: any) => `${p.port}/${p.service || "unknown"}`)
|
||||
.join(", ");
|
||||
}
|
||||
return value.slice(0, 5).join(", ") + (value.length > 5 ? ` +${value.length - 5} more` : "");
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h ${mins}m`;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const parseSSLCert = (scriptOutput: string): { subject?: string; issuer?: string; validity?: string } | null => {
|
||||
try {
|
||||
const subjectMatch = scriptOutput.match(/Subject:\s*(.+?)(?:\n|$)/);
|
||||
const issuerMatch = scriptOutput.match(/Issuer:\s*(.+?)(?:\n|$)/);
|
||||
const validityMatch = scriptOutput.match(/Not valid (?:before|after):\s*(.+?)(?:\n|$)/);
|
||||
return {
|
||||
subject: subjectMatch?.[1]?.trim(),
|
||||
issuer: issuerMatch?.[1]?.trim(),
|
||||
validity: validityMatch?.[1]?.trim(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return JSON.stringify(value).slice(0, 50);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const PortsTable: React.FC<{ ports: any[] }> = ({ ports }) => {
|
||||
const openPorts = ports.filter((p) => p.state === "open");
|
||||
if (openPorts.length === 0) return <div style={{ color: "var(--muted)", fontSize: "12px" }}>No open ports</div>;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: "11px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
marginTop: "8px"
|
||||
}}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead style={{ background: "var(--surface)" }}>
|
||||
<tr>
|
||||
<th style={{ padding: "6px 8px", textAlign: "left", borderBottom: "1px solid var(--border)" }}>Port</th>
|
||||
<th style={{ padding: "6px 8px", textAlign: "left", borderBottom: "1px solid var(--border)" }}>Service</th>
|
||||
<th style={{ padding: "6px 8px", textAlign: "left", borderBottom: "1px solid var(--border)" }}>Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{openPorts.map((port, idx) => (
|
||||
<tr key={idx} style={{ borderBottom: idx < openPorts.length - 1 ? "1px solid var(--border)" : "none" }}>
|
||||
<td style={{ padding: "6px 8px", color: "var(--success)" }}>{port.port}</td>
|
||||
<td style={{ padding: "6px 8px" }}>{port.service || "unknown"}</td>
|
||||
<td style={{ padding: "6px 8px", fontSize: "10px", color: "var(--muted)" }}>
|
||||
{port.product ? `${port.product}${port.version ? ` ${port.version}` : ""}` : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OSMatchesList: React.FC<{ matches: any[] }> = ({ matches }) => {
|
||||
if (matches.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: "8px", fontSize: "11px" }}>
|
||||
{matches.slice(0, 3).map((match, idx) => (
|
||||
<div key={idx} style={{
|
||||
marginBottom: "6px",
|
||||
padding: "6px 8px",
|
||||
background: "var(--surface)",
|
||||
borderRadius: "4px",
|
||||
borderLeft: `3px solid ${parseInt(match.accuracy || "0") > 90 ? "var(--success)" : parseInt(match.accuracy || "0") > 70 ? "var(--warning)" : "var(--muted)"}`
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span>{match.name}</span>
|
||||
<span style={{ fontSize: "10px", color: "var(--muted)" }}>{match.accuracy}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{matches.length > 3 && (
|
||||
<div style={{ fontSize: "10px", color: "var(--muted)", marginTop: "4px" }}>
|
||||
+{matches.length - 3} more matches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({
|
||||
title,
|
||||
defaultOpen = true,
|
||||
children
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
padding: "6px 0",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px",
|
||||
color: "var(--muted)",
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s",
|
||||
marginRight: "4px"
|
||||
}}
|
||||
/>
|
||||
{title}
|
||||
</div>
|
||||
{isOpen && <div style={{ marginTop: "4px" }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function GraphView({ showToast, refreshTrigger }: GraphViewProps) {
|
||||
@@ -576,12 +679,13 @@ export function GraphView({ showToast, refreshTrigger }: GraphViewProps) {
|
||||
<span>{selectedNode.name || `Unnamed ${selectedNode.label}`}</span>
|
||||
</div>
|
||||
|
||||
{/* Basic Info Section */}
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">Type</span>
|
||||
<span className="graph-details-value">
|
||||
{formatLabel(selectedNode.label)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="graph-details-label">Type</span>
|
||||
<span className="graph-details-value">
|
||||
{formatLabel(selectedNode.label)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedNode.kind && (
|
||||
<div className="graph-details-row">
|
||||
@@ -604,21 +708,137 @@ export function GraphView({ showToast, refreshTrigger }: GraphViewProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{Object.keys(selectedNode.metadata).length > 0 && (
|
||||
{/* Simplified Asset Details */}
|
||||
{selectedNode.label === "Asset" && (
|
||||
<>
|
||||
<div className="graph-details-divider" />
|
||||
<div className="graph-details-section-title">Metadata</div>
|
||||
{Object.entries(selectedNode.metadata).map(([key, value]) => (
|
||||
<div key={key} className="graph-details-row">
|
||||
<span className="graph-details-label">{key}</span>
|
||||
<span className="graph-details-value" title={String(value)}>
|
||||
{formatMetadataValue(key, value)}
|
||||
{selectedNode.metadata.hostname && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">Hostname</span>
|
||||
<span className="graph-details-value">{String(selectedNode.metadata.hostname)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.metadata.mac_address && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">MAC</span>
|
||||
<span className="graph-details-value" style={{ fontFamily: "monospace", fontSize: "11px" }}>
|
||||
{String(selectedNode.metadata.mac_address)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{selectedNode.metadata.vendor && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">Vendor</span>
|
||||
<span className="graph-details-value">{String(selectedNode.metadata.vendor)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.metadata.status && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">Status</span>
|
||||
<span className="graph-details-value">
|
||||
<span style={{
|
||||
color: selectedNode.metadata.status === "online" ? "var(--success)" : "var(--muted)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px"
|
||||
}}>
|
||||
<span style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
background: "currentColor"
|
||||
}} />
|
||||
{String(selectedNode.metadata.status)}
|
||||
</span>
|
||||
{selectedNode.metadata.uptime_seconds && (
|
||||
<span style={{ color: "var(--muted)", marginLeft: "6px" }}>
|
||||
• {formatUptime(Number(selectedNode.metadata.uptime_seconds))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.metadata.cidr && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">Network</span>
|
||||
<span className="graph-details-value" style={{ fontFamily: "monospace" }}>
|
||||
{String(selectedNode.metadata.cidr)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.metadata.os_matches && Array.isArray(selectedNode.metadata.os_matches) && selectedNode.metadata.os_matches.length > 0 && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">OS</span>
|
||||
<span className="graph-details-value">
|
||||
{String((selectedNode.metadata.os_matches[0] as any).name)}
|
||||
{(selectedNode.metadata.os_matches[0] as any).accuracy && (
|
||||
<span style={{
|
||||
marginLeft: "6px",
|
||||
color: Number((selectedNode.metadata.os_matches[0] as any).accuracy) >= 90 ? "var(--success)" : "var(--warning)"
|
||||
}}>
|
||||
{String((selectedNode.metadata.os_matches[0] as any).accuracy)}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.metadata.ports && Array.isArray(selectedNode.metadata.ports) && selectedNode.metadata.ports.length > 0 && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">Ports</span>
|
||||
<span className="graph-details-value" style={{ fontSize: "11px" }}>
|
||||
{(() => {
|
||||
const openPorts = selectedNode.metadata.ports.filter((p: any) => p.state === "open");
|
||||
if (openPorts.length === 0) {
|
||||
return <span style={{ color: "var(--muted)" }}>No open ports</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{openPorts.slice(0, 5).map((port: any, idx: number) => {
|
||||
const portNum = String(port.port || "?");
|
||||
const service = String(port.service || "");
|
||||
return (
|
||||
<span key={idx} style={{ color: "var(--success)" }}>
|
||||
{idx > 0 && ", "}
|
||||
{portNum}{service && `/${service}`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{openPorts.length > 5 && <span style={{ color: "var(--muted)" }}> +{openPorts.length - 5} more</span>}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedNode.metadata.distance !== undefined || selectedNode.metadata.rtt_srtt_us) && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">Latency</span>
|
||||
<span className="graph-details-value">
|
||||
{selectedNode.metadata.distance !== undefined && `${String(selectedNode.metadata.distance)} hops`}
|
||||
{selectedNode.metadata.distance !== undefined && selectedNode.metadata.rtt_srtt_us && " • "}
|
||||
{selectedNode.metadata.rtt_srtt_us && `${(Number(selectedNode.metadata.rtt_srtt_us) / 1000).toFixed(2)}ms`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Network Container specific info */}
|
||||
{selectedNode.label === "NetworkContainer" && selectedNode.metadata.cidr && (
|
||||
<div className="graph-details-row">
|
||||
<span className="graph-details-label">CIDR</span>
|
||||
<span className="graph-details-value" style={{ fontFamily: "monospace" }}>
|
||||
{String(selectedNode.metadata.cidr)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="graph-details-actions">
|
||||
<button onClick={() => handleCenterOnNode(selectedNode)}>
|
||||
<Target size={14} /> Center
|
||||
|
||||
@@ -840,7 +840,7 @@ export function ScannerControl({ onRefreshData, onOpenAudit, showToast }: Scanne
|
||||
}
|
||||
disabled={inputsDisabled}
|
||||
/>
|
||||
OS/Version detection
|
||||
Deep fingerprinting (OS/Version + scripts + traceroute)
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user