From 69626d705de944d86c83c09c0ab6bb920497aecc Mon Sep 17 00:00:00 2001 From: anti Date: Thu, 9 Apr 2026 01:52:42 -0400 Subject: [PATCH] feat: implement Bounty Vault for captured credentials and artifacts --- .hypothesis/constants/5a5554db0771f35b | 4 + .hypothesis/constants/5c5d66158637ff02 | 4 + .hypothesis/constants/5feefba3d1c668ca | 4 + .hypothesis/constants/cf9d3e39a6bf6308 | 4 + .../unicode_data/16.0.0/codec-utf-8.json.gz | Bin 60 -> 60 bytes decnet/web/api.py | 26 +++ decnet/web/ingester.py | 26 +++ decnet/web/repository.py | 21 +++ decnet/web/sqlite_repository.py | 83 +++++++++ decnet_web/src/App.tsx | 2 + decnet_web/src/components/Bounty.tsx | 166 ++++++++++++++++++ decnet_web/src/components/Layout.tsx | 3 +- test_decnet.db-shm | Bin 32768 -> 32768 bytes test_decnet.db-wal | Bin 57712 -> 65952 bytes test_fuzz_decnet.db-shm | Bin 32768 -> 32768 bytes test_fuzz_decnet.db-wal | Bin 49472 -> 49472 bytes tests/test_bounty.py | 28 +++ 17 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 .hypothesis/constants/5a5554db0771f35b create mode 100644 .hypothesis/constants/5c5d66158637ff02 create mode 100644 .hypothesis/constants/5feefba3d1c668ca create mode 100644 .hypothesis/constants/cf9d3e39a6bf6308 create mode 100644 decnet_web/src/components/Bounty.tsx create mode 100644 tests/test_bounty.py diff --git a/.hypothesis/constants/5a5554db0771f35b b/.hypothesis/constants/5a5554db0771f35b new file mode 100644 index 0000000..275bac0 --- /dev/null +++ b/.hypothesis/constants/5a5554db0771f35b @@ -0,0 +1,4 @@ +# file: /home/anti/Tools/DECNET/decnet/web/repository.py +# hypothesis_version: 6.151.11 + +[] \ No newline at end of file diff --git a/.hypothesis/constants/5c5d66158637ff02 b/.hypothesis/constants/5c5d66158637ff02 new file mode 100644 index 0000000..3671c6f --- /dev/null +++ b/.hypothesis/constants/5c5d66158637ff02 @@ -0,0 +1,4 @@ +# file: /home/anti/Tools/DECNET/decnet/web/api.py +# hypothesis_version: 6.151.11 + +[0.5, 400, 404, 500, 512, 1000, 1024, '*', '/api/v1/auth/login', '/api/v1/bounty', '/api/v1/deckies', '/api/v1/logs', '/api/v1/stats', '/api/v1/stream', '1.0.0', 'Authorization', 'Bearer', 'Bearer ', 'Decky not found', 'No active deployment', 'WWW-Authenticate', 'access_token', 'admin', 'bearer', 'data', 'decnet.web.api', 'histogram', 'id', 'lastEventId', 'limit', 'logs', 'message', 'must_change_password', 'offset', 'password_hash', 'role', 'stats', 'text/event-stream', 'token', 'token_type', 'total', 'type', 'unihost', 'username', 'uuid'] \ No newline at end of file diff --git a/.hypothesis/constants/5feefba3d1c668ca b/.hypothesis/constants/5feefba3d1c668ca new file mode 100644 index 0000000..0a65034 --- /dev/null +++ b/.hypothesis/constants/5feefba3d1c668ca @@ -0,0 +1,4 @@ +# file: /home/anti/Tools/DECNET/decnet/web/ingester.py +# hypothesis_version: 6.151.11 + +['.json', 'attacker_ip', 'bounty_type', 'credential', 'decky', 'decnet.web.ingester', 'fields', 'password', 'payload', 'r', 'replace', 'service', 'username', 'utf-8'] \ No newline at end of file diff --git a/.hypothesis/constants/cf9d3e39a6bf6308 b/.hypothesis/constants/cf9d3e39a6bf6308 new file mode 100644 index 0000000..886a15d --- /dev/null +++ b/.hypothesis/constants/cf9d3e39a6bf6308 @@ -0,0 +1,4 @@ +# file: /home/anti/Tools/DECNET/decnet/web/sqlite_repository.py +# hypothesis_version: 6.151.11 + +[' AND ', ' WHERE ', ':', '[^a-zA-Z0-9_]', 'active_deckies', 'attacker', 'attacker-ip', 'attacker_ip', 'bounty_type', 'bounty_type = ?', 'bucket_time', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'event', 'event_type', 'fields', 'id > ?', 'max_id', 'msg', 'must_change_password', 'password_hash', 'payload', 'raw_line', 'role', 'service', 'time', 'timestamp', 'timestamp <= ?', 'timestamp >= ?', 'total', 'total_logs', 'unique_attackers', 'username', 'uuid'] \ No newline at end of file diff --git a/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz b/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz index b7e01505ec1c7050e364f26f782ce13d7a1ce10d..2d4ad9cf60306c3d3558aae6b96d3efb9abb1c06 100644 GIT binary patch delta 27 icmcDq5tZ-e;9z86U|{-Rl3P$#P-+yPZDKZ2R2=|Uu?Hyt delta 27 icmcDq5tZ-e;9z86U|{-Rl3QSrU1gGNUY0*mR2=|TBnLPE diff --git a/decnet/web/api.py b/decnet/web/api.py index fc16146..df9a142 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -135,6 +135,13 @@ class LogsResponse(BaseModel): data: list[dict[str, Any]] +class BountyResponse(BaseModel): + total: int + limit: int + offset: int + data: list[dict[str, Any]] + + @app.post("/api/v1/auth/login", response_model=Token) async def login(request: LoginRequest) -> dict[str, Any]: _user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username) @@ -190,6 +197,25 @@ async def get_logs( } +@app.get("/api/v1/bounty", response_model=BountyResponse) +async def get_bounties( + limit: int = Query(50, ge=1, le=1000), + offset: int = Query(0, ge=0), + bounty_type: Optional[str] = None, + search: Optional[str] = None, + current_user: str = Depends(get_current_user) +) -> dict[str, Any]: + """Retrieve collected bounties (harvested credentials, payloads, etc.).""" + _data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bounty_type, search=search) + _total = await repo.get_total_bounties(bounty_type=bounty_type, search=search) + return { + "total": _total, + "limit": limit, + "offset": offset, + "data": _data + } + + @app.get("/api/v1/logs/histogram") async def get_logs_histogram( search: Optional[str] = None, diff --git a/decnet/web/ingester.py b/decnet/web/ingester.py index 9527762..3007910 100644 --- a/decnet/web/ingester.py +++ b/decnet/web/ingester.py @@ -54,6 +54,7 @@ async def log_ingestion_worker(repo: BaseRepository) -> None: try: _log_data: dict[str, Any] = json.loads(_line.strip()) await repo.add_log(_log_data) + await _extract_bounty(repo, _log_data) except json.JSONDecodeError: logger.error(f"Failed to decode JSON log line: {_line}") continue @@ -66,3 +67,28 @@ async def log_ingestion_worker(repo: BaseRepository) -> None: await asyncio.sleep(5) await asyncio.sleep(1) + + +async def _extract_bounty(repo: BaseRepository, log_data: dict[str, Any]) -> None: + """Detect and extract valuable artifacts (bounties) from log entries.""" + _fields = log_data.get("fields") + if not isinstance(_fields, dict): + return + + # 1. Credentials (User/Pass) + _user = _fields.get("username") + _pass = _fields.get("password") + + if _user and _pass: + await repo.add_bounty({ + "decky": log_data.get("decky"), + "service": log_data.get("service"), + "attacker_ip": log_data.get("attacker_ip"), + "bounty_type": "credential", + "payload": { + "username": _user, + "password": _pass + } + }) + + # 2. Add more extractors here later (e.g. file hashes, crypto keys) diff --git a/decnet/web/repository.py b/decnet/web/repository.py index cb4f958..91226b9 100644 --- a/decnet/web/repository.py +++ b/decnet/web/repository.py @@ -59,3 +59,24 @@ class BaseRepository(ABC): async def update_user_password(self, uuid: str, password_hash: str, must_change_password: bool = False) -> None: """Update a user's password and change the must_change_password flag.""" pass + + @abstractmethod + async def add_bounty(self, bounty_data: dict[str, Any]) -> None: + """Add a new harvested artifact (bounty) to the database.""" + pass + + @abstractmethod + async def get_bounties( + self, + limit: int = 50, + offset: int = 0, + bounty_type: Optional[str] = None, + search: Optional[str] = None + ) -> list[dict[str, Any]]: + """Retrieve paginated bounty entries.""" + pass + + @abstractmethod + async def get_total_bounties(self, bounty_type: Optional[str] = None, search: Optional[str] = None) -> int: + """Retrieve the total count of bounties, optionally filtered.""" + pass diff --git a/decnet/web/sqlite_repository.py b/decnet/web/sqlite_repository.py index db30940..e01328b 100644 --- a/decnet/web/sqlite_repository.py +++ b/decnet/web/sqlite_repository.py @@ -37,6 +37,17 @@ class SQLiteRepository(BaseRepository): must_change_password BOOLEAN DEFAULT 0 ) """) + _conn.execute(""" + CREATE TABLE IF NOT EXISTS bounty ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + decky TEXT, + service TEXT, + attacker_ip TEXT, + bounty_type TEXT, + payload TEXT + ) + """) _conn.commit() async def add_log(self, log_data: dict[str, Any]) -> None: @@ -296,3 +307,75 @@ class SQLiteRepository(BaseRepository): (password_hash, must_change_password, uuid) ) await _db.commit() + + async def add_bounty(self, bounty_data: dict[str, Any]) -> None: + import json + async with aiosqlite.connect(self.db_path) as _db: + await _db.execute( + "INSERT INTO bounty (decky, service, attacker_ip, bounty_type, payload) VALUES (?, ?, ?, ?, ?)", + ( + bounty_data.get("decky"), + bounty_data.get("service"), + bounty_data.get("attacker_ip"), + bounty_data.get("bounty_type"), + json.dumps(bounty_data.get("payload", {})) + ) + ) + await _db.commit() + + def _build_bounty_where( + self, + bounty_type: Optional[str] = None, + search: Optional[str] = None + ) -> tuple[str, list[Any]]: + _where_clauses = [] + _params = [] + + if bounty_type: + _where_clauses.append("bounty_type = ?") + _params.append(bounty_type) + + if search: + _where_clauses.append("(decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ? OR payload LIKE ?)") + _like_val = f"%{search}%" + _params.extend([_like_val, _like_val, _like_val, _like_val]) + + if _where_clauses: + return " WHERE " + " AND ".join(_where_clauses), _params + return "", [] + + async def get_bounties( + self, + limit: int = 50, + offset: int = 0, + bounty_type: Optional[str] = None, + search: Optional[str] = None + ) -> list[dict[str, Any]]: + import json + _where, _params = self._build_bounty_where(bounty_type, search) + _query = f"SELECT * FROM bounty{_where} ORDER BY timestamp DESC LIMIT ? OFFSET ?" # nosec B608 + _params.extend([limit, offset]) + + async with aiosqlite.connect(self.db_path) as _db: + _db.row_factory = aiosqlite.Row + async with _db.execute(_query, _params) as _cursor: + _rows: list[aiosqlite.Row] = await _cursor.fetchall() + _results = [] + for _row in _rows: + _d = dict(_row) + try: + _d["payload"] = json.loads(_d["payload"]) + except Exception: + pass + _results.append(_d) + return _results + + async def get_total_bounties(self, bounty_type: Optional[str] = None, search: Optional[str] = None) -> int: + _where, _params = self._build_bounty_where(bounty_type, search) + _query = f"SELECT COUNT(*) as total FROM bounty{_where}" # nosec B608 + + async with aiosqlite.connect(self.db_path) as _db: + _db.row_factory = aiosqlite.Row + async with _db.execute(_query, _params) as _cursor: + _row: Optional[aiosqlite.Row] = await _cursor.fetchone() + return _row["total"] if _row else 0 diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 80f761e..8748ef2 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -7,6 +7,7 @@ import DeckyFleet from './components/DeckyFleet'; import LiveLogs from './components/LiveLogs'; import Attackers from './components/Attackers'; import Config from './components/Config'; +import Bounty from './components/Bounty'; function App() { const [token, setToken] = useState(localStorage.getItem('token')); @@ -43,6 +44,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/Bounty.tsx b/decnet_web/src/components/Bounty.tsx new file mode 100644 index 0000000..23a3bd2 --- /dev/null +++ b/decnet_web/src/components/Bounty.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Archive, Search, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; +import api from '../utils/api'; +import './Dashboard.css'; + +interface BountyEntry { + id: number; + timestamp: string; + decky: string; + service: string; + attacker_ip: string; + bounty_type: string; + payload: any; +} + +const Bounty: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('q') || ''; + const typeFilter = searchParams.get('type') || ''; + const page = parseInt(searchParams.get('page') || '1'); + + const [bounties, setBounties] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(query); + + const limit = 50; + + const fetchBounties = async () => { + setLoading(true); + try { + const offset = (page - 1) * limit; + let url = `/bounty?limit=${limit}&offset=${offset}`; + if (query) url += `&search=${encodeURIComponent(query)}`; + if (typeFilter) url += `&bounty_type=${typeFilter}`; + + const res = await api.get(url); + setBounties(res.data.data); + setTotal(res.data.total); + } catch (err) { + console.error('Failed to fetch bounties', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchBounties(); + }, [query, typeFilter, page]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearchParams({ q: searchInput, type: typeFilter, page: '1' }); + }; + + const setPage = (p: number) => { + setSearchParams({ q: query, type: typeFilter, page: p.toString() }); + }; + + const setType = (t: string) => { + setSearchParams({ q: query, type: t, page: '1' }); + }; + + const totalPages = Math.ceil(total / limit); + + return ( +
+
+
+ +

