Add value-safe verification and audit reporting
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
tegwick 2026-08-23 12:33:38 +02:00
parent 491e706a70
commit c4504c6de9
19 changed files with 598 additions and 50 deletions

167
src/secrets_engine/audit.py Normal file
View file

@ -0,0 +1,167 @@
"""Allowlisted, non-secret summaries over local append-only evidence."""
from __future__ import annotations
import json
import re
import uuid
from collections import Counter
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_LABEL = re.compile(r"^[a-z][a-z0-9-]{0,79}$")
_DECISION_REF = re.compile(r"^[A-Z][A-Z0-9-]{2,80}$")
_DELIVERY_RESULTS = {"delivered", "failed", "skipped-no-topic"}
_VERIFY_RESULT = re.compile(r"^(positive|negative):(pass|fail)$")
_ERROR_RESULT = re.compile(
r"^failed-(Catalog|Decision|PolicyGuard|Backend|Provisioning|Verification|Delivery)Error$"
)
def _safe_label(value: object) -> str:
text = value if isinstance(value, str) else ""
return text if _LABEL.fullmatch(text) else "invalid-label"
def _safe_result(value: object) -> str:
text = value if isinstance(value, str) else ""
if (
_LABEL.fullmatch(text)
or _VERIFY_RESULT.fullmatch(text)
or _ERROR_RESULT.fullmatch(text)
):
return text
return "invalid-label"
def _safe_decision_ref(value: object) -> str:
text = value if isinstance(value, str) else ""
if not text:
return ""
try:
return str(uuid.UUID(text))
except ValueError:
return text if _DECISION_REF.fullmatch(text) else ""
def _safe_timestamp(value: object) -> str:
if not isinstance(value, str):
return ""
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
return ""
return parsed.astimezone(timezone.utc).isoformat()
except ValueError:
return ""
@dataclass(frozen=True)
class LaneAuditSummary:
catalog_id: str
operation_records: int
malformed_records: int
first_ts: str
last_ts: str
actions: dict[str, int]
results: dict[str, int]
decision_refs: list[str]
session_cleanup: dict[str, int]
hub_delivery: dict[str, int]
def to_json(self) -> dict[str, Any]:
return asdict(self)
def render(self) -> str:
lines = [
f"Lane audit summary for '{self.catalog_id}'",
f" operation records: {self.operation_records}",
f" malformed records: {self.malformed_records}",
f" first: {self.first_ts or 'n/a'}",
f" last: {self.last_ts or 'n/a'}",
" actions: " + _render_counts(self.actions),
" results: " + _render_counts(self.results),
" decisions: " + (", ".join(self.decision_refs) or "none"),
" session cleanup: " + _render_counts(self.session_cleanup),
" hub delivery: " + _render_counts(self.hub_delivery),
]
return "\n".join(lines)
def _render_counts(counts: dict[str, int]) -> str:
return ", ".join(f"{key}={value}" for key, value in sorted(counts.items())) or "none"
def summarize_lane_evidence(evidence_dir: Path, catalog_id: str) -> LaneAuditSummary:
"""Summarize one lane without returning arbitrary record fields or detail."""
actions: Counter[str] = Counter()
results: Counter[str] = Counter()
cleanup: Counter[str] = Counter()
hub_delivery: Counter[str] = Counter()
decisions: set[str] = set()
timestamps: list[str] = []
malformed = 0
operation_records = 0
for path in sorted(Path(evidence_dir).glob("evidence-*.jsonl")):
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
malformed += 1
continue
for line in lines:
try:
record = json.loads(line)
except (json.JSONDecodeError, TypeError):
malformed += 1
continue
if not isinstance(record, dict):
malformed += 1
continue
if record.get("catalog_id") != catalog_id:
continue
action = _safe_label(record.get("action"))
result = _safe_result(record.get("result"))
if action == "evidence-delivery":
if result in _DELIVERY_RESULTS:
hub_delivery[result] += 1
else:
hub_delivery["invalid"] += 1
continue
operation_records += 1
actions[action] += 1
results[result] += 1
timestamp = _safe_timestamp(record.get("ts"))
if timestamp:
timestamps.append(timestamp)
decision = _safe_decision_ref(record.get("decision_id"))
if decision:
decisions.add(decision)
detail = record.get("detail")
session = detail.get("session") if isinstance(detail, dict) else None
if isinstance(session, dict):
attempted = session.get("revocation_attempted") is True
succeeded = session.get("revocation_succeeded") is True
if succeeded:
cleanup["succeeded"] += 1
elif attempted:
cleanup["failed"] += 1
else:
cleanup["not-attempted"] += 1
timestamps.sort()
return LaneAuditSummary(
catalog_id=catalog_id,
operation_records=operation_records,
malformed_records=malformed,
first_ts=timestamps[0] if timestamps else "",
last_ts=timestamps[-1] if timestamps else "",
actions=dict(sorted(actions.items())),
results=dict(sorted(results.items())),
decision_refs=sorted(decisions),
session_cleanup=dict(sorted(cleanup.items())),
hub_delivery=dict(sorted(hub_delivery.items())),
)

