diff --git a/.env.example b/.env.example
index acd6414..b0738b4 100644
--- a/.env.example
+++ b/.env.example
@@ -54,6 +54,13 @@ NETBOT_LIVE_RING_DEFAULT_QUERY_LIMIT=250
NETBOT_LIVE_RING_MAX_QUERY_LIMIT=2000
NETBOT_LIVE_RING_TTL_SECONDS=0
+# Local, metadata-only destination attribution. No external lookup is performed.
+NETBOT_SERVICE_ATTRIBUTION_ENABLED=true
+NETBOT_SERVICE_ATTRIBUTION_REGISTRY=backend/app/data/service_fingerprints.json
+NETBOT_SERVICE_ATTRIBUTION_DNS_WINDOW_SEC=300
+NETBOT_SERVICE_ATTRIBUTION_MAX_REASONS=8
+NETBOT_SERVICE_ATTRIBUTION_UNKNOWN_RATE_WARN=0.75
+
# Bounded batch persistence. Counters and health are exposed in Ops Snapshot.
NETBOT_PERSISTENCE_BATCH_ENABLED=true
NETBOT_PERSISTENCE_PACKET_BATCH_SIZE=500
diff --git a/README.md b/README.md
index bdb3cca..bcde07a 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,7 @@ or PCAP artifacts.
| Conversation Timeline | Active | Protocol, alert, destination, and lifecycle events are correlated per flow. | Timeline summaries and metadata pass through central redaction. |
| Flow Risk Scoring | Active | Explainable `0..100` scoring covers alerts, volume, DNS failures, unusual protocols/ports, and destinations. | Risk is an investigation aid, not an automated verdict. |
| Offline PCAP Flow Summary | Active | Offline analysis returns flows, conversations, protocol summaries, timelines, and risk distribution. | Existing API response fields remain compatible; output stays redacted. |
+| Service Attribution / Destination Intelligence | Active MVP | DNS, visible TLS SNI, HTTP Host, recent DNS-answer correlation, ASN metadata, and a local fingerprint registry produce confidence-scored destination labels with evidence. Browser/container identity and CDN infrastructure are handled conservatively; encrypted traffic without evidence remains Unknown. | Local metadata-only inference; no external lookup, TLS decryption, browser history, cookies, credentials, or payload inspection. |
| Deep Packet Inspection | Active MVP | Inspect renders a searchable layer tree, safe bytes view, streams, and Expert Info. | No TLS decryption; visible metadata and ASCII previews are centrally redacted. |
| Display Filters | Active MVP | Safe packet and flow filter parser covers text, equality, range, and boolean operators. | Filters run on redacted metadata and never use Python `eval`. |
| Offline PCAP Deep Analysis | Active MVP | Offline results include packet details, Expert Info, and stream summaries. | Previous API fields remain compatible; raw secrets are not exposed. |
@@ -256,6 +257,7 @@ and surfaced in Ops Snapshot metrics.
- [Performance Pipeline](docs/PERFORMANCE_PIPELINE.md)
- [Performance Validation](docs/PERFORMANCE_VALIDATION.md)
- [Flow Analysis And Protocol Intelligence](docs/FLOW_ANALYSIS.md)
+- [Service Attribution And Destination Intelligence](docs/SERVICE_ATTRIBUTION.md)
- [Deep Packet Inspection](docs/DEEP_PACKET_INSPECTION.md)
- [Agent Operational QA Checklist](docs/AGENT_QA_CHECKLIST.md)
- [Deployment Overview](docs/DEPLOYMENT_OVERVIEW.md)
@@ -484,9 +486,9 @@ powershell -ExecutionPolicy Bypass -File .\packaging\windows\build.ps1
- Complete the performance foundation with a flow-aware worker pool, live ring
buffer, and benchmark/soak validation.
-- Add conservative Service Attribution / Destination Intelligence using DNS,
- TLS SNI, HTTP Host, QUIC-visible metadata, ASN, and local fingerprints. Low
- confidence remains `Unknown`; no TLS decryption or credential collection.
+- Validate and tune the implemented metadata-only Service Attribution registry
+ against authorized deployment traffic while preserving conservative Unknown
+ results for weak or hidden evidence.
- Build a read-only Incident / Correlation Engine after attribution quality is
validated, then consider a strictly read-only AI Analyst.
- Versioned SQLite schema migrations and longer-lived deployment operations.
diff --git a/backend/app/data/service_fingerprints.json b/backend/app/data/service_fingerprints.json
new file mode 100644
index 0000000..648d3bb
--- /dev/null
+++ b/backend/app/data/service_fingerprints.json
@@ -0,0 +1,28 @@
+{
+ "version": 1,
+ "services": [
+ {"service_name":"YouTube","category":"Video Streaming","domain_patterns":["youtube.com","*.youtube.com","googlevideo.com","*.googlevideo.com","ytimg.com","*.ytimg.com","youtu.be"],"sni_patterns":["youtube.com","*.youtube.com","googlevideo.com","*.googlevideo.com","ytimg.com","*.ytimg.com"],"http_host_patterns":["youtube.com","*.youtube.com","googlevideo.com","*.googlevideo.com","ytimg.com","*.ytimg.com"],"asn_org_patterns":["google"],"cdn":true,"risk_notes":"Known video streaming service; review only for policy or volume anomalies."},
+ {"service_name":"Netflix","category":"Video Streaming","domain_patterns":["netflix.com","*.netflix.com","nflxvideo.net","*.nflxvideo.net","nflximg.net","*.nflximg.net"],"asn_org_patterns":["netflix"],"cdn":true},
+ {"service_name":"Twitch","category":"Video Streaming","domain_patterns":["twitch.tv","*.twitch.tv","ttvnw.net","*.ttvnw.net"],"asn_org_patterns":["twitch"],"cdn":true},
+ {"service_name":"Telegram Web","category":"Messaging","domain_patterns":["web.telegram.org","telegram.org","*.telegram.org","t.me","*.t.me"],"asn_org_patterns":["telegram"]},
+ {"service_name":"WhatsApp Web","category":"Messaging","domain_patterns":["web.whatsapp.com","whatsapp.com","*.whatsapp.com","whatsapp.net","*.whatsapp.net"],"asn_org_patterns":["meta platforms","facebook"]},
+ {"service_name":"Discord","category":"Messaging","domain_patterns":["discord.com","*.discord.com","discord.gg","*.discord.gg","discordapp.com","*.discordapp.com","discordapp.net","*.discordapp.net"],"asn_org_patterns":["discord"],"cdn":true},
+ {"service_name":"GitHub","category":"Developer Platform","domain_patterns":["github.com","*.github.com","githubusercontent.com","*.githubusercontent.com","githubassets.com","*.githubassets.com"],"asn_org_patterns":["github"]},
+ {"service_name":"GitLab","category":"Developer Platform","domain_patterns":["gitlab.com","*.gitlab.com"],"asn_org_patterns":["gitlab"]},
+ {"service_name":"Docker Hub","category":"Developer Platform","domain_patterns":["docker.com","*.docker.com","docker.io","*.docker.io"],"asn_org_patterns":["docker"]},
+ {"service_name":"npm Registry","category":"Developer Platform","domain_patterns":["npmjs.com","*.npmjs.com","npmjs.org","*.npmjs.org"],"asn_org_patterns":["npm"]},
+ {"service_name":"PyPI","category":"Developer Platform","domain_patterns":["pypi.org","*.pypi.org","pythonhosted.org","*.pythonhosted.org"],"asn_org_patterns":["python software foundation"]},
+ {"service_name":"Google","category":"Search / Cloud","domain_patterns":["google.com","*.google.com","gstatic.com","*.gstatic.com","1e100.net","*.1e100.net"],"asn_org_patterns":["google"]},
+ {"service_name":"Microsoft","category":"Search / Cloud","domain_patterns":["microsoft.com","*.microsoft.com","live.com","*.live.com","office.com","*.office.com"],"asn_org_patterns":["microsoft"]},
+ {"service_name":"AWS","category":"Cloud","domain_patterns":["amazonaws.com","*.amazonaws.com","aws.amazon.com"],"asn_org_patterns":["amazon","aws"],"cdn":true},
+ {"service_name":"Azure","category":"Cloud","domain_patterns":["azure.com","*.azure.com","azurewebsites.net","*.azurewebsites.net"],"asn_org_patterns":["microsoft"]},
+ {"service_name":"Instagram","category":"Social","domain_patterns":["instagram.com","*.instagram.com","cdninstagram.com","*.cdninstagram.com"],"asn_org_patterns":["meta platforms","facebook"],"cdn":true},
+ {"service_name":"X / Twitter","category":"Social","domain_patterns":["x.com","*.x.com","twitter.com","*.twitter.com","twimg.com","*.twimg.com"],"asn_org_patterns":["twitter"]},
+ {"service_name":"TikTok","category":"Social","domain_patterns":["tiktok.com","*.tiktok.com","tiktokcdn.com","*.tiktokcdn.com","byteoversea.com","*.byteoversea.com"],"asn_org_patterns":["bytedance"],"cdn":true},
+ {"service_name":"Cloudflare CDN","category":"CDN","domain_patterns":["cloudflare.com","*.cloudflare.com","cloudflare.net","*.cloudflare.net"],"asn_org_patterns":["cloudflare"],"cdn":true},
+ {"service_name":"Akamai","category":"CDN","domain_patterns":["akamaized.net","*.akamaized.net","akamai.net","*.akamai.net","akamaiedge.net","*.akamaiedge.net"],"asn_org_patterns":["akamai"],"cdn":true},
+ {"service_name":"Fastly","category":"CDN","domain_patterns":["fastly.net","*.fastly.net","fastlylb.net","*.fastlylb.net"],"asn_org_patterns":["fastly"],"cdn":true},
+ {"service_name":"Google CDN","category":"CDN","domain_patterns":["googleusercontent.com","*.googleusercontent.com","ggpht.com","*.ggpht.com"],"asn_org_patterns":["google"],"cdn":true},
+ {"service_name":"Amazon CloudFront","category":"CDN","domain_patterns":["cloudfront.net","*.cloudfront.net"],"asn_org_patterns":["amazon"],"cdn":true}
+ ]
+}
diff --git a/backend/app/main.py b/backend/app/main.py
index c0f4121..f0ad486 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -119,6 +119,7 @@ def _observability_snapshot() -> dict[str, Any]:
"packet_queue": sniffer_service.packet_queue_stats(),
"flow_worker_pool": sniffer_service.flow_worker_pool_stats(),
"live_ring_buffer": sniffer_service.live_ring_buffer_stats(),
+ "service_attribution": sniffer_service.service_attribution_stats(),
"persistence": sniffer_service.persistence_stats(),
"auto_block": sniffer_service.auto_block_stats(),
}
diff --git a/backend/app/services/monitoring_service.py b/backend/app/services/monitoring_service.py
index 0d6dabc..b8e9411 100644
--- a/backend/app/services/monitoring_service.py
+++ b/backend/app/services/monitoring_service.py
@@ -187,6 +187,7 @@ def build_monitoring_metrics(
packet_queue = dict(observability.get("packet_queue") or {})
flow_worker_pool = dict(observability.get("flow_worker_pool") or {})
live_ring_buffer = dict(observability.get("live_ring_buffer") or {})
+ service_attribution = dict(observability.get("service_attribution") or {})
event_aggregator = dict(observability.get("event_aggregator") or {})
websocket = dict(observability.get("websocket") or {})
persistence = dict(observability.get("persistence") or {})
@@ -288,6 +289,18 @@ def build_monitoring_metrics(
pressure_reasons.extend(
reason for reason in live_ring_reasons if reason not in pressure_reasons
)
+ service_attribution_reasons = [
+ reason
+ for value in service_attribution.get("pressure_reasons") or []
+ if (reason := str(value)).startswith("service_attribution_")
+ and len(reason) <= 80
+ and reason.replace("_", "").isalnum()
+ ]
+ pressure_reasons.extend(
+ reason
+ for reason in service_attribution_reasons
+ if reason not in pressure_reasons
+ )
pressure_reasons.extend(
reason for reason in persistence_reasons if reason not in pressure_reasons
)
@@ -343,6 +356,7 @@ def build_monitoring_metrics(
or websocket_drops >= _int(event_aggregator.get("client_queue_max") or 1000)
or flow_worker_pool.get("health") == "critical"
or live_ring_buffer.get("health") == "critical"
+ or service_attribution.get("health") == "critical"
):
health = "critical"
@@ -588,6 +602,41 @@ def build_monitoring_metrics(
},
"pressure_reasons": live_ring_reasons,
},
+ "service_attribution": {
+ "enabled": bool(service_attribution.get("enabled", True)),
+ "health": str(service_attribution.get("health") or "healthy"),
+ "registry_size": _int(service_attribution.get("registry_size")),
+ "attributed_flows_total": _int(
+ service_attribution.get("attributed_flows_total")
+ ),
+ "unknown_flows_total": _int(service_attribution.get("unknown_flows_total")),
+ "high_confidence_total": _int(
+ service_attribution.get("high_confidence_total")
+ ),
+ "medium_confidence_total": _int(
+ service_attribution.get("medium_confidence_total")
+ ),
+ "low_confidence_total": _int(
+ service_attribution.get("low_confidence_total")
+ ),
+ "encrypted_unknown_total": _int(
+ service_attribution.get("encrypted_unknown_total")
+ ),
+ "cdn_only_total": _int(service_attribution.get("cdn_only_total")),
+ "attribution_errors_total": _int(
+ service_attribution.get("attribution_errors_total")
+ ),
+ "avg_attribution_latency_ms": _number(
+ service_attribution.get("avg_attribution_latency_ms")
+ ),
+ "p95_attribution_latency_ms": _number(
+ service_attribution.get("p95_attribution_latency_ms")
+ ),
+ "last_error": _safe_persistence_error(
+ service_attribution.get("last_error")
+ ),
+ "pressure_reasons": service_attribution_reasons,
+ },
"persistence": {
"enabled": bool(
persistence.get("persistence_enabled", persistence.get("enabled"))
diff --git a/backend/app/services/service_attribution.py b/backend/app/services/service_attribution.py
index 37672b2..9f1ce94 100644
--- a/backend/app/services/service_attribution.py
+++ b/backend/app/services/service_attribution.py
@@ -1,131 +1,726 @@
from __future__ import annotations
+import ipaddress
+import json
+import os
+import threading
+import time
+from collections import OrderedDict, deque
+from dataclasses import asdict, dataclass
+from datetime import datetime, timezone
from fnmatch import fnmatch
+from pathlib import Path
from typing import Any
from core.privacy_redaction import redact_sensitive_data, redact_sensitive_text
-SERVICE_PATTERNS = (
- (
- "YouTube",
- "Video Streaming",
- (
- "youtube.com",
- "*.youtube.com",
- "googlevideo.com",
- "*.googlevideo.com",
- "youtu.be",
- ),
- ),
- (
- "Google",
- "Search / Cloud",
- (
- "google.com",
- "*.google.com",
- "gstatic.com",
- "*.gstatic.com",
- "1e100.net",
- "*.1e100.net",
- ),
- ),
- (
- "GitHub",
- "Developer",
- (
- "github.com",
- "*.github.com",
- "githubusercontent.com",
- "*.githubusercontent.com",
- ),
- ),
- ("Telegram", "Messaging", ("telegram.org", "*.telegram.org", "t.me", "*.t.me")),
- ("Cloudflare", "CDN / Security", ("cloudflare.com", "*.cloudflare.com")),
+DEFAULT_REGISTRY_PATH = (
+ Path(__file__).resolve().parents[1] / "data" / "service_fingerprints.json"
)
+DEFAULT_DNS_CACHE_MAX = 10_000
+DEFAULT_FLOW_METRIC_CACHE_MAX = 100_000
+BROWSER_OR_CONTAINER_PROCESSES = {
+ "brave.exe",
+ "chrome.exe",
+ "discord.exe",
+ "electron.exe",
+ "firefox.exe",
+ "msedge.exe",
+ "opera.exe",
+ "slack.exe",
+ "telegram.exe",
+}
-ORG_FALLBACKS = (
- ("google", "Google Services", "Search / Cloud"),
- ("cloudflare", "Cloudflare Network", "CDN / Security"),
- ("github", "GitHub", "Developer"),
- ("microsoft", "Microsoft Services", "Cloud"),
- ("amazon", "Amazon / AWS", "Cloud / CDN"),
-)
+
+def _env_bool(name: str, default: bool) -> bool:
+ value = os.environ.get(name)
+ if value is None:
+ return default
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _safe_int(value: Any, default: int, minimum: int, maximum: int) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ return default
+ return max(minimum, min(parsed, maximum))
+
+
+def _timestamp(value: Any) -> float:
+ if isinstance(value, (int, float)):
+ return float(value)
+ try:
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
+ except (TypeError, ValueError):
+ return time.time()
+
+
+def _domain(value: Any) -> str:
+ normalized = str(value or "").strip().lower().rstrip(".")
+ if ":" in normalized and normalized.count(":") == 1:
+ normalized = normalized.split(":", 1)[0]
+ if (
+ not normalized
+ or len(normalized) > 253
+ or "/" in normalized
+ or "@" in normalized
+ ):
+ return ""
+ return redact_sensitive_text(normalized)
+
+
+def _safe_ip(value: Any) -> str:
+ candidate = str(value or "").strip()
+ try:
+ return str(ipaddress.ip_address(candidate))
+ except ValueError:
+ return ""
+
+
+def _percentile(values: deque[float], percentile: float) -> float:
+ ordered = sorted(values)
+ if not ordered:
+ return 0.0
+ index = min(len(ordered) - 1, int((len(ordered) - 1) * percentile))
+ return round(ordered[index], 3)
+
+
+@dataclass(frozen=True)
+class ServiceFingerprint:
+ service_name: str
+ category: str
+ domain_patterns: tuple[str, ...]
+ sni_patterns: tuple[str, ...]
+ http_host_patterns: tuple[str, ...]
+ asn_org_patterns: tuple[str, ...]
+ cdn: bool = False
+ risk_notes: str = ""
+
+ @classmethod
+ def from_dict(cls, value: dict[str, Any]) -> "ServiceFingerprint":
+ service_name = str(value.get("service_name") or "").strip()
+ category = str(value.get("category") or "").strip()
+ domain_patterns = _patterns(value.get("domain_patterns"))
+ if not service_name or not category or not domain_patterns:
+ raise ValueError("InvalidServiceFingerprint")
+ return cls(
+ service_name=service_name[:100],
+ category=category[:100],
+ domain_patterns=domain_patterns,
+ sni_patterns=_patterns(value.get("sni_patterns")) or domain_patterns,
+ http_host_patterns=_patterns(value.get("http_host_patterns"))
+ or domain_patterns,
+ asn_org_patterns=tuple(
+ str(item).strip().lower()[:100]
+ for item in value.get("asn_org_patterns") or []
+ if str(item).strip()
+ ),
+ cdn=bool(value.get("cdn")),
+ risk_notes=redact_sensitive_text(str(value.get("risk_notes") or ""))[:500],
+ )
-def _domain(packet: dict[str, Any]) -> tuple[str, str]:
- candidates = (
- ("tls_sni", packet.get("tls_sni") or packet.get("sni")),
- ("http_host", packet.get("http_host")),
- ("dns", packet.get("dns_qname")),
+def _patterns(value: Any) -> tuple[str, ...]:
+ return tuple(
+ pattern
+ for item in (value if isinstance(value, list) else [])
+ if (pattern := _domain(item))
)
- for source, value in candidates:
- normalized = str(value or "").strip().lower().rstrip(".")
- if normalized:
- return normalized, source
- return "", ""
-def attribute_service(packet: dict[str, Any]) -> dict[str, Any]:
- domain, source = _domain(packet)
- application = str(packet.get("process_name") or "").strip()
- org = str(packet.get("org") or "").strip()
- encrypted = int(packet.get("sport") or packet.get("dport") or 0) == 443 or str(
- packet.get("app_protocol") or ""
- ).upper() in {"TLS", "HTTPS", "QUIC"}
-
- for service, category, patterns in SERVICE_PATTERNS:
- if domain and any(fnmatch(domain, pattern) for pattern in patterns):
- return redact_sensitive_data(
- {
- "application_name": application,
- "service_name": service,
- "service_category": category,
- "service_domain": domain,
- "service_confidence": "high",
- "service_reasons": [f"{source.upper()} matched {domain}"],
- "service_sources": [source],
- "service_encrypted": encrypted,
- "service_unknown": False,
- }
+@dataclass(frozen=True)
+class ServiceAttributionResult:
+ application_name: str
+ service_name: str
+ service_category: str
+ domain: str
+ destination_ip: str
+ destination_port: int
+ attribution_confidence: str
+ confidence_score: int
+ attribution_reasons: tuple[str, ...]
+ attribution_sources: tuple[str, ...]
+ is_encrypted: bool
+ is_unknown: bool
+ is_cdn: bool
+ is_proxy_or_vpn_suspected: bool
+ risk_hint: str
+
+ def to_dict(self) -> dict[str, Any]:
+ return redact_sensitive_data(asdict(self))
+
+
+class ServiceAttributionEngine:
+ """Deterministic, metadata-only destination attribution with local state."""
+
+ def __init__(
+ self,
+ registry_path: str | Path | None = None,
+ *,
+ enabled: bool = True,
+ dns_window_sec: int = 300,
+ max_reasons: int = 8,
+ unknown_rate_warn: float = 0.75,
+ dns_cache_max: int = DEFAULT_DNS_CACHE_MAX,
+ flow_metric_cache_max: int = DEFAULT_FLOW_METRIC_CACHE_MAX,
+ ) -> None:
+ self.enabled = bool(enabled)
+ self.registry_path = Path(registry_path or DEFAULT_REGISTRY_PATH)
+ self.dns_window_sec = _safe_int(dns_window_sec, 300, 1, 3600)
+ self.max_reasons = _safe_int(max_reasons, 8, 1, 20)
+ self.unknown_rate_warn = max(0.0, min(float(unknown_rate_warn), 1.0))
+ self.dns_cache_max = _safe_int(
+ dns_cache_max, DEFAULT_DNS_CACHE_MAX, 1, 1_000_000
+ )
+ self.flow_metric_cache_max = _safe_int(
+ flow_metric_cache_max,
+ DEFAULT_FLOW_METRIC_CACHE_MAX,
+ 1,
+ 1_000_000,
+ )
+ self._lock = threading.RLock()
+ self._dns_by_ip: OrderedDict[str, deque[tuple[float, str]]] = OrderedDict()
+ self._latencies: deque[float] = deque(maxlen=512)
+ self._flow_outcomes: OrderedDict[str, tuple[bool, str, bool, bool]] = (
+ OrderedDict()
+ )
+ self._attributed = self._unknown = 0
+ self._high = self._medium = self._low = 0
+ self._encrypted_unknown = self._cdn_only = self._errors = 0
+ self._last_error = ""
+ self._registry_loaded = False
+ self._fingerprints = self._load_registry()
+
+ @classmethod
+ def from_env(cls) -> "ServiceAttributionEngine":
+ try:
+ unknown_rate = float(
+ os.environ.get("NETBOT_SERVICE_ATTRIBUTION_UNKNOWN_RATE_WARN", "0.75")
+ )
+ except ValueError:
+ unknown_rate = 0.75
+ return cls(
+ os.environ.get("NETBOT_SERVICE_ATTRIBUTION_REGISTRY")
+ or DEFAULT_REGISTRY_PATH,
+ enabled=_env_bool("NETBOT_SERVICE_ATTRIBUTION_ENABLED", True),
+ dns_window_sec=_safe_int(
+ os.environ.get("NETBOT_SERVICE_ATTRIBUTION_DNS_WINDOW_SEC"),
+ 300,
+ 1,
+ 3600,
+ ),
+ max_reasons=_safe_int(
+ os.environ.get("NETBOT_SERVICE_ATTRIBUTION_MAX_REASONS"),
+ 8,
+ 1,
+ 20,
+ ),
+ unknown_rate_warn=unknown_rate,
+ )
+
+ def _load_registry(self) -> tuple[ServiceFingerprint, ...]:
+ try:
+ payload = json.loads(self.registry_path.read_text(encoding="utf-8"))
+ rows = payload.get("services") if isinstance(payload, dict) else payload
+ if not isinstance(rows, list):
+ raise ValueError("InvalidServiceRegistry")
+ fingerprints: list[ServiceFingerprint] = []
+ for row in rows:
+ try:
+ if isinstance(row, dict):
+ fingerprints.append(ServiceFingerprint.from_dict(row))
+ except (TypeError, ValueError):
+ continue
+ if not fingerprints:
+ raise ValueError("EmptyServiceRegistry")
+ self._registry_loaded = True
+ return tuple(fingerprints)
+ except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ self._last_error = type(exc).__name__
+ return ()
+
+ def enrich(self, packet: dict[str, Any]) -> dict[str, Any]:
+ result = self.attribute(packet)
+ public = result.to_dict()
+ packet["service_attribution"] = public
+ packet.update(
+ {
+ "service_name": public["service_name"],
+ "service_category": public["service_category"],
+ "service_domain": public["domain"],
+ "service_confidence": public["attribution_confidence"],
+ "service_confidence_score": public["confidence_score"],
+ "service_reasons": list(public["attribution_reasons"]),
+ "service_sources": list(public["attribution_sources"]),
+ "service_encrypted": public["is_encrypted"],
+ "service_unknown": public["is_unknown"],
+ "service_is_cdn": public["is_cdn"],
+ "service_risk_hint": public["risk_hint"],
+ }
+ )
+ return public
+
+ def attribute(self, packet: dict[str, Any]) -> ServiceAttributionResult:
+ started = time.perf_counter()
+ try:
+ self._observe_dns(packet)
+ result = self._attribute(packet)
+ except Exception as exc: # pragma: no cover - defensive hot-path guard
+ with self._lock:
+ self._errors += 1
+ self._last_error = type(exc).__name__
+ result = self._unknown_result(
+ packet, "Attribution metadata could not be evaluated safely."
+ )
+ self._record_result(packet, result, (time.perf_counter() - started) * 1000.0)
+ return result
+
+ def _attribute(self, packet: dict[str, Any]) -> ServiceAttributionResult:
+ if not self.enabled:
+ return self._unknown_result(packet, "Service attribution is disabled.")
+
+ application = redact_sensitive_text(str(packet.get("process_name") or ""))[:120]
+ destination_ip = _safe_ip(
+ packet.get("remote_ip") or packet.get("dst") or packet.get("destination_ip")
+ )
+ destination_port = _safe_int(
+ packet.get("dport")
+ or packet.get("destination_port")
+ or packet.get("sport"),
+ 0,
+ 0,
+ 65535,
+ )
+ encrypted = self._is_encrypted(packet, destination_port)
+ proxy_or_vpn = self._is_proxy_or_vpn(packet, application)
+ org = redact_sensitive_text(
+ str(
+ packet.get("org")
+ or packet.get("asn_org")
+ or packet.get("organization")
+ or ""
+ )
+ )[:200]
+
+ candidates: list[tuple[str, str]] = []
+ for source, value in (
+ ("http_host", packet.get("http_host")),
+ (
+ "tls_sni",
+ packet.get("tls_sni")
+ or packet.get("sni")
+ or packet.get("quic_sni")
+ or packet.get("quic_server_name"),
+ ),
+ ("dns", packet.get("dns_qname")),
+ ):
+ if normalized := _domain(value):
+ candidates.append((source, normalized))
+ for value in packet.get("resolved_domains") or []:
+ if normalized := _domain(value):
+ candidates.append(("dns", normalized))
+ if destination_ip:
+ for value in self._recent_domains(destination_ip, packet):
+ candidates.append(("dns", value))
+
+ evidence: list[tuple[str, str, ServiceFingerprint]] = []
+ for source, candidate in candidates:
+ fingerprint = self._match(candidate, source)
+ if fingerprint:
+ evidence.append((source, candidate, fingerprint))
+
+ if evidence:
+ return self._result_from_evidence(
+ packet,
+ evidence,
+ application,
+ destination_ip,
+ destination_port,
+ encrypted,
+ proxy_or_vpn,
+ org,
+ )
+
+ org_match = self._match_org(org)
+ if org_match:
+ if org_match.cdn:
+ service_name = "CDN only"
+ category = "Unknown / CDN"
+ risk_hint = "shared_cdn_only"
+ else:
+ service_name = f"{org_match.service_name} Services"
+ category = org_match.category
+ risk_hint = "organization_only_attribution"
+ return ServiceAttributionResult(
+ application,
+ service_name,
+ category,
+ "",
+ destination_ip,
+ destination_port,
+ "low",
+ 30,
+ (f"ASN organization matched {org}", "No service domain was visible."),
+ ("asn_org",),
+ encrypted,
+ False,
+ org_match.cdn,
+ proxy_or_vpn,
+ risk_hint,
+ )
+ return self._unknown_result(packet)
+
+ def _result_from_evidence(
+ self,
+ packet: dict[str, Any],
+ evidence: list[tuple[str, str, ServiceFingerprint]],
+ application: str,
+ destination_ip: str,
+ destination_port: int,
+ encrypted: bool,
+ proxy_or_vpn: bool,
+ org: str,
+ ) -> ServiceAttributionResult:
+ priority = {"http_host": 3, "tls_sni": 2, "dns": 1}
+ selected_source, selected_domain, selected = max(
+ evidence, key=lambda item: priority[item[0]]
+ )
+ agreeing = [
+ item for item in evidence if item[2].service_name == selected.service_name
+ ]
+ conflicting = any(
+ item[2].service_name != selected.service_name for item in evidence
+ )
+ base_scores = {"http_host": 90, "tls_sni": 80, "dns": 60}
+ score = base_scores[selected_source]
+ sources: list[str] = []
+ reasons: list[str] = []
+ source_labels = {
+ "http_host": "HTTP Host",
+ "tls_sni": "TLS SNI",
+ "dns": "Recent DNS query",
+ }
+ for source, domain, _fingerprint in agreeing:
+ if source not in sources:
+ sources.append(source)
+ reasons.append(f"{source_labels[source]} matched {domain}")
+ if source != selected_source:
+ score += 10 if source == "dns" else 15
+ if org and any(pattern in org.lower() for pattern in selected.asn_org_patterns):
+ sources.append("asn_org")
+ reasons.append(f"ASN organization matched {org}")
+ score += 15
+ reasons.append(f"Domain pattern matched {selected.service_name} fingerprint")
+ if "fingerprint" not in sources:
+ sources.append("fingerprint")
+ if conflicting:
+ reasons.append("Conflicting service metadata reduced confidence.")
+ score -= 20
+ score = max(0, min(score, 100))
+ confidence = "high" if score >= 80 else "medium" if score >= 50 else "low"
+ return ServiceAttributionResult(
+ application,
+ selected.service_name,
+ selected.category,
+ selected_domain,
+ destination_ip,
+ destination_port,
+ confidence,
+ score,
+ tuple(reasons[: self.max_reasons]),
+ tuple(dict.fromkeys(sources)),
+ encrypted,
+ False,
+ selected.cdn,
+ proxy_or_vpn,
+ f"known_{selected.category.lower().replace(' ', '_').replace('/', '_')}_service",
+ )
+
+ def _unknown_result(
+ self, packet: dict[str, Any], reason: str | None = None
+ ) -> ServiceAttributionResult:
+ application = redact_sensitive_text(str(packet.get("process_name") or ""))[:120]
+ destination_ip = _safe_ip(packet.get("remote_ip") or packet.get("dst"))
+ destination_port = _safe_int(
+ packet.get("dport") or packet.get("sport"), 0, 0, 65535
+ )
+ encrypted = self._is_encrypted(packet, destination_port)
+ reasons = [
+ reason or "No visible DNS, SNI, or HTTP Host evidence was available."
+ ]
+ if application.lower() in BROWSER_OR_CONTAINER_PROCESSES:
+ reasons.append(
+ "Browser or container process identity alone is not service evidence."
)
+ if encrypted:
+ reasons.append("Destination appears encrypted.")
+ return ServiceAttributionResult(
+ application,
+ "Unknown encrypted destination" if encrypted else "Unknown",
+ "Unknown",
+ "",
+ destination_ip,
+ destination_port,
+ "low" if encrypted else "unknown",
+ 20 if encrypted else (5 if application else 0),
+ tuple(reasons[: self.max_reasons]),
+ (),
+ encrypted,
+ True,
+ False,
+ self._is_proxy_or_vpn(packet, application),
+ "unknown_encrypted_destination" if encrypted else "unknown_destination",
+ )
- org_lower = org.lower()
- for org_pattern, service, category in ORG_FALLBACKS:
- if org_pattern in org_lower:
- return redact_sensitive_data(
- {
- "application_name": application,
- "service_name": service,
- "service_category": category,
- "service_domain": domain,
- "service_confidence": "low",
- "service_reasons": [
- f"Network organization matched {redact_sensitive_text(org)}",
- "Encrypted traffic did not expose a reliable service domain",
- ],
- "service_sources": ["asn_org"],
- "service_encrypted": encrypted,
- "service_unknown": False,
- }
+ def _match(self, domain: str, source: str) -> ServiceFingerprint | None:
+ best: tuple[int, ServiceFingerprint] | None = None
+ for fingerprint in self._fingerprints:
+ patterns = (
+ fingerprint.http_host_patterns
+ if source == "http_host"
+ else (
+ fingerprint.sni_patterns
+ if source == "tls_sni"
+ else fingerprint.domain_patterns
+ )
)
+ for pattern in patterns:
+ if fnmatch(domain, pattern) or domain == pattern.removeprefix("*."):
+ specificity = len(pattern.replace("*", ""))
+ if best is None or specificity > best[0]:
+ best = (specificity, fingerprint)
+ return best[1] if best else None
- return {
- "application_name": application,
- "service_name": "Unknown Encrypted" if encrypted else "Unknown",
- "service_category": "Unknown",
- "service_domain": domain,
- "service_confidence": "unknown",
- "service_reasons": ["No reliable DNS, SNI, HTTP Host, or organization match"],
- "service_sources": [],
- "service_encrypted": encrypted,
- "service_unknown": True,
- }
+ def _match_org(self, org: str) -> ServiceFingerprint | None:
+ lowered = org.lower()
+ if not lowered:
+ return None
+ matches = [
+ fingerprint
+ for fingerprint in self._fingerprints
+ if any(pattern in lowered for pattern in fingerprint.asn_org_patterns)
+ ]
+ matches.sort(
+ key=lambda fingerprint: (fingerprint.cdn, len(fingerprint.domain_patterns))
+ )
+ return matches[0] if matches else None
+
+ def _observe_dns(self, packet: dict[str, Any]) -> None:
+ domain = _domain(packet.get("dns_qname"))
+ if not domain:
+ return
+ answers: list[Any] = []
+ for key in ("dns_answer_ips", "resolved_ips", "dns_answers"):
+ value = packet.get(key)
+ answers.extend(
+ value if isinstance(value, list) else [value] if value else []
+ )
+ if packet.get("dns_answer_ip"):
+ answers.append(packet["dns_answer_ip"])
+ observed_at = _timestamp(packet.get("ts") or packet.get("timestamp"))
+ with self._lock:
+ for answer in answers:
+ candidate = (
+ answer.get("ip") or answer.get("address")
+ if isinstance(answer, dict)
+ else answer
+ )
+ if ip := _safe_ip(candidate):
+ values = self._dns_by_ip.setdefault(ip, deque(maxlen=20))
+ values.append((observed_at, domain))
+ self._dns_by_ip.move_to_end(ip)
+ if len(self._dns_by_ip) > self.dns_cache_max:
+ self._dns_by_ip.popitem(last=False)
+
+ def _recent_domains(self, destination_ip: str, packet: dict[str, Any]) -> list[str]:
+ now = _timestamp(packet.get("ts") or packet.get("timestamp"))
+ cutoff = now - self.dns_window_sec
+ with self._lock:
+ values = self._dns_by_ip.get(destination_ip, deque())
+ if destination_ip in self._dns_by_ip:
+ self._dns_by_ip.move_to_end(destination_ip)
+ recent = [item for item in values if item[0] >= cutoff]
+ recent.sort(key=lambda item: abs(now - item[0]))
+ return [domain for _observed_at, domain in recent]
+
+ @staticmethod
+ def _is_encrypted(packet: dict[str, Any], port: int) -> bool:
+ protocol = str(
+ packet.get("app_protocol") or packet.get("protocol") or ""
+ ).upper()
+ return port in {443, 8443, 9443} or protocol in {"TLS", "HTTPS", "QUIC"}
+
+ @staticmethod
+ def _is_proxy_or_vpn(packet: dict[str, Any], application: str) -> bool:
+ text = " ".join(
+ [
+ application,
+ str(packet.get("service_category") or ""),
+ str(packet.get("interface") or ""),
+ ]
+ ).lower()
+ return bool(
+ packet.get("proxy") or packet.get("vpn") or "vpn" in text or "proxy" in text
+ )
+
+ @staticmethod
+ def _flow_metric_key(packet: dict[str, Any]) -> str:
+ explicit = packet.get("flow_id") or packet.get("flow_key")
+ if explicit:
+ return str(explicit)
+ return "|".join(
+ str(value or "-")
+ for value in (
+ packet.get("src"),
+ packet.get("dst") or packet.get("remote_ip"),
+ packet.get("sport"),
+ packet.get("dport"),
+ packet.get("proto") or packet.get("transport"),
+ packet.get("direction"),
+ )
+ )
+
+ def _adjust_outcome(
+ self, outcome: tuple[bool, str, bool, bool], delta: int
+ ) -> None:
+ is_unknown, confidence, encrypted_unknown, cdn_only = outcome
+ if is_unknown:
+ self._unknown += delta
+ else:
+ self._attributed += delta
+ if confidence == "high":
+ self._high += delta
+ elif confidence == "medium":
+ self._medium += delta
+ elif confidence == "low":
+ self._low += delta
+ if encrypted_unknown:
+ self._encrypted_unknown += delta
+ if cdn_only:
+ self._cdn_only += delta
+
+ def _record_result(
+ self,
+ packet: dict[str, Any],
+ result: ServiceAttributionResult,
+ latency_ms: float,
+ ) -> None:
+ with self._lock:
+ self._latencies.append(latency_ms)
+ flow_key = self._flow_metric_key(packet)
+ outcome = (
+ result.is_unknown,
+ result.attribution_confidence,
+ result.is_unknown and result.is_encrypted,
+ result.service_name == "CDN only",
+ )
+ previous = self._flow_outcomes.get(flow_key)
+ if previous == outcome:
+ return
+ if previous:
+ self._adjust_outcome(previous, -1)
+ self._flow_outcomes[flow_key] = outcome
+ self._flow_outcomes.move_to_end(flow_key)
+ self._adjust_outcome(outcome, 1)
+ if len(self._flow_outcomes) > self.flow_metric_cache_max:
+ self._flow_outcomes.popitem(last=False)
+
+ def metrics(self) -> dict[str, Any]:
+ with self._lock:
+ total = self._attributed + self._unknown
+ unknown_rate = self._unknown / total if total else 0.0
+ avg_latency = (
+ round(sum(self._latencies) / len(self._latencies), 3)
+ if self._latencies
+ else 0.0
+ )
+ p95_latency = _percentile(self._latencies, 0.95)
+ reasons: list[str] = []
+ if self.enabled and not self._registry_loaded:
+ reasons.append("service_attribution_registry_error")
+ if p95_latency >= 25.0:
+ reasons.append("service_attribution_high_latency")
+ if self._errors:
+ reasons.append("service_attribution_errors")
+ if total >= 20 and unknown_rate >= self.unknown_rate_warn:
+ reasons.append("service_attribution_high_unknown_rate")
+ health = "healthy"
+ if reasons:
+ health = "degraded"
+ if self.enabled and not self._fingerprints:
+ health = "critical"
+ if self._errors >= 25:
+ health = "critical"
+ return {
+ "enabled": self.enabled,
+ "health": health,
+ "registry_size": len(self._fingerprints),
+ "attributed_flows_total": self._attributed,
+ "unknown_flows_total": self._unknown,
+ "high_confidence_total": self._high,
+ "medium_confidence_total": self._medium,
+ "low_confidence_total": self._low,
+ "encrypted_unknown_total": self._encrypted_unknown,
+ "cdn_only_total": self._cdn_only,
+ "attribution_errors_total": self._errors,
+ "avg_attribution_latency_ms": avg_latency,
+ "p95_attribution_latency_ms": p95_latency,
+ "last_error": self._last_error,
+ "pressure_reasons": reasons,
+ }
+
+ def reset_runtime(self) -> None:
+ with self._lock:
+ self._dns_by_ip.clear()
+ self._latencies.clear()
+ self._flow_outcomes.clear()
+ self._attributed = self._unknown = 0
+ self._high = self._medium = self._low = 0
+ self._encrypted_unknown = self._cdn_only = self._errors = 0
+ if self._registry_loaded:
+ self._last_error = ""
+
+
+_default_engine: ServiceAttributionEngine | None = None
+_default_engine_lock = threading.Lock()
+
+
+def default_service_attribution_engine() -> ServiceAttributionEngine:
+ global _default_engine
+ with _default_engine_lock:
+ if _default_engine is None:
+ _default_engine = ServiceAttributionEngine.from_env()
+ return _default_engine
+
+
+def attribute_service(packet: dict[str, Any]) -> dict[str, Any]:
+ public = default_service_attribution_engine().attribute(packet).to_dict()
+ public.update(
+ {
+ "service_domain": public["domain"],
+ "service_confidence": public["attribution_confidence"],
+ "service_reasons": list(public["attribution_reasons"]),
+ "service_sources": list(public["attribution_sources"]),
+ "service_encrypted": public["is_encrypted"],
+ "service_unknown": public["is_unknown"],
+ }
+ )
+ if public["is_unknown"] and public["is_encrypted"]:
+ public["service_name"] = "Unknown Encrypted"
+ return public
def enrich_service_attribution(packet: dict[str, Any]) -> dict[str, Any]:
- attribution = attribute_service(packet)
- packet.update(attribution)
- return attribution
+ return default_service_attribution_engine().enrich(packet)
-__all__ = ["attribute_service", "enrich_service_attribution"]
+__all__ = [
+ "BROWSER_OR_CONTAINER_PROCESSES",
+ "ServiceAttributionEngine",
+ "ServiceAttributionResult",
+ "ServiceFingerprint",
+ "attribute_service",
+ "default_service_attribution_engine",
+ "enrich_service_attribution",
+]
diff --git a/backend/app/services/sniffer_persistence.py b/backend/app/services/sniffer_persistence.py
index cfa1f28..bc74866 100644
--- a/backend/app/services/sniffer_persistence.py
+++ b/backend/app/services/sniffer_persistence.py
@@ -120,6 +120,11 @@ def persist(self, packet: dict[str, Any], alerts: list[dict[str, Any]]) -> None:
"attribution_reason_unavailable"
),
"attribution_source": packet.get("attribution_source"),
+ "service_attribution": packet.get("service_attribution"),
+ "service_name": packet.get("service_name"),
+ "service_category": packet.get("service_category"),
+ "service_domain": packet.get("service_domain"),
+ "service_confidence": packet.get("service_confidence"),
}
alert_rows = [
{
@@ -325,8 +330,7 @@ def _run_retention_cleanup(self) -> None:
return
self._last_retention_cleanup = now
try:
- from backend.app.services.settings_service import \
- get_settings_snapshot
+ from backend.app.services.settings_service import get_settings_snapshot
cleanup_retention(
int(get_settings_snapshot().get("retention_minutes") or 0)
diff --git a/backend/app/services/sniffer_service.py b/backend/app/services/sniffer_service.py
index 87dabfa..7a59c30 100644
--- a/backend/app/services/sniffer_service.py
+++ b/backend/app/services/sniffer_service.py
@@ -14,7 +14,7 @@
from backend.app.services.flow_worker_pool import FlowWorkerPool
from backend.app.services.live_ring_buffer import LiveRingBuffer
from backend.app.services.packet_queue import BoundedPacketQueue
-from backend.app.services.service_attribution import enrich_service_attribution
+from backend.app.services.service_attribution import ServiceAttributionEngine
from backend.app.services.settings_service import get_settings_snapshot
from backend.app.services.sniffer_dashboard_state import SnifferDashboardState
from backend.app.services.sniffer_detection_pipeline import SnifferDetectionPipeline
@@ -54,6 +54,7 @@ def __init__(
capture_provider: CaptureProvider | None = None,
flow_service: FlowService | None = None,
live_ring_buffer: LiveRingBuffer | None = None,
+ service_attribution: ServiceAttributionEngine | None = None,
) -> None:
self._lock = threading.Lock()
self._event_bus = event_bus
@@ -74,6 +75,9 @@ def __init__(
)
self._publisher = SnifferEventPublisher(event_bus)
self._live_ring_buffer = live_ring_buffer or LiveRingBuffer.from_env()
+ self._service_attribution = (
+ service_attribution or ServiceAttributionEngine.from_env()
+ )
self._packet_queue = BoundedPacketQueue(
max_size=PACKET_QUEUE_MAX_SIZE,
overflow_policy=PACKET_QUEUE_OVERFLOW_POLICY,
@@ -186,7 +190,7 @@ def _process_packet(self, meta: dict[str, Any]) -> None:
except Exception:
logger.exception("Packet analysis pipeline crashed")
alerts = []
- enrich_service_attribution(packet)
+ self._service_attribution.enrich(packet)
alerts = self._assign_alert_ids(packet, alerts)
for alert in alerts:
self._apply_payload_policy(alert, policy.to_public_dict())
@@ -344,6 +348,7 @@ def reset_session(self) -> dict[str, Any]:
self._state.reset()
self._flow_service.reset()
self._live_ring_buffer.clear()
+ self._service_attribution.reset_runtime()
state = self._state.state(running=running, iface=iface)
state["observability"] = self.observability()
self._publisher.publish_state("sniffer:reset", state)
@@ -366,6 +371,9 @@ def flow_worker_pool_stats(self) -> dict[str, Any]:
def live_ring_buffer_stats(self) -> dict[str, Any]:
return self._live_ring_buffer.metrics()
+ def service_attribution_stats(self) -> dict[str, Any]:
+ return self._service_attribution.metrics()
+
def recent_live_records(
self,
category: str = "all",
@@ -390,6 +398,7 @@ def observability(self) -> dict[str, Any]:
"packet_queue": self.packet_queue_stats(),
"flow_worker_pool": self.flow_worker_pool_stats(),
"live_ring_buffer": self.live_ring_buffer_stats(),
+ "service_attribution": self.service_attribution_stats(),
"persistence": self.persistence_stats(),
"auto_block": self.auto_block_stats(),
}
diff --git a/core/flow_engine.py b/core/flow_engine.py
index f7ed600..e4e5738 100644
--- a/core/flow_engine.py
+++ b/core/flow_engine.py
@@ -77,6 +77,7 @@ def _safe_packet_sample(
"length": int(packet.get("length") or 0),
"summary": redact_sensitive_text(str(packet.get("summary") or "")),
"metadata": deepcopy(protocol["metadata"]),
+ "service_attribution": deepcopy(packet.get("service_attribution") or {}),
}
@@ -132,6 +133,12 @@ def ingest(
"service_confidence": packet.get("service_confidence") or "unknown",
"service_reasons": list(packet.get("service_reasons") or []),
"service_sources": list(packet.get("service_sources") or []),
+ "service_confidence_score": int(
+ packet.get("service_confidence_score") or 0
+ ),
+ "service_attribution": deepcopy(
+ packet.get("service_attribution") or {}
+ ),
"country": packet.get("country_name") or packet.get("country"),
"asn": packet.get("asn"),
"related_alert_ids": [],
@@ -176,6 +183,12 @@ def ingest(
"service_confidence": packet.get("service_confidence"),
"service_reasons": list(packet.get("service_reasons") or []),
"service_sources": list(packet.get("service_sources") or []),
+ "service_confidence_score": int(
+ packet.get("service_confidence_score") or 0
+ ),
+ "service_attribution": deepcopy(
+ packet.get("service_attribution") or {}
+ ),
}
)
if len(flow["sample_packets"]) < _MAX_SAMPLE_PACKETS:
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index f74a91d..f6e5260 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -186,6 +186,34 @@ Raw PCAP artifacts are not treated like normal reports. They are exposed only
through the guarded raw export path, require Full or Forensic mode, require Safe
Use acceptance, require token authorization, and create audit records.
+## Service Attribution / Destination Intelligence
+
+`ServiceAttributionEngine` runs after metadata extraction and before flow
+aggregation. It correlates only locally available DNS query/answer metadata,
+visible TLS SNI, HTTP Host, optional ASN organization metadata, and the bundled
+`backend/app/data/service_fingerprints.json` registry. It never performs a
+network lookup.
+
+Attribution is attached to packet summaries before `FlowEngine` ingestion, so
+the same centrally redacted structure can travel through flow snapshots,
+conversation views, the Live Ring Buffer, WebSocket Event Aggregator, batch
+persistence, Inspect, and Flow Details. Flat service fields remain available
+for backward compatibility.
+
+Each result includes a confidence score and label, evidence sources, human
+readable reasons, encryption/CDN/proxy hints, and an explicit Unknown state.
+HTTP Host and visible SNI are stronger evidence than recent DNS correlation;
+agreement can raise confidence and conflicting metadata lowers it. A browser
+or container process name alone is never treated as proof of a destination
+service. Shared CDN/ASN evidence is labeled conservatively.
+
+Ops Snapshot receives only fixed counters, latency, health, and bounded
+pressure-reason identifiers. It does not receive observed domains, headers,
+packet text, credentials, cookies, sessions, or tokens. ECH, DoH, VPNs,
+proxies, shared CDNs, and connection reuse can legitimately leave a destination
+Unknown. No TLS decryption, MITM, browser history inspection, cookie/session
+inspection, or credential collection is part of this layer.
+
## Performance Pipeline Foundation
### Batch Persistence Foundation
diff --git a/docs/PERFORMANCE_PIPELINE.md b/docs/PERFORMANCE_PIPELINE.md
index 3de87ba..f410c33 100644
--- a/docs/PERFORMANCE_PIPELINE.md
+++ b/docs/PERFORMANCE_PIPELINE.md
@@ -584,16 +584,17 @@ behavior, or AI autonomous actions.
## Next Planned Steps
-1. Service Attribution / Destination Intelligence
-2. Incident / Correlation Engine
-3. Read-only AI Analyst
+1. Incident / Correlation Engine
+2. Read-only AI Analyst
### Recorded Product Direction
-After the remaining performance work, NetBotPro will add conservative Service
-Attribution / Destination Intelligence. It will correlate process metadata
-with DNS, TLS SNI, HTTP Host, QUIC-visible metadata, ASN, and local service
-fingerprints. Missing or weak evidence must remain `Unknown / Encrypted`.
+NetBotPro now includes conservative Service Attribution / Destination Intelligence
+after packet metadata extraction and before flow aggregation. It
+uses DNS, visible TLS SNI, HTTP Host, ASN metadata, and a local fingerprint
+registry. The attribution stage adds bounded local matching work; its average
+and p95 latency, unknown rate, errors, and registry health are visible in Ops
+Snapshot. Missing or weak evidence remains `Unknown / Encrypted`.
Incident correlation follows attribution, and a read-only AI Analyst follows
incident quality validation. None of these roadmap items authorize TLS
diff --git a/docs/SERVICE_ATTRIBUTION.md b/docs/SERVICE_ATTRIBUTION.md
new file mode 100644
index 0000000..10fad94
--- /dev/null
+++ b/docs/SERVICE_ATTRIBUTION.md
@@ -0,0 +1,123 @@
+# Service Attribution And Destination Intelligence
+
+Process attribution alone only identifies the program that opened a connection;
+it cannot tell which site or service a browser, Electron container, or messaging
+client reached. Service Attribution gives an analyst a conservative answer to:
+"Which known
+service is this flow probably reaching, and why?" It is a local metadata
+correlation layer. A label is an explainable inference, not proof of application
+content or user activity.
+
+## Pipeline Position
+
+```text
+Capture
+ -> metadata extraction and central redaction
+ -> Service Attribution Engine
+ -> Flow Engine and conversation views
+ -> Live Ring / Event Aggregator / Batch Persistence
+ -> Inspect, Flows, and Ops Snapshot
+```
+
+The engine is used by live capture. Its result is stored on redacted packet and
+flow summaries, allowing the same attribution to be reviewed in Inspect, Flow
+Details, recent ring-buffer records, and persistence without another lookup.
+
+## Local Evidence
+
+The engine may correlate:
+
+- visible HTTP Host metadata;
+- visible TLS SNI metadata;
+- DNS query names and recent DNS answer-to-IP observations;
+- optional ASN organization metadata already supplied by the capture path;
+- destination IP and port as context;
+- process name as context only; and
+- the bundled local fingerprint registry.
+
+The default registry is
+`backend/app/data/service_fingerprints.json`. It covers common video,
+messaging, developer-platform, cloud/search, social, and CDN providers. Registry
+matching is deterministic and performs no outbound network request.
+Recent DNS and unique-flow metric correlation caches have fixed internal caps,
+so long capture sessions cannot make this layer grow without a bound.
+
+## Confidence And Reasons
+
+Every result contains a `0..100` confidence score, a `high`, `medium`, `low`, or
+`unknown` label, evidence sources, and short reasons. Visible HTTP Host and TLS
+SNI are stronger evidence than recent DNS correlation. Multiple agreeing
+sources can increase confidence. Conflicting sources reduce confidence.
+
+Browser and container process names are deliberately weak context. For example,
+`chrome.exe` alone does not mean Google or YouTube. ASN evidence for a shared CDN
+is labeled `CDN only` unless stronger domain evidence identifies the final
+service.
+
+## Unknown Encrypted Destinations
+
+Encrypted traffic with no visible DNS, SNI, or Host evidence is reported as
+`Unknown encrypted destination`. NetBotPro does not invent a service name.
+Common reasons include Encrypted Client Hello (ECH), DNS over HTTPS, VPN or
+proxy use, encrypted QUIC, shared CDN infrastructure, browser connection reuse,
+NAT with multiple hosts, missing process metadata, missing DNS history, and
+missing SNI.
+
+Examples of intended output include `chrome.exe -> YouTube -> googlevideo.com`
+with high confidence when visible SNI agrees with the registry, and
+`chrome.exe -> Unknown encrypted destination` when only an encrypted endpoint
+is visible.
+
+## Operations Metrics
+
+`/api/monitoring/metrics` exposes a fixed `service_attribution` object:
+
+- enabled and health;
+- registry size;
+- attributed and unknown unique-flow totals;
+- high, medium, and low confidence totals;
+- encrypted-unknown and CDN-only totals;
+- attribution error total;
+- average and p95 attribution latency; and
+- safe pressure-reason identifiers.
+
+Metrics never include observed domains, headers, packet text, credentials,
+cookies, sessions, authorization values, or tokens. A high unknown rate is a
+diagnostic signal, not automatically a security problem.
+
+## Configuration
+
+| Variable | Default | Purpose |
+| --- | --- | --- |
+| `NETBOT_SERVICE_ATTRIBUTION_ENABLED` | `true` | Enables local attribution. |
+| `NETBOT_SERVICE_ATTRIBUTION_REGISTRY` | bundled JSON path | Selects a local registry file. |
+| `NETBOT_SERVICE_ATTRIBUTION_DNS_WINDOW_SEC` | `300` | Limits DNS answer correlation age. |
+| `NETBOT_SERVICE_ATTRIBUTION_MAX_REASONS` | `8` | Bounds explanation length. |
+| `NETBOT_SERVICE_ATTRIBUTION_UNKNOWN_RATE_WARN` | `0.75` | Sets the Ops warning threshold after a meaningful sample. |
+
+Invalid registry rows are skipped. A missing or wholly invalid registry does
+not crash capture; it is reported as critical attribution health and flows stay
+Unknown.
+
+## Privacy And Security Boundaries
+
+All displayed and persisted attribution data passes through central redaction.
+This feature does not add:
+
+- TLS decryption or MITM;
+- credential collection;
+- browser history scraping;
+- cookie/session inspection;
+- raw payload forwarding, raw packet forwarding, or Agent PCAP forwarding;
+- command/control, remote shell, or file collection; or
+- IPS/auto-blocking or autonomous AI actions.
+
+Agent/Fleet Mode remains telemetry-only and read-only. Service Attribution runs
+on the authorized capture host; it does not expand Agent collection or
+forwarding behavior.
+
+## Safe And Authorized Use
+
+Use NetBotPro only on systems and networks you own or are explicitly authorized
+to monitor. Attribution should guide investigation and validation, not serve as
+the sole basis for disciplinary, blocking, or incident decisions.
diff --git a/frontend/src/components/FlowsPanel.jsx b/frontend/src/components/FlowsPanel.jsx
index bcc528c..1802aa4 100644
--- a/frontend/src/components/FlowsPanel.jsx
+++ b/frontend/src/components/FlowsPanel.jsx
@@ -170,21 +170,36 @@ export function FlowsPanel({ api }) {
{selectedFlow ? (
<>
+ {(() => {
+ const attribution = selectedFlow.service_attribution || {};
+ const serviceName = attribution.service_name || selectedFlow.service_name || "Unknown";
+ const domain = attribution.domain || selectedFlow.service_domain || "";
+ const confidence = attribution.attribution_confidence || selectedFlow.service_confidence || "unknown";
+ const score = attribution.confidence_score ?? selectedFlow.service_confidence_score ?? 0;
+ const reasons = attribution.attribution_reasons || selectedFlow.service_reasons || [];
+ const sources = attribution.attribution_sources || selectedFlow.service_sources || [];
+ return (
+ Unknown encrypted destination. ECH, DoH, VPN, proxy, or connection reuse may hide service metadata. Shared CDN infrastructure may represent more than one final service.Service attribution
+
+
+ {attribution.is_unknown && attribution.is_encrypted ? {reasons.map((reason) =>
: null}
+