132 lines
5.7 KiB
Python
132 lines
5.7 KiB
Python
|
|
"""Stream-completeness findings: when the trail itself is the defect.
|
||
|
|
|
||
|
|
`AUDIT-WP-0009-T04` and `T07`. `INTENT.md` principle 10 already says a degraded
|
||
|
|
audit stream is itself an audit and operations event. The principle was in
|
||
|
|
place; the mechanism was not.
|
||
|
|
|
||
|
|
**What this can and cannot see.** The hash chain proves records held were not
|
||
|
|
altered or truncated. It says nothing about a record that never arrived, and
|
||
|
|
§9.6 is explicit that omission is the acute risk for exactly the rare negative
|
||
|
|
classes — revocation, denial, containment — where suppression is most valuable
|
||
|
|
and least visible. Rate monitoring cannot help there either: a class that is
|
||
|
|
legitimately silent for a month is indistinguishable from one being suppressed.
|
||
|
|
|
||
|
|
A heartbeat fixes that by inverting the burden. Instead of inferring health
|
||
|
|
from events that may never come, the source makes a positive claim —
|
||
|
|
*nothing to report for this class* — on a declared interval. The claim can
|
||
|
|
itself go missing, and a missing claim is a finding. That is the whole idea,
|
||
|
|
and it is the only control here that covers adversarial omission.
|
||
|
|
|
||
|
|
**The bound, stated because a finding surface invites over-reading.** A
|
||
|
|
compromised source emits a truthful-looking heartbeat while suppressing the
|
||
|
|
event it is supposed to be vouching for. Heartbeats cover loss, outage, drain
|
||
|
|
failure and accident — most of what actually goes wrong — and do not cover a
|
||
|
|
source lying about itself. Nothing an archive holds can close that; it needs an
|
||
|
|
observer independent of the emitter.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from typing import Any, Iterable
|
||
|
|
|
||
|
|
# The event class a source uses to say "nothing to report". A heartbeat is an
|
||
|
|
# ordinary event: same envelope, same append-only custody, same chain. It gets
|
||
|
|
# no special table, because a heartbeat that lived outside the chain would be
|
||
|
|
# the one record in this store that could be back-dated.
|
||
|
|
HEARTBEAT_ACTION = "audit-core.heartbeat"
|
||
|
|
|
||
|
|
MISSING_HEARTBEAT = "missing_heartbeat"
|
||
|
|
NEVER_HEARTBEAT = "no_heartbeat_since_registration"
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class StreamFinding:
|
||
|
|
"""One reason to believe a stream is not telling the whole truth."""
|
||
|
|
|
||
|
|
kind: str
|
||
|
|
sender: str
|
||
|
|
source: str
|
||
|
|
event_class: str
|
||
|
|
expected_within_seconds: int
|
||
|
|
last_seen: str | None
|
||
|
|
overdue_seconds: int
|
||
|
|
|
||
|
|
def as_dict(self) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"kind": self.kind,
|
||
|
|
"sender": self.sender,
|
||
|
|
"source": self.source,
|
||
|
|
"class": self.event_class,
|
||
|
|
"expected_within_seconds": self.expected_within_seconds,
|
||
|
|
"last_seen": self.last_seen,
|
||
|
|
"overdue_seconds": self.overdue_seconds,
|
||
|
|
# Said on every finding rather than in a document nobody opens
|
||
|
|
# alongside it: this is the absence of a positive claim, not proof
|
||
|
|
# that an event was suppressed.
|
||
|
|
"means": (
|
||
|
|
"a declared heartbeat did not arrive; the class may be healthy "
|
||
|
|
"and the emitter silent. Absence is not proof of suppression."
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _parse(value: str | None) -> datetime | None:
|
||
|
|
if not value:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
|
|
except ValueError:
|
||
|
|
return None
|
||
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||
|
|
|
||
|
|
|
||
|
|
def evaluate(
|
||
|
|
identities: Iterable[Any],
|
||
|
|
last_heartbeats: dict[tuple[str, str], str | None],
|
||
|
|
*,
|
||
|
|
now: datetime | None = None,
|
||
|
|
grace_factor: float = 1.5,
|
||
|
|
) -> list[StreamFinding]:
|
||
|
|
"""Findings for every declared heartbeat class that is overdue.
|
||
|
|
|
||
|
|
``last_heartbeats`` is keyed ``(source, class)``. A class with no heartbeat
|
||
|
|
ever recorded produces a finding of its own kind rather than being skipped:
|
||
|
|
a source that declared a heartbeat and never sent one is the case most
|
||
|
|
likely to be a broken integration, and it is precisely the one a
|
||
|
|
"compare against last seen" implementation silently drops.
|
||
|
|
|
||
|
|
``grace_factor`` exists so one late run does not flap a finding on and off,
|
||
|
|
matching the reasoning behind the attestation freshness window. It widens
|
||
|
|
the window; it never removes the finding.
|
||
|
|
"""
|
||
|
|
now = now or datetime.now(timezone.utc)
|
||
|
|
findings: list[StreamFinding] = []
|
||
|
|
for identity in identities:
|
||
|
|
for event_class, interval in getattr(identity, "heartbeat_classes", ()):
|
||
|
|
for source in sorted(identity.sources):
|
||
|
|
if source == "*":
|
||
|
|
# A wildcard source cannot be held to a heartbeat: there is
|
||
|
|
# no determinate set of streams to expect one from. Refused
|
||
|
|
# rather than guessed at.
|
||
|
|
continue
|
||
|
|
deadline = timedelta(seconds=interval * grace_factor)
|
||
|
|
last = _parse(last_heartbeats.get((source, event_class)))
|
||
|
|
if last is None:
|
||
|
|
findings.append(StreamFinding(
|
||
|
|
kind=NEVER_HEARTBEAT, sender=identity.name, source=source,
|
||
|
|
event_class=event_class, expected_within_seconds=interval,
|
||
|
|
last_seen=None, overdue_seconds=-1,
|
||
|
|
))
|
||
|
|
continue
|
||
|
|
overdue = (now - last) - deadline
|
||
|
|
if overdue.total_seconds() > 0:
|
||
|
|
findings.append(StreamFinding(
|
||
|
|
kind=MISSING_HEARTBEAT, sender=identity.name, source=source,
|
||
|
|
event_class=event_class, expected_within_seconds=interval,
|
||
|
|
last_seen=last.isoformat(),
|
||
|
|
overdue_seconds=int(overdue.total_seconds()),
|
||
|
|
))
|
||
|
|
return findings
|