View file

@ -7,12 +7,13 @@ Command surface (FR7):
plan <decision-or-ref> --stage <stage>
apply <decision-or-ref> --stage <stage> [--dry-run] [--bootstrap-token-file F]
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
verify <catalog-id> [--positive] [--negative] [--field NAME]
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
route <catalog-id> [--json]
revoke <catalog-id>
lifecycle suspend|deactivate|destroy <catalog-id>
audit <catalog-id> [--json]
Every privileged action is decision-gated and writes non-secret evidence.
`plan` and `apply --dry-run` never mutate OpenBao.
@ -221,6 +222,13 @@ def cmd_verify(cfg: Config, args) -> int:
raise VerificationError(f"lane '{entry.id}' has no field to verify")
positive = args.positive or not args.negative
negative = args.negative or not args.positive
unrelated_token = None
if entry.stores_kv_value() and negative and args.negative_token_file:
from secrets_engine.openbao import read_strict_token_file
unrelated_token = read_strict_token_file(
Path(args.negative_token_file), purpose="negative verification token"
)
if entry.stores_kv_value():
results = []
if positive:
@ -234,7 +242,12 @@ def cmd_verify(cfg: Config, args) -> int:
# Denial is path-scoped, so one probe covers every field on this path.
results.extend(
run_verification(
client, entry, fields[0], positive=False, negative=True
client,
entry,
fields[0],
positive=False,
negative=True,
unrelated_token=unrelated_token,
)
)
else:
@ -460,6 +473,21 @@ def cmd_lifecycle(cfg: Config, args) -> int:
return 0
def cmd_audit(cfg: Config, args) -> int:
"""Summarize allowlisted non-secret evidence for one cataloged lane."""
import json
from secrets_engine.audit import summarize_lane_evidence
entry = get_entry(cfg.catalog_dir, args.catalog_id)
summary = summarize_lane_evidence(cfg.evidence_dir, entry.id)
if args.json:
print(json.dumps(summary.to_json(), indent=2, sort_keys=True))
else:
print(summary.render())
return 0
# -- parser ----------------------------------------------------------------
@ -512,6 +540,11 @@ def build_parser() -> argparse.ArgumentParser:
ve.add_argument("--field", default=None)
ve.add_argument("--positive", action="store_true")
ve.add_argument("--negative", action="store_true")
ve.add_argument(
"--negative-token-file",
default=None,
help="mode-0600 out-of-repo token for a real unrelated identity",
)
add_token_arg(ve)
ve.set_defaults(func=cmd_verify)
@ -571,6 +604,11 @@ def build_parser() -> argparse.ArgumentParser:
add_token_arg(lp)
lp.set_defaults(func=cmd_lifecycle)
au = sub.add_parser("audit", help="summarize non-secret local lane evidence")
au.add_argument("catalog_id")
au.add_argument("--json", action="store_true")
au.set_defaults(func=cmd_audit)
return p

View file

@ -10,6 +10,7 @@ import json
import os
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
@ -55,6 +56,12 @@ class EvidenceWriter:
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return self.evidence_dir / f"evidence-{day}.jsonl"
def _append_local(self, record: dict[str, Any]) -> None:
"""Append one record before any best-effort external delivery."""
path = self._log_path()
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True) + "\n")
def record(
self,
action: str,
@ -67,7 +74,10 @@ class EvidenceWriter:
hub: bool = True,
) -> dict[str, Any]:
"""Append one non-secret evidence record. Returns the stored record."""
record_id = str(uuid.uuid4())
hub_requested = bool(hub and self.hub_url)
record = {
"record_id": record_id,
"ts": datetime.now(timezone.utc).isoformat(),
"action": action,
"result": result,
@ -76,20 +86,38 @@ class EvidenceWriter:
"stage": stage,
"decision_id": decision_id,
"detail": _scrub(detail or {}),
"hub_delivery_requested": hub_requested,
}
path = self._log_path()
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True) + "\n")
if hub and self.hub_url:
self._post_hub(action, result, catalog_id, stage, decision_id)
self._append_local(record)
if hub_requested:
delivery_result = self._post_hub(
action, result, catalog_id, stage, decision_id
)
# Append-only companion evidence makes an unavailable State Hub
# visible without rewriting or delaying the primary local record.
self._append_local(
{
"record_id": str(uuid.uuid4()),
"related_record_id": record_id,
"ts": datetime.now(timezone.utc).isoformat(),
"action": "evidence-delivery",
"result": delivery_result,
"actor": self.actor,
"catalog_id": catalog_id,
"stage": stage,
"decision_id": decision_id,
"detail": {},
"hub_delivery_requested": False,
}
)
return record
def _post_hub(
self, action: str, result: str, catalog_id: str, stage: str, decision_id: str
) -> None:
"""Best-effort progress note to State Hub. Never raises; never sends values."""
) -> str:
"""Best-effort progress note; return a non-secret delivery outcome."""
if not self.topic_id:
return
return "skipped-no-topic"
summary = f"secrets-engine {action}: {result}"
if catalog_id:
summary += f" [{catalog_id}{'/' + stage if stage else ''}]"
@ -111,6 +139,7 @@ class EvidenceWriter:
method="POST",
)
urllib.request.urlopen(req, timeout=3).read()
return "delivered"
except (urllib.error.URLError, OSError, ValueError):
# Hub being offline must never block secret work or leak anything.
pass
return "failed"

