Add value-safe verification and audit reporting
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
parent
491e706a70
commit
c4504c6de9
19 changed files with 598 additions and 50 deletions
167
src/secrets_engine/audit.py
Normal file
167
src/secrets_engine/audit.py
Normal 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())),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue