flex-auth fixed enrichment so registry facts beat caller-supplied ones (FLEX-DEC-2026-012) and asked each consumer to confirm the ceiling and allowlist keys are actually declared -- the fix wins only where the registry HAS a value, and a manifest omitting max_ttl_hours hands that ceiling back to the caller. Confirmed, and made durable rather than read once. scripts/check_flex_auth_manifest_coverage.py audits both ways a ceiling gets handed back: an actor with no manifest resource at all (warden sign names ssh-cert:actor/<name> whether or not the snapshot was rebuilt -- an honour-system step in SCOPE.md), and a resource missing one of the seven keys. A null is treated as absent, because for enrichment it is. Also asserts the property their exploitability assessment rested on and nothing here held: ops-warden sends no resource.attributes. It was true when they read it, secrets-engine sends them on every request, and it was one refactor from silently stopping being true. Current state: no gap. 4 actors, 4 resources, all seven keys declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013EPuTc18FjU5WFqoSEKH3C Assistant: claude-code Assistant-Model: opus Assistant-Process: 1276224@bnt-lap001 Assistant-Session: 426ec497-e1c4-4dd3-b417-dfce1ca1dbc3
161 lines
6.3 KiB
Python
161 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Assert every ssh-certificate resource declares its ceilings in the manifest.
|
|
|
|
Read-only. Reads `inventory.yaml` and the flex-auth registry snapshot; touches
|
|
no network, no OpenBao, and no secret material.
|
|
|
|
Why this exists (FLEX-DEC-2026-012, 2026-09-07). flex-auth's enrichment used to
|
|
overlay registry facts additive-if-absent, so a caller-supplied value for a key
|
|
won and the registry's ceiling never applied. That is fixed on their side:
|
|
registry facts now win. But they win only where the registry HAS a value —
|
|
|
|
"A resource whose manifest omits max_ttl_hours hands that ceiling back to
|
|
the caller, and registering the resource is not sufficient; the specific
|
|
key must be present."
|
|
|
|
So there are two ways to hand a ceiling back, and this check covers both:
|
|
|
|
1. An actor with no manifest resource at all. `warden sign` names
|
|
`ssh-cert:actor/<name>` for any actor in inventory, whether or not the
|
|
snapshot was regenerated. Adding an actor and forgetting to rebuild is an
|
|
honour-system step in `SCOPE.md`, and honour-system steps are what
|
|
`ADR-0004`'s WARDEN_AGENT_ID marker taught us to stop relying on.
|
|
2. A manifest resource missing one of the ceiling or allowlist keys. The
|
|
builder emits all of them today; this asserts it stays true, including for
|
|
resources added by hand or by a future code path.
|
|
|
|
ops-warden sends no `resource.attributes` on a CheckRequest (`src/warden/policy.py`),
|
|
which is why FLEX-DEC-2026-012 was defence-in-depth rather than a live hole for
|
|
this repo. That property is asserted separately in `tests/test_policy.py`; this
|
|
check covers the half that survives it — if anything ever does reach a request
|
|
field, the ceiling it would have to beat must actually exist.
|
|
|
|
Usage:
|
|
python scripts/check_flex_auth_manifest_coverage.py \
|
|
--inventory examples/inventory.seed.yaml \
|
|
--registry registry/flex-auth/production_registry_snapshot.json [--json]
|
|
|
|
Exit: 0 covered, 2 coverage gap, 1 usage/IO error.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
#: Every key ops-warden's shipped policy package branches on as a ceiling or an
|
|
#: allowlist. Named by flex-auth in FLEX-DEC-2026-012; kept here rather than
|
|
#: derived from the package so a package edit that drops a branch does not
|
|
#: silently shrink what this check requires.
|
|
REQUIRED_CEILING_KEYS = (
|
|
"actor_id",
|
|
"actor_type",
|
|
"allowed_principals",
|
|
"allowed_subjects",
|
|
"max_ttl_hours",
|
|
"security_zone",
|
|
"security_zone_admission",
|
|
)
|
|
|
|
RESOURCE_TYPE = "ssh-certificate"
|
|
|
|
|
|
def _resources(registry: dict[str, Any]) -> list[dict[str, Any]]:
|
|
out: list[dict[str, Any]] = []
|
|
for manifest in registry.get("resource_manifests") or []:
|
|
out.extend(manifest.get("resources") or [])
|
|
return out
|
|
|
|
|
|
def audit(inventory: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]:
|
|
actors = sorted((inventory.get("actors") or {}).keys())
|
|
resources = _resources(registry)
|
|
by_id = {str(r.get("id")): r for r in resources}
|
|
|
|
unregistered: list[str] = []
|
|
missing_keys: list[dict[str, Any]] = []
|
|
|
|
for name in actors:
|
|
if f"ssh-cert:actor/{name}" not in by_id:
|
|
unregistered.append(name)
|
|
|
|
# Check every ssh-certificate resource in the manifest, not only the ones an
|
|
# inventory actor maps to: a resource flex-auth can be asked about is one it
|
|
# holds, regardless of where it came from.
|
|
for resource in resources:
|
|
if str(resource.get("type")) != RESOURCE_TYPE:
|
|
continue
|
|
attributes = resource.get("attributes") or {}
|
|
absent = [
|
|
key for key in REQUIRED_CEILING_KEYS
|
|
# A null is not a declaration: flex-auth overlays a registry value
|
|
# only where one exists, so `key: null` hands the ceiling back
|
|
# exactly as an absent key does.
|
|
if attributes.get(key) is None
|
|
]
|
|
if absent:
|
|
missing_keys.append({"resource": str(resource.get("id")), "missing": absent})
|
|
|
|
# Not a security defect — a stale resource has ceilings, it just has no
|
|
# actor. Reported so drift is visible rather than accumulating silently.
|
|
orphaned = sorted(
|
|
rid for rid, r in by_id.items()
|
|
if str(r.get("type")) == RESOURCE_TYPE
|
|
and rid.removeprefix("ssh-cert:actor/") not in actors
|
|
)
|
|
|
|
return {
|
|
"actors": len(actors),
|
|
"resources": sum(1 for r in resources if str(r.get("type")) == RESOURCE_TYPE),
|
|
"required_keys": list(REQUIRED_CEILING_KEYS),
|
|
"unregistered_actors": unregistered,
|
|
"resources_missing_keys": missing_keys,
|
|
"orphaned_resources": orphaned,
|
|
"covered": not unregistered and not missing_keys,
|
|
}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--inventory", default="examples/inventory.seed.yaml", type=Path)
|
|
parser.add_argument(
|
|
"--registry",
|
|
default="registry/flex-auth/production_registry_snapshot.json",
|
|
type=Path,
|
|
)
|
|
parser.add_argument("--json", action="store_true", dest="as_json")
|
|
args = parser.parse_args(argv)
|
|
|
|
try:
|
|
inventory = yaml.safe_load(args.inventory.read_text()) or {}
|
|
registry = json.loads(args.registry.read_text())
|
|
except (OSError, ValueError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
report = audit(inventory, registry)
|
|
report["inventory"] = str(args.inventory)
|
|
report["registry"] = str(args.registry)
|
|
|
|
if args.as_json:
|
|
print(json.dumps(report, indent=2))
|
|
else:
|
|
print(f"inventory : {args.inventory} ({report['actors']} actors)")
|
|
print(f"registry : {args.registry} ({report['resources']} ssh-certificate resources)")
|
|
for name in report["unregistered_actors"]:
|
|
print(f" MISSING {name} — no manifest resource; every ceiling is caller-supplied")
|
|
for row in report["resources_missing_keys"]:
|
|
print(f" UNDECLARED {row['resource']} — {', '.join(row['missing'])}")
|
|
for rid in report["orphaned_resources"]:
|
|
print(f" orphaned {rid} — manifest resource with no inventory actor")
|
|
print("covered" if report["covered"] else "NOT COVERED")
|
|
|
|
return 0 if report["covered"] else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|