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())