From e9d151734d728c216ae0f09fec549c0cb3569be3 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 15 Apr 2026 18:02:52 -0400 Subject: [PATCH] feat: deduplicate bounties on (bounty_type, attacker_ip, payload) Before inserting a bounty, check whether an identical row already exists. Drops silent duplicates to prevent DB saturation from aggressive scanners. --- decnet/web/db/sqlmodel_repo.py | 9 ++++ tests/test_bounty_dedup.py | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/test_bounty_dedup.py diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index 3f7291b..3b0cf86 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -327,6 +327,15 @@ class SQLModelRepository(BaseRepository): data["payload"] = json.dumps(data["payload"]) async with self.session_factory() as session: + dup = await session.execute( + select(Bounty.id).where( + Bounty.bounty_type == data.get("bounty_type"), + Bounty.attacker_ip == data.get("attacker_ip"), + Bounty.payload == data.get("payload"), + ).limit(1) + ) + if dup.first() is not None: + return session.add(Bounty(**data)) await session.commit() diff --git a/tests/test_bounty_dedup.py b/tests/test_bounty_dedup.py new file mode 100644 index 0000000..16f23f0 --- /dev/null +++ b/tests/test_bounty_dedup.py @@ -0,0 +1,84 @@ +""" +Tests for bounty deduplication. + +Identical (bounty_type, attacker_ip, payload) tuples must be dropped so +aggressive scanners cannot saturate the bounty table. +""" +import pytest +from decnet.web.db.factory import get_repository + + +@pytest.fixture +async def repo(tmp_path): + r = get_repository(db_path=str(tmp_path / "test.db")) + await r.initialize() + return r + + +_BASE = { + "decky": "decky-01", + "service": "ssh", + "attacker_ip": "10.0.0.1", + "bounty_type": "credential", + "payload": {"username": "admin", "password": "password"}, +} + + +@pytest.mark.anyio +async def test_duplicate_dropped(repo): + await repo.add_bounty({**_BASE}) + await repo.add_bounty({**_BASE}) + bounties = await repo.get_bounties() + assert len(bounties) == 1 + + +@pytest.mark.anyio +async def test_different_ip_not_deduped(repo): + await repo.add_bounty({**_BASE}) + await repo.add_bounty({**_BASE, "attacker_ip": "10.0.0.2"}) + bounties = await repo.get_bounties() + assert len(bounties) == 2 + + +@pytest.mark.anyio +async def test_different_type_not_deduped(repo): + await repo.add_bounty({**_BASE}) + await repo.add_bounty({**_BASE, "bounty_type": "fingerprint"}) + bounties = await repo.get_bounties() + assert len(bounties) == 2 + + +@pytest.mark.anyio +async def test_different_payload_not_deduped(repo): + await repo.add_bounty({**_BASE}) + await repo.add_bounty({**_BASE, "payload": {"username": "root", "password": "toor"}}) + bounties = await repo.get_bounties() + assert len(bounties) == 2 + + +@pytest.mark.anyio +async def test_flood_protection(repo): + for _ in range(50): + await repo.add_bounty({**_BASE}) + bounties = await repo.get_bounties() + assert len(bounties) == 1 + + +@pytest.mark.anyio +async def test_dict_payload_dedup(repo): + """Payload passed as dict (pre-serialisation path) is still deduped.""" + await repo.add_bounty({**_BASE, "payload": {"username": "admin", "password": "password"}}) + await repo.add_bounty({**_BASE, "payload": {"username": "admin", "password": "password"}}) + bounties = await repo.get_bounties() + assert len(bounties) == 1 + + +@pytest.mark.anyio +async def test_string_payload_dedup(repo): + """Payload passed as pre-serialised string is also deduped.""" + import json + p = json.dumps({"username": "admin", "password": "password"}) + await repo.add_bounty({**_BASE, "payload": p}) + await repo.add_bounty({**_BASE, "payload": p}) + bounties = await repo.get_bounties() + assert len(bounties) == 1