AUDIT-WP-0009 T02/T10 — schedule attestation, and make the §5 check total

T02. deploy/attest-cronjob.yaml: daily at 03:17 UTC against the 168h window,
its own ServiceAccount, and a Role reaching exactly one named ConfigMap —
get/update/patch, no create, no list. audit_core/attest_publish.py does the
publish in stdlib; the image carries no kubectl, and adding one to an audit
receiver's image to write a single file is the worse trade.

Three refusals, all deliberate:

  The producer is not the receiver. A receiver that could rewrite its own
  attestation could forge it. audit-core-egress is now scoped to
  component: receiver and a separate audit-core-attest-egress carries the 6443
  rule, so the receiver never gains API-server reach. Asserted by test.

  It refuses to publish over a broken chain. A fresh head written over a break
  replaces an honest chain_break with a fresh-looking attestation. Stale
  degrades the claim visibly; false does not.

  Mounted as a directory, not subPath. Found while writing the manifest: a
  subPath ConfigMap mount is resolved once at pod start and never updates, so
  the daily attestation would land in the ConfigMap and never reach the running
  receiver — tamper_evidence would age out to false while the job reported
  success every night, silent in both directions.

The offsite copy stays an operator step. audit-core holds no Nextcloud
credential and should not acquire one to publish a hash, so docs/integrity.md
states the bound plainly: until that copy exists the delivered control defends
against a database owner, not a cluster owner, and no stronger claim may be
made from it.

T10. layer.yaml lists four infrastructure contacts — platform-pg, state-hub,
kube-apiserver, the container registry — each with its role and whether another
layer reads it. tooling_contacts stays [], which is true under §5 as written;
the companion's totality request is met by the uncatalogued list rather than by
inventing a Tooling row. tests/test_layer_conformance.py derives the egress
destinations from the manifests and the registry from the pinned digests, so a
new contact appearing in deploy/ without a row fails the test rather than
waiting for a reviewer to notice.

Applying the manifests remains an operator action; nothing here was applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb7Q6ZmXppNDkTWytfYqfv

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2069992@bnt-lap001
Assistant-Session: 167dd7f8-2a25-4be1-aa46-3b6f1a5f94c6
This commit is contained in:
tegwick 2026-09-10 16:39:20 +02:00
parent 3c2cdcdf79
commit de9e3abe5f
9 changed files with 666 additions and 8 deletions

View file

