AUDIT-WP-0009-T01 — derive tamper_evidence from live attestation state
The Postgres backend returned tamper_evidence=True as a constant while docs/integrity.md permits the claim only when a live external chain-head attestation exists. The one attestation on record is 2026-08-16 and no job renews it, so audit-core was telling every sender it had a property whose precondition was unverified — the §9.6 defect it twice corrected in gate-house's doctrine, turned inward. evaluate_tamper_evidence() derives the flag from the chain report and the mounted attestation, distinguishing seven states. Absence, staleness, mismatch, an undated or unreadable attestation, a chain break, and an unwalkable chain all degrade the claim rather than leave it standing. Unreadable is treated as absent on purpose: a malformed file must not hold up a claim a missing file would drop. The freshness window is 168h against an intended daily cadence — seven cadences, so a handful of missed runs degrade the claim rather than a single one flapping it. Window and cadence are one contract in docs/integrity.md. Production /readyz will now report tamper_evidence: false until AUDIT-WP-0009-T02 schedules attestation. The claim was already false; it now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185wifnLzCxjEY2MT1XbK7L Assistant: claude-code Assistant-Model: opus Assistant-Process: 713962@bnt-lap001 Assistant-Session: 2718d99d-d3ff-478f-83a2-3a30f01a02fc
This commit is contained in:
parent
95dcb78e17
commit
2f7f475e85
7 changed files with 408 additions and 12 deletions
|
|
@ -137,3 +137,182 @@ def test_http_integrity_requires_read(tmp_path):
|
|||
assert body["intact"] is True
|
||||
assert body["events"] == 1
|
||||
assert "record" not in body
|
||||
|
||||
|
||||
# --- AUDIT-WP-0009-T01: tamper_evidence is derived, not declared -----------
|
||||
|
||||
|
||||
def _report(**kw):
|
||||
from audit_core.integrity import ChainReport
|
||||
|
||||
fields = dict(
|
||||
intact=True,
|
||||
events=2,
|
||||
head="a" * 64,
|
||||
head_event_id="e2",
|
||||
head_accepted_at="2026-09-01T00:00:00+00:00",
|
||||
first_break=None,
|
||||
attestation_match=True,
|
||||
)
|
||||
fields.update(kw)
|
||||
return ChainReport(**fields)
|
||||
|
||||
|
||||
def _at(hours_ago: float):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
stamp = datetime(2026, 9, 6, tzinfo=timezone.utc) - timedelta(hours=hours_ago)
|
||||
return stamp.isoformat()
|
||||
|
||||
|
||||
def _now():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime(2026, 9, 6, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_fresh_matching_attestation_earns_the_claim():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
state = evaluate_tamper_evidence(
|
||||
_report(), {"chain_hash": "a" * 64, "observed_at": _at(6)}, now=_now()
|
||||
)
|
||||
assert state.claimed is True
|
||||
assert state.reason == "attested"
|
||||
|
||||
|
||||
def test_absent_attestation_degrades_the_claim():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
state = evaluate_tamper_evidence(_report(attestation_match=None), None, now=_now())
|
||||
assert state.claimed is False
|
||||
assert state.reason == "no_attestation"
|
||||
|
||||
|
||||
def test_stale_attestation_degrades_the_claim():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
# The one attestation on record when this task was written was 21 days old.
|
||||
state = evaluate_tamper_evidence(
|
||||
_report(), {"chain_hash": "a" * 64, "observed_at": _at(21 * 24)}, now=_now()
|
||||
)
|
||||
assert state.claimed is False
|
||||
assert state.reason == "attestation_stale"
|
||||
assert state.age_seconds > state.max_age_seconds
|
||||
|
||||
|
||||
def test_attestation_just_inside_the_window_still_counts():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
state = evaluate_tamper_evidence(
|
||||
_report(), {"chain_hash": "a" * 64, "observed_at": _at(167)}, now=_now()
|
||||
)
|
||||
assert state.claimed is True
|
||||
|
||||
|
||||
def test_mismatched_attestation_degrades_the_claim():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
state = evaluate_tamper_evidence(
|
||||
_report(attestation_match=False),
|
||||
{"chain_hash": "b" * 64, "observed_at": _at(1)},
|
||||
now=_now(),
|
||||
)
|
||||
assert state.claimed is False
|
||||
assert state.reason == "attestation_mismatch"
|
||||
|
||||
|
||||
def test_undated_attestation_degrades_the_claim():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
state = evaluate_tamper_evidence(_report(), {"chain_hash": "a" * 64}, now=_now())
|
||||
assert state.claimed is False
|
||||
assert state.reason == "attestation_undated"
|
||||
|
||||
|
||||
def test_broken_chain_degrades_the_claim_even_with_fresh_attestation():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
state = evaluate_tamper_evidence(
|
||||
_report(intact=False, first_break="e2"),
|
||||
{"chain_hash": "a" * 64, "observed_at": _at(1)},
|
||||
now=_now(),
|
||||
)
|
||||
assert state.claimed is False
|
||||
assert state.reason == "chain_break"
|
||||
|
||||
|
||||
def test_unreadable_chain_degrades_the_claim():
|
||||
from audit_core.integrity import evaluate_tamper_evidence
|
||||
|
||||
state = evaluate_tamper_evidence(
|
||||
None, {"chain_hash": "a" * 64, "observed_at": _at(1)}, now=_now()
|
||||
)
|
||||
assert state.claimed is False
|
||||
assert state.reason == "chain_unreadable"
|
||||
|
||||
|
||||
def _bare_postgres_backend(tmp_path, attestation_path=None):
|
||||
"""A PostgresAuditBackend with no pool — retention_policy needs no I/O."""
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("psycopg", reason="needs psycopg to import the backend")
|
||||
from audit_core.integrity import DEFAULT_ATTESTATION_MAX_AGE_HOURS
|
||||
from audit_core.postgres_backend import PostgresAuditBackend
|
||||
|
||||
backend = object.__new__(PostgresAuditBackend)
|
||||
backend.retention_days = None
|
||||
backend.recoverable_days = 30
|
||||
backend.recoverable_source = "test"
|
||||
backend.recoverable_basis = "measured"
|
||||
backend.attestation_path = str(attestation_path) if attestation_path else None
|
||||
backend.attestation_max_age_hours = DEFAULT_ATTESTATION_MAX_AGE_HOURS
|
||||
backend._attestation_cache_seconds = 0.0
|
||||
backend._tamper_state = None
|
||||
backend._tamper_state_at = 0.0
|
||||
return backend
|
||||
|
||||
|
||||
def test_postgres_policy_drops_the_claim_without_an_attestation(tmp_path):
|
||||
backend = _bare_postgres_backend(tmp_path)
|
||||
backend.verify_chain = lambda attestation=None: _report(attestation_match=None)
|
||||
policy = backend.retention_policy
|
||||
assert policy.tamper_evidence is False
|
||||
assert policy.immutable is True
|
||||
assert backend.tamper_evidence_state().reason == "no_attestation"
|
||||
|
||||
|
||||
def test_postgres_policy_earns_the_claim_from_a_fresh_attestation(tmp_path):
|
||||
from audit_core.integrity import utc_now
|
||||
|
||||
path = tmp_path / "chain-head.json"
|
||||
path.write_text(json.dumps({"chain_hash": "a" * 64, "observed_at": utc_now()}))
|
||||
backend = _bare_postgres_backend(tmp_path, attestation_path=path)
|
||||
backend.verify_chain = lambda attestation=None: _report(
|
||||
attestation_match=attestation is not None
|
||||
)
|
||||
assert backend.retention_policy.tamper_evidence is True
|
||||
|
||||
|
||||
def test_postgres_policy_treats_an_unreadable_attestation_as_absent(tmp_path):
|
||||
path = tmp_path / "chain-head.json"
|
||||
path.write_text("{not json")
|
||||
backend = _bare_postgres_backend(tmp_path, attestation_path=path)
|
||||
backend.verify_chain = lambda attestation=None: _report(attestation_match=None)
|
||||
assert backend.retention_policy.tamper_evidence is False
|
||||
assert backend.tamper_evidence_state().reason == "no_attestation"
|
||||
|
||||
|
||||
def test_postgres_policy_drops_the_claim_when_the_chain_cannot_be_walked(tmp_path):
|
||||
from audit_core.integrity import utc_now
|
||||
|
||||
path = tmp_path / "chain-head.json"
|
||||
path.write_text(json.dumps({"chain_hash": "a" * 64, "observed_at": utc_now()}))
|
||||
backend = _bare_postgres_backend(tmp_path, attestation_path=path)
|
||||
|
||||
def _unavailable(attestation=None):
|
||||
raise RuntimeError("backend unavailable")
|
||||
|
||||
backend.verify_chain = _unavailable
|
||||
assert backend.retention_policy.tamper_evidence is False
|
||||
assert backend.tamper_evidence_state().reason == "chain_unreadable"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue