Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Passive IPv6 link-local leak detection — sniffer unit tests.
|
|
|
|
Tests SnifferEngine._on_ipv6_packet and _ipv6_iid_classify via direct
|
|
packet injection (no sniff thread — per project constraint on scapy sniff
|
|
threads in pytest teardown).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from scapy.layers.inet6 import ICMPv6ND_NS, IPv6
|
|
|
|
from decnet.sniffer.fingerprint import SnifferEngine, _ipv6_iid_classify
|
|
|
|
_DECKY_IP6 = "fe80::1" # decky's own link-local (destination)
|
|
_DECKY_IP4 = "10.0.0.5" # corresponding v4 for ip_to_decky mapping
|
|
_DECKY = "decky-a"
|
|
|
|
# EUI-64 derived from MAC aa:bb:cc:dd:ee:ff →
|
|
# bytes: aa^0x02, bb, cc, ff, fe, dd, ee, ff
|
|
# → IID: a8:bb:cc:ff:fe:dd:ee:ff → fe80::aabb:ccff:fedd:eeff
|
|
_EUI64_ADDR = "fe80::aabb:ccff:fedd:eeff"
|
|
_EUI64_OUI = "a8:bb:cc" # U/L bit flipped (aa XOR 0x02 = a8)
|
|
|
|
# Stable-privacy / random IID — no fffe bytes at positions 3-4
|
|
_STABLE_ADDR = "fe80::1234:5678:9abc:def0"
|
|
|
|
|
|
def _engine(extra_map: dict[str, str] | None = None) -> tuple[SnifferEngine, list[str]]:
|
|
captured: list[str] = []
|
|
ip_map = {_DECKY_IP4: _DECKY}
|
|
if extra_map:
|
|
ip_map.update(extra_map)
|
|
engine = SnifferEngine(
|
|
ip_to_decky=ip_map,
|
|
write_fn=captured.append,
|
|
dedup_ttl=300.0,
|
|
)
|
|
return engine, captured
|
|
|
|
|
|
# ── _ipv6_iid_classify ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_iid_classify_eui64_returns_oui() -> None:
|
|
kind, oui = _ipv6_iid_classify(_EUI64_ADDR)
|
|
assert kind == "eui64"
|
|
assert oui == _EUI64_OUI
|
|
|
|
|
|
def test_iid_classify_stable_privacy() -> None:
|
|
kind, oui = _ipv6_iid_classify(_STABLE_ADDR)
|
|
assert kind == "stable_privacy"
|
|
assert oui == ""
|
|
|
|
|
|
def test_iid_classify_bad_addr_returns_unknown() -> None:
|
|
kind, oui = _ipv6_iid_classify("not-an-address")
|
|
assert kind == "unknown"
|
|
assert oui == ""
|
|
|
|
|
|
# ── _on_ipv6_packet ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_ndp_ns(src: str, dst: str) -> object:
|
|
"""Craft a Neighbor Solicitation from attacker link-local to decky."""
|
|
return IPv6(src=src, dst=dst) / ICMPv6ND_NS(tgt=dst)
|
|
|
|
|
|
def test_eui64_packet_emits_ipv6_leak_event() -> None:
|
|
engine, captured = _engine(extra_map={"fe80::1": _DECKY})
|
|
pkt = _make_ndp_ns(_EUI64_ADDR, _DECKY_IP6)
|
|
engine._on_ipv6_packet(pkt)
|
|
assert len(captured) == 1
|
|
line = captured[0]
|
|
assert "ipv6_link_local_leak" in line
|
|
assert _EUI64_ADDR in line
|
|
assert "eui64" in line
|
|
assert _EUI64_OUI in line
|
|
|
|
|
|
def test_stable_privacy_packet_emits_event() -> None:
|
|
engine, captured = _engine(extra_map={"fe80::1": _DECKY})
|
|
pkt = _make_ndp_ns(_STABLE_ADDR, _DECKY_IP6)
|
|
engine._on_ipv6_packet(pkt)
|
|
assert len(captured) == 1
|
|
assert "stable_privacy" in captured[0]
|
|
|
|
|
|
def test_non_link_local_src_is_ignored() -> None:
|
|
engine, captured = _engine(extra_map={"fe80::1": _DECKY})
|
|
# GUA source — not a link-local leak
|
|
pkt = IPv6(src="2001:db8::1", dst=_DECKY_IP6) / ICMPv6ND_NS(tgt=_DECKY_IP6)
|
|
engine._on_ipv6_packet(pkt)
|
|
assert captured == []
|
|
|
|
|
|
def test_packet_to_unknown_decky_is_ignored() -> None:
|
|
engine, captured = _engine() # ip_to_decky has no v6 entries
|
|
pkt = _make_ndp_ns(_EUI64_ADDR, "fe80::dead")
|
|
engine._on_ipv6_packet(pkt)
|
|
assert captured == []
|
|
|
|
|
|
def test_on_packet_dispatches_ipv6_branch() -> None:
|
|
"""on_packet() must route IPv6 packets to _on_ipv6_packet."""
|
|
engine, captured = _engine(extra_map={"fe80::1": _DECKY})
|
|
pkt = _make_ndp_ns(_EUI64_ADDR, _DECKY_IP6)
|
|
engine.on_packet(pkt)
|
|
assert any("ipv6_link_local_leak" in line for line in captured)
|
|
|
|
|
|
def test_dedup_suppresses_repeat_emit() -> None:
|
|
engine, captured = _engine(extra_map={"fe80::1": _DECKY})
|
|
pkt = _make_ndp_ns(_EUI64_ADDR, _DECKY_IP6)
|
|
engine._on_ipv6_packet(pkt)
|
|
engine._on_ipv6_packet(pkt)
|
|
assert len(captured) == 1 # second identical packet deduped
|
|
|
|
|
|
def test_publish_fn_fires_on_leak() -> None:
|
|
published: list[tuple[str, str, dict]] = []
|
|
engine = SnifferEngine(
|
|
ip_to_decky={"fe80::1": _DECKY},
|
|
write_fn=lambda _: None,
|
|
publish_fn=lambda node, event, payload: published.append((node, event, payload)),
|
|
)
|
|
pkt = _make_ndp_ns(_EUI64_ADDR, _DECKY_IP6)
|
|
engine._on_ipv6_packet(pkt)
|
|
assert len(published) == 1
|
|
node, event, payload = published[0]
|
|
assert event == "ipv6_link_local_leak"
|
|
assert payload["iid_kind"] == "eui64"
|
|
assert payload["mac_oui"] == _EUI64_OUI
|