View file

@ -28,29 +28,34 @@ from secrets_engine.errors import BackendError, ProvisioningError
from secrets_engine.safe_paths import containing_git_worktree
def _check_token_file(path: Path) -> str:
"""Read a bootstrap token file after enforcing mode-0600 and out-of-repo."""
def read_strict_token_file(path: Path, *, purpose: str = "token") -> str:
"""Read token material only from a mode-0600 path outside Git worktrees."""
if not path.exists():
raise ProvisioningError(f"bootstrap token file not found: {path}")
raise ProvisioningError(f"{purpose} file not found: {path}")
st = path.stat()
if st.st_mode & 0o077:
raise ProvisioningError(
f"bootstrap token file {path} is group/other-accessible "
f"{purpose} file {path} is group/other-accessible "
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
)
# Refuse a token file living inside a Git worktree.
worktree = containing_git_worktree(path)
if worktree is not None:
raise ProvisioningError(
f"bootstrap token file {path} is inside a Git worktree ({worktree}); "
f"{purpose} file {path} is inside a Git worktree ({worktree}); "
"store it outside any repo"
)
token = path.read_text(encoding="utf-8").strip()
if not token:
raise ProvisioningError(f"bootstrap token file {path} is empty")
raise ProvisioningError(f"{purpose} file {path} is empty")
return token
def _check_token_file(path: Path) -> str:
"""Compatibility wrapper for bootstrap authentication input."""
return read_strict_token_file(path, purpose="bootstrap token")
@dataclass
class ScopedTokenSession:
"""One AppRole login token that revokes itself on close."""

View file

@ -86,7 +86,8 @@ def route_lane(
if entry.kind == "kv" and entry.delivery_auth_management == "existing":
missing = "externally managed OpenBao policy/AppRole readiness"
next_command = (
f"secrets-engine verify {entry.id} --positive --negative"
f"secrets-engine verify {entry.id} --positive --negative "
"--negative-token-file <unrelated-token-path>"
)
elif entry.kind == "kv" and not entry.has_delivery_auth:
missing = "native delivery auth declaration"
@ -114,7 +115,8 @@ def route_lane(
next_command = f"secrets-engine exec --catalog {entry.id} -- <command...>"
else:
next_command = (
f"secrets-engine verify {entry.id} --positive --negative"
f"secrets-engine verify {entry.id} --positive --negative "
"--negative-token-file <unrelated-token-path>"
)
return RouteResult(

View file

@ -74,10 +74,25 @@ def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> V
)
def verify_negative(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
"""An unrelated token must be denied. Uses an empty (invalid) token."""
# An empty/garbage token stands in for an unrelated consumer.
denied = not client.kv_can_read(entry.mount, entry.path, token="se-unrelated-denied")
def verify_negative(
client: OpenBaoClient,
entry: CatalogEntry,
*,
unrelated_token: str | None,
) -> VerifyResult:
"""A real unrelated token must be denied the cataloged path."""
if not unrelated_token:
return VerifyResult(
"negative",
False,
{
"reason": "no real unrelated token supplied; denial not proven",
"path": entry.path,
},
)
denied = not client.kv_can_read(
entry.mount, entry.path, token=unrelated_token
)
return VerifyResult(
"negative",
denied,
@ -167,7 +182,13 @@ def verify_auth_capability_negative(client: OpenBaoClient, entry: CatalogEntry)
def run_verification(
client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool
client: OpenBaoClient,
entry: CatalogEntry,
field: str,
*,
positive: bool,
negative: bool,
unrelated_token: str | None = None,
) -> list[VerifyResult]:
if entry.kind == "kv" and field not in entry.fields:
raise VerificationError(
@ -183,7 +204,9 @@ def run_verification(
if positive:
results.append(verify_positive(client, entry, field))
if negative:
results.append(verify_negative(client, entry))
results.append(
verify_negative(client, entry, unrelated_token=unrelated_token)
)
if not results:
raise VerificationError("no verification check selected (use --positive/--negative)")
return results