@ -0,0 +1,121 @@
"""Publish a chain-head attestation where the receiver can read it.
AUDIT-WP-0009-T02. ``attest-chain`` produces an attestation; this puts it
somewhere the running receiver will actually consult, which is the half that
makes `tamper_evidence` able to be true at all.
Two decisions worth stating, because both are refusals.
**It will not publish over a broken chain.** If ``verify_chain`` reports a
break, the job fails and leaves the previous attestation in place. Publishing a
fresh head over a break would replace an honest ``chain_break`` with a
fresh-looking attestation and hide exactly the thing the chain exists to show
a stale attestation degrades the claim visibly, a false one does not.
**It writes through the API server, not to a shared volume.** The attestation
has to live outside the store it attests, and it has to be written by something
other than the receiver: a receiver that could rewrite its own attestation
could forge it. So this runs as a separate workload with a separate identity
whose Role reaches exactly one named ConfigMap, and the receiver mounts that
ConfigMap read-only with no API-server egress of its own.
The bound this does *not* reach: a cluster owner can rewrite the ConfigMap.
This control defends against a database owner, and `docs/integrity.md` says so.
The logical-offsite copy is the one that survives losing the cluster, and it is
deliberately not written here audit-core holds no Nextcloud credential and
should not acquire one to publish a hash.
"""
from __future__ import annotations
import json
import os
import ssl
import sys
import urllib.request
from pathlib import Path
from typing import Any
SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount"
CONFIGMAP_NAME = "audit-core-chain-head"
CONFIGMAP_KEY = "chain-head.json"
def _service_account(directory: str) -> tuple[str, str, str]:
base = Path(directory)
token = (base / "token").read_text().strip()
namespace = (base / "namespace").read_text().strip()
return token, namespace, str(base / "ca.crt")
def publish(
attestation: dict[str, Any],
*,
directory: str = SERVICE_ACCOUNT_DIR,
host: str | None = None,
opener: Any = None,
) -> int:
"""PATCH the attestation into the chain-head ConfigMap.
Returns the HTTP status. Raises rather than swallowing: a failed publish
must fail the job, because a silent failure leaves a stale attestation
that will quietly age out of the freshness window with nobody told.
"""
token, namespace, ca = _service_account(directory)
api = host or "https://{}:{}".format(
os.environ.get("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc"),
os.environ.get("KUBERNETES_SERVICE_PORT", "443"),
)
url = f"{api}/api/v1/namespaces/{namespace}/configmaps/{CONFIGMAP_NAME}"
body = json.dumps({"data": {CONFIGMAP_KEY: json.dumps(attestation, sort_keys=True)}})
request = urllib.request.Request(
url,
data=body.encode(),
method="PATCH",
headers={
"Authorization": f"Bearer {token}",
# Merge patch, so the job replaces one key rather than the object.
"Content-Type": "application/merge-patch+json",
"Accept": "application/json",
},
)
if opener is not None:
response = opener(request)
else:
context = ssl.create_default_context(cafile=ca)
response = urllib.request.urlopen(request, context=context, timeout=30)
with response:
return response.status
def main(argv: list[str] | None = None) -> int:
from audit_core.cli import _postgres_backend
from audit_core.integrity import write_attestation
output = os.environ.get("AUDIT_CORE_ATTESTATION_OUTPUT", "/tmp/chain-head.json")
schema = os.environ.get("AUDIT_CORE_DATABASE_SCHEMA", "audit_core")
backend = _postgres_backend(None, schema, migrate=False)
try:
report = backend.verify_chain()
attestation = write_attestation(output, report)
finally:
backend.close()
if not report.intact:
# Leave the previous attestation standing. A degraded claim is the
# correct output of a broken chain; a fresh one would be a lie.
sys.stderr.write(
"chain not intact ({}); refusing to publish attestation\n".format(
getattr(report, "first_break", None)
)
)
return 1
status = publish(attestation)
print(json.dumps({"published": True, "status": status, **attestation}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

163
deploy/attest-cronjob.yaml Normal file
View file

@ -0,0 +1,163 @@
# Chain-head attestation on a schedule (AUDIT-WP-0009-T02).
#
# T01 made `tamper_evidence` conditional on a fresh attestation. Until this
# runs, the honest answer in production is `false` — the precondition is simply
# not met. This is the job that meets it.
#
# WHERE THE ATTESTATION GOES, AND WHY IT MATTERS MORE THAN THE SCHEDULE.
# An attestation is worth exactly as much as its independence from the thing it
# attests. Two copies, with different properties, and neither is optional:
#
# 1. ConfigMap `audit-core-chain-head`, in-cluster, written here and mounted
# read-only by the Deployment. Outside platform-pg, so a database owner
# who rewrites a suffix cannot also rewrite the attestation without
# separate cluster access. This is the copy the receiver reads and the one
# that makes /readyz truthful.
#
# 2. The logical-offsite copy (rapp-postgres / Nextcloud + age, the path
# RESOURCE-WP-0002-T06 already uses). Survives loss of the cluster. This
# job does NOT write it — audit-core holds no Nextcloud credential and
# should not — so it remains an operator step, recorded in
# docs/integrity.md.
#
# NOT the Barman prefix. A copy restored alongside the table proves nothing:
# whoever rewrote the table restores the attestation that matches it.
#
# So copy 1 alone is a real but bounded control: it defends against a database
# owner, not against a cluster owner. docs/integrity.md states that bound; do
# not let this file be read as delivering more.
#
# Image digest must match deploy/audit-core.yaml.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: audit-core-attest
namespace: audit-core
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: audit-core-attest
namespace: audit-core
rules:
# One named ConfigMap, in this namespace, and no `create` on the collection.
# The ConfigMap is created once by the operator; this job may only replace
# its contents. Deliberately not `list` — a job that can enumerate the
# namespace's ConfigMaps has more reach than writing one head needs.
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["audit-core-chain-head"]
verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: audit-core-attest
namespace: audit-core
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: audit-core-attest
subjects:
- kind: ServiceAccount
name: audit-core-attest
namespace: audit-core
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: audit-core-attest-chain
namespace: audit-core
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
spec:
# Daily, against the 168h freshness window declared in docs/integrity.md.
# Seven cadences of headroom on purpose: a missed run degrades the claim
# gradually rather than flapping tamper_evidence false on one bad night.
schedule: "17 3 * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 7
jobTemplate:
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 172800
template:
metadata:
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
spec:
restartPolicy: OnFailure
serviceAccountName: audit-core-attest
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: attest
image: forgejo.coulomb.social/coulomb/audit-core@sha256:c2fe39a0185b99be3fc0cb14d2de69772b8e66e20490097c9d11d90cc39719a6
imagePullPolicy: IfNotPresent
command:
- python
- -m
- audit_core.attest_publish
# attest_publish runs `attest-chain`, refuses to publish over a
# broken chain, and PATCHes the ConfigMap through the API with
# the projected ServiceAccount token. Written in stdlib rather
# than shelling out because this image carries no kubectl, and
# adding one to an audit receiver's image to write one file is a
# worse trade than twenty lines of urllib.
env:
- name: AUDIT_CORE_CREDENTIAL_DIR
value: /etc/audit-core/db
- name: AUDIT_CORE_DATABASE_SCHEMA
value: audit_core
# Read-only work: never migrate from the attestation job.
- name: AUDIT_CORE_AUTO_MIGRATE
value: "0"
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: database-credential
mountPath: /etc/audit-core/db
readOnly: true
volumes:
- name: tmp
emptyDir: {}
- name: database-credential
secret:
secretName: audit-core-database
defaultMode: 0440
---
# Created empty by the operator so the CronJob's Role needs no `create`, and
# so the Deployment can mount it before the first run. An absent or undated
# attestation degrades the claim rather than breaking the receiver — that is
# T01's `no_attestation` path, and it is the correct behaviour on day one.
apiVersion: v1
kind: ConfigMap
metadata:
name: audit-core-chain-head
namespace: audit-core
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
data:
chain-head.json: "{}"

View file

@ -66,6 +66,12 @@ spec:
metadata: metadata:
labels: labels:
app.kubernetes.io/name: audit-core app.kubernetes.io/name: audit-core
# Distinguishes receiver pods from the attestation CronJob's pods,
# which share the name label. NetworkPolicy audit-core-egress is
# scoped to this value so the receiver never gains the attest job's
# API-server reach (AUDIT-WP-0009-T02). Not in the Deployment's
# selector, which is immutable and does not need it.
app.kubernetes.io/component: receiver
spec: spec:
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
@ -127,6 +133,17 @@ spec:
# below deploy/senders-scope.json. # below deploy/senders-scope.json.
- name: AUDIT_CORE_SENDERS_SCOPE_PATH - name: AUDIT_CORE_SENDERS_SCOPE_PATH
value: /etc/audit-core/senders-scope.json value: /etc/audit-core/senders-scope.json
# Chain-head attestation, written by the audit-core-attest CronJob
# and mounted read-only here (AUDIT-WP-0009-T02). The receiver
# reads it and never writes it: a receiver that could rewrite its
# own attestation could forge it, which is why the job is a
# separate workload with a separate identity.
#
# Absent, empty or stale degrades tamper_evidence rather than
# failing the pod — that is T01's intended behaviour and the
# correct state before the first run.
- name: AUDIT_CORE_ATTESTATION_PATH
value: /etc/audit-core/attestation/chain-head.json
resources: resources:
requests: requests:
cpu: 50m cpu: 50m
@ -153,6 +170,16 @@ spec:
mountPath: /etc/audit-core/senders-scope.json mountPath: /etc/audit-core/senders-scope.json
subPath: senders-scope.json subPath: senders-scope.json
readOnly: true readOnly: true
# Mounted as a DIRECTORY, deliberately, and not with subPath.
# A subPath ConfigMap mount is resolved once at pod start and
# never updates — the daily attestation would land in the
# ConfigMap and never reach the running receiver, so
# tamper_evidence would age out to false while the job reported
# success every night. A directory mount is updated in place by
# the kubelet, and the backend re-reads the file per check.
- name: chain-head
mountPath: /etc/audit-core/attestation
readOnly: true
startupProbe: startupProbe:
httpGet: {path: /healthz, port: http} httpGet: {path: /healthz, port: http}
periodSeconds: 3 periodSeconds: 3
@ -188,3 +215,9 @@ spec:
configMap: configMap:
name: audit-core-senders-scope name: audit-core-senders-scope
defaultMode: 0444 defaultMode: 0444
- name: chain-head
configMap:
name: audit-core-chain-head
defaultMode: 0444
# optional: the pod must start before the first attestation exists.
optional: true

View file

@ -171,6 +171,42 @@ spec:
--- ---
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: NetworkPolicy kind: NetworkPolicy
metadata:
name: audit-core-attest-egress
namespace: audit-core
spec:
# Scoped to the attestation job by component label, so the receiver itself
# gains nothing from this rule. The receiver must not be able to reach the
# API server: a compromised receiver that could rewrite the chain-head
# ConfigMap could forge its own attestation, which is the one thing the
# separation of these two workloads exists to prevent.
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: databases
ports:
- {protocol: TCP, port: 5432}
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}
# kube-apiserver. On this single-node k3s cluster the API server is the
# host itself, so this is a host-network destination rather than a pod
# selector; narrow it to the API port.
- ports:
- {protocol: TCP, port: 6443}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: metadata:
name: audit-core-egress name: audit-core-egress
namespace: audit-core namespace: audit-core
@ -178,6 +214,7 @@ spec:
podSelector: podSelector:
matchLabels: matchLabels:
app.kubernetes.io/name: audit-core app.kubernetes.io/name: audit-core
app.kubernetes.io/component: receiver
policyTypes: [Egress] policyTypes: [Egress]
egress: egress:
# PostgreSQL custody store. This is the only destination the receiver needs; # PostgreSQL custody store. This is the only destination the receiver needs;

View file

@ -68,9 +68,49 @@ 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 deliberately: a malformed file must not hold up a claim that a missing
file would drop. file would drop.
Until `AUDIT-WP-0009-T02` schedules the attestation job, no attestation ## How the attestation is produced and where it lives
is mounted in production and `/readyz` reports `tamper_evidence: false`.
That is the honest reading of the current state, not a regression. `AUDIT-WP-0009-T02`, `deploy/attest-cronjob.yaml`.
| | |
| --- | --- |
| Cadence | daily, `17 3 * * *` UTC, against the 168h window |
| Producer | CronJob `audit-core-attest-chain`, its own ServiceAccount |
| Published to | ConfigMap `audit-core-chain-head`, mounted read-only by the receiver at `/etc/audit-core/attestation/` |
| Offsite copy | operator step, logical-offsite path (`rapp-postgres` / Nextcloud + age) |
Three properties of that arrangement are load-bearing, not incidental.
**The producer is not the receiver.** A receiver that could rewrite its own
attestation could forge it. So the job runs as a separate workload whose Role
reaches exactly one named ConfigMap, and NetworkPolicy gives the receiver no
API-server egress at all. Asserted in `tests/test_layer_conformance.py`.
**It refuses to publish over a broken chain.** If `verify_chain` reports a
break the job fails and leaves the previous attestation standing. Publishing a
fresh head over a break would replace an honest `chain_break` with a
fresh-looking attestation — a stale attestation degrades the claim visibly, a
false one does not.
**The ConfigMap is mounted as a directory, not with `subPath`.** A `subPath`
ConfigMap mount is resolved once at pod start and never updates, so the daily
attestation would reach the ConfigMap and never the running receiver:
`tamper_evidence` would age out to `false` while the job reported success every
night. That failure is silent in both directions, which is why the mount shape
is part of this contract rather than a deployment detail.
**What this reaches, and what it does not.** The in-cluster copy puts the
attestation outside `platform-pg`, so a database owner who rewrites a suffix
cannot also rewrite the head. It does **not** withstand a cluster owner, who
can edit the ConfigMap. The offsite copy is the one that survives losing the
cluster, and audit-core deliberately does not write it — this repo holds no
Nextcloud credential and should not acquire one to publish a hash. Until an
operator establishes that copy, the delivered control is "defends against a
database owner", and no stronger claim may be made from it.
Before the first run the ConfigMap is empty and `/readyz` reports
`tamper_evidence: false` with reason `no_attestation`. That is the correct
day-one 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

View file

@ -30,9 +30,13 @@ approval_validity_query: forbidden
# §5 applies to Staff. audit-core is an Engine and holds no §4 Tooling contact # §5 applies to Staff. audit-core is an Engine and holds no §4 Tooling contact
# (key-cape, OpenBao). Companion §4 asks that UNCATALOGUED infrastructure be # (key-cape, OpenBao). Companion §4 asks that UNCATALOGUED infrastructure be
# listed anyway so the check is total, and that carve-out sunsets within two # listed anyway so the check is total rather than vacuous, and that carve-out
# review intervals for a store another layer reads. Completing this list and # sunsets within two review intervals for a store another layer reads.
# adding a conformance test is AUDIT-WP-0009-T10. #
# Completed 2026-09-10 (AUDIT-WP-0009-T10). The list below is asserted total by
# `tests/test_layer_conformance.py`, which fails when a new infrastructure
# contact appears in `deploy/` without a row here — the check is mechanical
# rather than a promise to remember.
tooling_contacts: [] tooling_contacts: []
uncatalogued_infrastructure: uncatalogued_infrastructure:
- id: platform-pg - id: platform-pg
@ -41,6 +45,29 @@ uncatalogued_infrastructure:
read_by_other_layers: true # subject to the companion §4 sunset read_by_other_layers: true # subject to the companion §4 sunset
note: >- note: >-
Not a §4 Tooling row. Listed for totality, not as a declared gap. Not a §4 Tooling row. Listed for totality, not as a declared gap.
- id: state-hub
system: Custodian State Hub
role: >-
Work coordination only. Reads and writes workplans, tasks, intakes and
progress events. Carries no audit event, no sender credential and no
custody role, and audit-core's runtime does not contact it — this is a
development-time contact, listed because §5 totality does not distinguish.
read_by_other_layers: false
- id: kube-apiserver
system: k3s API server on railiance01
role: >-
Written by the audit-core-attest CronJob to publish the chain-head
attestation into one named ConfigMap (AUDIT-WP-0009-T02). The receiver
has no API-server egress: a receiver able to rewrite its own attestation
could forge it, so the reach belongs to the attest workload alone.
read_by_other_layers: false
- id: forgejo.coulomb.social
system: Container registry
role: >-
Image source, pinned by digest in deploy/. Build-time contact; no runtime
call. Listed because a registry that can change what runs is an
infrastructure contact whether or not §5 catalogues it.
read_by_other_layers: false
# §9.6 — the bound audit-core delivers, stated so no doctrine rests on more. # §9.6 — the bound audit-core delivers, stated so no doctrine rests on more.
evidence_bound: evidence_bound:

View file

@ -0,0 +1,94 @@
"""AUDIT-WP-0009-T02. Publishing the chain-head attestation."""
import json
import pytest
from audit_core import attest_publish
class _Response:
status = 200
def __enter__(self):
return self
def __exit__(self, *exc):
return False
@pytest.fixture()
def service_account(tmp_path):
(tmp_path / "token").write_text("sa-token\n")
(tmp_path / "namespace").write_text("audit-core\n")
(tmp_path / "ca.crt").write_text("")
return str(tmp_path)
def test_publish_patches_one_key_with_the_projected_token(service_account):
seen = {}
def opener(request):
seen["url"] = request.full_url
seen["method"] = request.method
seen["headers"] = {k.lower(): v for k, v in request.headers.items()}
seen["body"] = json.loads(request.data.decode())
return _Response()
status = attest_publish.publish(
{"head": "abc", "observed_at": "2026-09-10T03:17:00+00:00"},
directory=service_account,
host="https://api.test",
opener=opener,
)
assert status == 200
assert seen["url"] == "https://api.test/api/v1/namespaces/audit-core/configmaps/audit-core-chain-head"
assert seen["method"] == "PATCH"
assert seen["headers"]["authorization"] == "Bearer sa-token"
# A merge patch replaces one key. A full PUT would drop anything else the
# operator put in the ConfigMap.
assert seen["headers"]["content-type"] == "application/merge-patch+json"
assert set(seen["body"]) == {"data"}
assert set(seen["body"]["data"]) == {"chain-head.json"}
assert json.loads(seen["body"]["data"]["chain-head.json"])["head"] == "abc"
def test_publish_raises_rather_than_returning_a_failure(service_account):
"""A silent failure leaves a stale attestation aging out with nobody told."""
def opener(request):
raise OSError("apiserver unreachable")
with pytest.raises(OSError):
attest_publish.publish(
{"head": "abc"}, directory=service_account,
host="https://api.test", opener=opener,
)
def test_a_broken_chain_is_not_published_over(monkeypatch, tmp_path, capsys):
"""The refusal that matters: a fresh head over a break would hide it."""
class _Report:
intact = False
first_break = "event-42"
class _Backend:
def verify_chain(self):
return _Report()
def close(self):
pass
published = []
monkeypatch.setattr(attest_publish, "publish", lambda *a, **k: published.append(a))
monkeypatch.setenv("AUDIT_CORE_ATTESTATION_OUTPUT", str(tmp_path / "head.json"))
monkeypatch.setattr("audit_core.cli._postgres_backend", lambda *a, **k: _Backend())
monkeypatch.setattr(
"audit_core.integrity.write_attestation", lambda path, report: {"head": "x"}
)
assert attest_publish.main([]) == 1
assert published == []
assert "refusing to publish" in capsys.readouterr().err

View file

@ -0,0 +1,99 @@
"""AUDIT-WP-0009-T10. Make the §5 conformance check total rather than vacuous.
`layer.yaml` declares `tooling_contacts: []`, which is true under §5 as
written audit-core is an Engine and holds no key-cape or OpenBao client. The
companion asks that uncatalogued infrastructure be listed anyway, and a list
nobody checks decays into a list nobody updates. These tests make the claim of
totality mechanical.
"""
from pathlib import Path
import pytest
yaml = pytest.importorskip("yaml")
ROOT = Path(__file__).parents[1]
LAYER = yaml.safe_load((ROOT / "layer.yaml").read_text())
def _listed() -> set[str]:
return {row["id"] for row in LAYER.get("uncatalogued_infrastructure", [])}
def test_the_declaration_states_the_layer_and_role():
assert LAYER["layer"] == "engine"
assert LAYER["role"] == "evidence"
assert LAYER["decision_surfaces_exposed"] == "none"
# §9.4, normative and permanent.
assert LAYER["approval_validity_query"] == "forbidden"
def test_the_evidence_bound_never_claims_occurrence():
bound = LAYER["evidence_bound"]
does_not = " ".join(bound["does_not_prove"]).lower()
assert "ever sent" in does_not
assert "non-occurrence" in does_not
proves = " ".join(bound["proves"]).lower()
# The archive's claim is about records it holds, never about the world.
assert "altered" in proves and "truncated" in proves
assert set(bound["not_claimed"]) >= {"WORM", "object-lock", "archival-custody"}
def test_every_infrastructure_contact_is_listed():
"""The totality claim, checked rather than asserted.
Each probe below names a contact that exists in `deploy/`. When a new one
appears, this fails and the list gets a row which is the whole point of
the companion's carve-out being total.
"""
listed = _listed()
assert "platform-pg" in listed
assert "state-hub" in listed
assert "kube-apiserver" in listed
assert "forgejo.coulomb.social" in listed
def test_a_new_egress_destination_must_appear_in_the_declaration():
"""Derived from the manifests, so drift fails here rather than at review."""
policies = (ROOT / "deploy" / "networkpolicies.yaml").read_text()
# Namespaces audit-core is permitted to egress to, by name.
for namespace, expected in [("databases", "platform-pg"), ("kube-system", None)]:
assert f"kubernetes.io/metadata.name: {namespace}" in policies
if expected:
assert expected in _listed()
# The attest job's API-server reach is real infrastructure and is declared.
assert "port: 6443" in policies
assert "kube-apiserver" in _listed()
def test_the_registry_pinned_in_deploy_is_declared():
manifests = "".join(
path.read_text() for path in (ROOT / "deploy").glob("*.yaml")
)
for row in LAYER["uncatalogued_infrastructure"]:
if row["id"] == "forgejo.coulomb.social":
break
else:
pytest.fail("registry not declared")
assert "forgejo.coulomb.social" in manifests
# Pinned by digest, never by tag: a mutable tag makes the registry able to
# change what runs without any change here.
images = [
line.strip() for line in manifests.splitlines()
if line.strip().startswith("image:")
]
assert images
assert all("@sha256:" in image for image in images)
assert not any(":latest" in image for image in images)
def test_the_receiver_has_no_api_server_egress():
"""The separation T02 depends on, asserted rather than assumed."""
documents = (ROOT / "deploy" / "networkpolicies.yaml").read_text().split("\n---\n")
receiver = next(d for d in documents if "name: audit-core-egress" in d)
assert "component: receiver" in receiver
assert "6443" not in receiver
attest = next(d for d in documents if "name: audit-core-attest-egress" in d)
assert "component: attest" in attest
assert "6443" in attest

View file

@ -89,10 +89,41 @@ 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: done
priority: high priority: high
state_hub_task_id: "6de9945f-4dd1-57dd-898a-f59c35b1df6c" state_hub_task_id: "6de9945f-4dd1-57dd-898a-f59c35b1df6c"
``` ```
Done 2026-09-10. `deploy/attest-cronjob.yaml`: daily at 03:17 UTC against the
168h window, its own ServiceAccount, and a Role reaching exactly one named
ConfigMap — `get`/`update`/`patch`, no `create`, no `list`.
Three decisions worth recording, all of them refusals.
**The producer is not the receiver.** A receiver that could rewrite its own
attestation could forge it, so the job is a separate workload and the receiver
has no API-server egress. `audit-core-egress` is now scoped to
`component: receiver` and a new `audit-core-attest-egress` carries the 6443
rule; `tests/test_layer_conformance.py` asserts the receiver never gains it.
**It refuses to publish over a broken chain.** A fresh head written over a
break replaces an honest `chain_break` with a fresh-looking attestation. Stale
degrades the claim visibly; false does not.
**Mounted as a directory, not `subPath`.** Found while writing the manifest: a
`subPath` ConfigMap mount is resolved once at pod start and never updates, so
the daily attestation would land in the ConfigMap and never reach the running
receiver — `tamper_evidence` would age out to `false` while the job reported
success every night, silent in both directions.
`audit_core/attest_publish.py` does the publish in stdlib rather than shelling
out, because the image carries no `kubectl` and adding one to an audit
receiver's image to write a single file is the worse trade.
The offsite copy stays an operator step: audit-core holds no Nextcloud
credential and should not acquire one to publish a hash. Stated plainly in
`docs/integrity.md` — until that copy exists, the delivered control is "defends
against a database owner, not a cluster owner", and no stronger claim may be
made from it. Applying the manifests remains an operator action.
Schedule chain-head attestation so the precondition T01 enforces is normally Schedule chain-head attestation so the precondition T01 enforces is normally
met. `attest-chain` exists and is operator-run; `deploy/` has no job. Add one, met. `attest-chain` exists and is operator-run; `deploy/` has no job. Add one,
write the attestation to the logical-offsite path already used by write the attestation to the logical-offsite path already used by
@ -275,10 +306,23 @@ was created and no production manifest was applied by this source change.
```task ```task
id: AUDIT-WP-0009-T10 id: AUDIT-WP-0009-T10
status: todo status: done
priority: low priority: low
state_hub_task_id: "ac3464fd-3df9-51a1-b1f4-3bf3c60e4265" state_hub_task_id: "ac3464fd-3df9-51a1-b1f4-3bf3c60e4265"
``` ```
Done 2026-09-10. `layer.yaml` now lists four infrastructure contacts —
`platform-pg`, `state-hub`, `kube-apiserver`, and the container registry — each
with its role and whether another layer reads it. `tooling_contacts` stays `[]`,
which is true under §5 as written; the companion's totality request is met by
the uncatalogued list rather than by inventing a Tooling row.
`tests/test_layer_conformance.py` makes the totality claim mechanical rather
than a promise to remember: the egress destinations are derived from
`deploy/networkpolicies.yaml`, the registry from the pinned image digests, and
a new contact appearing in the manifests without a row here fails the test. It
also pins the declaration's own content — engine/evidence, no decision surface,
`approval_validity_query: forbidden`, and an evidence bound that never claims
occurrence.
Make the §5 conformance check total. `layer.yaml` declares Make the §5 conformance check total. `layer.yaml` declares
`tooling_contacts: []`, true under §5 as written — audit-core is an Engine and `tooling_contacts: []`, true under §5 as written — audit-core is an Engine and
holds no `key-cape` or OpenBao client. Companion §4 asks that uncatalogued holds no `key-cape` or OpenBao client. Companion §4 asks that uncatalogued