feat(prober-cert): roll up fingerprints onto AttackerIdentity
Brings the federation-gossip columns on AttackerIdentity to life —
ja3_hashes, hassh_hashes, and the new tls_cert_sha256 — by projecting
the union of every member observation's fingerprints JSON onto the
identity at clusterer create / link / merge time.
- decnet/profiler/identity_rollup.py: pure extract_fp_summaries()
reads the production bounty shape (payload.fingerprint_type +
payload.{ja3,hash,cert_sha256}) and returns deduped+sorted JSON
list[str] per family, or None when a family has no signal so the
column stays NULL instead of '[]'.
- BaseRepository.update_identity_fingerprints + SQLModel impl: one
idempotent write that overwrites the three summary columns and
bumps updated_at.
- ConnectedComponentsClusterer: after every per-component
reconciliation (fresh-create OR existing-merge+link), recomputes
and writes the rollup for the target identity. Wrapped in a
best-effort helper so a write failure logs but never breaks the
tick.
- Tests: extract_fp_summaries unit (dedup, sort determinism,
unknown types ignored, malformed JSON, nested-stringified
payloads, non-string values); end-to-end clusterer ticks
populate the columns on create + on later observation links;
no-fingerprint clusters keep the columns NULL.
This commit is contained in:
@@ -474,6 +474,26 @@ class BaseRepository(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_identity_fingerprints(
|
||||
self,
|
||||
identity_uuid: str,
|
||||
*,
|
||||
ja3_hashes: Optional[str] = None,
|
||||
hassh_hashes: Optional[str] = None,
|
||||
tls_cert_sha256: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Set the fingerprint summary columns on one ``AttackerIdentity``.
|
||||
|
||||
Each argument is a JSON-encoded ``list[str]`` (the federation
|
||||
wire shape) or ``None`` to leave the corresponding column at
|
||||
``NULL``. Always overwrites — the rollup writer is the source
|
||||
of truth for these columns, computed deterministically from
|
||||
the identity's member observations every clusterer tick. Also
|
||||
bumps ``updated_at`` so cache subscribers can invalidate.
|
||||
"""
|
||||
pass
|
||||
|
||||
# ─── Campaign clustering reads ────────────────────────────────────────
|
||||
# Layer above identity resolution: campaigns group identities into
|
||||
# operations. Populated by ``decnet campaign-clusterer``. The
|
||||
|
||||
@@ -1545,6 +1545,28 @@ class SQLModelRepository(BaseRepository):
|
||||
await session.execute(statement)
|
||||
await session.commit()
|
||||
|
||||
async def update_identity_fingerprints(
|
||||
self,
|
||||
identity_uuid: str,
|
||||
*,
|
||||
ja3_hashes: Optional[str] = None,
|
||||
hassh_hashes: Optional[str] = None,
|
||||
tls_cert_sha256: Optional[str] = None,
|
||||
) -> None:
|
||||
statement = (
|
||||
update(AttackerIdentity)
|
||||
.where(AttackerIdentity.uuid == identity_uuid)
|
||||
.values(
|
||||
ja3_hashes=ja3_hashes,
|
||||
hassh_hashes=hassh_hashes,
|
||||
tls_cert_sha256=tls_cert_sha256,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
async with self._session() as session:
|
||||
await session.execute(statement)
|
||||
await session.commit()
|
||||
|
||||
# ─── Campaign clustering reads ────────────────────────────────────────
|
||||
|
||||
async def get_campaign_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
|
||||
|
||||
Reference in New Issue
Block a user