feat(security): reject secret last-applied annotations (CUST-WP-0073)
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e7fb-275d-7db0-b1df-39ed943427da
This commit is contained in:
parent
7a12760160
commit
800cbfa870
5 changed files with 141 additions and 0 deletions
23
argocd/platform-addons/secret-annotation-guard/README.md
Normal file
23
argocd/platform-addons/secret-annotation-guard/README.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- policy.yaml
|
||||||
27
argocd/platform-addons/secret-annotation-guard/policy.yaml
Normal file
27
argocd/platform-addons/secret-annotation-guard/policy.yaml
Normal file
|
|
@ -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]
|
||||||
|
|
@ -29,6 +29,10 @@ spec:
|
||||||
kind: ClusterRoleBinding
|
kind: ClusterRoleBinding
|
||||||
- group: external-secrets.io
|
- group: external-secrets.io
|
||||||
kind: ClusterSecretStore
|
kind: ClusterSecretStore
|
||||||
|
- group: admissionregistration.k8s.io
|
||||||
|
kind: ValidatingAdmissionPolicy
|
||||||
|
- group: admissionregistration.k8s.io
|
||||||
|
kind: ValidatingAdmissionPolicyBinding
|
||||||
namespaceResourceWhitelist:
|
namespaceResourceWhitelist:
|
||||||
- group: ""
|
- group: ""
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
|
|
|
||||||
83
scripts/secret_annotation_maintenance.py
Normal file
83
scripts/secret_annotation_maintenance.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue