From 800cbfa870661d47bee34c747f04f6145875adf1 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 28 Sep 2026 14:51:25 +0200 Subject: [PATCH] feat(security): reject secret last-applied annotations (CUST-WP-0073) Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e7fb-275d-7db0-b1df-39ed943427da --- .../secret-annotation-guard/README.md | 23 +++++ .../kustomization.yaml | 4 + .../secret-annotation-guard/policy.yaml | 27 ++++++ .../02-railiance-platform-addons-project.yaml | 4 + scripts/secret_annotation_maintenance.py | 83 +++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 argocd/platform-addons/secret-annotation-guard/README.md create mode 100644 argocd/platform-addons/secret-annotation-guard/kustomization.yaml create mode 100644 argocd/platform-addons/secret-annotation-guard/policy.yaml create mode 100644 scripts/secret_annotation_maintenance.py diff --git a/argocd/platform-addons/secret-annotation-guard/README.md b/argocd/platform-addons/secret-annotation-guard/README.md new file mode 100644 index 0000000..8179d7d --- /dev/null +++ b/argocd/platform-addons/secret-annotation-guard/README.md @@ -0,0 +1,23 @@ +# Secret annotation guard + +Implemented under the existing CUST-WP-0073-T03; no new platform workplan. +Native admission policy denies the last-applied annotation on Secret CREATE +and UPDATE, including an empty value. No new workload or controller. + +Before the first manual sync, run `scripts/secret_annotation_maintenance.py` +on railiance01 through the supervised admin path, first without arguments, +then with `--clean`. It removes only the duplicate annotation; it never prints +kubectl output or Secret values. Concurrent Secret churn can stop cleanup; +inspect the receipt before retrying. Preserve only names, counts and booleans. + +Declare the policy through the pinned `secret-annotation-guard` Argo Application; +automated sync and prune are off. Platform-addons AppProject must allow both +admission kinds. Sync policy first and inspect typeChecking; bind only after +cleanup. Test clean CREATE/UPDATE and denied annotated CREATE/UPDATE using +synthetic data in whitehat; verify client-side apply is denied, then delete +only the test fixture. Secret writers must use server-side apply or replace. + +Rollback is a manual Argo sync of a reviewed revision without the binding +(with explicit resource-scoped pruning), or attended break-glass deletion of +the binding followed by Git reconciliation. Removing the binding reopens this +leak path. The policy does not rotate credentials or isolate agent accounts. diff --git a/argocd/platform-addons/secret-annotation-guard/kustomization.yaml b/argocd/platform-addons/secret-annotation-guard/kustomization.yaml new file mode 100644 index 0000000..8fca817 --- /dev/null +++ b/argocd/platform-addons/secret-annotation-guard/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - policy.yaml diff --git a/argocd/platform-addons/secret-annotation-guard/policy.yaml b/argocd/platform-addons/secret-annotation-guard/policy.yaml new file mode 100644 index 0000000..22b8200 --- /dev/null +++ b/argocd/platform-addons/secret-annotation-guard/policy.yaml @@ -0,0 +1,27 @@ +# CUST-WP-0073-T03: Secret annotation guard; manual ArgoCD sync. +# Owner: railiance-platform. Clean existing annotations in an attended session +# before binding; otherwise subsequent updates to those Secrets are rejected. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: reject-secret-last-applied +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["secrets"] + scope: "*" + validations: + - expression: '!has(object.metadata.annotations) || !("kubectl.kubernetes.io/last-applied-configuration" in object.metadata.annotations)' + message: "Secret last-applied annotations are forbidden; use server-side apply or replace." +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: reject-secret-last-applied +spec: + policyName: reject-secret-last-applied + validationActions: [Deny] diff --git a/argocd/railiance01/bootstrap/02-railiance-platform-addons-project.yaml b/argocd/railiance01/bootstrap/02-railiance-platform-addons-project.yaml index e61f072..2bc5e2a 100644 --- a/argocd/railiance01/bootstrap/02-railiance-platform-addons-project.yaml +++ b/argocd/railiance01/bootstrap/02-railiance-platform-addons-project.yaml @@ -29,6 +29,10 @@ spec: kind: ClusterRoleBinding - group: external-secrets.io kind: ClusterSecretStore + - group: admissionregistration.k8s.io + kind: ValidatingAdmissionPolicy + - group: admissionregistration.k8s.io + kind: ValidatingAdmissionPolicyBinding namespaceResourceWhitelist: - group: "" kind: ConfigMap diff --git a/scripts/secret_annotation_maintenance.py b/scripts/secret_annotation_maintenance.py new file mode 100644 index 0000000..670c6d3 --- /dev/null +++ b/scripts/secret_annotation_maintenance.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Bounded CUST-WP-0073-T03 maintenance. Never emit kubectl output/errors. + +Run on railiance01 through the supervised admin path. Default is inspection; +--clean removes only the last-applied annotation, leaving Secret data untouched. +""" +import argparse +import json +import re +import subprocess +from datetime import datetime, timezone + +KEY = "kubectl.kubernetes.io/last-applied-configuration" +PRESENCE = ('{{ $found := false }}{{ range $key, $_ := .metadata.annotations }}' + '{{ if eq $key "' + KEY + '" }}{{ $found = true }}{{ end }}{{ end }}' + '{{ if $found }}HAS-ANNOTATION{{ else }}clean{{ end }}') +NAME = re.compile(r"^[a-z0-9][a-z0-9.-]*$") + + +def run(args): + # Even a template error can contain the entire Secret. Never forward it. + result = subprocess.run(["kubectl", *args], capture_output=True, text=True, timeout=30) + if result.returncode: + raise RuntimeError("kubectl operation failed; output suppressed") + return result.stdout.strip() + + +def inspect(namespace, name): + value = run(["-n", namespace, "get", "secret", name, "-o", "go-template=" + PRESENCE]) + if value not in ("clean", "HAS-ANNOTATION"): + raise RuntimeError("unexpected presence result; output suppressed") + return value == "HAS-ANNOTATION" + + +def maintain(clean=False): + # Custom columns use fixed universally-present identity fields; no annotation + # or data output. Validate before using any returned text as an argument. + identities = run(["get", "secrets", "-A", "--no-headers", "-o", + "custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name"]) + rows = [] + for line in identities.splitlines(): + pair = line.split() + if len(pair) != 2 or not all(NAME.fullmatch(value) for value in pair): + raise RuntimeError("invalid Secret identity output; suppressed") + rows.append(pair) + report = {"captured_at": datetime.now(timezone.utc).isoformat(), + "mode": "clean" if clean else "inspect", "checked": 0, + "annotated": [], "cleaned": [], "complete": False} + try: + for namespace, name in rows: + report["checked"] += 1 + if not inspect(namespace, name): + continue + identity = namespace + "/" + name + report["annotated"].append(identity) + if clean: + # A single JSON patch operation cannot modify credential data. + patch = [{"op": "remove", "path": "/metadata/annotations/" + KEY.replace("/", "~1")}] + run(["-n", namespace, "patch", "secret", name, "--type=json", + "-p", json.dumps(patch)]) + if inspect(namespace, name): + raise RuntimeError("annotation still present") + report["cleaned"].append(identity) + report["complete"] = True + except (RuntimeError, subprocess.SubprocessError, OSError): + report["error"] = "maintenance incomplete; raw output suppressed; inspect before retry" + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--clean", action="store_true") + args = parser.parse_args() + try: + report = maintain(args.clean) + except (RuntimeError, subprocess.SubprocessError, OSError): + report = {"complete": False, "error": "inventory failed; raw output suppressed"} + print(json.dumps(report, indent=2)) + return 0 if report["complete"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main())