feat(creds): cred-reuse foundation + vectorstore scaffold
Lays the storage and bus substrate for the "credential reuse patterns"
task in DEVELOPMENT.md and scaffolds decnet/vectorstore/ as the future
substrate for statistical attacker re-identification over behavioral
fingerprints. No correlator, profiler, API, or dashboard wiring in
this commit — see TODO.md for the handoff.
Schema:
- Credential.attacker_uuid (nullable FK to attackers.uuid),
backfilled by the profiler post-write to avoid coupling the
capture path to the profiler's ordering.
- CredentialReuse table — UUID PK, JSON list columns for the
accumulating attacker_uuids/ips/deckies/services, target_count
(the discriminative scalar), confidence reserved for a future
fuzzy-credential pass.
Repo:
- upsert_credential_reuse / list_credential_reuses /
get_credential_reuse_by_id / update_credential_attacker_uuid.
- Renamed pre-existing get_credential_reuse(secret_sha256) to
get_credential_attempts_for_secret(secret_sha256) — the new
findings table needs the cleaner name.
Bus topics:
- credential.captured (one per Credential upsert)
- credential.reuse.detected (correlator-emitted on insert/grow)
Vectorstore subpackage (decnet/vectorstore/, flat layout mirroring
decnet/bus/):
- BaseVectorStore ABC keyed by (kind, id) — kind discriminator
means new feature families are additive, no schema migration.
- FakeVectorStore (in-memory L2 KNN), NullVectorStore (no-op for
DECNET_VECTORSTORE_ENABLED=false), SqliteVecVectorStore (lazy
sqlite_vec extension load, one vec0 virtual table per kind).
- get_vectorstore() env-driven dispatch with graceful fallback
to FakeVectorStore when the sqlite-vec extension isn't on the
host, so workers don't crash on a missing optional dep.
Tests: 26 new (11 cred-reuse repo, 15 vectorstore). Existing
credentials and base-repo tests updated for the rename. Total: 34
passing on the touched files.
This commit is contained in:
@@ -153,12 +153,59 @@ class BaseRepository(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_credential_reuse(
|
||||
async def get_credential_attempts_for_secret(
|
||||
self, secret_sha256: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Every (attacker, decky, service, principal) row sharing this secret hash."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def upsert_credential_reuse(
|
||||
self,
|
||||
*,
|
||||
secret_sha256: str,
|
||||
secret_kind: str,
|
||||
principal: Optional[str],
|
||||
attacker_uuid: Optional[str],
|
||||
attacker_ip: str,
|
||||
decky: str,
|
||||
service: str,
|
||||
attempt_count: int,
|
||||
ts: Optional[Any] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Upsert one credential-reuse finding. Returns the row dict (with
|
||||
``inserted: bool`` mixed in) on insert/update, or None if the row
|
||||
is below the reuse threshold and shouldn't be persisted yet.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_credential_reuses(
|
||||
self,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
min_target_count: int = 2,
|
||||
secret_kind: Optional[str] = None,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""Paged list of credential-reuse findings ordered by target_count desc."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_credential_reuse_by_id(
|
||||
self, reuse_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""One credential-reuse finding by UUID, or None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_credential_attacker_uuid(
|
||||
self, attacker_ip: str, attacker_uuid: str
|
||||
) -> int:
|
||||
"""Backfill ``attacker_uuid`` on every Credential row matching the IP
|
||||
whose ``attacker_uuid`` is currently null. Returns rows updated.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_state(self, key: str) -> Optional[dict[str, Any]]:
|
||||
"""Retrieve a specific state entry by key."""
|
||||
|
||||
Reference in New Issue
Block a user