#!/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 ''}:{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": "", "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())