From 7dbc71d664778db4f3c0a94da9031496809e1ac7 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 15 Apr 2026 12:51:38 -0400 Subject: [PATCH] test: add profiler behavioral analysis and RBAC endpoint tests - test_profiler_behavioral.py: attacker behavior pattern matching tests - api/test_rbac.py: comprehensive RBAC role separation tests - api/config/: configuration API endpoint tests (CRUD, reinit, user management) --- tests/api/config/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 151 bytes .../conftest.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 151 bytes ..._deploy_limit.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 6049 bytes ...st_get_config.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 13658 bytes .../test_reinit.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 10383 bytes ...update_config.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 9457 bytes ...er_management.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 20833 bytes tests/api/config/conftest.py | 1 + tests/api/config/test_deploy_limit.py | 57 ++++ tests/api/config/test_get_config.py | 69 +++++ tests/api/config/test_reinit.py | 76 +++++ tests/api/config/test_update_config.py | 77 +++++ tests/api/config/test_user_management.py | 188 ++++++++++++ tests/api/test_rbac.py | 116 ++++++++ tests/test_profiler_behavioral.py | 277 ++++++++++++++++++ 16 files changed, 861 insertions(+) create mode 100644 tests/api/config/__init__.py create mode 100644 tests/api/config/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/api/config/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/api/config/__pycache__/test_deploy_limit.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/api/config/__pycache__/test_get_config.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/api/config/__pycache__/test_reinit.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/api/config/__pycache__/test_update_config.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/api/config/__pycache__/test_user_management.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/api/config/conftest.py create mode 100644 tests/api/config/test_deploy_limit.py create mode 100644 tests/api/config/test_get_config.py create mode 100644 tests/api/config/test_reinit.py create mode 100644 tests/api/config/test_update_config.py create mode 100644 tests/api/config/test_user_management.py create mode 100644 tests/api/test_rbac.py create mode 100644 tests/test_profiler_behavioral.py diff --git a/tests/api/config/__init__.py b/tests/api/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/config/__pycache__/__init__.cpython-314.pyc b/tests/api/config/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a06feb9bb3ecf3c03988f3366f6fa6e359390f9a GIT binary patch literal 151 zcmdPq>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%S1mTKQ~oB zF|Q<3KO{dtr&!;`)!ENAM871pxTIJ=u^>}FIX^EgGhIJEJ~J<~BtBlRpz;=nO>TZl aX-=wL5i8ITkTu01#wTV*M#ds$APWExLLvwN literal 0 HcmV?d00001 diff --git a/tests/api/config/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/tests/api/config/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b77e9290782cf9fba5427b793468fd48d1517f32 GIT binary patch literal 151 zcmdPqUa(-S~W;&Px3F;M8-r}&y%}*)KNwq6t V0U81_t(X-^d}3x~WGrF=vH&(cB5VKv literal 0 HcmV?d00001 diff --git a/tests/api/config/__pycache__/test_deploy_limit.cpython-314-pytest-9.0.3.pyc b/tests/api/config/__pycache__/test_deploy_limit.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea634d6ccf80bc9ece371f27d06834dcaad9a3d4 GIT binary patch literal 6049 zcmdT|U2NOd6~2_HKT5J}CzaDWwizdNtfrA7%fD0I)v>+UU78xKp%!aeR+VU*wJge) zR1|wC=CvIb>}4qS&~(GlJZxA3>}gK}HgqWV&~Cs0IZmR0YqveD4}Ei!0^N)~>>N_0 z7*(0uVK1ZQkLTQb&bgQ8T=IV8iMB9D;CSizzsUda5|Ts3I@r2!_aH;a7I~2f%skN; z**Uo7x7g9U&~fj=L;ItcrA-j}Ngw!~xATPA5!_?-{G=I?DZ9#U(Ox`|G4>+aV(^TA z!;o)0P(Mm|a)KN;8^|)mI80<%#y!xBe`t|*_Yj#Gam!51vpM+`WSTEZT1BC&kZ(=O zX|ht}Ri&PMKCeh>@wmq4vV{Ww5|${Rr@XqNR9=d2j^?EtIL9l}%W9q-qtY33QO{h^F6 z#R%QcW>T(U$QT|%NC=7jJ|m375xM2g zkqKClt@8>axYJC@lV<1zp>zFb3lCbc8fR;N5e_{1P@{Fdvf$9H_VwR1s<8gYOb9En z{*jg2<)1g@wsrLnXTnhZo@amQsQy96wYAPLXI+!a}M zH0^aikFhpR7NyDyoGQ@^a!ykDNnTY~xV@;c9bQN#>f^x~cVj&$7iAGQLfDq|@Kjk_ zQK-C`#cgBHcUsC)*toB`boLchDeArzDGQsV`pMgH^Xl%>#wjEaY6-<5gps|rtO`SG z{KJE#jj@=DjbpmUxE|dLJ5p9IU|SMl7-1A)d@thgOP&Hp7K~0^WAHd6vi8h zsob~g0$dkLU>R4ntX5XxBJvXVDnDsZM7dW7W8AB;Hi}O~JphbE#33b^B5Z(q&@sNwJyF^)*hH4<9U|_sA}}nP zQ`Sn5N<9SK;>%^Zpvgs5_ZAeeQ1wHixi$<_ak-$poGpmO?3$zpL^Zo4VFbNx%1A8K zWtLKf>IcLng|1~aQMy-Fj*Q#;)0 z8hg6Rp8k+)GkOkR`OXeER%6Gi?AR{r3bI?%K>a!oz196@_iaCayRG~3Gu7~s+a3Is zGgqIy`dGDpyxKmo>m!{#cM03+{hY8N@7Bz2h=kf}!S3x~ca`nGtQxQ#?60x)-wxYf z4R+hAEnFJ5#p`UprQLE^-@tgbky8MN+riE%n|e!rQ~uHFmBE|Q6QI?i$?a$oglaUo z6HOUHYCD*!L6`B{VN;jjl2N=ZnHyq%cMPQWjxuv2?0d(iPJ;6L&ROjJV2D8+L2OOpe*D!) zT>k%;#0kOi=LpIF9k*>Ew7$3yfNVA$Ha!U6Ll2f(QM>hugC2Y@q~Y$-Z$u4BU>#= zw{SS)HR#%(9Q8N|B+RCu3x}E}3~FN?ILLZqq!!fDNxH2W*2#LquXR0bk`HA40U_$h zW@~$!#tB{f+J>!+H<9)GQNAx<)B91r5Y%$brg}#W!sj!n-p6&SH#l<-)l#ahsA1pF*SA4V8Jz$#FDS*9@r z{5V5TAUulDhrlBoLFh*~if{}d#_V~E3VwhAUNmR%BD3Yc=SK7-#!p~;lO@q4HVh%8 z5RjM9#}M$TMMn^D{n4)>q!C6D#t_B=)@r>oiOWEClgPDBW7E@)S67F)kmgxxS1L|Q)OrV_hiB!s`VAuX8;eeVP&7Gv+~mH%u;Wy!P#GA0z;4og^)_P#>t54E50{Iw zhRY|8yqe;-8odi%v8>N%z)_`QUMl9`o0<9k;9HU}!pAZ5Q={?iOJ~=zbXE6ciyN|H z@K=iKj^Z=Y@zKox7<{2D7o^AOx1a;oBdkh*T{pupeIGxy^Kb>WVPK*n40XrQKsq Qy&cSPqxX&rBx9!k1{dV5M*si- literal 0 HcmV?d00001 diff --git a/tests/api/config/__pycache__/test_get_config.cpython-314-pytest-9.0.3.pyc b/tests/api/config/__pycache__/test_get_config.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..614399487eb5b5237cee8069af45d6b38d397bd1 GIT binary patch literal 13658 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 XY`akl+RB!aS}z;ZYVSKh)1LC*^7bA_ literal 0 HcmV?d00001 diff --git a/tests/api/config/__pycache__/test_update_config.cpython-314-pytest-9.0.3.pyc b/tests/api/config/__pycache__/test_update_config.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1272090afb76e1bd79d16a1779291b0292cd42e GIT binary patch literal 9457 zcmeHM-A^0Y6~8kckBz@DkPi~l#hAdxyCDVwAz79rBoNq&MrfKkRh7n?*aOV2?Q!l5 z*^sJIy4$KlEH#~u$HoMmm%anxk&`(1EMj;nVt45 zvYlj_)4ZK#3$O9t=4T^z=kj?rTGnk%U=NcatDrUQqMygrFR+V#oU31O7yZ1hexY6T z<6Zs2yXfb0^@|8hH%a)P;XGZ<*sFmhSSj);{5P^5LgJ(oRwqSfVU?;60oLs1V3HA7 z!INS_WRL}&8-ZSUOcBBRA?SCR`<@wZfE3vjJL|rVX-0~kn_u3(oZ`+-6LR|-Z(FtV zUl4MP+~BMtndNP*eC{GbYs!0?5!$W~oo0tDTY(pRH~hBMtg!>_l6mfOcE>rWnEy)E zl|q%)Sn47vcF39m`wp{*tNPX0o$h{LV+5bzcRf*Udyjn`p{cGdXWJ${CtdSXvZhDNHEFXZJie$cC{(^DYO+$;^j(rfD$&?7t8*W!NQ&uSTsos*#h`C&vN=2Gc@4KP-^ro~b&Ph0e9vL_01*UY8QibYm z={bewMJ+AeE#}06G1(N|%W_EPGdUTSNDqjx6lqQQNGi}EH0eRSzB$1I?!u6}aM~=G z!=G^%&brO|-GxE7*|57XqN3mG?TtByem=RNMOj$S0jw`kCJ{SNgc$jrW22 zFgo!ToN7ZZf>>S1B`*)T0>5X-jTn&ITmiWVZ2J>({SClq8FB-40b_f}4GN*x2)Ww^ zU_$uKZh*H8j#}wKRRH#XvKyT6;f0UP{L=^_q7=h!imsS?QD8(xeITwr`#rd3igB<3 zP84y1hEZVrM4Lg#|3VWC>L|Lb47H;83F?Loa4 z1%e>OV2_?akwEbdig!V%=qYTVqWw_N6FVY}vZ$q%xil3E^Aa5ad%R^eQxkByO5x-C z6>ut{vAY!R-IsR?p!wnmz}NGLfnkX7pL9;Q1BOkvA%=naFnXySPLED7(}%c638U_h zhsVdD?#n|A$c&W)LSI)x8H4n%F1pzXC0jS4^rSpB6G{%y?V6Qx%sDB?7Y(Hx;S>)k zN96jXE9H1S=;ok~eTNMulukcLC>!x-4q^%Q(XE^gRVI`^0nyD@l@9rLkq$XYSyej3 zK{_Ov{%qzSh9_&@!IS->t|u!yF**!eZ@H=LmGsaZS^8L_u0VZzAB7}HZ>5v!+tEo` zV?#85OU`B?5g3CJV(?f00Ai=ec;>xEzOg5u0zX;q4 za2I`YL6hhmF{ig%Jcr$qc3RkxY;~x(bCgJtYrhwpGKiCyJz&fp(58qe(u@>&McRhq z0179zr0rNdg5o%cEv$tZ5WeH~wc9;`ML}GKVKBJdN)MNT|8UJ57oWt>%mf|x4x zOqDp4E5WH%&iXxu8l)hw_|&C8F)ST3w*pNj1wjX4W@>xuM!1Go;jIPVp1Fo*!&^ry zyfwx=`;%*EY9PXLqBkK}*Vj0a+ZK91BtV>66^*?ty9Q)JUtJ0pJ>+o_6Jde$kmUe%}=PO z%Z;H2G)7Ai_Yam`jPGx3rn(Md;&CRS%dPR7>*lmnBvSW zlMRr>jOzXG=auYYPC8G&4t2og5cLs=bq~WZe zbCKEb5VmdIP}dJswTGE*s~NQQSXC?Uw1T$YTGi@frpsebGkYBS1q)+S0UqSPfCT-` literal 0 HcmV?d00001 diff --git a/tests/api/config/__pycache__/test_user_management.cpython-314-pytest-9.0.3.pyc b/tests/api/config/__pycache__/test_user_management.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51269c0bec1c904f0be762b2096eec6d7222a781 GIT binary patch literal 20833 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 literal 0 HcmV?d00001 diff --git a/tests/api/config/conftest.py b/tests/api/config/conftest.py new file mode 100644 index 0000000..fb95821 --- /dev/null +++ b/tests/api/config/conftest.py @@ -0,0 +1 @@ +# viewer_token fixture is now in tests/api/conftest.py (shared across all API tests) diff --git a/tests/api/config/test_deploy_limit.py b/tests/api/config/test_deploy_limit.py new file mode 100644 index 0000000..82e5f0a --- /dev/null +++ b/tests/api/config/test_deploy_limit.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import patch + +from decnet.web.dependencies import repo + + +@pytest.fixture(autouse=True) +def contract_test_mode(monkeypatch): + """Skip actual Docker deployment in tests.""" + monkeypatch.setenv("DECNET_CONTRACT_TEST", "true") + + +@pytest.fixture(autouse=True) +def mock_network(): + """Mock network detection so deploy doesn't call `ip addr show`.""" + with patch("decnet.web.router.fleet.api_deploy_deckies.get_host_ip", return_value="192.168.1.100"): + yield + + +@pytest.mark.anyio +async def test_deploy_respects_limit(client, auth_token, mock_state_file): + """Deploy should reject if total deckies would exceed limit.""" + await repo.set_state("config_limits", {"deployment_limit": 1}) + await repo.set_state("deployment", mock_state_file) + + ini = """[decky-new] +services = ssh +""" + resp = await client.post( + "/api/v1/deckies/deploy", + json={"ini_content": ini}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + # 2 existing + 1 new = 3 > limit of 1 + assert resp.status_code == 409 + assert "limit" in resp.json()["detail"].lower() + + +@pytest.mark.anyio +async def test_deploy_within_limit(client, auth_token, mock_state_file): + """Deploy should succeed when within limit.""" + await repo.set_state("config_limits", {"deployment_limit": 100}) + await repo.set_state("deployment", mock_state_file) + + ini = """[decky-new] +services = ssh +""" + resp = await client.post( + "/api/v1/deckies/deploy", + json={"ini_content": ini}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + # Should not fail due to limit + if resp.status_code == 409: + assert "limit" not in resp.json()["detail"].lower() + else: + assert resp.status_code == 200 diff --git a/tests/api/config/test_get_config.py b/tests/api/config/test_get_config.py new file mode 100644 index 0000000..ab4a437 --- /dev/null +++ b/tests/api/config/test_get_config.py @@ -0,0 +1,69 @@ +import pytest + + +@pytest.mark.anyio +async def test_get_config_defaults_admin(client, auth_token): + """Admin gets full config with users list and defaults.""" + resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["role"] == "admin" + assert data["deployment_limit"] == 10 + assert data["global_mutation_interval"] == "30m" + assert "users" in data + assert isinstance(data["users"], list) + assert len(data["users"]) >= 1 + # Ensure no password_hash leaked + for user in data["users"]: + assert "password_hash" not in user + assert "uuid" in user + assert "username" in user + assert "role" in user + + +@pytest.mark.anyio +async def test_get_config_viewer_no_users(client, auth_token, viewer_token): + """Viewer gets config without users list — server-side gating.""" + resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["role"] == "viewer" + assert data["deployment_limit"] == 10 + assert data["global_mutation_interval"] == "30m" + assert "users" not in data + + +@pytest.mark.anyio +async def test_get_config_returns_stored_values(client, auth_token): + """Config returns stored values after update.""" + await client.put( + "/api/v1/config/deployment-limit", + json={"deployment_limit": 42}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + await client.put( + "/api/v1/config/global-mutation-interval", + json={"global_mutation_interval": "7d"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + + resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["deployment_limit"] == 42 + assert data["global_mutation_interval"] == "7d" + + +@pytest.mark.anyio +async def test_get_config_unauthenticated(client): + resp = await client.get("/api/v1/config") + assert resp.status_code == 401 diff --git a/tests/api/config/test_reinit.py b/tests/api/config/test_reinit.py new file mode 100644 index 0000000..32f09ac --- /dev/null +++ b/tests/api/config/test_reinit.py @@ -0,0 +1,76 @@ +import pytest + +from decnet.web.dependencies import repo + + +@pytest.fixture(autouse=True) +def enable_developer_mode(monkeypatch): + monkeypatch.setattr("decnet.web.router.config.api_reinit.DECNET_DEVELOPER", True) + monkeypatch.setattr("decnet.web.router.config.api_get_config.DECNET_DEVELOPER", True) + + +@pytest.mark.anyio +async def test_reinit_purges_data(client, auth_token): + """Admin can purge all logs, bounties, and attackers in developer mode.""" + # Seed some data + await repo.add_log({ + "decky": "d1", "service": "ssh", "event_type": "connect", + "attacker_ip": "1.2.3.4", "raw_line": "test", "fields": "{}", + }) + await repo.add_bounty({ + "decky": "d1", "service": "ssh", "attacker_ip": "1.2.3.4", + "bounty_type": "credential", "payload": '{"user":"root"}', + }) + + resp = await client.delete( + "/api/v1/config/reinit", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["deleted"]["logs"] >= 1 + assert data["deleted"]["bounties"] >= 1 + + +@pytest.mark.anyio +async def test_reinit_viewer_forbidden(client, auth_token, viewer_token): + resp = await client.delete( + "/api/v1/config/reinit", + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.anyio +async def test_reinit_forbidden_without_developer_mode(client, auth_token, monkeypatch): + monkeypatch.setattr("decnet.web.router.config.api_reinit.DECNET_DEVELOPER", False) + + resp = await client.delete( + "/api/v1/config/reinit", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 403 + assert "developer mode" in resp.json()["detail"].lower() + + +@pytest.mark.anyio +async def test_config_includes_developer_mode(client, auth_token): + """Admin config response includes developer_mode when enabled.""" + resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + assert resp.json()["developer_mode"] is True + + +@pytest.mark.anyio +async def test_config_excludes_developer_mode_when_disabled(client, auth_token, monkeypatch): + monkeypatch.setattr("decnet.web.router.config.api_get_config.DECNET_DEVELOPER", False) + + resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + assert "developer_mode" not in resp.json() diff --git a/tests/api/config/test_update_config.py b/tests/api/config/test_update_config.py new file mode 100644 index 0000000..9f83459 --- /dev/null +++ b/tests/api/config/test_update_config.py @@ -0,0 +1,77 @@ +import pytest + + +@pytest.mark.anyio +async def test_update_deployment_limit_admin(client, auth_token): + resp = await client.put( + "/api/v1/config/deployment-limit", + json={"deployment_limit": 50}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + assert resp.json()["message"] == "Deployment limit updated" + + +@pytest.mark.anyio +async def test_update_deployment_limit_out_of_range(client, auth_token): + resp = await client.put( + "/api/v1/config/deployment-limit", + json={"deployment_limit": 0}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 422 + + resp = await client.put( + "/api/v1/config/deployment-limit", + json={"deployment_limit": 501}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.anyio +async def test_update_deployment_limit_viewer_forbidden(client, auth_token, viewer_token): + resp = await client.put( + "/api/v1/config/deployment-limit", + json={"deployment_limit": 50}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.anyio +async def test_update_global_mutation_interval_admin(client, auth_token): + resp = await client.put( + "/api/v1/config/global-mutation-interval", + json={"global_mutation_interval": "7d"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + assert resp.json()["message"] == "Global mutation interval updated" + + +@pytest.mark.anyio +async def test_update_global_mutation_interval_invalid(client, auth_token): + resp = await client.put( + "/api/v1/config/global-mutation-interval", + json={"global_mutation_interval": "abc"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 422 + + resp = await client.put( + "/api/v1/config/global-mutation-interval", + json={"global_mutation_interval": "0m"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.anyio +async def test_update_global_mutation_interval_viewer_forbidden(client, auth_token, viewer_token): + resp = await client.put( + "/api/v1/config/global-mutation-interval", + json={"global_mutation_interval": "7d"}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 diff --git a/tests/api/config/test_user_management.py b/tests/api/config/test_user_management.py new file mode 100644 index 0000000..3f6d807 --- /dev/null +++ b/tests/api/config/test_user_management.py @@ -0,0 +1,188 @@ +import pytest + + +@pytest.mark.anyio +async def test_create_user(client, auth_token): + resp = await client.post( + "/api/v1/config/users", + json={"username": "newuser", "password": "securepass123", "role": "viewer"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["username"] == "newuser" + assert data["role"] == "viewer" + assert data["must_change_password"] is True + assert "password_hash" not in data + + +@pytest.mark.anyio +async def test_create_user_duplicate(client, auth_token): + await client.post( + "/api/v1/config/users", + json={"username": "dupuser", "password": "securepass123", "role": "viewer"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + resp = await client.post( + "/api/v1/config/users", + json={"username": "dupuser", "password": "securepass456", "role": "viewer"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 409 + + +@pytest.mark.anyio +async def test_create_user_viewer_forbidden(client, auth_token, viewer_token): + resp = await client.post( + "/api/v1/config/users", + json={"username": "blocked", "password": "securepass123", "role": "viewer"}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.anyio +async def test_delete_user(client, auth_token): + # Create a user to delete + create_resp = await client.post( + "/api/v1/config/users", + json={"username": "todelete", "password": "securepass123", "role": "viewer"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + user_uuid = create_resp.json()["uuid"] + + resp = await client.delete( + f"/api/v1/config/users/{user_uuid}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.anyio +async def test_delete_self_forbidden(client, auth_token): + # Get own UUID from config + config_resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + users = config_resp.json()["users"] + admin_uuid = next(u["uuid"] for u in users if u["role"] == "admin") + + resp = await client.delete( + f"/api/v1/config/users/{admin_uuid}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.anyio +async def test_delete_nonexistent_user(client, auth_token): + resp = await client.delete( + "/api/v1/config/users/00000000-0000-0000-0000-000000000000", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 404 + + +@pytest.mark.anyio +async def test_update_user_role(client, auth_token): + create_resp = await client.post( + "/api/v1/config/users", + json={"username": "roletest", "password": "securepass123", "role": "viewer"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + user_uuid = create_resp.json()["uuid"] + + resp = await client.put( + f"/api/v1/config/users/{user_uuid}/role", + json={"role": "admin"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + + # Verify role changed + config_resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + updated = next(u for u in config_resp.json()["users"] if u["uuid"] == user_uuid) + assert updated["role"] == "admin" + + +@pytest.mark.anyio +async def test_update_own_role_forbidden(client, auth_token): + config_resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + admin_uuid = next(u["uuid"] for u in config_resp.json()["users"] if u["role"] == "admin") + + resp = await client.put( + f"/api/v1/config/users/{admin_uuid}/role", + json={"role": "viewer"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.anyio +async def test_reset_user_password(client, auth_token): + create_resp = await client.post( + "/api/v1/config/users", + json={"username": "resetme", "password": "securepass123", "role": "viewer"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + user_uuid = create_resp.json()["uuid"] + + resp = await client.put( + f"/api/v1/config/users/{user_uuid}/reset-password", + json={"new_password": "newpass12345"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + + # Verify must_change_password is set + config_resp = await client.get( + "/api/v1/config", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + updated = next(u for u in config_resp.json()["users"] if u["uuid"] == user_uuid) + assert updated["must_change_password"] is True + + # Verify new password works + login_resp = await client.post( + "/api/v1/auth/login", + json={"username": "resetme", "password": "newpass12345"}, + ) + assert login_resp.status_code == 200 + + +@pytest.mark.anyio +async def test_all_user_endpoints_viewer_forbidden(client, auth_token, viewer_token): + """Viewer cannot access any user management endpoints.""" + resp = await client.post( + "/api/v1/config/users", + json={"username": "x", "password": "securepass123", "role": "viewer"}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + resp = await client.delete( + "/api/v1/config/users/fake-uuid", + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + resp = await client.put( + "/api/v1/config/users/fake-uuid/role", + json={"role": "admin"}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + resp = await client.put( + "/api/v1/config/users/fake-uuid/reset-password", + json={"new_password": "securepass123"}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 diff --git a/tests/api/test_rbac.py b/tests/api/test_rbac.py new file mode 100644 index 0000000..e05c561 --- /dev/null +++ b/tests/api/test_rbac.py @@ -0,0 +1,116 @@ +"""RBAC matrix tests — verify role enforcement on every API endpoint.""" +import pytest + + +# ── Read-only endpoints: viewer + admin should both get access ────────── + +_VIEWER_ENDPOINTS = [ + ("GET", "/api/v1/logs"), + ("GET", "/api/v1/logs/histogram"), + ("GET", "/api/v1/bounty"), + ("GET", "/api/v1/deckies"), + ("GET", "/api/v1/stats"), + ("GET", "/api/v1/attackers"), + ("GET", "/api/v1/config"), +] + + +@pytest.mark.anyio +@pytest.mark.parametrize("method,path", _VIEWER_ENDPOINTS) +async def test_viewer_can_access_read_endpoints(client, viewer_token, method, path): + resp = await client.request( + method, path, headers={"Authorization": f"Bearer {viewer_token}"} + ) + assert resp.status_code == 200, f"{method} {path} returned {resp.status_code}" + + +@pytest.mark.anyio +@pytest.mark.parametrize("method,path", _VIEWER_ENDPOINTS) +async def test_admin_can_access_read_endpoints(client, auth_token, method, path): + resp = await client.request( + method, path, headers={"Authorization": f"Bearer {auth_token}"} + ) + assert resp.status_code == 200, f"{method} {path} returned {resp.status_code}" + + +# ── Admin-only endpoints: viewer must get 403 ────────────────────────── + +_ADMIN_ENDPOINTS = [ + ("PUT", "/api/v1/config/deployment-limit", {"deployment_limit": 5}), + ("PUT", "/api/v1/config/global-mutation-interval", {"global_mutation_interval": "1d"}), + ("POST", "/api/v1/config/users", {"username": "rbac-test", "password": "pass123456", "role": "viewer"}), +] + + +@pytest.mark.anyio +@pytest.mark.parametrize("method,path,body", _ADMIN_ENDPOINTS) +async def test_viewer_blocked_from_admin_endpoints(client, viewer_token, method, path, body): + resp = await client.request( + method, path, + json=body, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403, f"{method} {path} returned {resp.status_code}" + + +@pytest.mark.anyio +@pytest.mark.parametrize("method,path,body", _ADMIN_ENDPOINTS) +async def test_admin_can_access_admin_endpoints(client, auth_token, method, path, body): + resp = await client.request( + method, path, + json=body, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200, f"{method} {path} returned {resp.status_code}" + + +# ── Unauthenticated access: must get 401 ─────────────────────────────── + +_ALL_PROTECTED = [ + ("GET", "/api/v1/logs"), + ("GET", "/api/v1/stats"), + ("GET", "/api/v1/deckies"), + ("GET", "/api/v1/bounty"), + ("GET", "/api/v1/attackers"), + ("GET", "/api/v1/config"), + ("PUT", "/api/v1/config/deployment-limit"), + ("POST", "/api/v1/config/users"), +] + + +@pytest.mark.anyio +@pytest.mark.parametrize("method,path", _ALL_PROTECTED) +async def test_unauthenticated_returns_401(client, method, path): + resp = await client.request(method, path) + assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}" + + +# ── Fleet write endpoints: viewer must get 403 ───────────────────────── + +@pytest.mark.anyio +async def test_viewer_blocked_from_deploy(client, viewer_token): + resp = await client.post( + "/api/v1/deckies/deploy", + json={"ini_content": "[decky-rbac-test]\nservices=ssh"}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.anyio +async def test_viewer_blocked_from_mutate(client, viewer_token): + resp = await client.post( + "/api/v1/deckies/test-decky/mutate", + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.anyio +async def test_viewer_blocked_from_mutate_interval(client, viewer_token): + resp = await client.put( + "/api/v1/deckies/test-decky/mutate-interval", + json={"mutate_interval": "5d"}, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert resp.status_code == 403 diff --git a/tests/test_profiler_behavioral.py b/tests/test_profiler_behavioral.py new file mode 100644 index 0000000..44f6dfc --- /dev/null +++ b/tests/test_profiler_behavioral.py @@ -0,0 +1,277 @@ +""" +Unit tests for the profiler behavioral/timing analyzer. + +Covers: + - timing_stats: mean/median/stdev/cv on synthetic event streams + - classify_behavior: beaconing vs interactive vs scanning vs mixed vs unknown + - guess_tool: attribution matching and tolerance boundaries + - phase_sequence: recon → exfil latency detection + - sniffer_rollup: OS-guess mode, hop median, retransmit sum + - build_behavior_record: composite output shape (JSON-encoded subfields) +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from decnet.correlation.parser import LogEvent +from decnet.profiler.behavioral import ( + build_behavior_record, + classify_behavior, + guess_tool, + phase_sequence, + sniffer_rollup, + timing_stats, +) + + +# ─── Helpers ──────────────────────────────────────────────────────────────── + +_BASE = datetime(2026, 4, 15, 12, 0, 0, tzinfo=timezone.utc) + + +def _mk( + ts_offset_s: float, + event_type: str = "connection", + service: str = "ssh", + decky: str = "decky-01", + fields: dict | None = None, + ip: str = "10.0.0.7", +) -> LogEvent: + """Build a synthetic LogEvent at BASE + offset seconds.""" + return LogEvent( + timestamp=_BASE + timedelta(seconds=ts_offset_s), + decky=decky, + service=service, + event_type=event_type, + attacker_ip=ip, + fields=fields or {}, + raw="", + ) + + +def _regular_beacon(count: int, interval_s: float, jitter_s: float = 0.0) -> list[LogEvent]: + """ + Build *count* events with alternating IATs of (interval_s ± jitter_s). + + This yields: + - mean IAT = interval_s + - stdev IAT = jitter_s + - coefficient of variation = jitter_s / interval_s + """ + events: list[LogEvent] = [] + offset = 0.0 + events.append(_mk(offset)) + for i in range(1, count): + iat = interval_s + (jitter_s if i % 2 == 1 else -jitter_s) + offset += iat + events.append(_mk(offset)) + return events + + +# ─── timing_stats ─────────────────────────────────────────────────────────── + +class TestTimingStats: + def test_empty_returns_nulls(self): + s = timing_stats([]) + assert s["event_count"] == 0 + assert s["mean_iat_s"] is None + assert s["cv"] is None + + def test_single_event(self): + s = timing_stats([_mk(0)]) + assert s["event_count"] == 1 + assert s["duration_s"] == 0.0 + assert s["mean_iat_s"] is None + + def test_regular_cadence_cv_is_zero(self): + events = _regular_beacon(count=10, interval_s=60.0) + s = timing_stats(events) + assert s["event_count"] == 10 + assert s["mean_iat_s"] == 60.0 + assert s["cv"] == 0.0 + assert s["stdev_iat_s"] == 0.0 + + def test_jittered_cadence(self): + events = _regular_beacon(count=20, interval_s=60.0, jitter_s=12.0) + s = timing_stats(events) + # Mean is close to 60, cv ~20% (jitter 12 / interval 60) + assert abs(s["mean_iat_s"] - 60.0) < 2.0 + assert s["cv"] is not None + assert 0.10 < s["cv"] < 0.50 + + +# ─── classify_behavior ────────────────────────────────────────────────────── + +class TestClassifyBehavior: + def test_unknown_if_too_few(self): + s = timing_stats(_regular_beacon(count=2, interval_s=60.0)) + assert classify_behavior(s, services_count=1) == "unknown" + + def test_beaconing_regular_cadence(self): + s = timing_stats(_regular_beacon(count=10, interval_s=60.0, jitter_s=3.0)) + assert classify_behavior(s, services_count=1) == "beaconing" + + def test_interactive_fast_irregular(self): + # Very fast events with high variance ≈ a human hitting keys + think time + events = [] + times = [0, 0.2, 0.5, 1.0, 5.0, 5.1, 5.3, 10.0, 10.1, 10.2, 12.0] + for t in times: + events.append(_mk(t)) + s = timing_stats(events) + assert classify_behavior(s, services_count=1) == "interactive" + + def test_scanning_many_services_fast(self): + # 10 events across 5 services, each 0.2s apart + events = [] + svcs = ["ssh", "http", "smb", "ftp", "rdp"] + for i in range(10): + events.append(_mk(i * 0.2, service=svcs[i % 5])) + s = timing_stats(events) + assert classify_behavior(s, services_count=5) == "scanning" + + def test_mixed_fallback(self): + # Moderate count, moderate cv, single service, moderate cadence + events = _regular_beacon(count=6, interval_s=20.0, jitter_s=10.0) + s = timing_stats(events) + # cv ~0.5, not tight enough for beaconing, mean 20s > interactive + result = classify_behavior(s, services_count=1) + assert result in ("mixed", "interactive") # either is acceptable + + +# ─── guess_tool ───────────────────────────────────────────────────────────── + +class TestGuessTool: + def test_cobalt_strike(self): + # Default: 60s interval, 20% jitter → cv 0.20 + assert guess_tool(mean_iat_s=60.0, cv=0.20) == "cobalt_strike" + + def test_havoc(self): + # 45s interval, 10% jitter → cv 0.10 + assert guess_tool(mean_iat_s=45.0, cv=0.10) == "havoc" + + def test_mythic(self): + assert guess_tool(mean_iat_s=30.0, cv=0.15) == "mythic" + + def test_no_match_outside_tolerance(self): + # 5-second beacon is far from any default + assert guess_tool(mean_iat_s=5.0, cv=0.10) is None + + def test_none_when_stats_missing(self): + assert guess_tool(None, None) is None + assert guess_tool(60.0, None) is None + + def test_ambiguous_returns_none(self): + # If a signature set is tweaked such that two profiles overlap, + # guess_tool must not attribute. + # Cobalt (60±10s, cv 0.20±0.08) and Sliver (60±15s, cv 0.30±0.10) + # overlap around (60s, cv=0.25). Both match → None. + result = guess_tool(mean_iat_s=60.0, cv=0.25) + assert result is None + + +# ─── phase_sequence ──────────────────────────────────────────────────────── + +class TestPhaseSequence: + def test_recon_then_exfil(self): + events = [ + _mk(0, event_type="scan"), + _mk(10, event_type="login_attempt"), + _mk(20, event_type="auth_failure"), + _mk(120, event_type="exec"), + _mk(150, event_type="download"), + ] + p = phase_sequence(events) + assert p["recon_end_ts"] is not None + assert p["exfil_start_ts"] is not None + assert p["exfil_latency_s"] == 100.0 # 120 - 20 + + def test_no_exfil(self): + events = [_mk(0, event_type="scan"), _mk(10, event_type="scan")] + p = phase_sequence(events) + assert p["exfil_start_ts"] is None + assert p["exfil_latency_s"] is None + + def test_large_payload_counted(self): + events = [ + _mk(0, event_type="download", fields={"bytes": "2097152"}), # 2 MiB + _mk(10, event_type="download", fields={"bytes": "500"}), # small + _mk(20, event_type="upload", fields={"size": "10485760"}), # 10 MiB + ] + p = phase_sequence(events) + assert p["large_payload_count"] == 2 + + +# ─── sniffer_rollup ───────────────────────────────────────────────────────── + +class TestSnifferRollup: + def test_os_mode(self): + events = [ + _mk(0, event_type="tcp_syn_fingerprint", + fields={"os_guess": "linux", "hop_distance": "3", + "window": "29200", "mss": "1460"}), + _mk(5, event_type="tcp_syn_fingerprint", + fields={"os_guess": "linux", "hop_distance": "3", + "window": "29200", "mss": "1460"}), + _mk(10, event_type="tcp_syn_fingerprint", + fields={"os_guess": "windows", "hop_distance": "8", + "window": "64240", "mss": "1460"}), + ] + r = sniffer_rollup(events) + assert r["os_guess"] == "linux" # mode + # Median of [3, 3, 8] = 3 + assert r["hop_distance"] == 3 + # Latest fingerprint snapshot wins + assert r["tcp_fingerprint"]["window"] == 64240 + + def test_retransmits_summed(self): + events = [ + _mk(0, event_type="tcp_flow_timing", fields={"retransmits": "2"}), + _mk(10, event_type="tcp_flow_timing", fields={"retransmits": "5"}), + _mk(20, event_type="tcp_flow_timing", fields={"retransmits": "0"}), + ] + r = sniffer_rollup(events) + assert r["retransmit_count"] == 7 + + def test_empty(self): + r = sniffer_rollup([]) + assert r["os_guess"] is None + assert r["hop_distance"] is None + assert r["retransmit_count"] == 0 + + +# ─── build_behavior_record (composite) ────────────────────────────────────── + +class TestBuildBehaviorRecord: + def test_beaconing_with_cobalt_strike_match(self): + # 60s interval, 20% jitter → cobalt strike default + events = _regular_beacon(count=20, interval_s=60.0, jitter_s=12.0) + r = build_behavior_record(events) + assert r["behavior_class"] == "beaconing" + assert r["beacon_interval_s"] is not None + assert 50 < r["beacon_interval_s"] < 70 + assert r["beacon_jitter_pct"] is not None + assert r["tool_guess"] == "cobalt_strike" + + def test_json_fields_are_strings(self): + events = _regular_beacon(count=5, interval_s=60.0) + r = build_behavior_record(events) + # timing_stats, phase_sequence, tcp_fingerprint must be JSON strings + assert isinstance(r["timing_stats"], str) + json.loads(r["timing_stats"]) # doesn't raise + assert isinstance(r["phase_sequence"], str) + json.loads(r["phase_sequence"]) + assert isinstance(r["tcp_fingerprint"], str) + json.loads(r["tcp_fingerprint"]) + + def test_non_beaconing_has_null_beacon_fields(self): + # Scanning behavior — should not report a beacon interval + events = [] + svcs = ["ssh", "http", "smb", "ftp", "rdp"] + for i in range(10): + events.append(_mk(i * 0.2, service=svcs[i % 5])) + r = build_behavior_record(events) + assert r["behavior_class"] == "scanning" + assert r["beacon_interval_s"] is None + assert r["beacon_jitter_pct"] is None