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())
|
||||||
163
deploy/attest-cronjob.yaml
Normal file
163
deploy/attest-cronjob.yaml
Normal 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: "{}"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
33
layer.yaml
33
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
|
# §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:
|
||||||
|
|
|
||||||
94
tests/test_attest_publish.py
Normal file
94
tests/test_attest_publish.py
Normal 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
|
||||||
99
tests/test_layer_conformance.py
Normal file
99
tests/test_layer_conformance.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue