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.
This commit is contained in:
287
tests/correlation/test_credential_reuse.py
Normal file
287
tests/correlation/test_credential_reuse.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user