feat(correlation/attribution): wire bus handler, persist state (Phase 4)
attribution_worker.handle_observation_event now executes the full end-to-end path: * ensure stub identity (Phase 1) * observations_for_identity_primitive() — new repo helper joining observations through attackers.identity_id, so v1's clusterer gets cross-attacker rollup for free * aggregate_observations() with ValueKind dispatched off the BEHAVE PRIMITIVE_REGISTRY; unknown primitives default to categorical * upsert_attribution_state() — last_change_ts locked when state is unchanged so the dashboard can render "stable since X" * publish attribution.profile.state_changed only on transition; idempotent re-runs over the same observation set fire nothing (loop-prevention invariant matching ttp.tagged) Tests: * 5 end-to-end attribution scenarios over in-memory SQLite + FakeBus. * test_base_repo's DummyRepo + coverage body now stub every abstract surface BaseRepository declares — the 6 added by this branch plus the 12 left un-stubbed by earlier work (BEHAVE Phase 1, TTP rollups, iter helpers). The coverage test could not previously even instantiate. * test_aggregate_categorical's dispatcher rejection updated for the Phase 3 + 4 contract — ValueError on unknown kinds, not NotImplementedError.
This commit is contained in:
@@ -26,12 +26,25 @@ from decnet.bus import topics as _topics
|
|||||||
from decnet.bus.base import BaseBus
|
from decnet.bus.base import BaseBus
|
||||||
from decnet.bus.factory import get_bus
|
from decnet.bus.factory import get_bus
|
||||||
from decnet.bus.publish import (
|
from decnet.bus.publish import (
|
||||||
|
publish_safely,
|
||||||
run_control_listener_signal as _run_control_listener_signal,
|
run_control_listener_signal as _run_control_listener_signal,
|
||||||
run_health_heartbeat as _run_health_heartbeat,
|
run_health_heartbeat as _run_health_heartbeat,
|
||||||
)
|
)
|
||||||
|
from decnet.correlation.attribution.aggregate import aggregate_observations
|
||||||
from decnet.logging import get_logger
|
from decnet.logging import get_logger
|
||||||
from decnet.web.db.repository import BaseRepository
|
from decnet.web.db.repository import BaseRepository
|
||||||
|
|
||||||
|
try:
|
||||||
|
from decnet_behave_shell.spec import (
|
||||||
|
PRIMITIVE_REGISTRY,
|
||||||
|
ValueKind,
|
||||||
|
)
|
||||||
|
_BEHAVE_REGISTRY_AVAILABLE = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
PRIMITIVE_REGISTRY = {}
|
||||||
|
ValueKind = None
|
||||||
|
_BEHAVE_REGISTRY_AVAILABLE = False
|
||||||
|
|
||||||
log = get_logger("correlation.attribution_worker")
|
log = get_logger("correlation.attribution_worker")
|
||||||
|
|
||||||
_WORKER_NAME = "attribution"
|
_WORKER_NAME = "attribution"
|
||||||
@@ -156,13 +169,103 @@ async def handle_observation_event(
|
|||||||
attacker_uuid,
|
attacker_uuid,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# Phase 4 will run the merger here and emit
|
primitive_str = str(primitive)
|
||||||
# ``attribution.profile.state_changed`` on transition. Phase 1
|
|
||||||
# ends with stub materialisation only.
|
# Load the full per-(identity, primitive) observation series.
|
||||||
log.debug(
|
# v0 with 1:1 stub identities, this is the single attacker's
|
||||||
"attribution worker: stub identity=%s for attacker=%s primitive=%s",
|
# series; v1's clusterer makes it a cross-attacker union.
|
||||||
identity_uuid, attacker_uuid, primitive,
|
observations = await repo.observations_for_identity_primitive(
|
||||||
|
identity_uuid, primitive_str,
|
||||||
)
|
)
|
||||||
|
if not observations:
|
||||||
|
log.debug(
|
||||||
|
"attribution worker: no observations yet for identity=%s "
|
||||||
|
"primitive=%s (race with upsert)",
|
||||||
|
identity_uuid, primitive_str,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run merger.
|
||||||
|
value_kind = _value_kind_for(primitive_str)
|
||||||
|
new_state = aggregate_observations(observations, value_kind=value_kind)
|
||||||
|
|
||||||
|
# Load prior state to detect transitions.
|
||||||
|
prior = await repo.get_attribution_state(identity_uuid, primitive_str)
|
||||||
|
state_changed = prior is None or prior.get("state") != new_state.state
|
||||||
|
|
||||||
|
# Persist. last_change_ts is locked to the prior row when state is
|
||||||
|
# unchanged so the dashboard's "stable since" timestamp doesn't
|
||||||
|
# reset on every observation.
|
||||||
|
if prior is not None and not state_changed:
|
||||||
|
last_change_ts = float(prior.get("last_change_ts", new_state.last_observation_ts))
|
||||||
|
else:
|
||||||
|
last_change_ts = new_state.last_observation_ts
|
||||||
|
await repo.upsert_attribution_state({
|
||||||
|
"identity_uuid": identity_uuid,
|
||||||
|
"primitive": primitive_str,
|
||||||
|
"current_value": new_state.current_value,
|
||||||
|
"state": new_state.state,
|
||||||
|
"confidence": new_state.confidence,
|
||||||
|
"observation_count": new_state.observation_count,
|
||||||
|
"last_change_ts": last_change_ts,
|
||||||
|
"last_observation_ts": new_state.last_observation_ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Emit state_changed only on transition. Idempotent re-runs (same
|
||||||
|
# observations, same merger output) produce no event — matches
|
||||||
|
# the loop-prevention invariant that ttp.tagged uses.
|
||||||
|
if state_changed and bus is not None:
|
||||||
|
await publish_safely(
|
||||||
|
bus,
|
||||||
|
_topics.attribution(_topics.ATTRIBUTION_PROFILE_STATE_CHANGED),
|
||||||
|
{
|
||||||
|
"identity_uuid": identity_uuid,
|
||||||
|
"primitive": primitive_str,
|
||||||
|
"old_state": prior.get("state") if prior else None,
|
||||||
|
"new_state": new_state.state,
|
||||||
|
"current_value": new_state.current_value,
|
||||||
|
"confidence": new_state.confidence,
|
||||||
|
"observation_count": new_state.observation_count,
|
||||||
|
"ts": new_state.last_observation_ts,
|
||||||
|
},
|
||||||
|
event_type=_topics.ATTRIBUTION_PROFILE_STATE_CHANGED,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"attribution worker: identity=%s primitive=%s %s -> %s confidence=%.2f",
|
||||||
|
identity_uuid, primitive_str,
|
||||||
|
(prior or {}).get("state") or "<new>", new_state.state,
|
||||||
|
new_state.confidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _value_kind_for(primitive: str) -> str:
|
||||||
|
"""Resolve a BEHAVE primitive name to the merger's ValueKind tag.
|
||||||
|
|
||||||
|
Maps the BEHAVE registry's ``ValueKind`` enum onto the three
|
||||||
|
mergers the engine ships:
|
||||||
|
|
||||||
|
* ``CATEGORICAL`` / ``BOOL`` / ``FREE_STRING`` / ``ARRAY`` →
|
||||||
|
``"categorical"`` (BOOL is a 2-cardinality categorical;
|
||||||
|
FREE_STRING and ARRAY collapse to opaque-token categorical
|
||||||
|
until a v1 specialised merger lands)
|
||||||
|
* ``NUMERIC`` → ``"numeric"``
|
||||||
|
* ``HASH`` → ``"hash"``
|
||||||
|
|
||||||
|
Unknown primitives (registry miss) default to categorical — the
|
||||||
|
safest fallback because the categorical merger is one-outlier-
|
||||||
|
tolerant and won't lie about confidence on noisy categorical
|
||||||
|
data the way a numeric merger would on non-numeric values.
|
||||||
|
"""
|
||||||
|
if not _BEHAVE_REGISTRY_AVAILABLE:
|
||||||
|
return "categorical"
|
||||||
|
spec = PRIMITIVE_REGISTRY.get(primitive)
|
||||||
|
if spec is None or ValueKind is None:
|
||||||
|
return "categorical"
|
||||||
|
if spec.kind is ValueKind.NUMERIC:
|
||||||
|
return "numeric"
|
||||||
|
if spec.kind is ValueKind.HASH:
|
||||||
|
return "hash"
|
||||||
|
return "categorical"
|
||||||
|
|
||||||
|
|
||||||
def _payload_of(event: Any) -> dict[str, Any]:
|
def _payload_of(event: Any) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -341,6 +341,20 @@ class BaseRepository(ABC):
|
|||||||
ordered by ``ts`` ASC. Empty list when none."""
|
ordered by ``ts`` ASC. Empty list when none."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def observations_for_identity_primitive(
|
||||||
|
self, identity_uuid: str, primitive: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Every observation of ``primitive`` across all attackers
|
||||||
|
rolling up to ``identity_uuid``, ordered by ``ts`` ASC.
|
||||||
|
|
||||||
|
Empty list when the identity has no observations of this
|
||||||
|
primitive. v0 with 1:1 stub identities returns the same set
|
||||||
|
as ``observations_time_series(attacker_uuid, primitive)``;
|
||||||
|
v1's clusterer makes the union meaningful.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def has_observations_for_evidence(self, evidence_ref: str) -> bool:
|
async def has_observations_for_evidence(self, evidence_ref: str) -> bool:
|
||||||
"""True iff any observation row carries this ``evidence_ref``.
|
"""True iff any observation row carries this ``evidence_ref``.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from typing import Any, Optional
|
|||||||
from sqlalchemy import desc, func, select
|
from sqlalchemy import desc, func, select
|
||||||
from sqlmodel import col
|
from sqlmodel import col
|
||||||
|
|
||||||
from decnet.web.db.models import ObservationRow
|
from decnet.web.db.models import Attacker, ObservationRow
|
||||||
from decnet.web.db.sqlmodel_repo._helpers import _MixinBase
|
from decnet.web.db.sqlmodel_repo._helpers import _MixinBase
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +164,34 @@ class ObservationsMixin(_MixinBase):
|
|||||||
return None
|
return None
|
||||||
return row.model_dump(mode="json")
|
return row.model_dump(mode="json")
|
||||||
|
|
||||||
|
async def observations_for_identity_primitive(
|
||||||
|
self, identity_uuid: str, primitive: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Union of every observation of *primitive* across the
|
||||||
|
attackers rolling up to *identity_uuid*, ordered ``ts`` ASC.
|
||||||
|
|
||||||
|
v0 with 1:1 stub identities returns the same set as
|
||||||
|
``observations_time_series(attacker_uuid, primitive)``.
|
||||||
|
v1's clusterer makes the union load-bearing — multiple
|
||||||
|
attackers point at the same identity_id and this query is
|
||||||
|
what gives the merger a cross-attacker view.
|
||||||
|
"""
|
||||||
|
async with self._session() as session:
|
||||||
|
stmt = (
|
||||||
|
select(ObservationRow)
|
||||||
|
.join(Attacker, ObservationRow.attacker_uuid == Attacker.uuid)
|
||||||
|
.where(
|
||||||
|
Attacker.identity_id == identity_uuid,
|
||||||
|
ObservationRow.primitive == primitive,
|
||||||
|
)
|
||||||
|
.order_by(ObservationRow.ts)
|
||||||
|
)
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
return [
|
||||||
|
{"ts": row.ts, "value": row.value, "confidence": row.confidence}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
async def has_observations_for_evidence(
|
async def has_observations_for_evidence(
|
||||||
self, evidence_ref: str,
|
self, evidence_ref: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -165,11 +165,12 @@ def test_dispatcher_routes_categorical() -> None:
|
|||||||
assert a == b == c
|
assert a == b == c
|
||||||
|
|
||||||
|
|
||||||
def test_dispatcher_rejects_unimplemented_kinds() -> None:
|
def test_dispatcher_rejects_unknown_value_kind() -> None:
|
||||||
"""numeric / hash kinds land in Phase 3; surface the gap loudly
|
"""Unknown ValueKind tags surface as ValueError so misuse doesn't
|
||||||
so a misuse doesn't silently fall through to categorical."""
|
silently fall through to categorical. Phase 3 wired numeric +
|
||||||
|
hash; the rejection is for typos and v1 kinds that haven't
|
||||||
|
landed yet."""
|
||||||
import pytest
|
import pytest
|
||||||
obs = _pad(value=5000.0, count=5)
|
obs = _pad(value="typed", count=5)
|
||||||
for kind in ("numeric", "hash"):
|
with pytest.raises(ValueError):
|
||||||
with pytest.raises(NotImplementedError):
|
aggregate_observations(obs, value_kind="bogus_kind")
|
||||||
aggregate_observations(obs, value_kind=kind)
|
|
||||||
|
|||||||
275
tests/correlation/attribution/test_attribution_worker_phase4.py
Normal file
275
tests/correlation/attribution/test_attribution_worker_phase4.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""Phase 4 — end-to-end worker wiring.
|
||||||
|
|
||||||
|
Observation event → stub identity → load series → merger → upsert
|
||||||
|
state → emit ``attribution.profile.state_changed`` on transition.
|
||||||
|
|
||||||
|
Phase 1 covered stub-only wiring; this file pins the merger /
|
||||||
|
persist / publish path against an in-memory SQLite + FakeBus.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from decnet.bus import topics as _topics
|
||||||
|
from decnet.bus.fake import FakeBus
|
||||||
|
from decnet.correlation import attribution_worker as _aw
|
||||||
|
from decnet.web.db.factory import get_repository
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def repo(tmp_path: Path):
|
||||||
|
r = get_repository(db_path=str(tmp_path / "phase4.db"))
|
||||||
|
await r.initialize()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def attacker_uuid(repo) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return await repo.upsert_attacker({
|
||||||
|
"ip": "10.0.0.5",
|
||||||
|
"first_seen": now,
|
||||||
|
"last_seen": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope(
|
||||||
|
*,
|
||||||
|
primitive: str,
|
||||||
|
value: Any,
|
||||||
|
attacker_uuid: str,
|
||||||
|
evidence_ref: str,
|
||||||
|
ts: float,
|
||||||
|
confidence: float = 0.9,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": f"obs-{evidence_ref}-{primitive}",
|
||||||
|
"primitive": primitive,
|
||||||
|
"value": value,
|
||||||
|
"confidence": confidence,
|
||||||
|
"window_start_ts": ts,
|
||||||
|
"window_end_ts": ts,
|
||||||
|
"source": "test",
|
||||||
|
"evidence_ref": evidence_ref,
|
||||||
|
"envelope_v": 1,
|
||||||
|
"ts": ts,
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bus_event(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Worker reads payload via getattr(.payload, fallback to dict)."""
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_observations(
|
||||||
|
repo, attacker_uuid: str, primitive: str, values: list[Any],
|
||||||
|
*, start_ts: float = 1714000000.0,
|
||||||
|
) -> None:
|
||||||
|
for i, v in enumerate(values):
|
||||||
|
ts = start_ts + i * 60.0
|
||||||
|
# ts in evidence_ref so repeated calls with overlapping i but
|
||||||
|
# distinct start_ts produce distinct rows.
|
||||||
|
await repo.upsert_observation(_envelope(
|
||||||
|
primitive=primitive,
|
||||||
|
value=v,
|
||||||
|
attacker_uuid=attacker_uuid,
|
||||||
|
evidence_ref=f"shard:test#{primitive}-{ts}",
|
||||||
|
ts=ts,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_handler_writes_unknown_below_threshold(
|
||||||
|
repo, attacker_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
"""Two observations for one primitive → state row written with
|
||||||
|
state='unknown' (< MIN_OBSERVATIONS_FOR_STATE)."""
|
||||||
|
bus = FakeBus()
|
||||||
|
await bus.connect()
|
||||||
|
await _seed_observations(
|
||||||
|
repo, attacker_uuid, "motor.input_modality", ["typed", "typed"],
|
||||||
|
)
|
||||||
|
await _aw.handle_observation_event(bus, repo, _bus_event({
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
"primitive": "motor.input_modality",
|
||||||
|
}))
|
||||||
|
|
||||||
|
attacker = await repo.get_attacker_by_uuid(attacker_uuid)
|
||||||
|
assert attacker is not None
|
||||||
|
identity_uuid = attacker["identity_id"]
|
||||||
|
state = await repo.get_attribution_state(
|
||||||
|
identity_uuid, "motor.input_modality",
|
||||||
|
)
|
||||||
|
assert state is not None
|
||||||
|
assert state["state"] == "unknown"
|
||||||
|
await bus.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_handler_emits_state_changed_on_transition(
|
||||||
|
repo, attacker_uuid: str, monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""As observations cross MIN_OBSERVATIONS_FOR_STATE, the worker
|
||||||
|
fires <new>→unknown then unknown→stable; idempotent re-runs in
|
||||||
|
between fire nothing."""
|
||||||
|
bus = FakeBus()
|
||||||
|
await bus.connect()
|
||||||
|
|
||||||
|
captured: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def _capture(_bus, topic, payload, *, event_type=""):
|
||||||
|
captured.append({"topic": topic, "payload": payload})
|
||||||
|
|
||||||
|
monkeypatch.setattr(_aw, "publish_safely", _capture)
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await _seed_observations(
|
||||||
|
repo, attacker_uuid, "motor.input_modality",
|
||||||
|
["typed"], start_ts=1714000000.0 + i * 60.0,
|
||||||
|
)
|
||||||
|
await _aw.handle_observation_event(bus, repo, _bus_event({
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
"primitive": "motor.input_modality",
|
||||||
|
}))
|
||||||
|
|
||||||
|
states_seen = [c["payload"]["new_state"] for c in captured]
|
||||||
|
assert states_seen == ["unknown", "stable"], states_seen
|
||||||
|
# The transition payload carries old + new + the observation that
|
||||||
|
# caused the flip.
|
||||||
|
assert captured[0]["payload"]["old_state"] is None
|
||||||
|
assert captured[1]["payload"]["old_state"] == "unknown"
|
||||||
|
await bus.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_handler_no_event_when_state_unchanged(
|
||||||
|
repo, attacker_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
"""Re-running the merger over an unchanged observation set must
|
||||||
|
not emit a duplicate state_changed event (loop-prevention)."""
|
||||||
|
bus = FakeBus()
|
||||||
|
await bus.connect()
|
||||||
|
|
||||||
|
captured: list[Any] = []
|
||||||
|
sub = bus.subscribe(
|
||||||
|
_topics.attribution(_topics.ATTRIBUTION_PROFILE_STATE_CHANGED),
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def drain() -> None:
|
||||||
|
try:
|
||||||
|
async with sub:
|
||||||
|
async for ev in sub:
|
||||||
|
captured.append(ev)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
drain_task = asyncio.create_task(drain())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
await _seed_observations(
|
||||||
|
repo, attacker_uuid, "motor.input_modality",
|
||||||
|
["typed"] * 5,
|
||||||
|
)
|
||||||
|
# First run: <new> → stable, fires event.
|
||||||
|
await _aw.handle_observation_event(bus, repo, _bus_event({
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
"primitive": "motor.input_modality",
|
||||||
|
}))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
first_count = len(captured)
|
||||||
|
|
||||||
|
# Re-run with no new observations: state stays "stable", no event.
|
||||||
|
for _ in range(3):
|
||||||
|
await _aw.handle_observation_event(bus, repo, _bus_event({
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
"primitive": "motor.input_modality",
|
||||||
|
}))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
drain_task.cancel()
|
||||||
|
assert len(captured) == first_count, (
|
||||||
|
"state didn't change; no additional events should fire"
|
||||||
|
)
|
||||||
|
await bus.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_handler_locks_last_change_ts_when_unchanged(
|
||||||
|
repo, attacker_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
"""When the state doesn't change, last_change_ts must NOT advance —
|
||||||
|
that's what tells the dashboard 'stable since X', not 'stable
|
||||||
|
since most-recent-observation'."""
|
||||||
|
bus = FakeBus()
|
||||||
|
await bus.connect()
|
||||||
|
await _seed_observations(
|
||||||
|
repo, attacker_uuid, "motor.input_modality",
|
||||||
|
["typed"] * 5,
|
||||||
|
)
|
||||||
|
await _aw.handle_observation_event(bus, repo, _bus_event({
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
"primitive": "motor.input_modality",
|
||||||
|
}))
|
||||||
|
attacker = await repo.get_attacker_by_uuid(attacker_uuid)
|
||||||
|
assert attacker is not None
|
||||||
|
identity_uuid = attacker["identity_id"]
|
||||||
|
first = await repo.get_attribution_state(
|
||||||
|
identity_uuid, "motor.input_modality",
|
||||||
|
)
|
||||||
|
assert first is not None
|
||||||
|
locked_ts = first["last_change_ts"]
|
||||||
|
|
||||||
|
# Add another stable observation, re-run.
|
||||||
|
await _seed_observations(
|
||||||
|
repo, attacker_uuid, "motor.input_modality",
|
||||||
|
["typed"], start_ts=1714010000.0,
|
||||||
|
)
|
||||||
|
await _aw.handle_observation_event(bus, repo, _bus_event({
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
"primitive": "motor.input_modality",
|
||||||
|
}))
|
||||||
|
second = await repo.get_attribution_state(
|
||||||
|
identity_uuid, "motor.input_modality",
|
||||||
|
)
|
||||||
|
assert second is not None
|
||||||
|
assert second["last_change_ts"] == locked_ts
|
||||||
|
# last_observation_ts DID advance.
|
||||||
|
assert second["last_observation_ts"] > locked_ts
|
||||||
|
await bus.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_handler_routes_numeric_primitive(
|
||||||
|
repo, attacker_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
"""Worker dispatches to the numeric merger when the primitive
|
||||||
|
registry kind is NUMERIC."""
|
||||||
|
bus = FakeBus()
|
||||||
|
await bus.connect()
|
||||||
|
# toolchain.c2.beacon_interval_ms is registered NUMERIC in BEHAVE.
|
||||||
|
primitive = "toolchain.c2.beacon_interval_ms"
|
||||||
|
await _seed_observations(
|
||||||
|
repo, attacker_uuid, primitive,
|
||||||
|
[5000.0, 5050.0, 4980.0, 5020.0, 5010.0],
|
||||||
|
)
|
||||||
|
await _aw.handle_observation_event(bus, repo, _bus_event({
|
||||||
|
"attacker_uuid": attacker_uuid,
|
||||||
|
"primitive": primitive,
|
||||||
|
}))
|
||||||
|
attacker = await repo.get_attacker_by_uuid(attacker_uuid)
|
||||||
|
assert attacker is not None
|
||||||
|
state = await repo.get_attribution_state(
|
||||||
|
attacker["identity_id"], primitive,
|
||||||
|
)
|
||||||
|
assert state is not None
|
||||||
|
# Numeric merger returns a smoothed mean, not a string.
|
||||||
|
assert isinstance(state["current_value"], float)
|
||||||
|
assert state["state"] == "stable"
|
||||||
|
await bus.close()
|
||||||
@@ -42,6 +42,24 @@ class DummyRepo(BaseRepository):
|
|||||||
async def upsert_observation(self, data): await super().upsert_observation(data); return ""
|
async def upsert_observation(self, data): await super().upsert_observation(data); return ""
|
||||||
async def latest_observation_per_primitive(self, attacker_uuid): await super().latest_observation_per_primitive(attacker_uuid); return {}
|
async def latest_observation_per_primitive(self, attacker_uuid): await super().latest_observation_per_primitive(attacker_uuid); return {}
|
||||||
async def observations_time_series(self, attacker_uuid, primitive): await super().observations_time_series(attacker_uuid, primitive); return []
|
async def observations_time_series(self, attacker_uuid, primitive): await super().observations_time_series(attacker_uuid, primitive); return []
|
||||||
|
async def observations_for_identity_primitive(self, identity_uuid, primitive):
|
||||||
|
await super().observations_for_identity_primitive(identity_uuid, primitive)
|
||||||
|
return []
|
||||||
|
# Attribution engine v0 (ATTRIBUTION-ENGINE.md Phase 1)
|
||||||
|
async def ensure_stub_identity_for_attacker(self, attacker_uuid):
|
||||||
|
await super().ensure_stub_identity_for_attacker(attacker_uuid)
|
||||||
|
return None
|
||||||
|
async def upsert_attribution_state(self, data):
|
||||||
|
await super().upsert_attribution_state(data)
|
||||||
|
async def get_attribution_state(self, identity_uuid, primitive):
|
||||||
|
await super().get_attribution_state(identity_uuid, primitive)
|
||||||
|
return None
|
||||||
|
async def get_attribution_state_for_identity(self, identity_uuid):
|
||||||
|
await super().get_attribution_state_for_identity(identity_uuid)
|
||||||
|
return []
|
||||||
|
async def list_multi_actor_identities(self):
|
||||||
|
await super().list_multi_actor_identities()
|
||||||
|
return []
|
||||||
async def increment_smtp_target(self, u, d): await super().increment_smtp_target(u, d)
|
async def increment_smtp_target(self, u, d): await super().increment_smtp_target(u, d)
|
||||||
async def list_smtp_targets(self, u): await super().list_smtp_targets(u)
|
async def list_smtp_targets(self, u): await super().list_smtp_targets(u)
|
||||||
async def get_attacker_stored_mail(self, u): await super().get_attacker_stored_mail(u)
|
async def get_attacker_stored_mail(self, u): await super().get_attacker_stored_mail(u)
|
||||||
@@ -86,6 +104,38 @@ class DummyRepo(BaseRepository):
|
|||||||
async def set_identity_campaign_id(self, i, c): await super().set_identity_campaign_id(i, c)
|
async def set_identity_campaign_id(self, i, c): await super().set_identity_campaign_id(i, c)
|
||||||
async def list_all_campaigns(self): await super().list_all_campaigns(); return []
|
async def list_all_campaigns(self): await super().list_all_campaigns(); return []
|
||||||
async def update_campaign_merged_into(self, u, w): await super().update_campaign_merged_into(u, w)
|
async def update_campaign_merged_into(self, u, w): await super().update_campaign_merged_into(u, w)
|
||||||
|
# Pre-existing abstract surface that DummyRepo never stubbed —
|
||||||
|
# added here so the coverage test exercises the full BaseRepository
|
||||||
|
# contract.
|
||||||
|
async def get_log_histogram(self, *a, **kw):
|
||||||
|
await super().get_log_histogram(*a, **kw); return []
|
||||||
|
async def has_observations_for_evidence(self, evidence_ref):
|
||||||
|
await super().has_observations_for_evidence(evidence_ref); return False
|
||||||
|
async def get_attacker_uuid_by_ip(self, ip):
|
||||||
|
await super().get_attacker_uuid_by_ip(ip); return None
|
||||||
|
# TTP rollup surface (TTP_TAGGING.md)
|
||||||
|
async def insert_tags(self, rows): await super().insert_tags(rows); return 0
|
||||||
|
async def list_techniques_by_identity(self, uuid):
|
||||||
|
await super().list_techniques_by_identity(uuid); return []
|
||||||
|
async def list_techniques_by_attacker(self, uuid):
|
||||||
|
await super().list_techniques_by_attacker(uuid); return []
|
||||||
|
async def list_techniques_by_campaign(self, uuid):
|
||||||
|
await super().list_techniques_by_campaign(uuid); return []
|
||||||
|
async def list_techniques_by_session(self, sid):
|
||||||
|
await super().list_techniques_by_session(sid); return []
|
||||||
|
async def list_tags_by_scope_and_technique(self, **kw):
|
||||||
|
await super().list_tags_by_scope_and_technique(**kw); return []
|
||||||
|
async def list_distinct_techniques(self):
|
||||||
|
await super().list_distinct_techniques(); return []
|
||||||
|
# Iter helpers — async generators, can't `await super()` on them
|
||||||
|
# because the base raises in the body before any yield. Just yield
|
||||||
|
# nothing so the consumer's ``async for`` exits cleanly.
|
||||||
|
async def iter_attacker_commands_since(self, since):
|
||||||
|
return
|
||||||
|
yield # unreachable, marks the function as a generator
|
||||||
|
async def iter_canary_triggers_since(self, since):
|
||||||
|
return
|
||||||
|
yield
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_base_repo_coverage():
|
async def test_base_repo_coverage():
|
||||||
@@ -127,9 +177,26 @@ async def test_base_repo_coverage():
|
|||||||
await dr.upsert_attacker_behavior("a", {})
|
await dr.upsert_attacker_behavior("a", {})
|
||||||
await dr.get_attacker_behavior("a")
|
await dr.get_attacker_behavior("a")
|
||||||
await dr.get_behaviors_for_ips({"1.1.1.1"})
|
await dr.get_behaviors_for_ips({"1.1.1.1"})
|
||||||
await dr.upsert_observation({})
|
# Observation surface — bases raise NotImplementedError.
|
||||||
await dr.latest_observation_per_primitive("a")
|
with pytest.raises(NotImplementedError):
|
||||||
await dr.observations_time_series("a", "motor.input_modality")
|
await dr.upsert_observation({})
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.latest_observation_per_primitive("a")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.observations_time_series("a", "motor.input_modality")
|
||||||
|
# observations_for_identity_primitive + attribution engine v0
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.observations_for_identity_primitive("i", "motor.input_modality")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.ensure_stub_identity_for_attacker("a")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.upsert_attribution_state({})
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.get_attribution_state("i", "motor.input_modality")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.get_attribution_state_for_identity("i")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.list_multi_actor_identities()
|
||||||
await dr.increment_smtp_target("uuid", "corp.com")
|
await dr.increment_smtp_target("uuid", "corp.com")
|
||||||
await dr.list_smtp_targets("uuid")
|
await dr.list_smtp_targets("uuid")
|
||||||
await dr.get_attacker_stored_mail("uuid")
|
await dr.get_attacker_stored_mail("uuid")
|
||||||
@@ -174,6 +241,37 @@ async def test_base_repo_coverage():
|
|||||||
await dr.update_campaign_merged_into("c", "d")
|
await dr.update_campaign_merged_into("c", "d")
|
||||||
await dr.update_campaign_merged_into("c", None)
|
await dr.update_campaign_merged_into("c", None)
|
||||||
|
|
||||||
|
# Pre-existing abstract surface. get_log_histogram's base body
|
||||||
|
# is ``pass`` (returns None), the rest raise NotImplementedError.
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
await dr.get_log_histogram()
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.has_observations_for_evidence("shard:x#1")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.get_attacker_uuid_by_ip("1.1.1.1")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.insert_tags([])
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.list_techniques_by_identity("i")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.list_techniques_by_attacker("a")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.list_techniques_by_campaign("c")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.list_techniques_by_session("s")
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.list_tags_by_scope_and_technique(
|
||||||
|
scope="identity", uuid="i", technique_id="T1059",
|
||||||
|
)
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
await dr.list_distinct_techniques()
|
||||||
|
# Iter helpers: just consume the empty generator.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async for _ in dr.iter_attacker_commands_since(now):
|
||||||
|
pass
|
||||||
|
async for _ in dr.iter_canary_triggers_since(now):
|
||||||
|
pass
|
||||||
|
|
||||||
# Swarm methods: default NotImplementedError on BaseRepository. Covering
|
# Swarm methods: default NotImplementedError on BaseRepository. Covering
|
||||||
# them here keeps the coverage contract honest for the swarm CRUD surface.
|
# them here keeps the coverage contract honest for the swarm CRUD surface.
|
||||||
for coro, args in [
|
for coro, args in [
|
||||||
|
|||||||
Reference in New Issue
Block a user