Implement AUDIT-WP-0007 hash-chain integrity.
Accept now extends a single-schema chain. Verify walks it; a rewritten payload_hash is a break. Tamper evidence is that detector plus an external chain-head attestation, not WORM.
This commit is contained in:
parent
5faede18fc
commit
5fd04e2095
17 changed files with 696 additions and 29 deletions
|
|
@ -201,11 +201,57 @@ def test_health_passes_on_a_live_backend(backend):
|
|||
backend.health()
|
||||
|
||||
|
||||
def test_chain_links_and_verify_is_clean(backend):
|
||||
from audit_core.integrity import GENESIS
|
||||
|
||||
first = make_event("chain-1")
|
||||
second = make_event("chain-2")
|
||||
backend.accept(first, digest(first))
|
||||
backend.accept(second, digest(second))
|
||||
report = backend.verify_chain()
|
||||
assert report.intact is True
|
||||
assert report.events >= 2
|
||||
assert report.first_break is None
|
||||
assert report.head != GENESIS
|
||||
replay = backend.accept(first, digest(first))
|
||||
assert replay.duplicate is True
|
||||
assert backend.verify_chain().events == report.events
|
||||
|
||||
|
||||
# --- postgres-specific guarantees -------------------------------------------
|
||||
|
||||
pg_only = pytest.mark.skipif(not HAVE_PG, reason="needs PostgreSQL")
|
||||
|
||||
|
||||
@pg_only
|
||||
def test_rewritten_payload_fails_verify_postgres():
|
||||
"""Superuser rewrite is the evidence the trigger never gave us."""
|
||||
from audit_core.postgres_backend import PostgresAuditBackend
|
||||
|
||||
schema = f"conf_{uuid.uuid4().hex[:12]}"
|
||||
backend = PostgresAuditBackend(PG_URL, schema=schema)
|
||||
try:
|
||||
event = make_event("break-1")
|
||||
backend.accept(event, digest(event))
|
||||
other = make_event("break-2")
|
||||
backend.accept(other, digest(other))
|
||||
assert backend.verify_chain().intact is True
|
||||
with backend.pool.connection() as conn:
|
||||
conn.execute(f'ALTER TABLE "{schema}".events DISABLE TRIGGER events_append_only')
|
||||
conn.execute(
|
||||
f'UPDATE "{schema}".events SET payload_hash = %s WHERE event_id = %s',
|
||||
("deadbeef" * 8, "break-2"),
|
||||
)
|
||||
conn.execute(f'ALTER TABLE "{schema}".events ENABLE TRIGGER events_append_only')
|
||||
report = backend.verify_chain()
|
||||
assert report.intact is False
|
||||
assert report.first_break == "break-2"
|
||||
finally:
|
||||
with backend.pool.connection() as conn:
|
||||
conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
||||
backend.close()
|
||||
|
||||
|
||||
@pg_only
|
||||
def test_replay_reconciles_rather_than_duplicating():
|
||||
"""Replay must never mint a second custody record for one source event."""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import types
|
|||
|
||||
from audit_core.cli import build_parser
|
||||
from audit_core.ingestion import build_backend
|
||||
from audit_core.integrity import ChainReport, GENESIS
|
||||
from audit_core.interface import AcceptResult, RetentionPolicy
|
||||
|
||||
|
||||
|
|
@ -36,6 +37,16 @@ class _FakePostgres:
|
|||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def verify_chain(self, attestation=None):
|
||||
return ChainReport(
|
||||
intact=True,
|
||||
events=0,
|
||||
head=GENESIS,
|
||||
head_event_id=None,
|
||||
head_accepted_at=None,
|
||||
first_break=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def retention_policy(self):
|
||||
return RetentionPolicy(
|
||||
|
|
@ -109,6 +120,19 @@ def test_replay_command_reconciles(monkeypatch, capsys):
|
|||
assert body["reference"] == "audit:evt-1"
|
||||
|
||||
|
||||
def test_verify_chain_command(monkeypatch, capsys):
|
||||
def fake(dsn, **kwargs):
|
||||
return _FakePostgres(dsn, **kwargs)
|
||||
|
||||
monkeypatch.setenv("AUDIT_CORE_DATABASE_URL", "postgresql://example/audit_core")
|
||||
_install_fake_postgres(monkeypatch, fake)
|
||||
args = build_parser().parse_args(["verify-chain", "--schema", "audit_core"])
|
||||
assert args.func(args) == 0
|
||||
body = json.loads(capsys.readouterr().out)
|
||||
assert body["intact"] is True
|
||||
assert body["head"] == GENESIS
|
||||
|
||||
|
||||
def test_replay_command_missing_event(monkeypatch, capsys):
|
||||
def fake(dsn, **kwargs):
|
||||
return _FakePostgres(dsn, **kwargs)
|
||||
|
|
|
|||
139
tests/test_integrity.py
Normal file
139
tests/test_integrity.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import json
|
||||
|
||||
from audit_core.ingestion import IngestionApplication
|
||||
from audit_core.integrity import GENESIS, chain_link, load_attestation, write_attestation
|
||||
from audit_core.interface import AuditEvent
|
||||
from audit_core.sqlite_backend import SQLiteAuditBackend
|
||||
|
||||
from test_ingestion import invoke
|
||||
|
||||
|
||||
def _event(event_id: str, **kw) -> AuditEvent:
|
||||
fields = dict(
|
||||
event_id=event_id,
|
||||
source="user-engine",
|
||||
action="membership.added",
|
||||
resource="membership-1",
|
||||
outcome="recorded",
|
||||
tenant="tenant:friendly:binky",
|
||||
scope="tenant",
|
||||
details={"correlation_id": "corr-1"},
|
||||
observed_at="2026-08-09T00:00:00+00:00",
|
||||
)
|
||||
fields.update(kw)
|
||||
return AuditEvent(**fields)
|
||||
|
||||
|
||||
def _digest(event: AuditEvent) -> str:
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(
|
||||
json.dumps(event.as_record(), sort_keys=True).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_first_accept_sets_genesis(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
event = _event("e1")
|
||||
backend.accept(event, _digest(event))
|
||||
row = backend.db.execute(
|
||||
"SELECT chain_prev, chain_hash FROM events WHERE event_id = 'e1'"
|
||||
).fetchone()
|
||||
assert row[0] == GENESIS
|
||||
assert row[1] == chain_link(GENESIS, _digest(event), "e1")
|
||||
|
||||
|
||||
def test_second_accept_links(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
first = _event("e1")
|
||||
second = _event("e2")
|
||||
backend.accept(first, _digest(first))
|
||||
backend.accept(second, _digest(second))
|
||||
head = backend.db.execute(
|
||||
"SELECT chain_hash FROM events WHERE event_id = 'e1'"
|
||||
).fetchone()[0]
|
||||
prev = backend.db.execute(
|
||||
"SELECT chain_prev FROM events WHERE event_id = 'e2'"
|
||||
).fetchone()[0]
|
||||
assert prev == head
|
||||
|
||||
|
||||
def test_duplicate_does_not_fork(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
event = _event("e1")
|
||||
digest = _digest(event)
|
||||
assert backend.accept(event, digest).duplicate is False
|
||||
assert backend.accept(event, digest).duplicate is True
|
||||
count = backend.db.execute("SELECT count(*) FROM events").fetchone()[0]
|
||||
assert count == 1
|
||||
assert backend.verify_chain().events == 1
|
||||
|
||||
|
||||
def test_verify_clean_on_fresh_store(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
empty = backend.verify_chain()
|
||||
assert empty.intact is True
|
||||
assert empty.events == 0
|
||||
assert empty.head == GENESIS
|
||||
first = _event("e1")
|
||||
second = _event("e2")
|
||||
backend.accept(first, _digest(first))
|
||||
backend.accept(second, _digest(second))
|
||||
report = backend.verify_chain()
|
||||
assert report.intact is True
|
||||
assert report.events == 2
|
||||
assert report.first_break is None
|
||||
|
||||
|
||||
def test_rewritten_payload_fails_verify(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
first = _event("e1")
|
||||
second = _event("e2")
|
||||
backend.accept(first, _digest(first))
|
||||
backend.accept(second, _digest(second))
|
||||
backend.db.execute("UPDATE events SET payload_hash = 'deadbeef' WHERE event_id = 'e2'")
|
||||
report = backend.verify_chain()
|
||||
assert report.intact is False
|
||||
assert report.first_break == "e2"
|
||||
|
||||
|
||||
def test_attestation_mismatch(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
event = _event("e1")
|
||||
backend.accept(event, _digest(event))
|
||||
path = tmp_path / "head.json"
|
||||
write_attestation(path, backend.verify_chain())
|
||||
cited = load_attestation(path)
|
||||
assert backend.verify_chain(cited).attestation_match is True
|
||||
cited["chain_hash"] = "f" * 64
|
||||
broken = backend.verify_chain(cited)
|
||||
assert broken.intact is False
|
||||
assert broken.first_break == "attestation_mismatch"
|
||||
assert broken.attestation_match is False
|
||||
|
||||
|
||||
def test_attestation_still_matches_after_growth(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
first = _event("e1")
|
||||
backend.accept(first, _digest(first))
|
||||
cited = backend.attest_chain()
|
||||
second = _event("e2")
|
||||
backend.accept(second, _digest(second))
|
||||
report = backend.verify_chain(cited)
|
||||
assert report.intact is True
|
||||
assert report.attestation_match is True
|
||||
assert report.events == 2
|
||||
|
||||
|
||||
def test_http_integrity_requires_read(tmp_path):
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
|
||||
app = IngestionApplication(backend, "opaque")
|
||||
status, _ = invoke(app, None, path="/v1/integrity", method="GET", body=b"")
|
||||
assert status.startswith("200")
|
||||
event = _event("e1")
|
||||
backend.accept(event, _digest(event))
|
||||
status, body = invoke(app, None, path="/v1/integrity", method="GET", body=b"")
|
||||
assert status.startswith("200")
|
||||
assert body["intact"] is True
|
||||
assert body["events"] == 1
|
||||
assert "record" not in body
|
||||
Loading…
Add table
Add a link
Reference in a new issue