From c78ab032bddde21184ff63687445bcd50250ace8 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 24 Apr 2026 18:25:46 -0400 Subject: [PATCH] fix(xff): truncate LEAKED IPs + ROTATION badge for rotation attacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for i in $(seq 1 100); do curl -H "X-Forwarded-For: 191.100.20.$i" ...` was dumping 100 distinct IPs into AttackerDetail's LEAKED IPs row, drowning the rest of the ORIGIN section. The 100-IP wall is itself a signal (WAF-bypass-list probing) that deserves a short badge, not a flood. Backend: - get_attacker_ip_leaks gains `limit: int = 10` parameter — caller only ever needs a sample, not the full set. - New count_attacker_ip_leaks() returns the unbounded COUNT(*) via one cheap SQL aggregate. - Detail endpoint returns {ip_leaks: [first 10], ip_leaks_total: N} so the UI can render a rotation badge independent of list length. UI: - New LeakedIPsRow component. First 5 distinct IPs rendered inline with hover tooltips (unchanged). When > 5, a `+ N more` expand button reveals the rest of the sample; when total exceeds the 10-row cap, a subtle `(+M beyond sample)` note appears. - When total ≥ 20, a red `ROTATION · N` tag renders leading the row with a tooltip explaining the semantic: "almost certainly XFF-rotation / WAF-bypass probing, not a real attribution leak." DB churn is deliberately not capped — 100k rows × ~500 B is tolerable. If it becomes a problem we can add an ingester-side count-and-skip; for now the UX fix is the whole story. Added test_ip_leaks_total_reported_separately_from_list asserting the endpoint shape matches what the UI consumes. --- decnet/web/db/repository.py | 18 ++- decnet/web/db/sqlmodel_repo.py | 27 +++- .../attackers/api_get_attacker_detail.py | 9 +- decnet_web/src/components/AttackerDetail.tsx | 152 ++++++++++++++---- tests/web/test_api_attackers.py | 44 +++++ 5 files changed, 204 insertions(+), 46 deletions(-) diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index e53306a4..e37295ba 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -258,12 +258,20 @@ class BaseRepository(ABC): raise NotImplementedError async def get_attacker_ip_leaks( - self, attacker_uuid: str + self, attacker_uuid: str, *, limit: int = 10, ) -> list[dict[str, Any]]: - """Return ``bounty_type='ip_leak'`` rows for the attacker, newest - first. Each row's payload carries the TCP source IP, the header - that leaked, and the claimed real IP — see the XFF-mismatch - extractor in ``decnet.web.ingester`` for the shape.""" + """Return up to ``limit`` ``bounty_type='ip_leak'`` rows for the + attacker, newest first. Each row's payload carries the TCP + source IP, the header that leaked, and the claimed real IP — + see the XFF-mismatch extractor in ``decnet.web.ingester`` for + the shape. Caller pairs with :meth:`count_attacker_ip_leaks` + to detect XFF-rotation (100+ claimed IPs from one source).""" + raise NotImplementedError + + async def count_attacker_ip_leaks(self, attacker_uuid: str) -> int: + """Total number of ``ip_leak`` bounties recorded for this + attacker. Used to detect XFF-rotation signal where the attacker + cycles through many claimed IPs (WAF-bypass-list probing).""" raise NotImplementedError @abstractmethod diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index 55d7c8dc..40dcdcde 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -908,12 +908,14 @@ class SQLModelRepository(BaseRepository): return [(svc, evt) for svc, evt in rows.all()] async def get_attacker_ip_leaks( - self, attacker_uuid: str + self, attacker_uuid: str, *, limit: int = 10, ) -> list[dict[str, Any]]: """Return ``bounty_type='ip_leak'`` rows for this attacker, newest - first. Shape matches the XFF-mismatch payload emitted by the - ingester: keys include ``real_ip_claim``, ``source_header``, - ``headers_seen``, ``path``, ``method``.""" + first, capped at ``limit``. Shape matches the XFF-mismatch + payload emitted by the ingester: keys include ``real_ip_claim``, + ``source_header``, ``headers_seen``. Use + :meth:`count_attacker_ip_leaks` to get the unbounded total for + rotation detection.""" async with self._session() as session: ip_res = await session.execute( select(Attacker.ip).where(Attacker.uuid == attacker_uuid) @@ -926,6 +928,7 @@ class SQLModelRepository(BaseRepository): .where(Bounty.attacker_ip == ip) .where(Bounty.bounty_type == "ip_leak") .order_by(desc(Bounty.timestamp)) + .limit(limit) ) out: list[dict[str, Any]] = [] for row in rows.scalars().all(): @@ -940,6 +943,22 @@ class SQLModelRepository(BaseRepository): out.append(rec) return out + async def count_attacker_ip_leaks(self, attacker_uuid: str) -> int: + """Cheap COUNT(*) for XFF-rotation detection.""" + async with self._session() as session: + ip_res = await session.execute( + select(Attacker.ip).where(Attacker.uuid == attacker_uuid) + ) + ip = ip_res.scalar_one_or_none() + if not ip: + return 0 + count_res = await session.execute( + select(func.count(Bounty.id)) + .where(Bounty.attacker_ip == ip) + .where(Bounty.bounty_type == "ip_leak") + ) + return int(count_res.scalar() or 0) + async def get_attacker_artifacts(self, uuid: str) -> list[dict[str, Any]]: """Return `file_captured` logs for the attacker identified by UUID. diff --git a/decnet/web/router/attackers/api_get_attacker_detail.py b/decnet/web/router/attackers/api_get_attacker_detail.py index 5ae70406..f7263142 100644 --- a/decnet/web/router/attackers/api_get_attacker_detail.py +++ b/decnet/web/router/attackers/api_get_attacker_detail.py @@ -35,7 +35,10 @@ async def get_attacker_detail( pairs = await repo.get_attacker_service_activity(uuid) attacker["service_activity"] = bucket_services(pairs) # Attribution leaks — XFF / Forwarded / X-Real-IP mismatches captured - # by the HTTP bounty extractor. Empty list when no HTTP interaction - # or no mismatch. - attacker["ip_leaks"] = await repo.get_attacker_ip_leaks(uuid) + # by the HTTP bounty extractor. Cap the returned list at 10 so a + # rotation attack (100s of forged XFF values) doesn't flood the UI; + # `ip_leaks_total` carries the unbounded count so the UI can render + # a ROTATION DETECTED badge when the count crosses a threshold. + attacker["ip_leaks"] = await repo.get_attacker_ip_leaks(uuid, limit=10) + attacker["ip_leaks_total"] = await repo.count_attacker_ip_leaks(uuid) return attacker diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index a18b354b..045d2029 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -78,10 +78,9 @@ interface AttackerData { real_ip_claim?: string; source_header?: string; headers_seen?: Record; - path?: string; - method?: string; }; }>; + ip_leaks_total?: number; } // ─── Fingerprint rendering ─────────────────────────────────────────────────── @@ -835,6 +834,119 @@ const Section: React.FC<{ ); +// ─── Leaked-IPs row (truncated view + rotation-detection badge) ──────────── + +const ROTATION_THRESHOLD = 20; +const INLINE_LIMIT = 5; + +interface LeakedIPsRowProps { + leaks: NonNullable; + total: number; +} + +const LeakedIPsRow: React.FC = ({ leaks, total }) => { + const [expanded, setExpanded] = useState(false); + const distinctIPs = Array.from( + new Set( + leaks + .map((l) => l.payload?.real_ip_claim) + .filter((v): v is string => !!v), + ), + ); + const rotationDetected = total >= ROTATION_THRESHOLD; + const visible = expanded ? distinctIPs : distinctIPs.slice(0, INLINE_LIMIT); + const hiddenInList = distinctIPs.length - visible.length; + // Backend caps server-side leaks at 10 rows; "total" is the unbounded + // count — may exceed what we actually have IP values for. + const remainingBeyondSample = total - distinctIPs.length; + + const ipTooltip = (ip: string): string => { + const latest = leaks.find((l) => l.payload?.real_ip_claim === ip); + return latest + ? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}` + : ''; + }; + + return ( +
+ + LEAKED IPs:{' '} + + {rotationDetected && ( + + + ROTATION · {total} + + + )} + {visible.map((ip, i, arr) => ( + + {ip} + {i < arr.length - 1 ? ', ' : ''} + + ))} + {!expanded && hiddenInList > 0 && ( + <> + {' '} + + + )} + {remainingBeyondSample > 0 && ( + + (+{remainingBeyondSample} beyond sample) + + )} + {expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && ( + <> + {' '} + + + )} +
+ ); +}; + + // ─── Main component ───────────────────────────────────────────────────────── const AttackerDetail: React.FC = () => { @@ -1165,38 +1277,10 @@ const AttackerDetail: React.FC = () => { )} {attacker.ip_leaks && attacker.ip_leaks.length > 0 && ( -
- - LEAKED IPs:{' '} - - {Array.from( - new Set( - (attacker.ip_leaks || []) - .map((l) => l.payload?.real_ip_claim) - .filter((v): v is string => !!v), - ), - ).map((ip, i, arr) => { - const latest = (attacker.ip_leaks || []).find( - (l) => l.payload?.real_ip_claim === ip, - ); - const tooltip = latest - ? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}` - : ''; - return ( - - {ip} - {i < arr.length - 1 ? ', ' : ''} - - ); - })} -
+ )} diff --git a/tests/web/test_api_attackers.py b/tests/web/test_api_attackers.py index 9e80cdc2..109aa4c0 100644 --- a/tests/web/test_api_attackers.py +++ b/tests/web/test_api_attackers.py @@ -185,6 +185,7 @@ class TestGetAttackerDetail: mock_repo.get_attacker_behavior = AsyncMock(return_value=None) mock_repo.get_attacker_service_activity = AsyncMock(return_value=[]) mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=[]) + mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=0) result = await get_attacker_detail(uuid="att-uuid-1", user={"uuid": "test-user", "role": "viewer"}) @@ -215,6 +216,7 @@ class TestGetAttackerDetail: mock_repo.get_attacker_behavior = AsyncMock(return_value=None) mock_repo.get_attacker_service_activity = AsyncMock(return_value=[]) mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=[]) + mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=0) result = await get_attacker_detail(uuid="att-uuid-1", user={"uuid": "test-user", "role": "viewer"}) @@ -241,6 +243,7 @@ class TestGetAttackerDetail: mock_repo.get_attacker_behavior = AsyncMock(return_value=None) mock_repo.get_attacker_service_activity = AsyncMock(return_value=pairs) mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=[]) + mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=0) result = await get_attacker_detail( uuid="att-uuid-1", @@ -279,6 +282,7 @@ class TestGetAttackerDetail: mock_repo.get_attacker_behavior = AsyncMock(return_value=None) mock_repo.get_attacker_service_activity = AsyncMock(return_value=[]) mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=leaks) + mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=1) result = await get_attacker_detail( uuid="att-uuid-1", @@ -287,6 +291,46 @@ class TestGetAttackerDetail: assert result["ip_leaks"] == leaks assert result["ip_leaks"][0]["payload"]["real_ip_claim"] == "198.51.100.7" + assert result["ip_leaks_total"] == 1 + + @pytest.mark.asyncio + async def test_ip_leaks_total_reported_separately_from_list(self): + """Rotation attack: DB has 100 ip_leak rows, endpoint caps the + returned list at 10 but reports the full count so the UI can + render a ROTATION DETECTED badge.""" + from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail + + sample = _sample_attacker() + # Caller already limited; we just assert the shape of the response. + first_ten = [ + { + "timestamp": "2026-04-24T12:00:00+00:00", + "decky": "http-01", + "service": "http", + "bounty_type": "ip_leak", + "payload": { + "source_ip": "203.0.113.42", + "real_ip_claim": f"198.51.100.{i}", + "source_header": "X-Forwarded-For", + "headers_seen": {}, + }, + } + for i in range(1, 11) + ] + with patch("decnet.web.router.attackers.api_get_attacker_detail.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample) + mock_repo.get_attacker_behavior = AsyncMock(return_value=None) + mock_repo.get_attacker_service_activity = AsyncMock(return_value=[]) + mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=first_ten) + mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=100) + + result = await get_attacker_detail( + uuid="att-uuid-1", + user={"uuid": "test-user", "role": "viewer"}, + ) + + assert len(result["ip_leaks"]) == 10 + assert result["ip_leaks_total"] == 100 # ─── GET /attackers/{uuid}/commands ──────────────────────────────────────────