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.
288 lines
11 KiB
Python
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()
|