From 24e0d984259c7f39265de6f374cac740779dfbda Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 14 Apr 2026 01:35:12 -0400 Subject: [PATCH] feat: add service filter to attacker profiles API now accepts ?service=https to filter attackers by targeted service. Service badges are clickable in both the attacker list and detail views, navigating to a filtered view. Active filter shows as a dismissable tag. --- decnet/web/db/repository.py | 3 +- decnet/web/db/sqlite/repository.py | 7 +++- .../web/router/attackers/api_get_attackers.py | 6 ++- decnet_web/src/components/AttackerDetail.tsx | 8 +++- decnet_web/src/components/Attackers.tsx | 41 ++++++++++++++++--- tests/test_api_attackers.py | 28 ++++++++++--- 6 files changed, 78 insertions(+), 15 deletions(-) diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index ecca4a1..944b73b 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -133,11 +133,12 @@ class BaseRepository(ABC): offset: int = 0, search: Optional[str] = None, sort_by: str = "recent", + service: Optional[str] = None, ) -> list[dict[str, Any]]: """Retrieve paginated attacker profile records.""" pass @abstractmethod - async def get_total_attackers(self, search: Optional[str] = None) -> int: + async def get_total_attackers(self, search: Optional[str] = None, service: Optional[str] = None) -> int: """Retrieve the total count of attacker profile records, optionally filtered.""" pass diff --git a/decnet/web/db/sqlite/repository.py b/decnet/web/db/sqlite/repository.py index db6bd3f..c58747d 100644 --- a/decnet/web/db/sqlite/repository.py +++ b/decnet/web/db/sqlite/repository.py @@ -484,6 +484,7 @@ class SQLiteRepository(BaseRepository): offset: int = 0, search: Optional[str] = None, sort_by: str = "recent", + service: Optional[str] = None, ) -> List[dict[str, Any]]: order = { "active": desc(Attacker.event_count), @@ -493,6 +494,8 @@ class SQLiteRepository(BaseRepository): statement = select(Attacker).order_by(order).offset(offset).limit(limit) if search: statement = statement.where(Attacker.ip.like(f"%{search}%")) + if service: + statement = statement.where(Attacker.services.like(f'%"{service}"%')) async with self.session_factory() as session: result = await session.execute(statement) @@ -501,10 +504,12 @@ class SQLiteRepository(BaseRepository): for a in result.scalars().all() ] - async def get_total_attackers(self, search: Optional[str] = None) -> int: + async def get_total_attackers(self, search: Optional[str] = None, service: Optional[str] = None) -> int: statement = select(func.count()).select_from(Attacker) if search: statement = statement.where(Attacker.ip.like(f"%{search}%")) + if service: + statement = statement.where(Attacker.services.like(f'%"{service}"%')) async with self.session_factory() as session: result = await session.execute(statement) diff --git a/decnet/web/router/attackers/api_get_attackers.py b/decnet/web/router/attackers/api_get_attackers.py index aa3fa07..0b33994 100644 --- a/decnet/web/router/attackers/api_get_attackers.py +++ b/decnet/web/router/attackers/api_get_attackers.py @@ -22,6 +22,7 @@ async def get_attackers( offset: int = Query(0, ge=0, le=2147483647), search: Optional[str] = None, sort_by: str = Query("recent", pattern="^(recent|active|traversals)$"), + service: Optional[str] = None, current_user: str = Depends(get_current_user), ) -> dict[str, Any]: """Retrieve paginated attacker profiles.""" @@ -31,6 +32,7 @@ async def get_attackers( return v s = _norm(search) - _data = await repo.get_attackers(limit=limit, offset=offset, search=s, sort_by=sort_by) - _total = await repo.get_total_attackers(search=s) + svc = _norm(service) + _data = await repo.get_attackers(limit=limit, offset=offset, search=s, sort_by=sort_by, service=svc) + _total = await repo.get_total_attackers(search=s, service=svc) return {"total": _total, "limit": limit, "offset": offset, "data": _data} diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 098ffff..394845e 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -331,7 +331,13 @@ const AttackerDetail: React.FC = () => {
{attacker.services.length > 0 ? attacker.services.map((svc) => ( - + navigate(`/attackers?service=${encodeURIComponent(svc)}`)} + title={`Filter attackers by ${svc.toUpperCase()}`} + > {svc.toUpperCase()} )) : ( diff --git a/decnet_web/src/components/Attackers.tsx b/decnet_web/src/components/Attackers.tsx index a8453a3..24e8577 100644 --- a/decnet_web/src/components/Attackers.tsx +++ b/decnet_web/src/components/Attackers.tsx @@ -39,6 +39,7 @@ const Attackers: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get('q') || ''; const sortBy = searchParams.get('sort_by') || 'recent'; + const serviceFilter = searchParams.get('service') || ''; const page = parseInt(searchParams.get('page') || '1'); const [attackers, setAttackers] = useState([]); @@ -54,6 +55,7 @@ const Attackers: React.FC = () => { const offset = (page - 1) * limit; let url = `/attackers?limit=${limit}&offset=${offset}&sort_by=${sortBy}`; if (query) url += `&search=${encodeURIComponent(query)}`; + if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`; const res = await api.get(url); setAttackers(res.data.data); @@ -67,19 +69,28 @@ const Attackers: React.FC = () => { useEffect(() => { fetchAttackers(); - }, [query, sortBy, page]); + }, [query, sortBy, serviceFilter, page]); + + const _params = (overrides: Record = {}) => { + const base: Record = { q: query, sort_by: sortBy, service: serviceFilter, page: '1' }; + return Object.fromEntries(Object.entries({ ...base, ...overrides }).filter(([, v]) => v !== '')); + }; const handleSearch = (e: React.FormEvent) => { e.preventDefault(); - setSearchParams({ q: searchInput, sort_by: sortBy, page: '1' }); + setSearchParams(_params({ q: searchInput })); }; const setPage = (p: number) => { - setSearchParams({ q: query, sort_by: sortBy, page: p.toString() }); + setSearchParams(_params({ page: p.toString() })); }; const setSort = (s: string) => { - setSearchParams({ q: query, sort_by: s, page: '1' }); + setSearchParams(_params({ sort_by: s })); + }; + + const clearService = () => { + setSearchParams(_params({ service: '' })); }; const totalPages = Math.ceil(total / limit); @@ -125,6 +136,19 @@ const Attackers: React.FC = () => {
{total} THREATS PROFILED + {serviceFilter && ( + + )}
@@ -196,7 +220,14 @@ const Attackers: React.FC = () => { {/* Services */}
{a.services.map((svc) => ( - {svc.toUpperCase()} + { e.stopPropagation(); setSearchParams(_params({ service: svc })); }} + > + {svc.toUpperCase()} + ))}
diff --git a/tests/test_api_attackers.py b/tests/test_api_attackers.py index 2b62399..e873efa 100644 --- a/tests/test_api_attackers.py +++ b/tests/test_api_attackers.py @@ -84,9 +84,9 @@ class TestGetAttackers: ) mock_repo.get_attackers.assert_awaited_once_with( - limit=50, offset=0, search="192.168", sort_by="recent", + limit=50, offset=0, search="192.168", sort_by="recent", service=None, ) - mock_repo.get_total_attackers.assert_awaited_once_with(search="192.168") + mock_repo.get_total_attackers.assert_awaited_once_with(search="192.168", service=None) @pytest.mark.asyncio async def test_null_search_normalized(self): @@ -102,7 +102,7 @@ class TestGetAttackers: ) mock_repo.get_attackers.assert_awaited_once_with( - limit=50, offset=0, search=None, sort_by="recent", + limit=50, offset=0, search=None, sort_by="recent", service=None, ) @pytest.mark.asyncio @@ -119,7 +119,7 @@ class TestGetAttackers: ) mock_repo.get_attackers.assert_awaited_once_with( - limit=50, offset=0, search=None, sort_by="active", + limit=50, offset=0, search=None, sort_by="active", service=None, ) @pytest.mark.asyncio @@ -136,9 +136,27 @@ class TestGetAttackers: ) mock_repo.get_attackers.assert_awaited_once_with( - limit=50, offset=0, search=None, sort_by="recent", + limit=50, offset=0, search=None, sort_by="recent", service=None, ) + @pytest.mark.asyncio + async def test_service_filter_forwarded(self): + from decnet.web.router.attackers.api_get_attackers import get_attackers + + with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo: + mock_repo.get_attackers = AsyncMock(return_value=[]) + mock_repo.get_total_attackers = AsyncMock(return_value=0) + + await get_attackers( + limit=50, offset=0, search=None, sort_by="recent", + service="https", current_user="test-user", + ) + + mock_repo.get_attackers.assert_awaited_once_with( + limit=50, offset=0, search=None, sort_by="recent", service="https", + ) + mock_repo.get_total_attackers.assert_awaited_once_with(search=None, service="https") + # ─── GET /attackers/{uuid} ───────────────────────────────────────────────────