feat(network): expand Nmap fingerprinting (OS, TLS, traceroute, NSE results)

This commit is contained in:
GH05TCREW
2026-02-06 15:15:31 -07:00
parent 50ad0533e1
commit 304139dea6
6 changed files with 784 additions and 90 deletions
+400 -48
View File
@@ -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
+4
View File
@@ -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 = (
-7
View File
@@ -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.
+128 -3
View File
@@ -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)
+251 -31
View File
@@ -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
+1 -1
View File
@@ -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>