diff --git a/audit_core/attest_publish.py b/audit_core/attest_publish.py new file mode 100644 index 0000000..625e2c4 --- /dev/null +++ b/audit_core/attest_publish.py @@ -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()) diff --git a/deploy/attest-cronjob.yaml b/deploy/attest-cronjob.yaml new file mode 100644 index 0000000..4be0cb1 --- /dev/null +++ b/deploy/attest-cronjob.yaml @@ -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: "{}" diff --git a/deploy/audit-core.yaml b/deploy/audit-core.yaml index 1d00e28..41c9554 100644 --- a/deploy/audit-core.yaml +++ b/deploy/audit-core.yaml @@ -66,6 +66,12 @@ spec: metadata: labels: 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: securityContext: runAsNonRoot: true @@ -127,6 +133,17 @@ spec: # below deploy/senders-scope.json. - name: AUDIT_CORE_SENDERS_SCOPE_PATH 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: requests: cpu: 50m @@ -153,6 +170,16 @@ spec: mountPath: /etc/audit-core/senders-scope.json subPath: senders-scope.json 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: httpGet: {path: /healthz, port: http} periodSeconds: 3 @@ -188,3 +215,9 @@ spec: configMap: name: audit-core-senders-scope 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 diff --git a/deploy/networkpolicies.yaml b/deploy/networkpolicies.yaml index 57f3f0a..9841129 100644 --- a/deploy/networkpolicies.yaml +++ b/deploy/networkpolicies.yaml @@ -171,6 +171,42 @@ spec: --- apiVersion: networking.k8s.io/v1 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: name: audit-core-egress namespace: audit-core @@ -178,6 +214,7 @@ spec: podSelector: matchLabels: app.kubernetes.io/name: audit-core + app.kubernetes.io/component: receiver policyTypes: [Egress] egress: # PostgreSQL custody store. This is the only destination the receiver needs; diff --git a/docs/integrity.md b/docs/integrity.md index cd9d75f..e5e5bff 100644 --- a/docs/integrity.md +++ b/docs/integrity.md @@ -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 file would drop. -Until `AUDIT-WP-0009-T02` schedules the attestation job, no attestation -is mounted in production and `/readyz` reports `tamper_evidence: false`. -That is the honest reading of the current state, not a regression. +## How the attestation is produced and where it lives + +`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 (`platform-pg/` on `resource:platform:audit-storage`). That copy is diff --git a/layer.yaml b/layer.yaml index efe70f7..338284a 100644 --- a/layer.yaml +++ b/layer.yaml @@ -30,9 +30,13 @@ approval_validity_query: forbidden # §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 -# listed anyway so the check is total, and that carve-out sunsets within two -# review intervals for a store another layer reads. Completing this list and -# adding a conformance test is AUDIT-WP-0009-T10. +# listed anyway so the check is total rather than vacuous, and that carve-out +# sunsets within two review intervals for a store another layer reads. +# +# 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: [] uncatalogued_infrastructure: - id: platform-pg @@ -41,6 +45,29 @@ uncatalogued_infrastructure: read_by_other_layers: true # subject to the companion §4 sunset note: >- 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. evidence_bound: diff --git a/tests/test_attest_publish.py b/tests/test_attest_publish.py new file mode 100644 index 0000000..510deb8 --- /dev/null +++ b/tests/test_attest_publish.py @@ -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 diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py new file mode 100644 index 0000000..5718405 --- /dev/null +++ b/tests/test_layer_conformance.py @@ -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 diff --git a/workplans/AUDIT-WP-0009-evidence-role-conformance.md b/workplans/AUDIT-WP-0009-evidence-role-conformance.md index 41f9f4d..fbe1805 100644 --- a/workplans/AUDIT-WP-0009-evidence-role-conformance.md +++ b/workplans/AUDIT-WP-0009-evidence-role-conformance.md @@ -89,10 +89,41 @@ removed, not a regression — the claim was false before and now says so. ```task id: AUDIT-WP-0009-T02 -status: todo +status: done priority: high 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 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 @@ -275,10 +306,23 @@ was created and no production manifest was applied by this source change. ```task id: AUDIT-WP-0009-T10 -status: todo +status: done priority: low 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 `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