Add attended Whitehat E2 credential projection
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
parent
ab7dee5382
commit
864cc20b96
7 changed files with 672 additions and 1 deletions
11
Makefile
11
Makefile
|
|
@ -466,5 +466,14 @@ help: ## Show this help
|
|||
/^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \
|
||||
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)
|
||||
|
||||
.PHONY: apps-pg-isolation-probe
|
||||
audit-whitehat-e2-preflight: ## Value-safe live preflight for the approved audit-core E2 window
|
||||
python3 scripts/audit-core-whitehat-e2-credentials.py preflight
|
||||
|
||||
audit-whitehat-e2-project: ## Project two exact mount-only credentials during the approved window
|
||||
python3 scripts/audit-core-whitehat-e2-credentials.py project --confirm WH-ENG-20260822-AUDIT-E2-01:attended
|
||||
|
||||
audit-whitehat-e2-cleanup: ## Remove the exact engagement credentials and reload audit-core
|
||||
python3 scripts/audit-core-whitehat-e2-credentials.py cleanup --confirm WH-ENG-20260822-AUDIT-E2-01:attended
|
||||
|
||||
.PHONY: audit-whitehat-e2-preflight audit-whitehat-e2-project audit-whitehat-e2-cleanup apps-pg-isolation-probe
|
||||
.PHONY: db-deploy db-status db-shell db-logs forgejo-db-deploy forgejo-db-status forgejo-db-shell apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-configure-external-secrets-forgejo openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory agent-high-risk-boundary-check openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup forgejo-backup forgejo-backup-dry-run forgejo-backup-status forgejo-package-prune forgejo-package-prune-dry-run reef-deployables apps-pg-evidence consumption-preflight help
|
||||
|
|
|
|||
60
docs/audit-core-whitehat-e2-credential-projection.md
Normal file
60
docs/audit-core-whitehat-e2-credential-projection.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Audit-core Whitehat E2 credential projection
|
||||
|
||||
This is the attended custody procedure for
|
||||
`WH-ENG-20260822-AUDIT-E2-01`. It creates two ordinary, tenant-scoped audit-core
|
||||
sender identities and projects only `token-a` and `token-b` into the Whitehat
|
||||
runner mount. It never returns credential values.
|
||||
|
||||
## Safety gates
|
||||
|
||||
- `project` is hard-gated to 18:00Z–18:03Z on 2026-08-22. This leaves the
|
||||
remainder of the approved 18:00Z–18:15Z window for the bounded probe and
|
||||
cleanup.
|
||||
- Both audit-core identities carry `expires_at: 2026-08-22T18:15:00Z`, checked
|
||||
by the receiver on every authentication.
|
||||
- OpenBao stores the two values at separate engagement paths. The ephemeral ESO
|
||||
policy can read only those paths; it cannot read the full sender registry.
|
||||
- The generated Kubernetes Secret has exactly `token-a` and `token-b` and is
|
||||
consumed only through the read-only volume declared by Whitehat.
|
||||
- Cleanup removes identities by exact name, preserving every unrelated sender.
|
||||
It deletes the two KV paths, ESO role/policy, ClusterSecretStore,
|
||||
ExternalSecret, and mounted Secret, then reloads audit-core if required.
|
||||
|
||||
## Attended sequence
|
||||
|
||||
Keep the operator terminal attached from projection through cleanup. The
|
||||
platform-admin token file must remain outside Git with mode `0600`.
|
||||
|
||||
```bash
|
||||
python3 scripts/audit-core-whitehat-e2-credentials.py preflight
|
||||
|
||||
# Run only after 18:00Z and no later than 18:03Z.
|
||||
python3 scripts/audit-core-whitehat-e2-credentials.py project \
|
||||
--confirm WH-ENG-20260822-AUDIT-E2-01:attended
|
||||
```
|
||||
|
||||
After projection succeeds, Whitehat may apply its approved runner contract and
|
||||
execute only the invocation recorded in
|
||||
`whitehat-security/engagements/runtime/WH-ENG-20260822-AUDIT-E2-01-invocation.md`.
|
||||
Stop immediately on an engagement abort threshold.
|
||||
|
||||
Whether the probe succeeds, fails, or aborts, clean up before 18:15Z:
|
||||
|
||||
```bash
|
||||
python3 scripts/audit-core-whitehat-e2-credentials.py cleanup \
|
||||
--confirm WH-ENG-20260822-AUDIT-E2-01:attended
|
||||
```
|
||||
|
||||
The command output is a value-safe JSON receipt. Record that receipt, the
|
||||
audit-core rollout/readiness result, the Whitehat runner result, and the
|
||||
absence of the mounted Secret. Never record a bearer, Secret data, sender
|
||||
registry payload, or OpenBao response body.
|
||||
|
||||
## Abort path
|
||||
|
||||
If `project` fails after changing custody, it invokes the same exact cleanup
|
||||
automatically. If the process itself is interrupted, run `cleanup` immediately;
|
||||
it is idempotent and is allowed before or after the projection window. If
|
||||
audit-core does not return 1/1 Ready after registry cleanup, do not re-add the
|
||||
temporary identities—keep the Whitehat Secret absent and escalate the target
|
||||
recovery separately.
|
||||
54
manifests/whitehat-audit-e2-projection.yaml
Normal file
54
manifests/whitehat-audit-e2-projection.yaml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Ephemeral, exact-path projection for WH-ENG-20260822-AUDIT-E2-01.
|
||||
# Apply only through scripts/audit-core-whitehat-e2-credentials.py during the
|
||||
# approved window. No value is present in this file.
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: openbao-whitehat-audit-e2
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/engagement: WH-ENG-20260822-AUDIT-E2-01
|
||||
spec:
|
||||
provider:
|
||||
vault:
|
||||
server: http://openbao.openbao.svc:8200
|
||||
path: platform
|
||||
version: v2
|
||||
auth:
|
||||
kubernetes:
|
||||
mountPath: kubernetes
|
||||
role: external-secrets-whitehat-audit-e2
|
||||
serviceAccountRef:
|
||||
name: external-secrets
|
||||
namespace: external-secrets
|
||||
conditions:
|
||||
- namespaces:
|
||||
- whitehat
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: whitehat-e2-audit-credentials
|
||||
namespace: whitehat
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/engagement: WH-ENG-20260822-AUDIT-E2-01
|
||||
spec:
|
||||
refreshInterval: 1m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao-whitehat-audit-e2
|
||||
target:
|
||||
name: whitehat-e2-audit-credentials
|
||||
creationPolicy: Owner
|
||||
deletionPolicy: Delete
|
||||
data:
|
||||
- secretKey: token-a
|
||||
remoteRef:
|
||||
key: engagements/WH-ENG-20260822-AUDIT-E2-01/audit-core/token-a
|
||||
property: token
|
||||
- secretKey: token-b
|
||||
remoteRef:
|
||||
key: engagements/WH-ENG-20260822-AUDIT-E2-01/audit-core/token-b
|
||||
property: token
|
||||
20
openbao/policies/external-secrets-whitehat-audit-e2.hcl
Normal file
20
openbao/policies/external-secrets-whitehat-audit-e2.hcl
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Ephemeral read policy for WH-ENG-20260822-AUDIT-E2-01.
|
||||
#
|
||||
# The External Secrets Operator may read exactly two per-engagement values.
|
||||
# It cannot read the audit-core sender registry or any other engagement path.
|
||||
|
||||
path "platform/data/engagements/WH-ENG-20260822-AUDIT-E2-01/audit-core/token-a" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/engagements/WH-ENG-20260822-AUDIT-E2-01/audit-core/token-a" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/data/engagements/WH-ENG-20260822-AUDIT-E2-01/audit-core/token-b" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/engagements/WH-ENG-20260822-AUDIT-E2-01/audit-core/token-b" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
466
scripts/audit-core-whitehat-e2-credentials.py
Executable file
466
scripts/audit-core-whitehat-e2-credentials.py
Executable file
|
|
@ -0,0 +1,466 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended, value-safe credentials for WH-ENG-20260822-AUDIT-E2-01."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ENGAGEMENT = "WH-ENG-20260822-AUDIT-E2-01"
|
||||
START = datetime(2026, 8, 22, 18, 0, tzinfo=UTC)
|
||||
LATEST_PROJECT = datetime(2026, 8, 22, 18, 3, tzinfo=UTC)
|
||||
EXPIRES = datetime(2026, 8, 22, 18, 15, tzinfo=UTC)
|
||||
EXPIRES_TEXT = "2026-08-22T18:15:00Z"
|
||||
EXPECTED_IMAGE = (
|
||||
"forgejo.coulomb.social/coulomb/audit-core@"
|
||||
"sha256:c2fe39a0185b99be3fc0cb14d2de69772b8e66e20490097c9d11d90cc39719a6"
|
||||
)
|
||||
AUTHORITY_PATH = "platform/workloads/audit-core/senders"
|
||||
TOKEN_PATHS = {
|
||||
"token-a": f"platform/engagements/{ENGAGEMENT}/audit-core/token-a",
|
||||
"token-b": f"platform/engagements/{ENGAGEMENT}/audit-core/token-b",
|
||||
}
|
||||
IDENTITIES = {
|
||||
"token-a": {
|
||||
"name": "whitehat-e2-a-20260822",
|
||||
"tenant": "tenant:trial:whitehat-a-20260822",
|
||||
},
|
||||
"token-b": {
|
||||
"name": "whitehat-e2-b-20260822",
|
||||
"tenant": "tenant:trial:whitehat-b-20260822",
|
||||
},
|
||||
}
|
||||
POLICY = "external-secrets-whitehat-audit-e2"
|
||||
ROLE = "external-secrets-whitehat-audit-e2"
|
||||
STORE = "openbao-whitehat-audit-e2"
|
||||
SECRET = "whitehat-e2-audit-credentials"
|
||||
CONFIRM = f"{ENGAGEMENT}:attended"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
POLICY_FILE = ROOT / "openbao" / "policies" / "external-secrets-whitehat-audit-e2.hcl"
|
||||
MANIFEST_FILE = ROOT / "manifests" / "whitehat-audit-e2-projection.yaml"
|
||||
|
||||
|
||||
class ProcedureError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def safe_run(
|
||||
command: list[str],
|
||||
*,
|
||||
label: str,
|
||||
env: dict[str, str] | None = None,
|
||||
input_text: str | None = None,
|
||||
allow_missing: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
input=input_text,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0 and not allow_missing:
|
||||
# stdout/stderr may contain secret material for Bao and Kubernetes
|
||||
# reads, so deliberately do not include either in the exception.
|
||||
raise ProcedureError(f"{label} failed (exit {completed.returncode})")
|
||||
return completed
|
||||
|
||||
|
||||
def secure_unlink(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
size = path.stat().st_size
|
||||
with path.open("r+b", buffering=0) as handle:
|
||||
handle.write(b"\0" * size)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
path.unlink()
|
||||
|
||||
|
||||
class Operator:
|
||||
def __init__(self, remote: str, token_file: Path) -> None:
|
||||
mode = stat.S_IMODE(token_file.stat().st_mode) if token_file.exists() else None
|
||||
if mode != 0o600:
|
||||
raise ProcedureError(f"OpenBao token file must exist with mode 0600: {token_file}")
|
||||
token = token_file.read_text(encoding="utf-8").splitlines()[0].strip()
|
||||
if not token:
|
||||
raise ProcedureError("OpenBao token file is empty")
|
||||
self.remote = remote
|
||||
self.bao_env = dict(os.environ, BAO_ADDR="https://bao.coulomb.social", BAO_TOKEN=token)
|
||||
|
||||
def bao(self, args: list[str], *, label: str, input_text: str | None = None, allow_missing: bool = False) -> subprocess.CompletedProcess[str]:
|
||||
return safe_run(["bao", *args], label=label, env=self.bao_env, input_text=input_text, allow_missing=allow_missing)
|
||||
|
||||
def kubectl(self, args: list[str], *, label: str, input_text: str | None = None, allow_missing: bool = False) -> subprocess.CompletedProcess[str]:
|
||||
return safe_run(
|
||||
["ssh", "-o", "BatchMode=yes", self.remote, "kubectl", *args],
|
||||
label=label,
|
||||
input_text=input_text,
|
||||
allow_missing=allow_missing,
|
||||
)
|
||||
|
||||
def registry(self) -> list[dict[str, Any]]:
|
||||
result = self.bao(
|
||||
["kv", "get", "-field=senders.json", AUTHORITY_PATH],
|
||||
label="read sender registry",
|
||||
)
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProcedureError("sender registry is not valid JSON") from exc
|
||||
if not isinstance(payload, list) or not payload:
|
||||
raise ProcedureError("sender registry must be a non-empty list")
|
||||
return payload
|
||||
|
||||
def bao_exists(self, args: list[str], *, label: str) -> bool:
|
||||
result = self.bao(args, label=label, allow_missing=True)
|
||||
return result.returncode == 0
|
||||
|
||||
def kubectl_exists(self, args: list[str], *, label: str) -> bool:
|
||||
result = self.kubectl(args, label=label, allow_missing=True)
|
||||
return result.returncode == 0 and bool(result.stdout.strip())
|
||||
|
||||
def write_registry(self, registry: list[dict[str, Any]]) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="railiance-audit-registry-") as temp:
|
||||
root = Path(temp)
|
||||
os.chmod(root, 0o700)
|
||||
document = root / "senders.json"
|
||||
document.write_text(json.dumps(registry, separators=(",", ":")), encoding="utf-8")
|
||||
os.chmod(document, 0o600)
|
||||
try:
|
||||
self.bao(
|
||||
["kv", "patch", AUTHORITY_PATH, f"senders.json=@{document}"],
|
||||
label="write sender registry",
|
||||
)
|
||||
finally:
|
||||
secure_unlink(document)
|
||||
|
||||
|
||||
def temporary_names() -> set[str]:
|
||||
return {entry["name"] for entry in IDENTITIES.values()}
|
||||
|
||||
|
||||
def add_temporary_identities(registry: list[dict[str, Any]], tokens: dict[str, str]) -> list[dict[str, Any]]:
|
||||
present = {str(entry.get("name")) for entry in registry if isinstance(entry, dict)}
|
||||
overlap = present & temporary_names()
|
||||
if overlap:
|
||||
raise ProcedureError(f"temporary identities already exist: {sorted(overlap)}")
|
||||
updated = list(registry)
|
||||
for handle, identity in IDENTITIES.items():
|
||||
updated.append({
|
||||
"name": identity["name"],
|
||||
"tokens": [tokens[handle]],
|
||||
"sources": ["whitehat-security"],
|
||||
"tenants": [identity["tenant"]],
|
||||
"may_write": True,
|
||||
"may_read": True,
|
||||
"expires_at": EXPIRES_TEXT,
|
||||
})
|
||||
return updated
|
||||
|
||||
|
||||
def remove_temporary_identities(registry: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
|
||||
names = temporary_names()
|
||||
updated = [entry for entry in registry if not isinstance(entry, dict) or entry.get("name") not in names]
|
||||
return updated, len(registry) - len(updated)
|
||||
|
||||
|
||||
def projection_manifest() -> str:
|
||||
rendered = MANIFEST_FILE.read_text(encoding="utf-8")
|
||||
if "data:" not in rendered or "token-a" not in rendered or "token-b" not in rendered:
|
||||
raise ProcedureError("projection manifest is incomplete")
|
||||
return rendered
|
||||
|
||||
|
||||
def assert_confirm(value: str | None) -> None:
|
||||
if value != CONFIRM:
|
||||
raise ProcedureError(f"live command requires --confirm {CONFIRM}")
|
||||
|
||||
|
||||
def time_state(now: datetime) -> str:
|
||||
if now < START:
|
||||
return "before-window"
|
||||
if now > LATEST_PROJECT:
|
||||
return "projection-cutoff-passed"
|
||||
return "projection-window-open"
|
||||
|
||||
|
||||
def live_preflight(operator: Operator) -> dict[str, Any]:
|
||||
status_result = operator.bao(["status", "-format=json"], label="read OpenBao status")
|
||||
try:
|
||||
bao_status = json.loads(status_result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProcedureError("OpenBao status is not valid JSON") from exc
|
||||
if not bao_status.get("initialized") or bao_status.get("sealed"):
|
||||
raise ProcedureError("OpenBao is not initialized and unsealed")
|
||||
image = operator.kubectl(
|
||||
["-n", "audit-core", "get", "deploy", "audit-core", "-o", "jsonpath={.spec.template.spec.containers[0].image}"],
|
||||
label="read audit-core image",
|
||||
).stdout.strip()
|
||||
if image != EXPECTED_IMAGE:
|
||||
raise ProcedureError("audit-core is not on the engagement-approved image digest")
|
||||
ready = operator.kubectl(
|
||||
["-n", "audit-core", "get", "deploy", "audit-core", "-o", "jsonpath={.status.readyReplicas}/{.status.replicas}"],
|
||||
label="read audit-core readiness",
|
||||
).stdout.strip()
|
||||
if ready != "1/1":
|
||||
raise ProcedureError(f"audit-core is not 1/1 Ready (observed {ready or 'unknown'})")
|
||||
operator.kubectl(["get", "ns", "whitehat", "-o", "name"], label="verify whitehat namespace")
|
||||
operator.kubectl(["-n", "external-secrets", "get", "sa", "external-secrets", "-o", "name"], label="verify ESO service account")
|
||||
registry = operator.registry()
|
||||
existing = sorted(
|
||||
temporary_names()
|
||||
& {str(item.get("name")) for item in registry if isinstance(item, dict)}
|
||||
)
|
||||
exact_paths_present = sorted(
|
||||
handle
|
||||
for handle, path in TOKEN_PATHS.items()
|
||||
if operator.bao_exists(
|
||||
["kv", "metadata", "get", "-format=json", path],
|
||||
label=f"check exact {handle} metadata",
|
||||
)
|
||||
)
|
||||
projection_resources = []
|
||||
for label, args in (
|
||||
(f"ClusterSecretStore/{STORE}", ["get", "clustersecretstore", STORE, "-o", "name"]),
|
||||
(f"ExternalSecret/whitehat/{SECRET}", ["-n", "whitehat", "get", "externalsecret", SECRET, "-o", "name"]),
|
||||
(f"Secret/whitehat/{SECRET}", ["-n", "whitehat", "get", "secret", SECRET, "-o", "name"]),
|
||||
):
|
||||
if operator.kubectl_exists(args, label=f"check {label}"):
|
||||
projection_resources.append(label)
|
||||
return {
|
||||
"engagement_id": ENGAGEMENT,
|
||||
"time_state": time_state(utc_now()),
|
||||
"target_image_matches": True,
|
||||
"target_ready": ready,
|
||||
"openbao_initialized": True,
|
||||
"openbao_sealed": False,
|
||||
"temporary_identities_present": existing,
|
||||
"exact_token_paths_present": exact_paths_present,
|
||||
"projection_resources_present": projection_resources,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def write_exact_tokens(operator: Operator, tokens: dict[str, str]) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="railiance-whitehat-e2-") as temp:
|
||||
root = Path(temp)
|
||||
os.chmod(root, 0o700)
|
||||
files: list[Path] = []
|
||||
try:
|
||||
for handle, value in tokens.items():
|
||||
path = root / handle
|
||||
path.write_text(value, encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
files.append(path)
|
||||
operator.bao(
|
||||
[
|
||||
"kv", "put", TOKEN_PATHS[handle], f"token=@{path}",
|
||||
f"engagement_id={ENGAGEMENT}", f"expires_at={EXPIRES_TEXT}",
|
||||
],
|
||||
label=f"write exact {handle} path",
|
||||
)
|
||||
finally:
|
||||
for path in files:
|
||||
secure_unlink(path)
|
||||
|
||||
|
||||
def configure_projection(operator: Operator) -> None:
|
||||
operator.bao(["policy", "write", POLICY, str(POLICY_FILE)], label="write exact ESO policy")
|
||||
operator.bao(
|
||||
[
|
||||
"write", f"auth/kubernetes/role/{ROLE}",
|
||||
"bound_service_account_names=external-secrets",
|
||||
"bound_service_account_namespaces=external-secrets",
|
||||
f"policies={POLICY}", "ttl=5m", "max_ttl=15m",
|
||||
],
|
||||
label="write exact ESO Kubernetes role",
|
||||
)
|
||||
operator.kubectl(["apply", "-f", "-"], label="apply projection manifest", input_text=projection_manifest())
|
||||
operator.kubectl(["wait", "--for=condition=Ready", f"clustersecretstore/{STORE}", "--timeout=90s"], label="wait for exact store")
|
||||
operator.kubectl(["-n", "whitehat", "wait", "--for=condition=Ready", f"externalsecret/{SECRET}", "--timeout=90s"], label="wait for exact ExternalSecret")
|
||||
keys = operator.kubectl(
|
||||
["-n", "whitehat", "get", "secret", SECRET, "-o", "go-template={{range $k, $_ := .data}}{{$k}}{{\"\\n\"}}{{end}}"],
|
||||
label="verify projected key names",
|
||||
).stdout.splitlines()
|
||||
if sorted(keys) != ["token-a", "token-b"]:
|
||||
raise ProcedureError(f"projected Secret has unexpected key names: {sorted(keys)}")
|
||||
|
||||
|
||||
def force_sender_sync_and_restart(operator: Operator) -> None:
|
||||
before = operator.kubectl(
|
||||
["-n", "audit-core", "get", "secret", "audit-core-senders", "-o", "jsonpath={.metadata.resourceVersion}"],
|
||||
label="read sender Secret resource version",
|
||||
).stdout.strip()
|
||||
stamp = str(int(time.time()))
|
||||
operator.kubectl(
|
||||
["-n", "audit-core", "annotate", "externalsecret", "audit-core-senders", f"force-sync={stamp}", "--overwrite"],
|
||||
label="force sender registry sync",
|
||||
)
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
current = operator.kubectl(
|
||||
["-n", "audit-core", "get", "secret", "audit-core-senders", "-o", "jsonpath={.metadata.resourceVersion}"],
|
||||
label="poll sender Secret resource version",
|
||||
).stdout.strip()
|
||||
if current and current != before:
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise ProcedureError("audit-core sender Secret did not refresh within 120s")
|
||||
operator.kubectl(["-n", "audit-core", "rollout", "restart", "deploy/audit-core"], label="restart audit-core registry reader")
|
||||
operator.kubectl(["-n", "audit-core", "rollout", "status", "deploy/audit-core", "--timeout=120s"], label="wait for audit-core rollout")
|
||||
|
||||
|
||||
def delete_projection(operator: Operator) -> None:
|
||||
operator.kubectl(["-n", "whitehat", "delete", "externalsecret", SECRET, "--ignore-not-found"], label="delete exact ExternalSecret")
|
||||
operator.kubectl(["-n", "whitehat", "delete", "secret", SECRET, "--ignore-not-found"], label="delete mounted Secret")
|
||||
operator.kubectl(["delete", "clustersecretstore", STORE, "--ignore-not-found"], label="delete exact store")
|
||||
for handle, path in TOKEN_PATHS.items():
|
||||
if operator.bao_exists(["kv", "metadata", "get", "-format=json", path], label=f"check exact {handle} metadata before delete"):
|
||||
operator.bao(["kv", "metadata", "delete", path], label=f"delete exact {handle} KV path")
|
||||
if operator.bao_exists(["read", f"auth/kubernetes/role/{ROLE}"], label="check exact ESO role before delete"):
|
||||
operator.bao(["delete", f"auth/kubernetes/role/{ROLE}"], label="delete exact ESO role")
|
||||
if operator.bao_exists(["policy", "read", POLICY], label="check exact ESO policy before delete"):
|
||||
operator.bao(["policy", "delete", POLICY], label="delete exact ESO policy")
|
||||
|
||||
|
||||
def verify_cleanup_absent(operator: Operator) -> None:
|
||||
# Recheck both control planes after deletion. A connectivity failure is
|
||||
# caught by the status/namespace reads before absence is interpreted.
|
||||
operator.bao(["status", "-format=json"], label="verify OpenBao after cleanup")
|
||||
operator.kubectl(["get", "ns", "whitehat", "-o", "name"], label="verify Kubernetes after cleanup")
|
||||
for handle, path in TOKEN_PATHS.items():
|
||||
if operator.bao_exists(["kv", "metadata", "get", "-format=json", path], label=f"verify exact {handle} path absence"):
|
||||
raise ProcedureError(f"exact {handle} KV path remains after cleanup")
|
||||
if operator.bao_exists(["read", f"auth/kubernetes/role/{ROLE}"], label="verify exact ESO role absence"):
|
||||
raise ProcedureError("exact ESO role remains after cleanup")
|
||||
if operator.bao_exists(["policy", "read", POLICY], label="verify exact ESO policy absence"):
|
||||
raise ProcedureError("exact ESO policy remains after cleanup")
|
||||
resources = (
|
||||
["-n", "whitehat", "get", "externalsecret", SECRET, "-o", "name"],
|
||||
["-n", "whitehat", "get", "secret", SECRET, "-o", "name"],
|
||||
["get", "clustersecretstore", STORE, "-o", "name"],
|
||||
)
|
||||
if any(operator.kubectl_exists(args, label="verify projection resource absence") for args in resources):
|
||||
raise ProcedureError("a projection resource remains after cleanup")
|
||||
|
||||
|
||||
def cleanup(operator: Operator) -> dict[str, Any]:
|
||||
registry = operator.registry()
|
||||
updated, removed = remove_temporary_identities(registry)
|
||||
if removed:
|
||||
operator.write_registry(updated)
|
||||
force_sender_sync_and_restart(operator)
|
||||
delete_projection(operator)
|
||||
remaining = sorted(
|
||||
temporary_names()
|
||||
& {str(item.get("name")) for item in operator.registry() if isinstance(item, dict)}
|
||||
)
|
||||
if remaining:
|
||||
raise ProcedureError(f"temporary identities remain after cleanup: {remaining}")
|
||||
verify_cleanup_absent(operator)
|
||||
ready = operator.kubectl(
|
||||
["-n", "audit-core", "get", "deploy", "audit-core", "-o", "jsonpath={.status.readyReplicas}/{.status.replicas}"],
|
||||
label="verify audit-core readiness after cleanup",
|
||||
).stdout.strip()
|
||||
if ready != "1/1":
|
||||
raise ProcedureError(f"audit-core is not 1/1 Ready after cleanup (observed {ready or 'unknown'})")
|
||||
return {
|
||||
"engagement_id": ENGAGEMENT,
|
||||
"cleanup_completed_at": utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"temporary_identities_removed": removed,
|
||||
"mounted_secret_absent": True,
|
||||
"exact_kv_paths_deleted": sorted(TOKEN_PATHS.values()),
|
||||
"target_ready": True,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def project(operator: Operator) -> dict[str, Any]:
|
||||
now = utc_now()
|
||||
if not START <= now <= LATEST_PROJECT:
|
||||
raise ProcedureError(
|
||||
f"projection permitted only {START.isoformat()} through {LATEST_PROJECT.isoformat()}; "
|
||||
f"current time is {now.isoformat()}"
|
||||
)
|
||||
preflight = live_preflight(operator)
|
||||
if (
|
||||
preflight["temporary_identities_present"]
|
||||
or preflight["exact_token_paths_present"]
|
||||
or preflight["projection_resources_present"]
|
||||
):
|
||||
raise ProcedureError("cleanup is required before a new projection")
|
||||
tokens = {"token-a": secrets.token_urlsafe(48), "token-b": secrets.token_urlsafe(48)}
|
||||
if tokens["token-a"] == tokens["token-b"]:
|
||||
raise ProcedureError("token generator returned duplicate values")
|
||||
registry_written = False
|
||||
try:
|
||||
write_exact_tokens(operator, tokens)
|
||||
operator.write_registry(add_temporary_identities(operator.registry(), tokens))
|
||||
registry_written = True
|
||||
configure_projection(operator)
|
||||
force_sender_sync_and_restart(operator)
|
||||
except Exception:
|
||||
if registry_written:
|
||||
cleanup(operator)
|
||||
else:
|
||||
delete_projection(operator)
|
||||
raise
|
||||
finally:
|
||||
tokens.clear()
|
||||
return {
|
||||
"engagement_id": ENGAGEMENT,
|
||||
"projected_at": utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"expires_at": EXPIRES_TEXT,
|
||||
"identities": sorted(temporary_names()),
|
||||
"mounted_secret": f"whitehat/{SECRET}",
|
||||
"mounted_keys": ["token-a", "token-b"],
|
||||
"target_image_matches": True,
|
||||
"target_ready": True,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=["preflight", "project", "cleanup"])
|
||||
parser.add_argument("--remote", default="railiance01")
|
||||
parser.add_argument("--token-file", type=Path, default=Path.home() / ".local/openbao/platform-admin.token")
|
||||
parser.add_argument("--confirm")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
operator = Operator(args.remote, args.token_file)
|
||||
if args.command == "preflight":
|
||||
result = live_preflight(operator)
|
||||
elif args.command == "project":
|
||||
assert_confirm(args.confirm)
|
||||
result = project(operator)
|
||||
else:
|
||||
assert_confirm(args.confirm)
|
||||
result = cleanup(operator)
|
||||
except (OSError, IndexError, ProcedureError) as exc:
|
||||
print(f"credential procedure failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
51
tests/test_audit_core_whitehat_e2_credentials.py
Normal file
51
tests/test_audit_core_whitehat_e2_credentials.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"audit_core_whitehat_e2_credentials",
|
||||
ROOT / "scripts" / "audit-core-whitehat-e2-credentials.py",
|
||||
)
|
||||
assert SPEC and SPEC.loader
|
||||
module = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(module)
|
||||
|
||||
|
||||
class CredentialProcedureTests(unittest.TestCase):
|
||||
def test_add_and_remove_only_named_temporary_identities(self) -> None:
|
||||
original = [{"name": "user-engine", "tokens": ["existing"]}]
|
||||
updated = module.add_temporary_identities(original, {"token-a": "a", "token-b": "b"})
|
||||
self.assertEqual(3, len(updated))
|
||||
cleaned, removed = module.remove_temporary_identities(updated)
|
||||
self.assertEqual(2, removed)
|
||||
self.assertEqual(original, cleaned)
|
||||
|
||||
def test_temporary_identities_are_exactly_scoped_and_expiring(self) -> None:
|
||||
updated = module.add_temporary_identities(
|
||||
[{"name": "existing"}], {"token-a": "a", "token-b": "b"}
|
||||
)
|
||||
temporary = updated[1:]
|
||||
self.assertEqual({"whitehat-security"}, {item["sources"][0] for item in temporary})
|
||||
self.assertTrue(all(item["may_read"] and item["may_write"] for item in temporary))
|
||||
self.assertTrue(all(item["expires_at"] == module.EXPIRES_TEXT for item in temporary))
|
||||
self.assertEqual(2, len({item["tenants"][0] for item in temporary}))
|
||||
|
||||
def test_projection_window_is_fail_closed(self) -> None:
|
||||
self.assertEqual("before-window", module.time_state(datetime(2026, 8, 22, 17, 59, tzinfo=UTC)))
|
||||
self.assertEqual("projection-window-open", module.time_state(datetime(2026, 8, 22, 18, 1, tzinfo=UTC)))
|
||||
self.assertEqual("projection-cutoff-passed", module.time_state(datetime(2026, 8, 22, 18, 4, tzinfo=UTC)))
|
||||
|
||||
def test_manifest_projects_only_two_exact_keys(self) -> None:
|
||||
manifest = module.projection_manifest()
|
||||
self.assertEqual(2, manifest.count("secretKey: token-"))
|
||||
self.assertNotIn("workloads/audit-core/senders", manifest)
|
||||
self.assertIn("conditions:\n - namespaces:\n - whitehat", manifest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -72,6 +72,17 @@ both identities must expire no later than 18:15Z and cleanup/reload must be
|
|||
attended. No unattended scheduler or background credential job is authorized
|
||||
by this record.
|
||||
|
||||
**Procedure implementation (2026-08-22):**
|
||||
`scripts/audit-core-whitehat-e2-credentials.py` now provides fail-closed
|
||||
`preflight`, `project`, and idempotent `cleanup` commands. The projection uses
|
||||
two exact per-engagement OpenBao paths, an ephemeral least-privilege Kubernetes
|
||||
auth role, a Whitehat-only ClusterSecretStore, and an ExternalSecret that emits
|
||||
exactly `token-a` and `token-b`. Project is hard-gated to 18:00Z–18:03Z;
|
||||
cleanup remains callable after interruption and removes only the named
|
||||
temporary identities and exact engagement resources. Four unit tests, a live
|
||||
value-safe preflight, policy formatting, and Kubernetes server-side dry-run
|
||||
passed. Live projection and cleanup remain waiting for the attended window.
|
||||
|
||||
## T02 — Define the runtime database lease recovery exercise
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue