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:
parent
3c2cdcdf79
commit
de9e3abe5f
9 changed files with 666 additions and 8 deletions
121
audit_core/attest_publish.py
Normal file
121
audit_core/attest_publish.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue