Founder clarifications: the SI-14 token ceiling is 60 minutes (3600 s); ld-admin is for LLDAP only and the name admin remains the human platform administrator's wherever it appears. The LLDAP rehearsal is now introducing ld-admin by configuration and demoting admin, not renaming. The validator rejects a service-admin named admin. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 328925@bnt-lap001 Assistant-Session: 14b795fd-c632-4c01-8d28-e7e490c7b0ed
280 lines
12 KiB
Python
280 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# dependencies = ["jsonschema>=4.10,<5", "PyYAML>=6,<7"]
|
|
# ///
|
|
"""Check service identity declarations against the proposed standard (NK-WP-0054-T03).
|
|
|
|
Validates `canon/schemas/service-identity_v0.1.schema.json`, then evaluates the rules of
|
|
NetKingdom Service Identity and Privilege v0.1 that can be decided from a declaration alone.
|
|
Each identity ends as `conformant`, `non-conformant`, `excepted` or `unknown`; unknown is a
|
|
finding, not a pass. Observed state is the health check's job (T04), not this tool's.
|
|
|
|
Offline and read-only. It never contacts a service and never prints a declared value: schema
|
|
messages can contain a rejected secret, so invalid input reports only a count.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
from typing import Any
|
|
|
|
import jsonschema
|
|
import yaml
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
SCHEMA = ROOT / "canon/schemas/service-identity_v0.1.schema.json"
|
|
|
|
# Value shapes that look like secret material. Field names are limited by the schema;
|
|
# this catches smuggled values.
|
|
SECRET_VALUE = re.compile(
|
|
r"(\bhv[sbr]\.[A-Za-z0-9_-]{8,}|\bs\.[A-Za-z0-9]{20,}"
|
|
r"|-----BEGIN [A-Z ]*PRIVATE KEY-----|AGE-SECRET-KEY-1[A-Z0-9]{10,}"
|
|
r"|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\."
|
|
r"|://[^/\s:@]+:[^/\s@]{3,}@)"
|
|
)
|
|
|
|
BIND_NAME = re.compile(r"^svc-[a-z0-9]+(-[a-z0-9]+)*$")
|
|
ADMIN_NAME = re.compile(r"^[a-z]{2,6}-admin$")
|
|
AGENT_MAX_TTL_SECONDS = 3600 # 60 minutes, decided 2026-10-24 (standard SI-14)
|
|
ORDER = {"non-conformant": 0, "unknown": 1, "excepted": 2, "planned": 3, "conformant": 4}
|
|
|
|
|
|
def load_schema() -> dict[str, Any]:
|
|
return json.loads(SCHEMA.read_text(encoding="utf-8"))
|
|
|
|
|
|
def load_declaration(path: pathlib.Path) -> Any:
|
|
text = path.read_text(encoding="utf-8")
|
|
data = json.loads(text) if path.suffix == ".json" else yaml.safe_load(text)
|
|
# YAML turns dates into date objects; the schema speaks JSON.
|
|
return json.loads(json.dumps(data, default=str))
|
|
|
|
|
|
def parse_date(value: str) -> dt.date:
|
|
return dt.date.fromisoformat(value)
|
|
|
|
|
|
def _strings(value: Any):
|
|
if isinstance(value, str):
|
|
yield value
|
|
elif isinstance(value, dict):
|
|
for item in value.values():
|
|
yield from _strings(item)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
yield from _strings(item)
|
|
|
|
|
|
def structural_errors(document: Any, schema: dict[str, Any]) -> list[str]:
|
|
"""Locations only. Messages can echo the rejected value, so they are never returned."""
|
|
validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker())
|
|
errors = [f"schema:{'/'.join(str(p) for p in e.absolute_path) or '<root>'}:{e.validator}"
|
|
for e in validator.iter_errors(document)]
|
|
if errors:
|
|
return sorted(errors)
|
|
out = []
|
|
names = [i["name"] for i in document["identities"]]
|
|
if len(set(names)) != len(names):
|
|
out.append("duplicate identity name")
|
|
ids = [e["id"] for e in document.get("exceptions", [])]
|
|
if len(set(ids)) != len(ids):
|
|
out.append("duplicate exception id")
|
|
known = set(names)
|
|
for exception in document.get("exceptions", []):
|
|
if exception["identity"] not in known:
|
|
out.append("exception names an undeclared identity")
|
|
if any(SECRET_VALUE.search(s) for s in _strings(document)):
|
|
out.append("value looks like secret material")
|
|
return out
|
|
|
|
|
|
def _violations(identity: dict[str, Any]) -> tuple[set[str], list[str]]:
|
|
"""Rules broken by one identity on declared facts, and the facts still unknown."""
|
|
broken: set[str] = set()
|
|
unknown: list[str] = []
|
|
classes = identity["classes"]
|
|
consumers = identity["consumers"]
|
|
persons = [c for c in consumers if c["kind"] == "person"]
|
|
services = [c for c in consumers if c["kind"] == "service"]
|
|
name = identity["name"]
|
|
privilege, second, custody = identity["privilege_level"], identity["second_factor"], identity["custody"]
|
|
|
|
if len(classes) != 1:
|
|
broken.add("SI-1")
|
|
if (persons and services) or ("human" in classes and services):
|
|
broken.add("SI-2")
|
|
if len(services) > 1:
|
|
broken.add("SI-3") # a credential used by more than one consumer
|
|
# Unknown is a finding for every class: a fact nobody has established is not a pass.
|
|
if privilege == "unknown":
|
|
unknown.append("privilege_level")
|
|
if "unknown" in custody:
|
|
unknown.append("custody")
|
|
if "bind" in classes:
|
|
if privilege not in ("read", "unknown"):
|
|
broken.add("SI-3")
|
|
if identity["interactive_login"] or persons or len(services) != 1:
|
|
broken.add("SI-3")
|
|
if "human" in classes or "service-admin" in classes:
|
|
if second == "unknown":
|
|
unknown.append("second_factor")
|
|
elif second == "not_enforced":
|
|
broken.add("SI-8")
|
|
if identity.get("grants_admin_policy") and not identity.get("assurance_claim_bound"):
|
|
broken.add("SI-8")
|
|
if ("bind" in classes or "workload" in classes or "agent" in classes) and "personal-safe" in custody:
|
|
broken.add("SI-9") # a person's safe must not be the home of a service or agent credential
|
|
if "agent" in classes:
|
|
agent = identity["agent"]
|
|
if agent["presents_principal_type"] == "unknown":
|
|
unknown.append("agent.presents_principal_type")
|
|
elif agent["presents_principal_type"] != "agent":
|
|
broken.add("SI-14") # IAM Profile v0.3: agent tokens carry principal_type agent
|
|
if agent["mode"] == "unknown":
|
|
unknown.append("agent.mode")
|
|
if agent["max_token_ttl_seconds"] > AGENT_MAX_TTL_SECONDS:
|
|
broken.add("SI-14")
|
|
if persons:
|
|
broken.add("SI-2")
|
|
if "break-glass" in classes:
|
|
if "unknown" not in custody and custody != ["offline-key"]:
|
|
broken.add("SI-6")
|
|
if services:
|
|
broken.add("SI-6")
|
|
recovery = identity.get("recovery")
|
|
if recovery and recovery["needed"]:
|
|
if not recovery["in_crisis_list"]:
|
|
broken.add("SI-12")
|
|
if recovery["works_with_sso_down"] == "unknown":
|
|
unknown.append("recovery.works_with_sso_down")
|
|
# Naming (section 4). Reserved names belong to their class.
|
|
reserved_bind, reserved_admin = BIND_NAME.match(name), ADMIN_NAME.match(name)
|
|
if "bind" in classes and not reserved_bind:
|
|
broken.add("NAMING")
|
|
if "service-admin" in classes and not reserved_admin:
|
|
broken.add("NAMING")
|
|
holds_credential = custody != ["none"]
|
|
if "human" in classes and holds_credential and (name.startswith("svc-") or name.endswith("-admin")):
|
|
broken.add("NAMING") # svc- and -admin names are reserved for their classes; a federated role holds no credential
|
|
return broken, sorted(set(unknown))
|
|
|
|
|
|
def evaluate(document: dict[str, Any], as_of: dt.date) -> dict[str, Any]:
|
|
"""Evaluate a structurally valid declaration as of a stated date (never the wall clock)."""
|
|
exceptions = document.get("exceptions", [])
|
|
findings: list[dict[str, Any]] = []
|
|
used: set[str] = set()
|
|
advisories: list[str] = []
|
|
for identity in document["identities"]:
|
|
broken, unknown = _violations(identity)
|
|
if identity["status"] == "legacy" and parse_date(identity["disposition"]["date"]) < as_of:
|
|
broken.add("SI-11")
|
|
excepted: dict[str, str] = {}
|
|
open_rules: set[str] = set()
|
|
expired_ids: list[str] = []
|
|
for rule in sorted(broken):
|
|
match = [e for e in exceptions if e["identity"] == identity["name"] and rule in e["rules"]]
|
|
live = [e for e in match if as_of <= parse_date(e["expires"])]
|
|
if live:
|
|
excepted[rule] = live[0]["id"]
|
|
used.add(live[0]["id"])
|
|
else:
|
|
open_rules.add(rule)
|
|
expired_ids += [e["id"] for e in match]
|
|
if expired_ids:
|
|
open_rules.add("SI-EXC-EXPIRED")
|
|
if identity["status"] == "planned" and not open_rules:
|
|
result = "planned"
|
|
elif open_rules:
|
|
result = "non-conformant"
|
|
elif unknown:
|
|
result = "unknown"
|
|
elif excepted:
|
|
result = "excepted"
|
|
else:
|
|
result = "conformant"
|
|
findings.append({"identity": identity["name"], "result": result, "rules": sorted(open_rules),
|
|
"excepted": excepted, "unknown": unknown})
|
|
for exception in exceptions:
|
|
if exception["id"] not in used and as_of <= parse_date(exception["expires"]):
|
|
advisories.append(f"{exception['id']}: no matching violation (stale exception)")
|
|
admins = [i["name"] for i in document["identities"]
|
|
if "service-admin" in i["classes"] and i["status"] != "planned"]
|
|
if len(admins) > 1:
|
|
advisories.append("SI-4: more than one service-admin identity: " + ", ".join(sorted(admins)))
|
|
if not document["complete"]:
|
|
advisories.append("SI-11: declaration is not asserted complete by the owner")
|
|
if not document["attestation"]["owner_confirmed"]:
|
|
advisories.append("declaration is not confirmed by the owning service")
|
|
return {"service": document["service"], "as_of": as_of.isoformat(), "identities": findings,
|
|
"advisories": advisories}
|
|
|
|
|
|
def run(paths: list[pathlib.Path], as_of: dt.date) -> tuple[list[dict[str, Any]], list[str]]:
|
|
schema = load_schema()
|
|
reports: list[dict[str, Any]] = []
|
|
rejected: list[str] = []
|
|
breakglass: set[str] = set()
|
|
for path in paths:
|
|
try:
|
|
document = load_declaration(path)
|
|
except (OSError, ValueError, yaml.YAMLError):
|
|
rejected.append(f"{path.name}: unreadable input")
|
|
continue
|
|
errors = structural_errors(document, schema)
|
|
if errors:
|
|
rejected.append(f"{path.name}: rejected ({len(errors)} errors)")
|
|
continue
|
|
reports.append(evaluate(document, as_of))
|
|
breakglass |= {i["name"] for i in document["identities"] if "break-glass" in i["classes"]}
|
|
if len(breakglass) > 1:
|
|
reports.append({"service": "<run>", "as_of": as_of.isoformat(), "identities": [],
|
|
"advisories": ["SI-6: more than one break-glass identity across declarations: "
|
|
+ ", ".join(sorted(breakglass))]})
|
|
return reports, rejected
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("paths", nargs="+", type=pathlib.Path)
|
|
parser.add_argument("--as-of", default=dt.date.today().isoformat(), help="evaluation date, YYYY-MM-DD")
|
|
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
parser.add_argument("--fail-on-findings", action="store_true", help="exit 2 when anything is non-conformant or unknown")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
as_of = parse_date(args.as_of)
|
|
except ValueError:
|
|
print("service identity: invalid --as-of", file=sys.stderr)
|
|
return 1
|
|
reports, rejected = run(args.paths, as_of)
|
|
for line in rejected:
|
|
print(f"service identity: {line}", file=sys.stderr)
|
|
counts: dict[str, int] = {k: 0 for k in ORDER}
|
|
for report in reports:
|
|
for item in report["identities"]:
|
|
counts[item["result"]] += 1
|
|
if args.json:
|
|
print(json.dumps({"as_of": as_of.isoformat(), "counts": counts, "reports": reports, "rejected": len(rejected)}, indent=2))
|
|
else:
|
|
for report in reports:
|
|
for item in sorted(report["identities"], key=lambda i: (ORDER[i["result"]], i["identity"])):
|
|
detail = (item["rules"] or item["unknown"] if item["result"] in ("non-conformant", "unknown", "planned")
|
|
else sorted(item["excepted"]))
|
|
print(f"{report['service']}/{item['identity']}: {item['result']}" + (f" [{', '.join(detail)}]" if detail else ""))
|
|
for note in report["advisories"]:
|
|
print(f"{report['service']}: advisory: {note}")
|
|
print("summary: " + ", ".join(f"{counts[k]} {k}" for k in ORDER) + f" (as of {as_of.isoformat()})")
|
|
if rejected:
|
|
return 1
|
|
if args.fail_on_findings and (counts["non-conformant"] or counts["unknown"]):
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|