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:
2026-04-26 03:37:49 -04:00
parent 00ecea924a
commit 590c2b0fac
8 changed files with 705 additions and 5 deletions

View File

@@ -214,6 +214,62 @@ class CorrelationEngine:
"traversals": [t.to_dict() for t in self.traversals(min_deckies)],
}
# ------------------------------------------------------------------ #
# Credential reuse #
# ------------------------------------------------------------------ #
async def correlate_credential_reuse(
self,
repo: Any,
min_targets: int = 2,
) -> list[dict[str, Any]]:
"""Detect cross-target credential reuse and persist findings.
Groups every ``Credential`` row by ``(secret_sha256, secret_kind,
principal)``. Groups crossing *min_targets* distinct
``(decky, service)`` pairs are folded into ``CredentialReuse`` via
:meth:`BaseRepository.upsert_credential_reuse` — one upsert per
underlying credential row, since the upsert itself dedups on the
unique key and recomputes aggregates from the credentials table.
Returns the upsert results that flipped ``inserted`` or
``changed``, so the caller can publish ``credential.reuse.detected``
for each new or grown finding without re-querying.
"""
results: list[dict[str, Any]] = []
candidates = await repo.find_credential_reuse_candidates(min_targets)
for group in candidates:
# Per-group flags: each credential in a group hits the same
# CredentialReuse row, so several upserts may flip
# ``inserted``/``changed`` along the way. Collapse to one
# publish per group keyed by the final state — otherwise a
# group of N creds emits N partial reuse.detected events
# with intermediate target_counts.
final_row: dict[str, Any] | None = None
saw_insert = False
saw_change = False
for cred in group["credentials"]:
row = await repo.upsert_credential_reuse(
secret_sha256=group["secret_sha256"],
secret_kind=group["secret_kind"],
principal=group["principal"],
attacker_uuid=cred.get("attacker_uuid"),
attacker_ip=cred["attacker_ip"],
decky=cred["decky_name"],
service=cred["service"],
attempt_count=int(cred.get("attempt_count") or 1),
)
if row is None:
continue
final_row = row
saw_insert = saw_insert or bool(row.get("inserted"))
saw_change = saw_change or bool(row.get("changed"))
if final_row is not None and (saw_insert or saw_change):
final_row["inserted"] = saw_insert
final_row["changed"] = saw_change
results.append(final_row)
return results
@_traced("correlation.traversal_syslog_lines")
def traversal_syslog_lines(self, min_deckies: int = 2) -> list[str]:
"""

View File

@@ -0,0 +1,153 @@
"""Long-running credential-reuse correlator.
Loops :meth:`CorrelationEngine.correlate_credential_reuse` over the
credentials table and publishes ``credential.reuse.detected`` for every
new or grown ``CredentialReuse`` row. Mirrors the mutator's bus-wake +
slow-tick pattern from :mod:`decnet.mutator.engine`: woken on
``credential.captured`` and ``attacker.observed`` for sub-second latency,
falls back to a 60s poll if the bus is unavailable.
"""
from __future__ import annotations
import asyncio
import contextlib
from decnet.bus import topics as _topics
from decnet.bus.base import BaseBus
from decnet.bus.factory import get_bus
from decnet.bus.publish import (
publish_safely,
run_control_listener_signal as _run_control_listener_signal,
run_health_heartbeat as _run_health_heartbeat,
)
from decnet.correlation.engine import CorrelationEngine
from decnet.logging import get_logger
from decnet.web.db.repository import BaseRepository
log = get_logger("correlation.reuse_worker")
_DEFAULT_POLL_SECS = 60.0
_DEFAULT_MIN_TARGETS = 2
async def run_reuse_loop(
repo: BaseRepository,
*,
poll_interval_secs: float = _DEFAULT_POLL_SECS,
min_targets: int = _DEFAULT_MIN_TARGETS,
shutdown: asyncio.Event | None = None,
) -> None:
"""Run the credential-reuse correlator until cancelled.
*shutdown* is an optional external stop signal; the loop also exits
cleanly on ``CancelledError`` and ``KeyboardInterrupt``. The
*min_targets* threshold is the minimum number of distinct
``(decky, service)`` pairs a secret must touch before it's persisted
as a reuse finding.
"""
log.info(
"reuse correlator started poll_interval_secs=%s min_targets=%s",
poll_interval_secs, min_targets,
)
bus: BaseBus | None = None
wake = asyncio.Event()
wake_tasks: list[asyncio.Task] = []
heartbeat_task: asyncio.Task | None = None
try:
candidate = get_bus(client_name="reuse-correlator")
await candidate.connect()
bus = candidate
wake_tasks.append(asyncio.create_task(
_wake_on(bus, wake, _topics.credential(_topics.CREDENTIAL_CAPTURED)),
))
wake_tasks.append(asyncio.create_task(
_wake_on(bus, wake, _topics.attacker(_topics.ATTACKER_OBSERVED)),
))
heartbeat_task = asyncio.create_task(
_run_health_heartbeat(bus, "reuse-correlator"),
)
wake_tasks.append(asyncio.create_task(
_run_control_listener_signal(bus, "reuse-correlator"),
))
except Exception as exc: # noqa: BLE001
log.warning(
"reuse correlator: bus unavailable, running in poll-only mode: %s",
exc,
)
engine = CorrelationEngine()
if shutdown is None:
shutdown = asyncio.Event()
try:
while not shutdown.is_set():
try:
results = await engine.correlate_credential_reuse(
repo, min_targets=min_targets,
)
except Exception: # noqa: BLE001
log.exception("reuse correlator: tick failed")
results = []
for row in results:
await publish_safely(
bus,
_topics.credential(_topics.CREDENTIAL_REUSE_DETECTED),
{
"id": row.get("id"),
"secret_kind": row.get("secret_kind"),
"target_count": row.get("target_count"),
"attacker_uuids": row.get("attacker_uuids"),
"attacker_ips": row.get("attacker_ips"),
"deckies": row.get("deckies"),
"services": row.get("services"),
},
event_type=_topics.CREDENTIAL_REUSE_DETECTED,
)
try:
await asyncio.wait_for(
wake.wait(), timeout=float(poll_interval_secs),
)
except asyncio.TimeoutError:
pass
wake.clear()
except (asyncio.CancelledError, KeyboardInterrupt):
log.info("reuse correlator stopped")
finally:
for t in wake_tasks:
t.cancel()
if heartbeat_task is not None:
heartbeat_task.cancel()
for t in (*wake_tasks, heartbeat_task):
if t is None:
continue
with contextlib.suppress(asyncio.CancelledError, Exception):
await t
if bus is not None:
with contextlib.suppress(Exception):
await bus.close()
async def _wake_on(bus: BaseBus, wake: asyncio.Event, pattern: str) -> None:
"""Flip *wake* every time *pattern* fires on the bus.
Survives transient subscriber errors by logging and exiting; the
poll-interval fallback keeps the loop alive in poll-only mode.
"""
try:
sub = bus.subscribe(pattern)
async with sub:
async for _event in sub:
wake.set()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.warning(
"reuse correlator: subscriber for %s died (%s); falling back to poll",
pattern, exc,
)
__all__ = ["run_reuse_loop"]