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
10
SCOPE.md
10
SCOPE.md
|
|
@ -97,10 +97,12 @@ effect of its own.
|
||||||
**Known conformance gaps** (assessed 2026-08-29,
|
**Known conformance gaps** (assessed 2026-08-29,
|
||||||
`history/2026-08-29-v0.7-alignment-and-scope-assessment.md`):
|
`history/2026-08-29-v0.7-alignment-and-scope-assessment.md`):
|
||||||
|
|
||||||
- `tamper_evidence=True` is returned unconditionally by the Postgres backend
|
- ~~`tamper_evidence=True` returned unconditionally by the Postgres backend~~
|
||||||
while `docs/integrity.md` permits it only when a live external attestation
|
**Closed 2026-09-06 (`AUDIT-WP-0009-T01`).** The flag is now derived per
|
||||||
exists. The one attestation on record is 2026-08-16 and no job renews it.
|
read from live chain and attestation state against a declared 168h
|
||||||
This is Audit Core overclaiming its own bound — the §9.6 defect turned inward.
|
freshness window. Until `AUDIT-WP-0009-T02` schedules attestation, the
|
||||||
|
honest answer in production is `false` — the overclaim is gone, the
|
||||||
|
precondition is not yet met.
|
||||||
- No cadence, heartbeat, reconciliation, or load-bearing classification exists.
|
- No cadence, heartbeat, reconciliation, or load-bearing classification exists.
|
||||||
The §9.6 obligations Audit Core argued for are not yet supportable by Audit
|
The §9.6 obligations Audit Core argued for are not yet supportable by Audit
|
||||||
Core.
|
Core.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ from pathlib import Path
|
||||||
from typing import Any, Iterable, Mapping
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
SCHEMA = "audit-core.chain-head.v1"
|
SCHEMA = "audit-core.chain-head.v1"
|
||||||
|
# How old a chain-head attestation may be before the tamper-evidence claim
|
||||||
|
# degrades. Declared in ``docs/integrity.md`` alongside the attestation
|
||||||
|
# cadence; the two are one contract and must move together.
|
||||||
|
DEFAULT_ATTESTATION_MAX_AGE_HOURS = 168.0
|
||||||
GENESIS = "0" * 64
|
GENESIS = "0" * 64
|
||||||
# Documented advisory-lock key so concurrent accepts cannot fork the head.
|
# Documented advisory-lock key so concurrent accepts cannot fork the head.
|
||||||
CHAIN_LOCK_KEY = 0xA0D17007
|
CHAIN_LOCK_KEY = 0xA0D17007
|
||||||
|
|
@ -122,6 +126,106 @@ def _apply_attestation(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TamperEvidenceState:
|
||||||
|
"""Whether the ``tamper_evidence`` claim is currently earned.
|
||||||
|
|
||||||
|
``docs/integrity.md`` permits the claim only while both preconditions are
|
||||||
|
live. Absence or staleness degrades it — it does not leave it standing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
claimed: bool
|
||||||
|
reason: str
|
||||||
|
observed_at: str | None = None
|
||||||
|
age_seconds: float | None = None
|
||||||
|
max_age_seconds: float | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tamper_evidence": self.claimed,
|
||||||
|
"reason": self.reason,
|
||||||
|
"attestation_observed_at": self.observed_at,
|
||||||
|
"attestation_age_seconds": self.age_seconds,
|
||||||
|
"attestation_max_age_seconds": self.max_age_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(str(value))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_tamper_evidence(
|
||||||
|
report: ChainReport | None,
|
||||||
|
attestation: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
max_age_hours: float = DEFAULT_ATTESTATION_MAX_AGE_HOURS,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> TamperEvidenceState:
|
||||||
|
"""Derive the tamper-evidence claim from live state.
|
||||||
|
|
||||||
|
``report`` must be the chain report produced *against* ``attestation``, so
|
||||||
|
that ``attestation_match`` reflects the cited head. ``report is None``
|
||||||
|
means the chain could not be walked at all, which is not a licence to keep
|
||||||
|
claiming.
|
||||||
|
"""
|
||||||
|
max_age_seconds = float(max_age_hours) * 3600.0
|
||||||
|
if report is None:
|
||||||
|
return TamperEvidenceState(
|
||||||
|
False, "chain_unreadable", max_age_seconds=max_age_seconds
|
||||||
|
)
|
||||||
|
if not report.intact:
|
||||||
|
return TamperEvidenceState(
|
||||||
|
False, "chain_break", max_age_seconds=max_age_seconds
|
||||||
|
)
|
||||||
|
if attestation is None:
|
||||||
|
return TamperEvidenceState(
|
||||||
|
False, "no_attestation", max_age_seconds=max_age_seconds
|
||||||
|
)
|
||||||
|
observed_at = str(attestation.get("observed_at") or "") or None
|
||||||
|
if report.attestation_match is not True:
|
||||||
|
return TamperEvidenceState(
|
||||||
|
False,
|
||||||
|
"attestation_mismatch",
|
||||||
|
observed_at=observed_at,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
)
|
||||||
|
stamped = _parse_iso(observed_at)
|
||||||
|
if stamped is None:
|
||||||
|
return TamperEvidenceState(
|
||||||
|
False,
|
||||||
|
"attestation_undated",
|
||||||
|
observed_at=observed_at,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
)
|
||||||
|
reference = now or datetime.now(timezone.utc)
|
||||||
|
if reference.tzinfo is None:
|
||||||
|
reference = reference.replace(tzinfo=timezone.utc)
|
||||||
|
age_seconds = (reference - stamped).total_seconds()
|
||||||
|
if age_seconds > max_age_seconds:
|
||||||
|
return TamperEvidenceState(
|
||||||
|
False,
|
||||||
|
"attestation_stale",
|
||||||
|
observed_at=observed_at,
|
||||||
|
age_seconds=age_seconds,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
)
|
||||||
|
return TamperEvidenceState(
|
||||||
|
True,
|
||||||
|
"attested",
|
||||||
|
observed_at=observed_at,
|
||||||
|
age_seconds=age_seconds,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def attestation_from_report(report: ChainReport, *, observed_at: str | None = None) -> dict[str, Any]:
|
def attestation_from_report(report: ChainReport, *, observed_at: str | None = None) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"schema": SCHEMA,
|
"schema": SCHEMA,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -35,10 +36,14 @@ from audit_core.interface import (
|
||||||
from audit_core.credentials import CredentialDirectory
|
from audit_core.credentials import CredentialDirectory
|
||||||
from audit_core.integrity import (
|
from audit_core.integrity import (
|
||||||
CHAIN_LOCK_KEY,
|
CHAIN_LOCK_KEY,
|
||||||
|
DEFAULT_ATTESTATION_MAX_AGE_HOURS,
|
||||||
GENESIS,
|
GENESIS,
|
||||||
ChainRow,
|
ChainRow,
|
||||||
|
TamperEvidenceState,
|
||||||
attestation_from_report,
|
attestation_from_report,
|
||||||
chain_link,
|
chain_link,
|
||||||
|
evaluate_tamper_evidence,
|
||||||
|
load_attestation,
|
||||||
verify_rows,
|
verify_rows,
|
||||||
)
|
)
|
||||||
from audit_core.redaction import Finding
|
from audit_core.redaction import Finding
|
||||||
|
|
@ -177,6 +182,9 @@ class PostgresAuditBackend:
|
||||||
statement_timeout_ms: int = 30_000,
|
statement_timeout_ms: int = 30_000,
|
||||||
migrate: bool = True,
|
migrate: bool = True,
|
||||||
credential_dir: str | None = None,
|
credential_dir: str | None = None,
|
||||||
|
attestation_path: str | None = None,
|
||||||
|
attestation_max_age_hours: float = DEFAULT_ATTESTATION_MAX_AGE_HOURS,
|
||||||
|
attestation_cache_seconds: float = 60.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
# A mounted credential directory takes precedence: it is the only
|
# A mounted credential directory takes precedence: it is the only
|
||||||
# source that can change while the process runs, which is what dynamic
|
# source that can change while the process runs, which is what dynamic
|
||||||
|
|
@ -202,6 +210,19 @@ class PostgresAuditBackend:
|
||||||
self.recoverable_days = recoverable_days
|
self.recoverable_days = recoverable_days
|
||||||
self.recoverable_source = recoverable_source
|
self.recoverable_source = recoverable_source
|
||||||
self.recoverable_basis = recoverable_basis
|
self.recoverable_basis = recoverable_basis
|
||||||
|
# The chain-head attestation lives outside this database by design
|
||||||
|
# (docs/integrity.md): a copy restored with the table proves nothing.
|
||||||
|
# Its path is mounted, not configured here, so a rotated attestation
|
||||||
|
# takes effect without a restart.
|
||||||
|
self.attestation_path = (
|
||||||
|
attestation_path
|
||||||
|
if attestation_path is not None
|
||||||
|
else os.environ.get("AUDIT_CORE_ATTESTATION_PATH") or None
|
||||||
|
)
|
||||||
|
self.attestation_max_age_hours = float(attestation_max_age_hours)
|
||||||
|
self._attestation_cache_seconds = float(attestation_cache_seconds)
|
||||||
|
self._tamper_state: TamperEvidenceState | None = None
|
||||||
|
self._tamper_state_at = 0.0
|
||||||
base_kwargs = {
|
base_kwargs = {
|
||||||
"autocommit": True,
|
"autocommit": True,
|
||||||
# A stalled write must surface as unavailable rather than hold a
|
# A stalled write must surface as unavailable rather than hold a
|
||||||
|
|
@ -288,10 +309,12 @@ class PostgresAuditBackend:
|
||||||
``immutable`` is True because migration 0002 installs a trigger that
|
``immutable`` is True because migration 0002 installs a trigger that
|
||||||
rejects UPDATE and DELETE, so no consumer credential can alter a stored
|
rejects UPDATE and DELETE, so no consumer credential can alter a stored
|
||||||
record. It is not a claim against the database owner or a superuser,
|
record. It is not a claim against the database owner or a superuser,
|
||||||
who can drop the trigger. ``tamper_evidence`` is True because a
|
who can drop the trigger. ``tamper_evidence`` is *derived*, not
|
||||||
hash chain plus verify detects a rewritten payload, and a chain-head
|
declared: a hash chain plus verify detects a rewritten payload only
|
||||||
attestation outside this database detects a suffix rewrite that
|
while a live chain-head attestation outside this database also
|
||||||
stays inside Postgres. It is not WORM or ``data.archive``.
|
detects a suffix rewrite that stays inside Postgres. Absent, stale,
|
||||||
|
or mismatched attestation degrades the claim to False — see
|
||||||
|
:meth:`tamper_evidence_state`. It is never WORM or ``data.archive``.
|
||||||
|
|
||||||
``custody_class`` is ``operational``, not ``archive``. This store is
|
``custody_class`` is ``operational``, not ``archive``. This store is
|
||||||
durable append-only Postgres recovered through the platform
|
durable append-only Postgres recovered through the platform
|
||||||
|
|
@ -303,7 +326,7 @@ class PostgresAuditBackend:
|
||||||
custody_class="operational",
|
custody_class="operational",
|
||||||
retention_days=self.retention_days,
|
retention_days=self.retention_days,
|
||||||
immutable=True,
|
immutable=True,
|
||||||
tamper_evidence=True,
|
tamper_evidence=self.tamper_evidence_state().claimed,
|
||||||
durable=True,
|
durable=True,
|
||||||
recoverable_days=self.recoverable_days,
|
recoverable_days=self.recoverable_days,
|
||||||
recoverable_source=self.recoverable_source,
|
recoverable_source=self.recoverable_source,
|
||||||
|
|
@ -492,6 +515,44 @@ class PostgresAuditBackend:
|
||||||
def attest_chain(self) -> dict:
|
def attest_chain(self) -> dict:
|
||||||
return attestation_from_report(self.verify_chain())
|
return attestation_from_report(self.verify_chain())
|
||||||
|
|
||||||
|
def load_current_attestation(self) -> dict | None:
|
||||||
|
"""The mounted chain-head attestation, or None if absent/unreadable.
|
||||||
|
|
||||||
|
Unreadable is treated as absent on purpose: a malformed file must not
|
||||||
|
be able to keep a claim standing that a missing file would drop.
|
||||||
|
"""
|
||||||
|
if not self.attestation_path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return load_attestation(self.attestation_path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def tamper_evidence_state(self) -> TamperEvidenceState:
|
||||||
|
"""Evaluate the tamper-evidence preconditions against live state.
|
||||||
|
|
||||||
|
Cached briefly because ``/readyz`` reads it on every probe and the
|
||||||
|
evaluation walks the chain. The cache only ever delays a *change* of
|
||||||
|
state; it cannot manufacture one.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = self._tamper_state
|
||||||
|
if cached is not None and now - self._tamper_state_at < self._attestation_cache_seconds:
|
||||||
|
return cached
|
||||||
|
attestation = self.load_current_attestation()
|
||||||
|
try:
|
||||||
|
report = self.verify_chain(attestation)
|
||||||
|
except Exception: # backend unavailable, and so is the claim
|
||||||
|
report = None
|
||||||
|
state = evaluate_tamper_evidence(
|
||||||
|
report,
|
||||||
|
attestation,
|
||||||
|
max_age_hours=self.attestation_max_age_hours,
|
||||||
|
)
|
||||||
|
self._tamper_state = state
|
||||||
|
self._tamper_state_at = now
|
||||||
|
return state
|
||||||
|
|
||||||
def health(self) -> None:
|
def health(self) -> None:
|
||||||
self._query("SELECT 1", ())
|
self._query("SELECT 1", ())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ Compatibility rules (not yet implemented):
|
||||||
| `custody_class` | `development`, `operational`, `archive`, or `hot_search` |
|
| `custody_class` | `development`, `operational`, `archive`, or `hot_search` |
|
||||||
| `retention_days` | Maximum age before eligible deletion; `None` is a lifecycle statement (no expiry), not a recovery guarantee |
|
| `retention_days` | Maximum age before eligible deletion; `None` is a lifecycle statement (no expiry), not a recovery guarantee |
|
||||||
| `immutable` | Whether stored records are protected from in-place alteration |
|
| `immutable` | Whether stored records are protected from in-place alteration |
|
||||||
| `tamper_evidence` | Whether manifests, hash chains, or signatures exist |
|
| `tamper_evidence` | Whether manifests, hash chains, or signatures exist **and their preconditions are currently live** — backends derive this, they do not declare it |
|
||||||
| `durable` | Whether survival is expected across process restarts and host reboots |
|
| `durable` | Whether survival is expected across process restarts and host reboots |
|
||||||
| `recoverable_days` | Cited platform backup window; `None` if not declared |
|
| `recoverable_days` | Cited platform backup window; `None` if not declared |
|
||||||
| `recoverable_source` | Where the recoverable window is cited from |
|
| `recoverable_source` | Where the recoverable window is cited from |
|
||||||
|
|
@ -165,7 +165,10 @@ integrity proofs, or survival of `/tmp` across reboots.
|
||||||
- `custody_class`: `operational`
|
- `custody_class`: `operational`
|
||||||
- `retention_days`: unset in production (the service does not expire rows)
|
- `retention_days`: unset in production (the service does not expire rows)
|
||||||
- `immutable`: true (trigger `events_append_only`; not a claim against the database owner)
|
- `immutable`: true (trigger `events_append_only`; not a claim against the database owner)
|
||||||
- `tamper_evidence`: true (hash chain + verify + external head attestation; not WORM)
|
- `tamper_evidence`: **derived per read**, not constant — true only while the
|
||||||
|
chain verifies and a fresh, matching external head attestation is mounted
|
||||||
|
(`docs/integrity.md`, freshness window 168h; not WORM). Absent, stale,
|
||||||
|
unreadable, or mismatched attestation reports false.
|
||||||
- `durable`: true
|
- `durable`: true
|
||||||
- `recoverable_days`: 30, cited from the platform `data.backup` provision
|
- `recoverable_days`: 30, cited from the platform `data.backup` provision
|
||||||
- `recoverable_source`: `resource-control/data/capability/platform-audit-storage.json#provisions[capability=data.backup]`
|
- `recoverable_source`: `resource-control/data/capability/platform-audit-storage.json#provisions[capability=data.backup]`
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,39 @@ owner can. Tamper evidence against that class of attacker requires a
|
||||||
1. `verify` exists and fails on a rewritten row
|
1. `verify` exists and fails on a rewritten row
|
||||||
2. an external head attestation exists and verify-against-attestation
|
2. an external head attestation exists and verify-against-attestation
|
||||||
reports a missing cited head as a break
|
reports a missing cited head as a break
|
||||||
|
3. that attestation is **fresh** — its `observed_at` is within the
|
||||||
|
freshness window below
|
||||||
|
|
||||||
It still does not mean WORM, object lock, or ITC-CAP `data.archive`.
|
It still does not mean WORM, object lock, or ITC-CAP `data.archive`.
|
||||||
It does not raise provision maturity to D5.
|
It does not raise provision maturity to D5.
|
||||||
|
|
||||||
|
## Attestation cadence and freshness window
|
||||||
|
|
||||||
|
An attestation dated once is not a live precondition. The claim is
|
||||||
|
derived from the mounted attestation on every read, not declared:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Intended attestation cadence | daily |
|
||||||
|
| Freshness window | **168 hours (7 days)** |
|
||||||
|
| Mount path | `AUDIT_CORE_ATTESTATION_PATH` |
|
||||||
|
|
||||||
|
The window is seven times the cadence so that a handful of missed runs
|
||||||
|
degrade the claim rather than a single one flapping it. Widening the
|
||||||
|
window without shortening the cadence weakens the claim and is a
|
||||||
|
change to this contract, not a tuning knob.
|
||||||
|
|
||||||
|
`tamper_evidence` degrades to `False`, with the reason recorded, when
|
||||||
|
the attestation is absent, unreadable, undated, older than the window,
|
||||||
|
or cites a head the live chain does not carry — and when the chain
|
||||||
|
itself is broken or cannot be walked. Unreadable is treated as absent
|
||||||
|
deliberately: a malformed file must not hold up a claim that a missing
|
||||||
|
file would drop.
|
||||||
|
|
||||||
|
Until `AUDIT-WP-0009-T02` schedules the attestation job, no attestation
|
||||||
|
is mounted in production and `/readyz` reports `tamper_evidence: false`.
|
||||||
|
That is the honest reading of the current state, not a regression.
|
||||||
|
|
||||||
Do not write the attestation into the Barman prefix
|
Do not write the attestation into the Barman prefix
|
||||||
(`platform-pg/` on `resource:platform:audit-storage`). That copy is
|
(`platform-pg/` on `resource:platform:audit-storage`). That copy is
|
||||||
restored with the table. A second copy may follow the logical-offsite
|
restored with the table. A second copy may follow the logical-offsite
|
||||||
|
|
|
||||||
|
|
@ -137,3 +137,182 @@ def test_http_integrity_requires_read(tmp_path):
|
||||||
assert body["intact"] is True
|
assert body["intact"] is True
|
||||||
assert body["events"] == 1
|
assert body["events"] == 1
|
||||||
assert "record" not in body
|
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"
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ Fixed by the statute; not deferred, not ours:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: AUDIT-WP-0009-T01
|
id: AUDIT-WP-0009-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f545b0e4-8c99-5186-affd-ce9a41209ed7"
|
state_hub_task_id: "f545b0e4-8c99-5186-affd-ce9a41209ed7"
|
||||||
```
|
```
|
||||||
|
|
@ -69,6 +69,24 @@ leave it standing. Assert the degradation with a test, and state the freshness
|
||||||
window in `docs/integrity.md` alongside the two existing preconditions, which
|
window in `docs/integrity.md` alongside the two existing preconditions, which
|
||||||
today are documented but unenforced.
|
today are documented but unenforced.
|
||||||
|
|
||||||
|
Done 2026-09-06. `evaluate_tamper_evidence` in `audit_core/integrity.py` derives
|
||||||
|
the claim from a `ChainReport` and the mounted attestation; `PostgresAuditBackend`
|
||||||
|
reads it per `retention_policy` call through `tamper_evidence_state()`, briefly
|
||||||
|
cached because `/readyz` probes it. Seven degradation reasons are distinguished
|
||||||
|
and asserted — `no_attestation`, `attestation_stale`, `attestation_mismatch`,
|
||||||
|
`attestation_undated`, `chain_break`, `chain_unreadable`, against `attested`.
|
||||||
|
Unreadable is treated as absent deliberately: a malformed file must not hold up
|
||||||
|
a claim a missing file would drop. The freshness window is **168h** against an
|
||||||
|
intended daily cadence, declared in `docs/integrity.md` with the reasoning that
|
||||||
|
the window is seven cadences so missed runs degrade rather than flap. Twelve
|
||||||
|
tests in `tests/test_integrity.py`; `docs/audit-backend-contract.md` now states
|
||||||
|
the field is derived rather than declared.
|
||||||
|
|
||||||
|
Consequence to state plainly: production `/readyz` will report
|
||||||
|
`tamper_evidence: false` until T02 mounts a renewed attestation. The 2026-08-16
|
||||||
|
attestation is 21 days old against a 7-day window. That is the overclaim being
|
||||||
|
removed, not a regression — the claim was false before and now says so.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: AUDIT-WP-0009-T02
|
id: AUDIT-WP-0009-T02
|
||||||
status: todo
|
status: todo
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue