Files
DECNET/tests/correlation/test_credential_reuse.py
anti 590c2b0fac feat(correlation): credential-reuse engine + reuse-correlate worker
Adds CorrelationEngine.correlate_credential_reuse + the
`decnet reuse-correlate` long-running worker. The worker mirrors the
mutator's bus-wake + slow-tick pattern: wakes on credential.captured
and attacker.observed for sub-second latency, falls back to a 60s
poll if the bus is unavailable, and publishes
credential.reuse.detected once per new or grown CredentialReuse row
(group-deduped so a 5-cred reuse doesn't emit 5 partial events).

The web ingester now publishes credential.captured after every
successful Credential upsert; bus + new repo helper
find_credential_reuse_candidates feed the engine pass.
2026-04-26 03:37:49 -04:00

288 lines
11 KiB
Python

"""Credential-reuse correlator tests.
Covers:
- ``CorrelationEngine.correlate_credential_reuse`` — group detection,
threshold gating, idempotency on a second call.
- ``run_reuse_loop`` — bus-driven wake, reuse.detected publish on
insert/grow, clean shutdown via the *shutdown* signal.
- Repo helper ``find_credential_reuse_candidates`` — used by the engine.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
from pathlib import Path
import pytest
from decnet.bus import topics as _topics
from decnet.bus.fake import FakeBus
from decnet.correlation.engine import CorrelationEngine
from decnet.correlation.reuse_worker import run_reuse_loop
from decnet.web.db.factory import get_repository
def _sha256(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
@pytest.fixture
async def repo(tmp_path: Path):
r = get_repository(db_path=str(tmp_path / "reuse_corr.db"))
await r.initialize()
return r
async def _seed_credential(repo, **overrides):
base = {
"attacker_ip": "10.0.0.5",
"decky_name": "decky-01",
"service": "ssh",
"principal": "root",
"secret_kind": "plaintext",
"secret_sha256": _sha256("hunter2"),
"secret_b64": "aHVudGVyMg==",
"secret_printable": "hunter2",
"fields": {},
}
base.update(overrides)
return await repo.upsert_credential(base)
# ─── find_credential_reuse_candidates ────────────────────────────────────────
class TestFindCandidates:
@pytest.mark.anyio
async def test_below_threshold_excluded(self, repo) -> None:
sha = _sha256("solo")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
groups = await repo.find_credential_reuse_candidates(min_targets=2)
assert groups == []
@pytest.mark.anyio
async def test_threshold_exact_match_included(self, repo) -> None:
sha = _sha256("p4ss")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
await _seed_credential(repo, secret_sha256=sha, decky_name="d2", service="ftp")
groups = await repo.find_credential_reuse_candidates(min_targets=2)
assert len(groups) == 1
g = groups[0]
assert g["secret_sha256"] == sha
assert g["secret_kind"] == "plaintext"
assert g["target_count"] == 2
assert len(g["credentials"]) == 2
@pytest.mark.anyio
async def test_distinct_principals_form_distinct_groups(self, repo) -> None:
"""Same secret + different principals → two separate groups."""
sha = _sha256("hunter2")
await _seed_credential(
repo, secret_sha256=sha, principal="root",
decky_name="d1", service="ssh",
)
await _seed_credential(
repo, secret_sha256=sha, principal="root",
decky_name="d2", service="ftp",
)
await _seed_credential(
repo, secret_sha256=sha, principal="admin",
decky_name="d1", service="ssh",
)
await _seed_credential(
repo, secret_sha256=sha, principal="admin",
decky_name="d2", service="ftp",
)
groups = await repo.find_credential_reuse_candidates(min_targets=2)
principals = sorted(g["principal"] for g in groups)
assert principals == ["admin", "root"]
@pytest.mark.anyio
async def test_repeated_decky_service_does_not_count_twice(self, repo) -> None:
"""A repeat attempt on the same (decky, service) doesn't pad target_count."""
sha = _sha256("h2")
# Two attempts on the same decky/service → upsert dedups.
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
groups = await repo.find_credential_reuse_candidates(min_targets=2)
assert groups == []
# ─── CorrelationEngine.correlate_credential_reuse ────────────────────────────
class TestEngineCorrelate:
@pytest.mark.anyio
async def test_emits_reuse_for_qualifying_group(self, repo) -> None:
sha = _sha256("hunter2")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
await _seed_credential(repo, secret_sha256=sha, decky_name="d2", service="ftp")
engine = CorrelationEngine()
results = await engine.correlate_credential_reuse(repo, min_targets=2)
assert len(results) >= 1
assert any(r.get("inserted") for r in results)
total, rows = await repo.list_credential_reuses(min_target_count=2)
assert total == 1
assert rows[0]["target_count"] == 2
@pytest.mark.anyio
async def test_below_threshold_persists_nothing(self, repo) -> None:
sha = _sha256("loner")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
engine = CorrelationEngine()
results = await engine.correlate_credential_reuse(repo, min_targets=2)
assert results == []
total, _ = await repo.list_credential_reuses(min_target_count=2)
assert total == 0
@pytest.mark.anyio
async def test_idempotent_on_second_run(self, repo) -> None:
"""A second call with no new credentials returns no
insert/grow rows and leaves the table at the same row count.
"""
sha = _sha256("idempotent")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
await _seed_credential(repo, secret_sha256=sha, decky_name="d2", service="ftp")
engine = CorrelationEngine()
await engine.correlate_credential_reuse(repo, min_targets=2)
before_total, _ = await repo.list_credential_reuses(min_target_count=2)
results2 = await engine.correlate_credential_reuse(repo, min_targets=2)
after_total, _ = await repo.list_credential_reuses(min_target_count=2)
assert before_total == after_total == 1
assert results2 == []
@pytest.mark.anyio
async def test_growth_emits_changed(self, repo) -> None:
"""Adding a third target after an initial reuse run yields a
``changed`` row on the next correlation pass.
"""
sha = _sha256("grower")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
await _seed_credential(repo, secret_sha256=sha, decky_name="d2", service="ftp")
engine = CorrelationEngine()
await engine.correlate_credential_reuse(repo, min_targets=2)
await _seed_credential(repo, secret_sha256=sha, decky_name="d3", service="rdp")
results = await engine.correlate_credential_reuse(repo, min_targets=2)
assert any(r.get("changed") for r in results)
_, rows = await repo.list_credential_reuses(min_target_count=2)
assert rows[0]["target_count"] == 3
# ─── run_reuse_loop ──────────────────────────────────────────────────────────
class TestRunReuseLoop:
@pytest.mark.anyio
async def test_publishes_reuse_detected_on_insert(self, repo, monkeypatch) -> None:
"""One ``credential.reuse.detected`` per new CredentialReuse row."""
bus = FakeBus()
await bus.connect()
# Force the worker to pick up our FakeBus.
from decnet.correlation import reuse_worker as _rw
monkeypatch.setattr(_rw, "get_bus", lambda client_name=None: bus)
sha = _sha256("loop-insert")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
await _seed_credential(repo, secret_sha256=sha, decky_name="d2", service="ftp")
sub = bus.subscribe(_topics.credential(_topics.CREDENTIAL_REUSE_DETECTED))
shutdown = asyncio.Event()
task = asyncio.create_task(run_reuse_loop(
repo, poll_interval_secs=60.0, min_targets=2, shutdown=shutdown,
))
# Wait for the first tick to publish.
async with sub:
event = await asyncio.wait_for(sub.__anext__(), timeout=5.0)
assert event.topic == _topics.credential(_topics.CREDENTIAL_REUSE_DETECTED)
assert event.payload["target_count"] == 2
assert event.payload["secret_kind"] == "plaintext"
shutdown.set()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await bus.close()
@pytest.mark.anyio
async def test_no_reuse_no_publish(self, repo, monkeypatch) -> None:
"""A loop with no qualifying groups publishes nothing on its tick."""
bus = FakeBus()
await bus.connect()
from decnet.correlation import reuse_worker as _rw
monkeypatch.setattr(_rw, "get_bus", lambda client_name=None: bus)
sha = _sha256("loner-loop")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
sub = bus.subscribe(_topics.credential(_topics.CREDENTIAL_REUSE_DETECTED))
shutdown = asyncio.Event()
task = asyncio.create_task(run_reuse_loop(
repo, poll_interval_secs=0.05, min_targets=2, shutdown=shutdown,
))
# Let the loop run a few ticks.
await asyncio.sleep(0.3)
async with sub:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(sub.__anext__(), timeout=0.1)
shutdown.set()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await bus.close()
@pytest.mark.anyio
async def test_no_duplicate_publish_on_second_tick(
self, repo, monkeypatch,
) -> None:
"""A subsequent tick with no new credentials must not republish."""
bus = FakeBus()
await bus.connect()
from decnet.correlation import reuse_worker as _rw
monkeypatch.setattr(_rw, "get_bus", lambda client_name=None: bus)
sha = _sha256("once")
await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh")
await _seed_credential(repo, secret_sha256=sha, decky_name="d2", service="ftp")
sub = bus.subscribe(_topics.credential(_topics.CREDENTIAL_REUSE_DETECTED))
shutdown = asyncio.Event()
task = asyncio.create_task(run_reuse_loop(
repo, poll_interval_secs=0.05, min_targets=2, shutdown=shutdown,
))
# Drain the first publish (the insert).
async with sub:
await asyncio.wait_for(sub.__anext__(), timeout=5.0)
# Subsequent ticks must produce no further publishes.
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(sub.__anext__(), timeout=0.3)
shutdown.set()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await bus.close()