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
121 lines
4.5 KiB
Python
121 lines
4.5 KiB
Python
"""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())
|