BOUNTY VAULT

+
+ +
+
+ + +
+ +
+ + setSearchInput(e.target.value)} + /> + +
+
+ +
+
+
+ {total} ARTIFACTS CAPTURED +
+
+ + PAGE {page} OF {totalPages || 1} + +
+
+ +
+ + + + + + + + + + + + + {bounties.map((b) => ( + + + + + + + + + ))} + {!loading && bounties.length === 0 && ( + + + + )} + +
TIMESTAMPDECKYSERVICEATTACKERTYPEDATA
{b.timestamp}{b.decky}{b.service}{b.attacker_ip} + + {b.bounty_type.toUpperCase()} + + +
+ {b.bounty_type === 'credential' ? ( + <> + user: {b.payload.username} + pass: {b.payload.password} + + ) : ( + JSON.stringify(b.payload) + )} +
+
+ THE VAULT IS EMPTY +
+
+
+
+ ); +}; + +export default Bounty; diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 0fb109f..7645caa 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { NavLink } from 'react-router-dom'; -import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server } from 'lucide-react'; +import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive } from 'lucide-react'; import api from '../utils/api'; import './Layout.css'; @@ -50,6 +50,7 @@ const Layout: React.FC = ({ children, onLogout, onSearch }) => { } label="Dashboard" open={sidebarOpen} /> } label="Decoy Fleet" open={sidebarOpen} /> } label="Live Logs" open={sidebarOpen} /> + } label="Bounty" open={sidebarOpen} /> } label="Attackers" open={sidebarOpen} /> } label="Config" open={sidebarOpen} /> diff --git a/test_decnet.db-shm b/test_decnet.db-shm index 812ee5a6c0c37a0e8b7c8a159a6853dd7682cf7d..cb6f416c6bcda80da1933466a1bc4e6b244954b7 100644 GIT binary patch delta 206 zcmZo@U}|V!s+V}A%K!pwK+MR%ARqvw*@0Mz^-^?*Y{uDZb9Zhl`4JlwdG_wXbtRS) zNmUOt3Jmyw*%-LNG&=(ih-T#6c#u~c00Fx`4FCWD delta 195 zcmZo@U}|V!s+V}A%K!qbK+MR%AixKt*?{;-d32Gx9h1@Cs(o)lW6O)@Zs_l<2+;US zs(PSNV88<;|04mY@Wgu6jcbCOCO+U`W@KR5c=0zAD-#1Z1Mg-=x8F?6JPdprFaBg= aWZlf@4CDe`w)tboUnXXD2Cj`4IrRV!;yV)n diff --git a/test_decnet.db-wal b/test_decnet.db-wal index 81a1a688c31ec42fb17ee735208879efa60cd4fc..532c4f941d241ca6ea24767cc09f49dbff08d8cc 100644 GIT binary patch delta 719 zcmexxhPe-2nfRL)qj(ay!=A1G1ulm0VXsn zW_~+0>wXAd63DT<>>#tCfO+y70~-N0Zea$#NIrI+M&2vDE4hU?Zk)|!*p$u2E-op_ z*lJjkn3R*6lwX=xQVAj1or7E*LtGU?9G!ez6(C{?8j}MZL?=ty@0?B40}OhWlnx#%49_W@y(33P0XSoN3g@3zBgvx9N(#rc0&Bh0&*Kn>e5`k3%k=l z@os)lFpa-H(ahA$(je7HH_321ULlXqI+3?Br22_6aYn;1w>Iyl71P|(tZ8o zkIfARNE%*PZ(a9K@5y$E23BON*K{8)`eeWV;N}eh90Gzcd;k0>5POq(Yctp8KmjHJ zQ5Iw!-W#{(8ObhKvRPo_H2!*X69Y37^RzTwL(4QHT@!OlQ(a3Va|2z=MAI}wqhy1m zkg;!b#6fleLAc_V0v-qFGxS++F1U!Km_6&V_3scd#?2iM Tkrfw3mp?ILvU6X&;G+NllS|Dz diff --git a/test_fuzz_decnet.db-shm b/test_fuzz_decnet.db-shm index ca9c60d25ead92deb95ce1e6164b0e194cb6a2f3..b8e8535f16a28303f5718630a23be5a421086d02 100644 GIT binary patch delta 146 zcmZo@U}|V!njj*>&cMKMs{7B%T}PPD|DV~R_dbo)gR5@t^)m<4-Vl(UnBXGF2-FTT zf(3|KftYRML0hMZ7kF41fkGS`5B_9gWZHP}FVn^?|CpFr8Mrnc{LRG7#=s4x*%^31 LG$ZH6gS^@RQi?SV delta 144 zcmZo@U}|V!njj*>#=yYPGeI#?`ZME}sh8WdW1brnHobqWe(Bk7Mgr0k6I=vYfZADs z7-S3+5HoK)XzMib0uM7I1IxyPznPes7`VYSGXoEZW@O#W=nNDF>e&1-*HJF8W8^M(Kp0YR9(FKf3wSZulDJ=f+y0VXsn z0<<FIk@pJkN^aqe8)tJFHf6K1i%Uu} zwi=csCgr3i<(KA_R6R#+OtU zOn%5O!(NbBnUkNGGFeeTd^4kM6SFAD5$rIhKlmqk@VRg#E5xrXAh*G!T$h?U|C#mS z+2#iY)A;KROpVQxQ!UJO6H|bMiJ@VNZjx!DiEgTiS+bF(d5W2(xnW{TZf2f}QId+G zk%~u(NpZQJd09btk#CtiJ@C%c7~6sX`-*O ziCH1o0H704!hGKH`}{`Cc1$Lle>B|Tw;{$MKqJ_I81BoJf4WbdXFjrPvA{$D03smO A!vFvP delta 736 zcmX@m#C)KM*}}Y@t%*VRkpu&S00Rh2z1*f9^W312A@|w}&97zV~)Z z5*PmHTKnWK$*Y3F@TYq!B|~XTDmb+ zFx<$%$V}J3MAyJV!NA1I(89{pASE?9yHdA2H7PeSGbf{@q@cJYv8bf9AT%#KFTXs` z#?aWr&QJlNF$AVj&(PA;!rV;Tz`)ADKmo-V1&}!kx(dZ5xnQ$^OplO|0EObzqO#N? zpfFH0GcR2su^3ffbuCfKfL>$jLf2(Ir-_u0NQo= AYXATM diff --git a/tests/test_bounty.py b/tests/test_bounty.py new file mode 100644 index 0000000..189146f --- /dev/null +++ b/tests/test_bounty.py @@ -0,0 +1,28 @@ +import pytest +from fastapi.testclient import TestClient +from decnet.web.api import app +from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD + +@pytest.fixture +def auth_token(): + with TestClient(app) as client: + resp = client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD}) + return resp.json()["access_token"] + +def test_add_and_get_bounty(auth_token): + with TestClient(app) as client: + # We can't directly call add_bounty from API yet (it's internal to ingester) + # But we can test the repository if we want, or mock a log line that triggers it. + # For now, let's test the endpoint returns 200 even if empty. + resp = client.get("/api/v1/bounty", headers={"Authorization": f"Bearer {auth_token}"}) + assert resp.status_code == 200 + data = resp.json() + assert "total" in data + assert "data" in data + assert isinstance(data["data"], list) + +def test_bounty_pagination(auth_token): + with TestClient(app) as client: + resp = client.get("/api/v1/bounty?limit=1&offset=0", headers={"Authorization": f"Bearer {auth_token}"}) + assert resp.status_code == 200 + assert resp.json()["limit"] == 1