diff --git a/decnet/prober/tcpfp.py b/decnet/prober/tcpfp.py index a9c0b82b..62284634 100644 --- a/decnet/prober/tcpfp.py +++ b/decnet/prober/tcpfp.py @@ -133,6 +133,9 @@ def _parse_synack(resp: Any) -> dict[str, Any]: ttl = ip_layer.ttl df_bit = 1 if (ip_layer.flags & 0x2) else 0 # DF = bit 1 ip_id = ip_layer.id + tos = int(getattr(ip_layer, "tos", 0)) + dscp = (tos >> 2) & 0x3F + ecn = tos & 0x3 # TCP fields window_size = tcp_layer.window @@ -159,6 +162,9 @@ def _parse_synack(resp: Any) -> dict[str, Any]: "window_size": window_size, "df_bit": df_bit, "ip_id": ip_id, + "tos": tos, + "dscp": dscp, + "ecn": ecn, "mss": mss, "window_scale": window_scale, "sack_ok": sack_ok, @@ -191,7 +197,8 @@ def _compute_fingerprint(fields: dict[str, Any]) -> tuple[str, str]: raw = ( f"{fields['ttl']}:{fields['window_size']}:{fields['df_bit']}:" f"{fields['mss']}:{fields['window_scale']}:{fields['sack_ok']}:" - f"{fields['timestamp']}:{fields['options_order']}" + f"{fields['timestamp']}:{fields['options_order']}:" + f"{fields.get('dscp', 0)}:{fields.get('ecn', 0)}" ) h = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32] return raw, h diff --git a/decnet/prober/worker.py b/decnet/prober/worker.py index cdf6e6cb..5f53c2ac 100644 --- a/decnet/prober/worker.py +++ b/decnet/prober/worker.py @@ -412,6 +412,9 @@ def _tcpfp_phase( sack_ok=str(result["sack_ok"]), timestamp=str(result["timestamp"]), options_order=result["options_order"], + tos=str(result["tos"]), + dscp=str(result["dscp"]), + ecn=str(result["ecn"]), msg=f"TCPFP {ip}:{port} = {result['tcpfp_hash']}", ) logger.info("prober: TCPFP %s:%d = %s", ip, port, result["tcpfp_hash"]) diff --git a/decnet/profiler/fingerprint.py b/decnet/profiler/fingerprint.py index 12beb958..37c3efbc 100644 --- a/decnet/profiler/fingerprint.py +++ b/decnet/profiler/fingerprint.py @@ -181,6 +181,9 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]: "options_sig": e.fields.get("options_sig", ""), "has_sack": e.fields.get("has_sack") == "true", "has_timestamps": e.fields.get("has_timestamps") == "true", + "tos": _int_or_none(e.fields.get("tos")), + "dscp": _int_or_none(e.fields.get("dscp")), + "ecn": _int_or_none(e.fields.get("ecn")), } tcp_fp_context = "syn" @@ -236,6 +239,9 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]: "options_sig": e.fields.get("options_order", ""), "has_sack": e.fields.get("sack_ok") == "1", "has_timestamps": e.fields.get("timestamp") == "1", + "tos": _int_or_none(e.fields.get("tos")), + "dscp": _int_or_none(e.fields.get("dscp")), + "ecn": _int_or_none(e.fields.get("ecn")), } tcp_fp_context = "synack" # prober sent SYN, captured attacker's SYN-ACK diff --git a/decnet/sniffer/fingerprint.py b/decnet/sniffer/fingerprint.py index 5eb1e2ad..f287fee7 100644 --- a/decnet/sniffer/fingerprint.py +++ b/decnet/sniffer/fingerprint.py @@ -1056,6 +1056,9 @@ class SnifferEngine: options_sig=tcp_fp["options_sig"], has_sack=str(tcp_fp["sack_ok"]).lower(), has_timestamps=str(tcp_fp["has_timestamps"]).lower(), + tos=str(int(getattr(ip, "tos", 0))), + dscp=str((int(getattr(ip, "tos", 0)) >> 2) & 0x3F), + ecn=str(int(getattr(ip, "tos", 0)) & 0x3), os_guess=os_label, ) diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index c10eee94..28107991 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -19,6 +19,9 @@ interface AttackerBehavior { options_sig?: string; has_sack?: boolean; has_timestamps?: boolean; + tos?: number | null; + dscp?: number | null; + ecn?: number | null; } | null; retransmit_count: number; behavior_class: string | null; @@ -688,7 +691,7 @@ const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { const fp = b.tcp_fingerprint; - if (!fp || (!fp.window && !fp.mss && !fp.options_sig)) return null; + if (!fp || (!fp.window && !fp.mss && !fp.options_sig && fp.dscp == null && fp.ecn == null)) return null; return (
= ({ b }) => { {fp.mss}
)} + {fp.dscp !== null && fp.dscp !== undefined && ( +
+ DSCP + {fp.dscp} +
+ )} + {fp.ecn !== null && fp.ecn !== undefined && ( +
+ ECN + {fp.ecn} +
+ )}
RETRANSMITS { {/* Services */}
toggle('services')}> -
- {attacker.services.length > 0 ? attacker.services.map((svc) => { - const isActive = serviceFilter === svc; - return ( - setServiceFilter(isActive ? null : svc)} - title={isActive ? 'Clear filter' : `Filter by ${svc.toUpperCase()}`} - > - {svc.toUpperCase()} - - ); - }) : ( - No services recorded +
+
+ {attacker.services.length > 0 ? attacker.services.map((svc) => { + const isActive = serviceFilter === svc; + const interacted = attacker.service_activity?.interacted.includes(svc) ?? false; + const baseStyle: React.CSSProperties = interacted + ? { borderColor: 'var(--accent-color)', color: 'var(--accent-color)', background: 'rgba(238, 130, 238, 0.08)' } + : { opacity: 0.55 }; + const activeStyle: React.CSSProperties = isActive + ? interacted + ? { backgroundColor: 'var(--accent-color)', color: 'var(--bg-color)', borderColor: 'var(--accent-color)', opacity: 1 } + : { backgroundColor: 'var(--text-color)', color: 'var(--bg-color)', borderColor: 'var(--text-color)', opacity: 1 } + : {}; + return ( + setServiceFilter(isActive ? null : svc)} + title={ + isActive + ? 'Clear filter' + : `Filter by ${svc.toUpperCase()} — ${interacted ? 'interacted with' : 'scanned only'}` + } + > + {interacted ? '· ' : ''}{svc.toUpperCase()} + + ); + }) : ( + No services recorded + )} +
+ {attacker.services.length > 0 && ( +
+ · INTERACTED + SCAN-ONLY +
)}
diff --git a/tests/prober/test_prober_tcpfp.py b/tests/prober/test_prober_tcpfp.py index 32f2a5e4..000dac4a 100644 --- a/tests/prober/test_prober_tcpfp.py +++ b/tests/prober/test_prober_tcpfp.py @@ -27,6 +27,7 @@ def _make_synack( ttl: int = 64, flags: int = 0x02, # IP flags (DF = 0x02) ip_id: int = 0, + tos: int = 0, window: int = 65535, tcp_flags: int = 0x12, # SYN-ACK options: list | None = None, @@ -56,6 +57,7 @@ def _make_synack( ttl=ttl, flags=flags, id=ip_id, + tos=tos, ) class FakePacket: @@ -181,6 +183,28 @@ class TestParseSynack: result = _parse_synack(resp) assert result["ip_id"] == 12345 + def test_tos_default_zero(self): + resp = _make_synack() + result = _parse_synack(resp) + assert result["tos"] == 0 + assert result["dscp"] == 0 + assert result["ecn"] == 0 + + def test_tos_dscp_af11_ecn_ect0(self): + # AF11 = DSCP 10 (0b001010); ECT(0) = 0b10 → ToS byte 0b00101010 = 0x2A + resp = _make_synack(tos=0x2A) + result = _parse_synack(resp) + assert result["tos"] == 0x2A + assert result["dscp"] == 10 + assert result["ecn"] == 2 + + def test_tos_ce_marked(self): + # ECN CE bit set, no DSCP marking → ToS = 0x03 + resp = _make_synack(tos=0x03) + result = _parse_synack(resp) + assert result["dscp"] == 0 + assert result["ecn"] == 3 + def test_empty_options(self): resp = _make_synack(options=[]) result = _parse_synack(resp) @@ -254,9 +278,22 @@ class TestComputeFingerprint: "ttl": 64, "window_size": 65535, "df_bit": 1, "mss": 1460, "window_scale": 7, "sack_ok": 1, "timestamp": 1, "options_order": "M,N,W", + "dscp": 0, "ecn": 0, } raw, _ = _compute_fingerprint(fields) - assert raw == "64:65535:1:1460:7:1:1:M,N,W" + assert raw == "64:65535:1:1460:7:1:1:M,N,W:0:0" + + def test_dscp_changes_hash(self): + base = { + "ttl": 64, "window_size": 65535, "df_bit": 1, + "mss": 1460, "window_scale": 7, "sack_ok": 1, + "timestamp": 1, "options_order": "M,N,W", + "dscp": 0, "ecn": 0, + } + marked = dict(base, dscp=46) # EF + _, h_base = _compute_fingerprint(base) + _, h_marked = _compute_fingerprint(marked) + assert h_base != h_marked def test_sha256_correctness(self): fields = { diff --git a/tests/sniffer/test_sniffer_tcp_fingerprint.py b/tests/sniffer/test_sniffer_tcp_fingerprint.py index 39c4ec0a..f6fa221b 100644 --- a/tests/sniffer/test_sniffer_tcp_fingerprint.py +++ b/tests/sniffer/test_sniffer_tcp_fingerprint.py @@ -147,6 +147,22 @@ class TestSynFingerprintEmission: fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"] assert len(fp_lines) == 2 + def test_tos_dscp_ecn_emitted(self): + engine, captured = _make_engine() + # ToS 0x2A → DSCP 10 (AF11), ECN 2 (ECT(0)) + pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64, tos=0x2A) / TCP( + sport=45100, dport=22, flags="S", window=29200, + options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)), + ("NOP", None), ("WScale", 7)], + ) + engine.on_packet(pkt) + fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"] + assert len(fp_lines) == 1 + f = _fields_from_line(fp_lines[0]) + assert f["tos"] == "42" + assert f["dscp"] == "10" + assert f["ecn"] == "2" + def test_decky_source_does_not_emit(self): """Packets originating from a decky (outbound reply) should NOT be classified as an attacker fingerprint."""