NK-WP-0048: canon accepted; SecurityCaseType schemas, validator and template (T01, T03)
security-case-types_v0.1 accepted by the founder 2026-10-08. Adds JSON schemas for types and cases, tools/security-case-types/security_case_validator.py (validate-type, validate-case, render via the scenario composer, plan-only), and a runnable _template type with README sections, an example case and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5457@bnt-lap001 Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
This commit is contained in:
parent
dcacdf57f3
commit
d8cf42bab6
11 changed files with 1122 additions and 7 deletions
127
canon/schemas/security-case-type_v0.1.schema.json
Normal file
127
canon/schemas/security-case-type_v0.1.schema.json
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://netkingdom.local/schemas/security-case-type_v0.1.schema.json",
|
||||
"title": "NetKingdom SecurityCaseType v0.1",
|
||||
"description": "Structure of security-case-types/<type-id>/type.yaml (canon/standards/security-case-types_v0.1.md). Semantic rules (rollback coverage, template placeholders, secret-shaped values, acceptance by risk class) are enforced by tools/security-case-types/security_case_validator.py.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["apiVersion", "kind", "spec"],
|
||||
"properties": {
|
||||
"apiVersion": {"const": "netkingdom.io/security-case-type/v0.1"},
|
||||
"kind": {"const": "SecurityCaseType"},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version", "status", "title", "purpose", "parameters", "composition",
|
||||
"materialization", "follow_through", "verification", "rollback", "acceptance"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"},
|
||||
"version": {"type": "string", "pattern": "^\\d+\\.\\d+$"},
|
||||
"status": {"enum": ["draft", "proposed", "acknowledged", "implemented", "deprecated", "retired"]},
|
||||
"title": {"type": "string", "minLength": 1},
|
||||
"purpose": {"type": "string", "minLength": 1},
|
||||
"parameters": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/parameter"}},
|
||||
"composition": {"$ref": "#/$defs/composition"},
|
||||
"materialization": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/materialization"}},
|
||||
"follow_through": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/step"}},
|
||||
"verification": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/check"}},
|
||||
"rollback": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/rollback"}},
|
||||
"acceptance": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["accepted_by", "risk_class"],
|
||||
"properties": {
|
||||
"accepted_by": {"type": "array", "minItems": 1, "uniqueItems": true,
|
||||
"items": {"enum": ["owner", "founder"]}},
|
||||
"risk_class": {"enum": ["low", "medium", "high"]}
|
||||
}
|
||||
},
|
||||
"history": {"type": "array", "items": {"type": "object"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"parameter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "type", "case_settable", "sensitivity", "description"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"},
|
||||
"type": {"enum": ["string", "integer", "number", "boolean", "array", "object"]},
|
||||
"case_settable": {"type": "boolean"},
|
||||
"required": {"type": "boolean"},
|
||||
"default": {},
|
||||
"sensitivity": {"enum": ["public", "operational", "security_sensitive", "secret_reference"]},
|
||||
"constraints": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pattern": {"type": "string"},
|
||||
"enum": {"type": "array"},
|
||||
"min_items": {"type": "integer", "minimum": 0},
|
||||
"unique": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"description": {"type": "string", "minLength": 1}
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["authority", "initial_trust", "capabilities", "providers"],
|
||||
"properties": {
|
||||
"authority": {"enum": ["platform", "netkingdom", "tenant"]},
|
||||
"initial_trust": {"type": "array", "minItems": 1, "items": {"type": "string"}},
|
||||
"capabilities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}},
|
||||
"providers": {"type": "object", "additionalProperties": {"type": "string", "minLength": 1}},
|
||||
"parameter_bindings": {"type": "object", "additionalProperties": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"materialization": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "template"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"kind": {"enum": ["openbao_policy", "openbao_role", "kv_path", "catalog_entry"]},
|
||||
"template": {"type": "string", "minLength": 1},
|
||||
"description": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"step": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "owner", "entry_point", "auth", "warden_executes"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"entry_point": {"type": "string", "minLength": 1},
|
||||
"auth": {"type": "string", "minLength": 1},
|
||||
"warden_executes": {"type": "boolean"},
|
||||
"attended": {"type": "boolean"},
|
||||
"attended_justification": {"type": "string"},
|
||||
"description": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"check": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "check", "expect", "reads_secret_value"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"check": {"type": "string", "minLength": 1},
|
||||
"expect": {"enum": ["allow", "present", "deny"]},
|
||||
"reads_secret_value": {"const": false}
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["reverses", "action"],
|
||||
"properties": {
|
||||
"reverses": {"type": "string", "minLength": 1},
|
||||
"action": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
canon/schemas/security-case_v0.1.schema.json
Normal file
56
canon/schemas/security-case_v0.1.schema.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://netkingdom.local/schemas/security-case_v0.1.schema.json",
|
||||
"title": "NetKingdom SecurityCase v0.1",
|
||||
"description": "Structure of a security case file (canon/standards/security-case-types_v0.1.md section 4). Parameter values are checked against the referenced type by tools/security-case-types/security_case_validator.py.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["apiVersion", "kind", "spec"],
|
||||
"properties": {
|
||||
"apiVersion": {"const": "netkingdom.io/security-case/v0.1"},
|
||||
"kind": {"const": "SecurityCase"},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "status", "owner", "parameters"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"},
|
||||
"type": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*@\\d+\\.\\d+$"},
|
||||
"status": {"enum": ["proposed", "accepted", "materialized", "live", "retired"]},
|
||||
"owner": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["repo", "person"],
|
||||
"properties": {
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"person": {"type": "string", "minLength": 1}
|
||||
}
|
||||
},
|
||||
"parameters": {"type": "object"},
|
||||
"acceptance": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["at", "record", "by"],
|
||||
"properties": {
|
||||
"at": {"type": "string", "minLength": 1},
|
||||
"record": {"type": "string", "minLength": 1},
|
||||
"by": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["role", "name"],
|
||||
"properties": {
|
||||
"role": {"enum": ["owner", "founder"]},
|
||||
"name": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"receipts": {"type": "array", "items": {"type": "object"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ id: netkingdom-security-case-types-v0.1
|
|||
type: standard
|
||||
title: "NetKingdom Security Case Types v0.1"
|
||||
domain: netkingdom
|
||||
status: proposed
|
||||
status: accepted
|
||||
version: "0.1"
|
||||
owner: net-kingdom
|
||||
created: "2026-10-08"
|
||||
|
|
@ -11,6 +11,15 @@ updated: "2026-10-08"
|
|||
last_reviewed: "2026-10-08"
|
||||
review_interval: 3m
|
||||
scope: meta-orchestration
|
||||
accepted: "2026-10-08"
|
||||
accepted_by: founder
|
||||
schema:
|
||||
- canon/schemas/security-case-type_v0.1.schema.json
|
||||
- canon/schemas/security-case_v0.1.schema.json
|
||||
validator:
|
||||
- tools/security-case-types/security_case_validator.py
|
||||
template:
|
||||
- security-case-types/_template/
|
||||
related:
|
||||
- canon/standards/playbook-capability-contract_v0.2.md
|
||||
- canon/standards/security-scenario-composition_v0.1.md
|
||||
|
|
@ -163,7 +172,7 @@ incomplete.
|
|||
- Existing hand-built instances keep working. They are adopted as cases when
|
||||
their type is implemented, not rewritten in place.
|
||||
|
||||
## 10. Open questions for v0.1 review
|
||||
## 10. Open questions (carried after acceptance, 2026-10-08)
|
||||
|
||||
- Where should case files live by default: with the requester, or centrally in
|
||||
net-kingdom?
|
||||
|
|
|
|||
40
security-case-types/_template/README.md
Normal file
40
security-case-types/_template/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# SecurityCaseType template
|
||||
|
||||
Copy `_template/` to `security-case-types/<type-id>/` and rewrite every
|
||||
section. Canon: `canon/standards/security-case-types_v0.1.md`. A type is
|
||||
acknowledged through informed-decision only when this README, `type.yaml`, the
|
||||
example cases and the tests are complete and green.
|
||||
|
||||
## Purpose
|
||||
|
||||
Which recurring use case the type serves, and the hand-built instances it
|
||||
replaces or adopts (reference instances). For the template: a loopback bootstrap
|
||||
identity for one operator, composed from `net-kingdom.local-identity`.
|
||||
|
||||
## Threat model
|
||||
|
||||
What the type protects, from whom, and how. Name the assets (credentials,
|
||||
sessions, signing keys), the actors (anonymous, low-assurance operator, tenant,
|
||||
compromised workload) and what each step does to prevent misuse. For the
|
||||
template: the store is loopback-only and private to its owner; the signing key
|
||||
is referenced from custody, never written into a case.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
The other shapes this pattern could have taken and why they were not chosen.
|
||||
This is the part an informed-decision is mostly about.
|
||||
|
||||
## Attended steps
|
||||
|
||||
Every step marked `attended: true`, why it cannot run unattended, and how
|
||||
ops-warden groups such steps into as few attended sessions as possible.
|
||||
|
||||
## Known limits
|
||||
|
||||
What the type does not cover, and when a request should become a new type or a
|
||||
new major version instead of a case.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
How the checks in `type.yaml` prove a case is live without reading any value,
|
||||
and how rollback restores the state from before the case.
|
||||
15
security-case-types/_template/examples/example-operator.yaml
Normal file
15
security-case-types/_template/examples/example-operator.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Example SecurityCase for the template type. Example values only.
|
||||
apiVersion: netkingdom.io/security-case/v0.1
|
||||
kind: SecurityCase
|
||||
spec:
|
||||
id: example-operator
|
||||
type: template-example@0.1
|
||||
status: proposed
|
||||
owner:
|
||||
repo: net-kingdom
|
||||
person: example-owner
|
||||
parameters:
|
||||
operator_username: bootstrap-admin
|
||||
operator_fullname: Bootstrap Reference Operator
|
||||
operator_email: bootstrap@example.invalid
|
||||
signing_key_ref: bao:platform/workloads/example/local-identity-signing
|
||||
53
security-case-types/_template/tests/test_type.py
Normal file
53
security-case-types/_template/tests/test_type.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Tests every SecurityCaseType carries: the type validates, its example cases
|
||||
validate against it, and each example renders into a composer plan.
|
||||
|
||||
Copied with the template; keep it and add type-specific tests beside it.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
TYPE_DIR = Path(__file__).resolve().parents[1]
|
||||
REPO = TYPE_DIR.parents[1]
|
||||
VALIDATOR = REPO / "tools" / "security-case-types" / "security_case_validator.py"
|
||||
SPEC = importlib.util.spec_from_file_location("security_case_validator", VALIDATOR)
|
||||
validator = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = validator
|
||||
SPEC.loader.exec_module(validator)
|
||||
|
||||
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
|
||||
EXAMPLES = sorted((TYPE_DIR / "examples").glob("*.yaml"))
|
||||
DECLARATIONS = sorted(str(p) for p in (REPO / "capabilities" / "playbooks").glob("*.yaml"))
|
||||
# Until the type is implemented, examples are checked as if it were.
|
||||
IMPLEMENTED = TYPE["spec"]["status"] == "implemented"
|
||||
|
||||
|
||||
def errors(issues):
|
||||
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
|
||||
|
||||
|
||||
def test_type_is_complete():
|
||||
assert errors(validator.validate_type(TYPE, TYPE_DIR)) == []
|
||||
|
||||
|
||||
def test_examples_exist_and_validate():
|
||||
assert EXAMPLES
|
||||
for path in EXAMPLES:
|
||||
case = yaml.safe_load(path.read_text())
|
||||
assert errors(validator.validate_case(case, TYPE, require_implemented=IMPLEMENTED)) == [], path.name
|
||||
|
||||
|
||||
def test_examples_render_deterministically():
|
||||
declarations, load_issues = validator.composer.load_declarations(DECLARATIONS)
|
||||
assert errors(load_issues) == []
|
||||
for path in EXAMPLES:
|
||||
case = yaml.safe_load(path.read_text())
|
||||
issues, first = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
|
||||
assert errors(issues) == [], path.name
|
||||
_, second = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
|
||||
assert first["digest"] == second["digest"]
|
||||
assert first["execution"] == {"permitted": False}
|
||||
assert first["composition"]["execution"]["permitted"] is False
|
||||
115
security-case-types/_template/type.yaml
Normal file
115
security-case-types/_template/type.yaml
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# SecurityCaseType template (canon/standards/security-case-types_v0.1.md).
|
||||
#
|
||||
# Copy this directory to security-case-types/<type-id>/ and replace every part.
|
||||
# This template is itself a valid, runnable type: it composes the checked-in
|
||||
# net-kingdom.local-identity declaration (a C0 bootstrap identity), so that its
|
||||
# tests prove the validator and renderer end to end. It is not for real cases.
|
||||
apiVersion: netkingdom.io/security-case-type/v0.1
|
||||
kind: SecurityCaseType
|
||||
spec:
|
||||
id: template-example
|
||||
version: "0.1"
|
||||
status: draft
|
||||
title: "Template: local bootstrap identity for one operator"
|
||||
purpose: >-
|
||||
Shows every section a type must define, using a loopback bootstrap identity
|
||||
as the example. A real type states which recurring use case it serves and
|
||||
which hand-built instances it replaces.
|
||||
|
||||
# What a case fills in. Secret values are never parameters: a secret appears
|
||||
# only as a custody reference (sensitivity: secret_reference).
|
||||
parameters:
|
||||
- name: operator_username
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {pattern: "^[a-z_][a-z0-9_-]{0,31}$"}
|
||||
sensitivity: operational
|
||||
description: Username of the one operator this case is for.
|
||||
- name: operator_fullname
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {pattern: "^[^\\n\\r]{1,120}$"}
|
||||
sensitivity: operational
|
||||
description: Display name of the operator.
|
||||
- name: operator_email
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {pattern: "^[^@\\s]+@[^@\\s]+$"}
|
||||
sensitivity: operational
|
||||
description: Contact address of the operator.
|
||||
- name: signing_key_ref
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {pattern: "^bao:platform/workloads/[a-z0-9/-]+$"}
|
||||
sensitivity: secret_reference
|
||||
description: Custody reference for the signing key; never the key itself.
|
||||
- name: store_home
|
||||
type: string
|
||||
case_settable: false
|
||||
default: .local/local-identity
|
||||
sensitivity: operational
|
||||
description: Where the store lives; fixed by the type, not by cases.
|
||||
|
||||
# Which declared playbook capabilities the type composes, with exact provider
|
||||
# pins, and how case parameters bind to the declarations' parameters.
|
||||
composition:
|
||||
authority: netkingdom
|
||||
initial_trust: [bare_host_trust]
|
||||
capabilities: [c0.bootstrap-identity]
|
||||
providers:
|
||||
c0.bootstrap-identity: net-kingdom.local-identity
|
||||
parameter_bindings:
|
||||
net-kingdom.local-identity.bootstrap_username: operator_username
|
||||
net-kingdom.local-identity.bootstrap_fullname: operator_fullname
|
||||
net-kingdom.local-identity.bootstrap_email: operator_email
|
||||
net-kingdom.local-identity.local_identity_home: store_home
|
||||
|
||||
# What ops-mason builds per case (structure only, never values).
|
||||
materialization:
|
||||
- id: signing-key-path
|
||||
kind: kv_path
|
||||
template: "{signing_key_ref}"
|
||||
- id: signing-key-policy
|
||||
kind: openbao_policy
|
||||
template: "local-identity-{operator_username}-read"
|
||||
- id: catalog-entry
|
||||
kind: catalog_entry
|
||||
template: "local-identity-{operator_username}"
|
||||
|
||||
# What ops-warden carries out, step by step, on whose entry point.
|
||||
follow_through:
|
||||
- id: init-store
|
||||
owner: net-kingdom
|
||||
entry_point: "uv run --project local-identity local-identity init"
|
||||
auth: "local operator shell on the target host"
|
||||
warden_executes: false
|
||||
attended: true
|
||||
attended_justification: >-
|
||||
The bootstrap identity exists before any managed identity, so no
|
||||
unattended principal can run it.
|
||||
|
||||
# Checks that prove the case is live; never reading a secret value.
|
||||
verification:
|
||||
- id: discovery-serves
|
||||
check: "loopback discovery document issuer matches the planned port"
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: store-private
|
||||
check: "store under {store_home} is not readable by other users"
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
|
||||
# The exact inverse of every follow-through step and materialized item.
|
||||
rollback:
|
||||
- reverses: init-store
|
||||
action: "stop the loopback issuer and remove {store_home}"
|
||||
- reverses: signing-key-path
|
||||
action: "remove the KV path {signing_key_ref}"
|
||||
- reverses: signing-key-policy
|
||||
action: "delete policy local-identity-{operator_username}-read"
|
||||
- reverses: catalog-entry
|
||||
action: "remove catalog entry local-identity-{operator_username}"
|
||||
|
||||
acceptance:
|
||||
accepted_by: [owner]
|
||||
risk_class: low
|
||||
34
tools/security-case-types/README.md
Normal file
34
tools/security-case-types/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# security-case-types tools
|
||||
|
||||
Validator and renderer for SecurityCaseTypes and SecurityCases
|
||||
(`canon/standards/security-case-types_v0.1.md`, schemas
|
||||
`canon/schemas/security-case-type_v0.1.schema.json` and
|
||||
`security-case_v0.1.schema.json`).
|
||||
|
||||
```bash
|
||||
# a type directory: type.yaml, README.md sections, tests, example cases
|
||||
python3 tools/security-case-types/security_case_validator.py validate-type security-case-types/<type-id>
|
||||
|
||||
# a case against its type (security-case-types/<type-id>/type.yaml)
|
||||
python3 tools/security-case-types/security_case_validator.py validate-case <case.yaml> --types security-case-types
|
||||
|
||||
# render a case into the composer's plan plus materialization, follow-through,
|
||||
# verification and rollback; execution is never permitted
|
||||
python3 tools/security-case-types/security_case_validator.py render <case.yaml> \
|
||||
--types security-case-types capabilities/playbooks/*.yaml
|
||||
```
|
||||
|
||||
Only `implemented` types accept cases. `--allow-unimplemented` exists for a type's
|
||||
own example cases while the type is being written and acknowledged.
|
||||
|
||||
The validator refuses, among other things: missing sections, unknown or unpinned
|
||||
capabilities, attended steps without justification, steps or materialized items
|
||||
without a rollback, verification without a negative check or that reads a value,
|
||||
templates with unknown parameters, credential-shaped values anywhere, secret
|
||||
parameters that are not custody references, and high-risk types without founder
|
||||
acceptance.
|
||||
|
||||
To start a type: copy `security-case-types/_template/`, rename the directory to
|
||||
the type id, and rewrite every section. The template's tests travel with it.
|
||||
|
||||
Tests: `python3 -m pytest tools/security-case-types/tests security-case-types/*/tests`.
|
||||
500
tools/security-case-types/security_case_validator.py
Normal file
500
tools/security-case-types/security_case_validator.py
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate SecurityCaseTypes and SecurityCases, and render a case into a plan.
|
||||
|
||||
Canon: canon/standards/security-case-types_v0.1.md. A type is a NetKingdom-owned
|
||||
composition of declared playbook capabilities; a case fills in the type's
|
||||
parameters. Rendering produces the security-scenario composer's deterministic,
|
||||
plan-only output (execution is never permitted here) plus the type's
|
||||
materialization and follow-through, with parameters substituted.
|
||||
|
||||
security_case_validator.py validate-type security-case-types/<type-id>
|
||||
security_case_validator.py validate-case <case.yaml> --types security-case-types
|
||||
security_case_validator.py render <case.yaml> --types security-case-types <declarations...>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
TOOL_DIR = Path(__file__).resolve().parent
|
||||
COMPOSER_DIR = TOOL_DIR.parent / "security-scenario-composer"
|
||||
sys.path.insert(0, str(COMPOSER_DIR))
|
||||
|
||||
import security_scenario_composer as composer # noqa: E402
|
||||
|
||||
contract = composer.contract
|
||||
Issue = contract.Issue
|
||||
|
||||
TYPE_API_VERSION = "netkingdom.io/security-case-type/v0.1"
|
||||
CASE_API_VERSION = "netkingdom.io/security-case/v0.1"
|
||||
RENDER_API_VERSION = "netkingdom.io/security-case-render/v0.1"
|
||||
|
||||
ID = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
||||
VERSION = re.compile(r"^\d+\.\d+$")
|
||||
TYPE_REF = re.compile(r"^([a-z0-9][a-z0-9-]*)@(\d+\.\d+)$")
|
||||
TYPE_STATUSES = ["draft", "proposed", "acknowledged", "implemented", "deprecated", "retired"]
|
||||
CASE_STATUSES = ["proposed", "accepted", "materialized", "live", "retired"]
|
||||
RISK_CLASSES = {"low", "medium", "high"}
|
||||
ACCEPTERS = {"owner", "founder"}
|
||||
MATERIALIZATION_KINDS = {"openbao_policy", "openbao_role", "kv_path", "catalog_entry"}
|
||||
TYPE_SECTIONS = [
|
||||
"id", "version", "status", "title", "purpose", "parameters", "composition",
|
||||
"materialization", "follow_through", "verification", "rollback", "acceptance",
|
||||
]
|
||||
REQUIRED_README_HEADINGS = ["## Threat model", "## Alternatives considered", "## Known limits"]
|
||||
|
||||
# Values that look like credentials are refused anywhere in a case. A
|
||||
# secret_reference parameter must hold a custody reference, never a value.
|
||||
SECRET_SHAPES = [
|
||||
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||||
re.compile(r"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{8,}"),
|
||||
re.compile(r"\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}"),
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\."),
|
||||
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"),
|
||||
]
|
||||
CUSTODY_REFERENCE = re.compile(r"^(?:bao|openbao|vault|env|file):[^\s]+$")
|
||||
|
||||
|
||||
def err(path: str, message: str) -> Issue:
|
||||
return Issue("ERROR", path, message)
|
||||
|
||||
|
||||
def has_errors(issues: list[Issue]) -> bool:
|
||||
return any(i.level == "ERROR" for i in issues)
|
||||
|
||||
|
||||
def placeholders(text: str) -> set[str]:
|
||||
return {f[1] for f in string.Formatter().parse(text) if f[1]}
|
||||
|
||||
|
||||
def _walk_strings(value: Any, path: str):
|
||||
if isinstance(value, str):
|
||||
yield path, value
|
||||
elif isinstance(value, dict):
|
||||
for k, v in value.items():
|
||||
yield from _walk_strings(v, f"{path}.{k}")
|
||||
elif isinstance(value, list):
|
||||
for i, v in enumerate(value):
|
||||
yield from _walk_strings(v, f"{path}[{i}]")
|
||||
|
||||
|
||||
def secret_shaped(text: str) -> bool:
|
||||
return any(p.search(text) for p in SECRET_SHAPES)
|
||||
|
||||
|
||||
# ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def validate_type(data: dict[str, Any], type_dir: Path | None = None) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
if data.get("apiVersion") != TYPE_API_VERSION:
|
||||
issues.append(err("type.apiVersion", f"must be {TYPE_API_VERSION}"))
|
||||
if data.get("kind") != "SecurityCaseType":
|
||||
issues.append(err("type.kind", "must be SecurityCaseType"))
|
||||
spec = data.get("spec")
|
||||
if not isinstance(spec, dict):
|
||||
return issues + [err("type.spec", "missing")]
|
||||
for section in TYPE_SECTIONS:
|
||||
if section not in spec:
|
||||
issues.append(err(f"type.spec.{section}", "required section is missing"))
|
||||
if has_errors(issues):
|
||||
return issues
|
||||
|
||||
if not ID.fullmatch(str(spec["id"])):
|
||||
issues.append(err("type.spec.id", "must be lowercase letters, digits and hyphens"))
|
||||
if not VERSION.fullmatch(str(spec["version"])):
|
||||
issues.append(err("type.spec.version", "must be <major>.<minor>"))
|
||||
if spec["status"] not in TYPE_STATUSES:
|
||||
issues.append(err("type.spec.status", f"must be one of {TYPE_STATUSES}"))
|
||||
|
||||
params = _validate_type_parameters(spec["parameters"], issues)
|
||||
step_ids = _validate_follow_through(spec["follow_through"], issues)
|
||||
_validate_composition(spec["composition"], params, issues)
|
||||
_validate_materialization(spec["materialization"], params, issues)
|
||||
_validate_verification(spec["verification"], params, issues)
|
||||
_validate_rollback(spec["rollback"], step_ids, spec["materialization"], issues)
|
||||
_validate_acceptance(spec["acceptance"], issues)
|
||||
|
||||
for path, text in _walk_strings(spec, "type.spec"):
|
||||
if secret_shaped(text):
|
||||
issues.append(err(path, "contains a credential-shaped value"))
|
||||
|
||||
if type_dir is not None:
|
||||
readme = type_dir / "README.md"
|
||||
if not readme.is_file():
|
||||
issues.append(err("type.README.md", "missing"))
|
||||
else:
|
||||
text = readme.read_text()
|
||||
for heading in REQUIRED_README_HEADINGS:
|
||||
if heading not in text:
|
||||
issues.append(err("type.README.md", f"missing section {heading!r}"))
|
||||
tests = type_dir / "tests"
|
||||
if not tests.is_dir() or not any(tests.glob("test_*.py")):
|
||||
issues.append(err("type.tests", "a type needs runnable tests (tests/test_*.py)"))
|
||||
examples = sorted((type_dir / "examples").glob("*.yaml")) if (type_dir / "examples").is_dir() else []
|
||||
if not examples:
|
||||
issues.append(err("type.examples", "a type needs at least one example case"))
|
||||
return issues
|
||||
|
||||
|
||||
def _validate_type_parameters(params: Any, issues: list[Issue]) -> dict[str, dict]:
|
||||
out: dict[str, dict] = {}
|
||||
if not isinstance(params, list) or not params:
|
||||
issues.append(err("type.spec.parameters", "must be a non-empty list"))
|
||||
return out
|
||||
for i, p in enumerate(params):
|
||||
path = f"type.spec.parameters[{i}]"
|
||||
if not isinstance(p, dict) or not ID.fullmatch(str(p.get("name", "")).replace("_", "-")):
|
||||
issues.append(err(path, "needs a name of lowercase letters, digits, '-' or '_'"))
|
||||
continue
|
||||
name = p["name"]
|
||||
if name in out:
|
||||
issues.append(err(path, f"duplicate parameter {name!r}"))
|
||||
if p.get("type") not in contract.PARAM_TYPES:
|
||||
issues.append(err(f"{path}.type", f"must be one of {sorted(contract.PARAM_TYPES)}"))
|
||||
if p.get("sensitivity") not in contract.SENSITIVITIES:
|
||||
issues.append(err(f"{path}.sensitivity", f"must be one of {sorted(contract.SENSITIVITIES)}"))
|
||||
if p.get("sensitivity") == "secret_reference" and p.get("type") != "string":
|
||||
issues.append(err(path, "a secret_reference parameter must be a string custody reference"))
|
||||
if not isinstance(p.get("case_settable"), bool):
|
||||
issues.append(err(f"{path}.case_settable", "must be true or false"))
|
||||
if not p.get("description"):
|
||||
issues.append(err(f"{path}.description", "required"))
|
||||
if not p.get("case_settable") and "default" not in p:
|
||||
issues.append(err(path, "a parameter cases cannot set needs a default"))
|
||||
out[name] = p
|
||||
return out
|
||||
|
||||
|
||||
def _validate_composition(comp: Any, params: dict[str, dict], issues: list[Issue]) -> None:
|
||||
path = "type.spec.composition"
|
||||
if not isinstance(comp, dict):
|
||||
issues.append(err(path, "must be a mapping"))
|
||||
return
|
||||
if comp.get("authority") not in contract.SCENARIO_AUTHORITIES:
|
||||
issues.append(err(f"{path}.authority", f"must be one of {sorted(contract.SCENARIO_AUTHORITIES)}"))
|
||||
trust = comp.get("initial_trust")
|
||||
if not isinstance(trust, list) or not trust or not set(trust) <= contract.TRUST_STATES:
|
||||
issues.append(err(f"{path}.initial_trust", "must be a non-empty list of known trust states"))
|
||||
caps = comp.get("capabilities")
|
||||
if not isinstance(caps, list) or not caps:
|
||||
issues.append(err(f"{path}.capabilities", "must list at least one capability"))
|
||||
caps = []
|
||||
for c in caps:
|
||||
if c not in contract.CAPABILITIES:
|
||||
issues.append(err(f"{path}.capabilities", f"unknown capability {c!r}"))
|
||||
providers = comp.get("providers") or {}
|
||||
for c in caps:
|
||||
if not providers.get(c):
|
||||
issues.append(err(f"{path}.providers", f"capability {c!r} needs an exact provider pin"))
|
||||
for target, source in (comp.get("parameter_bindings") or {}).items():
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*\.[a-z_][a-z0-9_]*", str(target)):
|
||||
issues.append(err(f"{path}.parameter_bindings", f"{target!r} must be <declaration-id>.<parameter>"))
|
||||
if source not in params:
|
||||
issues.append(err(f"{path}.parameter_bindings.{target}", f"binds unknown case parameter {source!r}"))
|
||||
|
||||
|
||||
def _validate_templates(items: Any, path: str, params: dict[str, dict], issues: list[Issue]) -> None:
|
||||
for i, item in enumerate(items):
|
||||
for spath, text in _walk_strings(item, f"{path}[{i}]"):
|
||||
for name in placeholders(text):
|
||||
if name not in params:
|
||||
issues.append(err(spath, f"template uses unknown parameter {{{name}}}"))
|
||||
|
||||
|
||||
def _validate_materialization(items: Any, params: dict[str, dict], issues: list[Issue]) -> None:
|
||||
path = "type.spec.materialization"
|
||||
if not isinstance(items, list) or not items:
|
||||
issues.append(err(path, "must list what ops-mason builds per case"))
|
||||
return
|
||||
seen = set()
|
||||
for i, item in enumerate(items):
|
||||
if not isinstance(item, dict) or item.get("kind") not in MATERIALIZATION_KINDS:
|
||||
issues.append(err(f"{path}[{i}].kind", f"must be one of {sorted(MATERIALIZATION_KINDS)}"))
|
||||
continue
|
||||
if not item.get("id") or item["id"] in seen:
|
||||
issues.append(err(f"{path}[{i}].id", "needs a unique id"))
|
||||
seen.add(item.get("id"))
|
||||
if not item.get("template"):
|
||||
issues.append(err(f"{path}[{i}].template", "required"))
|
||||
_validate_templates(items, path, params, issues)
|
||||
|
||||
|
||||
def _validate_follow_through(steps: Any, issues: list[Issue]) -> set[str]:
|
||||
path = "type.spec.follow_through"
|
||||
ids: set[str] = set()
|
||||
if not isinstance(steps, list) or not steps:
|
||||
issues.append(err(path, "must list the ops-warden steps"))
|
||||
return ids
|
||||
for i, s in enumerate(steps):
|
||||
p = f"{path}[{i}]"
|
||||
if not isinstance(s, dict) or not s.get("id"):
|
||||
issues.append(err(p, "needs an id"))
|
||||
continue
|
||||
if s["id"] in ids:
|
||||
issues.append(err(p, f"duplicate step {s['id']!r}"))
|
||||
ids.add(s["id"])
|
||||
for key in ("owner", "entry_point", "auth"):
|
||||
if not s.get(key):
|
||||
issues.append(err(f"{p}.{key}", "required"))
|
||||
if not isinstance(s.get("warden_executes"), bool):
|
||||
issues.append(err(f"{p}.warden_executes", "must be true or false"))
|
||||
if s.get("attended") is True and not s.get("attended_justification"):
|
||||
issues.append(err(f"{p}.attended_justification", "an attended step must say why it cannot be unattended"))
|
||||
return ids
|
||||
|
||||
|
||||
def _validate_verification(checks: Any, params: dict[str, dict], issues: list[Issue]) -> None:
|
||||
path = "type.spec.verification"
|
||||
if not isinstance(checks, list) or not checks:
|
||||
issues.append(err(path, "must list checks that prove a case is live"))
|
||||
return
|
||||
expects = {c.get("expect") for c in checks if isinstance(c, dict)}
|
||||
if "deny" not in expects:
|
||||
issues.append(err(path, "needs at least one negative check (expect: deny)"))
|
||||
if not expects & {"allow", "present"}:
|
||||
issues.append(err(path, "needs at least one positive check (expect: allow or present)"))
|
||||
for i, c in enumerate(checks):
|
||||
if not isinstance(c, dict) or not c.get("id") or not c.get("check"):
|
||||
issues.append(err(f"{path}[{i}]", "needs id, check and expect"))
|
||||
if isinstance(c, dict) and c.get("reads_secret_value") is not False:
|
||||
issues.append(err(f"{path}[{i}].reads_secret_value", "must be false: verification never reads a value"))
|
||||
_validate_templates(checks, path, params, issues)
|
||||
|
||||
|
||||
def _validate_rollback(steps: Any, step_ids: set[str], materialization: Any, issues: list[Issue]) -> None:
|
||||
path = "type.spec.rollback"
|
||||
if not isinstance(steps, list) or not steps:
|
||||
issues.append(err(path, "must give the exact inverse of each step"))
|
||||
return
|
||||
covered = {s.get("reverses") for s in steps if isinstance(s, dict)}
|
||||
mat_ids = {m.get("id") for m in materialization if isinstance(m, dict)} if isinstance(materialization, list) else set()
|
||||
for missing in sorted((step_ids | mat_ids) - covered):
|
||||
issues.append(err(path, f"no rollback reverses {missing!r}"))
|
||||
|
||||
|
||||
def _validate_acceptance(acc: Any, issues: list[Issue]) -> None:
|
||||
path = "type.spec.acceptance"
|
||||
if not isinstance(acc, dict):
|
||||
issues.append(err(path, "must be a mapping"))
|
||||
return
|
||||
who = acc.get("accepted_by")
|
||||
if not isinstance(who, list) or not who or not set(who) <= ACCEPTERS:
|
||||
issues.append(err(f"{path}.accepted_by", f"must be a non-empty subset of {sorted(ACCEPTERS)}"))
|
||||
if acc.get("risk_class") not in RISK_CLASSES:
|
||||
issues.append(err(f"{path}.risk_class", f"must be one of {sorted(RISK_CLASSES)}"))
|
||||
if acc.get("risk_class") == "high" and "founder" not in (who or []):
|
||||
issues.append(err(f"{path}.accepted_by", "a high-risk type needs founder acceptance of each case"))
|
||||
|
||||
|
||||
# ── Cases ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_type(types_root: Path, ref: str) -> tuple[list[Issue], dict[str, Any] | None, Path | None]:
|
||||
m = TYPE_REF.fullmatch(ref or "")
|
||||
if not m:
|
||||
return [err("case.spec.type", "must be <type-id>@<major>.<minor>")], None, None
|
||||
type_dir = types_root / m.group(1)
|
||||
type_file = type_dir / "type.yaml"
|
||||
if not type_file.is_file():
|
||||
return [err("case.spec.type", f"unknown type {m.group(1)!r}")], None, None
|
||||
data = yaml.safe_load(type_file.read_text()) or {}
|
||||
if str(data.get("spec", {}).get("version")) != m.group(2):
|
||||
return [err("case.spec.type", f"type {m.group(1)!r} is at version "
|
||||
f"{data.get('spec', {}).get('version')}, not {m.group(2)}")], None, None
|
||||
return [], data, type_dir
|
||||
|
||||
|
||||
def validate_case(case: dict[str, Any], type_data: dict[str, Any], *, require_implemented: bool = True) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
if case.get("apiVersion") != CASE_API_VERSION:
|
||||
issues.append(err("case.apiVersion", f"must be {CASE_API_VERSION}"))
|
||||
if case.get("kind") != "SecurityCase":
|
||||
issues.append(err("case.kind", "must be SecurityCase"))
|
||||
spec = case.get("spec") or {}
|
||||
if not ID.fullmatch(str(spec.get("id", ""))):
|
||||
issues.append(err("case.spec.id", "must be lowercase letters, digits and hyphens"))
|
||||
if spec.get("status") not in CASE_STATUSES:
|
||||
issues.append(err("case.spec.status", f"must be one of {CASE_STATUSES}"))
|
||||
owner = spec.get("owner") or {}
|
||||
if not owner.get("repo") or not owner.get("person"):
|
||||
issues.append(err("case.spec.owner", "needs repo and person"))
|
||||
tspec = type_data.get("spec", {})
|
||||
if require_implemented and tspec.get("status") != "implemented":
|
||||
issues.append(err("case.spec.type", f"type {tspec.get('id')!r} is {tspec.get('status')!r}; "
|
||||
"only implemented types accept cases"))
|
||||
if spec.get("status") != "proposed":
|
||||
acc = spec.get("acceptance") or {}
|
||||
needed = set(tspec.get("acceptance", {}).get("accepted_by", []))
|
||||
given = {a.get("role") for a in acc.get("by", []) if isinstance(a, dict)}
|
||||
if not acc.get("at") or not acc.get("record") or not needed <= given:
|
||||
issues.append(err("case.spec.acceptance", f"needs at, record and acceptance by {sorted(needed)}"))
|
||||
|
||||
params = {p["name"]: p for p in tspec.get("parameters", [])}
|
||||
values = spec.get("parameters") or {}
|
||||
for name in sorted(set(values) - set(params)):
|
||||
issues.append(err(f"case.spec.parameters.{name}", "not a parameter of this type"))
|
||||
for name, p in params.items():
|
||||
path = f"case.spec.parameters.{name}"
|
||||
if name not in values:
|
||||
if p.get("case_settable") and p.get("required", True) and "default" not in p:
|
||||
issues.append(err(path, "required"))
|
||||
continue
|
||||
if not p.get("case_settable"):
|
||||
issues.append(err(path, "this type does not let cases set it"))
|
||||
continue
|
||||
issues.extend(_check_value(path, values[name], p))
|
||||
for path, text in _walk_strings(spec, "case.spec"):
|
||||
if secret_shaped(text):
|
||||
issues.append(err(path, "contains a credential-shaped value; cases hold references, never secrets"))
|
||||
return issues
|
||||
|
||||
|
||||
def _check_value(path: str, value: Any, p: dict[str, Any]) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
kinds = {"string": str, "integer": int, "number": (int, float), "boolean": bool,
|
||||
"array": list, "object": dict}
|
||||
if not isinstance(value, kinds[p["type"]]) or (p["type"] in {"integer", "number"} and isinstance(value, bool)):
|
||||
return [err(path, f"must be {p['type']}")]
|
||||
c = p.get("constraints") or {}
|
||||
items = value if isinstance(value, list) else [value]
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
if "pattern" in c and not re.fullmatch(c["pattern"], item):
|
||||
issues.append(err(path, f"{item!r} does not match {c['pattern']}"))
|
||||
if p.get("sensitivity") == "secret_reference" and not CUSTODY_REFERENCE.fullmatch(item):
|
||||
issues.append(err(path, "must be a custody reference such as bao:<mount>/<path>"))
|
||||
if "enum" in c and item not in c["enum"]:
|
||||
issues.append(err(path, f"{item!r} is not one of {c['enum']}"))
|
||||
if isinstance(value, list):
|
||||
if "min_items" in c and len(value) < c["min_items"]:
|
||||
issues.append(err(path, f"needs at least {c['min_items']} items"))
|
||||
if c.get("unique") and len(set(map(str, value))) != len(value):
|
||||
issues.append(err(path, "items must be unique"))
|
||||
return issues
|
||||
|
||||
|
||||
# ── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
def effective_values(case: dict[str, Any], type_data: dict[str, Any]) -> dict[str, Any]:
|
||||
values = {p["name"]: p.get("default") for p in type_data["spec"]["parameters"] if "default" in p}
|
||||
values.update(case["spec"].get("parameters") or {})
|
||||
return values
|
||||
|
||||
|
||||
def _fill(value: Any, values: dict[str, Any]) -> Any:
|
||||
if isinstance(value, str):
|
||||
return value.format_map({k: (",".join(map(str, v)) if isinstance(v, list) else v) for k, v in values.items()})
|
||||
if isinstance(value, dict):
|
||||
return {k: _fill(v, values) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_fill(v, values) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def render_case(case: dict[str, Any], type_data: dict[str, Any], declarations: list,
|
||||
*, require_implemented: bool = True) -> tuple[list[Issue], dict[str, Any] | None]:
|
||||
issues = validate_case(case, type_data, require_implemented=require_implemented)
|
||||
issues += validate_type(type_data)
|
||||
if has_errors(issues):
|
||||
return issues, None
|
||||
tspec, cspec = type_data["spec"], case["spec"]
|
||||
values = effective_values(case, type_data)
|
||||
comp = tspec["composition"]
|
||||
overrides: dict[str, dict[str, Any]] = {}
|
||||
for target, source in (comp.get("parameter_bindings") or {}).items():
|
||||
decl_id, param = target.rsplit(".", 1)
|
||||
overrides.setdefault(decl_id, {})[param] = values[source]
|
||||
scenario = {
|
||||
"id": f"scenario:case:{cspec['id']}",
|
||||
"authority": comp["authority"],
|
||||
"initial_trust": list(comp["initial_trust"]),
|
||||
"requires": {"capabilities": list(comp["capabilities"])},
|
||||
"providers": dict(comp["providers"]),
|
||||
}
|
||||
if overrides:
|
||||
scenario["parameter_overrides"] = overrides
|
||||
scenario_issues, plan = composer.compose_scenario(declarations, scenario)
|
||||
issues.extend(scenario_issues)
|
||||
if plan is None or has_errors(issues):
|
||||
return issues, None
|
||||
rendered = {
|
||||
"apiVersion": RENDER_API_VERSION,
|
||||
"kind": "SecurityCaseRender",
|
||||
"case": cspec["id"],
|
||||
"type": f"{tspec['id']}@{tspec['version']}",
|
||||
"composition": plan,
|
||||
"materialization": _fill(tspec["materialization"], values),
|
||||
"follow_through": _fill(tspec["follow_through"], values),
|
||||
"verification": _fill(tspec["verification"], values),
|
||||
"rollback": _fill(tspec["rollback"], values),
|
||||
"execution": {"permitted": False},
|
||||
}
|
||||
rendered["digest"] = "sha256:" + hashlib.sha256(
|
||||
json.dumps(rendered, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
return issues, rendered
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _report(issues: list[Issue]) -> int:
|
||||
for i in issues:
|
||||
print(f"{i.level} {i.path}: {i.message}", file=sys.stderr)
|
||||
return 1 if has_errors(issues) else 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
vt = sub.add_parser("validate-type")
|
||||
vt.add_argument("type_dir")
|
||||
vc = sub.add_parser("validate-case")
|
||||
vc.add_argument("case")
|
||||
vc.add_argument("--types", required=True)
|
||||
vc.add_argument("--allow-unimplemented", action="store_true",
|
||||
help="validate an example case against a type that is not implemented yet")
|
||||
rd = sub.add_parser("render")
|
||||
rd.add_argument("case")
|
||||
rd.add_argument("declarations", nargs="+")
|
||||
rd.add_argument("--types", required=True)
|
||||
rd.add_argument("--allow-unimplemented", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.cmd == "validate-type":
|
||||
d = Path(args.type_dir)
|
||||
data = yaml.safe_load((d / "type.yaml").read_text()) or {}
|
||||
code = _report(validate_type(data, d))
|
||||
if code == 0:
|
||||
print(f"OK {data['spec']['id']}@{data['spec']['version']} ({data['spec']['status']})")
|
||||
return code
|
||||
|
||||
case = yaml.safe_load(Path(args.case).read_text()) or {}
|
||||
issues, type_data, _ = load_type(Path(args.types), (case.get("spec") or {}).get("type", ""))
|
||||
if type_data is None:
|
||||
return _report(issues)
|
||||
if args.cmd == "validate-case":
|
||||
code = _report(validate_case(case, type_data, require_implemented=not args.allow_unimplemented))
|
||||
if code == 0:
|
||||
print(f"OK case {case['spec']['id']}")
|
||||
return code
|
||||
declarations, load_issues = composer.load_declarations(args.declarations)
|
||||
issues, rendered = render_case(case, type_data, declarations,
|
||||
require_implemented=not args.allow_unimplemented)
|
||||
issues = load_issues + issues
|
||||
if rendered is None:
|
||||
return _report(issues)
|
||||
json.dump(rendered, sys.stdout, indent=2, sort_keys=True)
|
||||
print()
|
||||
return _report(issues)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
159
tools/security-case-types/tests/test_security_case_validator.py
Normal file
159
tools/security-case-types/tests/test_security_case_validator.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
TOOL_DIR = Path(__file__).resolve().parents[1]
|
||||
REPO = TOOL_DIR.parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("security_case_validator", TOOL_DIR / "security_case_validator.py")
|
||||
v = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = v
|
||||
SPEC.loader.exec_module(v)
|
||||
|
||||
TEMPLATE_DIR = REPO / "security-case-types" / "_template"
|
||||
TYPE = yaml.safe_load((TEMPLATE_DIR / "type.yaml").read_text())
|
||||
CASE = yaml.safe_load((TEMPLATE_DIR / "examples" / "example-operator.yaml").read_text())
|
||||
DECLS, _ = v.composer.load_declarations([str(REPO / "capabilities/playbooks/net-kingdom.local-identity.yaml")])
|
||||
|
||||
|
||||
def msgs(issues):
|
||||
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
|
||||
|
||||
|
||||
def implemented(t=None):
|
||||
t = copy.deepcopy(t or TYPE)
|
||||
t["spec"]["status"] = "implemented"
|
||||
return t
|
||||
|
||||
|
||||
def test_template_type_validates():
|
||||
assert msgs(v.validate_type(TYPE, TEMPLATE_DIR)) == []
|
||||
|
||||
|
||||
def test_missing_section_is_refused():
|
||||
t = copy.deepcopy(TYPE)
|
||||
del t["spec"]["rollback"]
|
||||
assert any("rollback" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_unpinned_provider_is_refused():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["composition"]["providers"] = {}
|
||||
assert any("provider pin" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_unknown_capability_is_refused():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["composition"]["capabilities"] = ["c9.invented"]
|
||||
assert any("unknown capability" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_attended_step_needs_justification():
|
||||
t = copy.deepcopy(TYPE)
|
||||
del t["spec"]["follow_through"][0]["attended_justification"]
|
||||
assert any("attended" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_every_step_needs_a_rollback():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["rollback"] = [r for r in t["spec"]["rollback"] if r["reverses"] != "init-store"]
|
||||
assert any("init-store" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_verification_needs_a_negative_check_and_never_reads_values():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["verification"] = [c for c in t["spec"]["verification"] if c["expect"] != "deny"]
|
||||
t["spec"]["verification"][0]["reads_secret_value"] = True
|
||||
found = msgs(v.validate_type(t))
|
||||
assert any("negative check" in m for m in found)
|
||||
assert any("never reads a value" in m for m in found)
|
||||
|
||||
|
||||
def test_template_with_unknown_parameter_is_refused():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["materialization"][1]["template"] = "policy-{nonexistent}"
|
||||
assert any("unknown parameter" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_high_risk_type_needs_founder_acceptance():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["acceptance"]["risk_class"] = "high"
|
||||
assert any("founder" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_credential_shaped_value_in_type_is_refused():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["purpose"] = "token hvs.CAESIabcdefghijklmnop"
|
||||
assert any("credential-shaped" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_case_against_unimplemented_type_is_refused():
|
||||
assert any("only implemented types" in m for m in msgs(v.validate_case(CASE, TYPE)))
|
||||
assert msgs(v.validate_case(CASE, implemented())) == []
|
||||
|
||||
|
||||
def test_case_cannot_carry_a_secret_value():
|
||||
c = copy.deepcopy(CASE)
|
||||
c["spec"]["parameters"]["signing_key_ref"] = "-----BEGIN PRIVATE KEY-----"
|
||||
found = msgs(v.validate_case(c, implemented()))
|
||||
assert any("custody reference" in m for m in found)
|
||||
assert any("credential-shaped" in m for m in found)
|
||||
|
||||
|
||||
def test_case_parameter_rules():
|
||||
c = copy.deepcopy(CASE)
|
||||
c["spec"]["parameters"]["operator_username"] = "Not Valid"
|
||||
c["spec"]["parameters"]["store_home"] = "/tmp/elsewhere"
|
||||
c["spec"]["parameters"]["surprise"] = 1
|
||||
del c["spec"]["parameters"]["operator_email"]
|
||||
found = msgs(v.validate_case(c, implemented()))
|
||||
assert any("does not match" in m for m in found)
|
||||
assert any("does not let cases set it" in m for m in found)
|
||||
assert any("not a parameter of this type" in m for m in found)
|
||||
assert any("operator_email: required" in m for m in found)
|
||||
|
||||
|
||||
def test_accepted_case_needs_acceptance_record():
|
||||
c = copy.deepcopy(CASE)
|
||||
c["spec"]["status"] = "accepted"
|
||||
assert any("acceptance" in m for m in msgs(v.validate_case(c, implemented())))
|
||||
c["spec"]["acceptance"] = {"at": "2026-10-08", "record": "decision:x", "by": [{"role": "owner", "name": "a"}]}
|
||||
assert msgs(v.validate_case(c, implemented())) == []
|
||||
|
||||
|
||||
def test_render_is_deterministic_plan_only_and_substitutes_parameters():
|
||||
issues, r = v.render_case(CASE, implemented(), DECLS)
|
||||
assert msgs(issues) == []
|
||||
assert r["execution"] == {"permitted": False}
|
||||
assert r["materialization"][1]["template"] == "local-identity-bootstrap-admin-read"
|
||||
assert r["rollback"][1]["action"].endswith("bao:platform/workloads/example/local-identity-signing")
|
||||
_, again = v.render_case(CASE, implemented(), DECLS)
|
||||
assert r["digest"] == again["digest"]
|
||||
|
||||
|
||||
def test_load_type_checks_reference_and_version(tmp_path):
|
||||
root = tmp_path / "types"
|
||||
(root / "template-example").mkdir(parents=True)
|
||||
(root / "template-example" / "type.yaml").write_text(yaml.safe_dump(TYPE))
|
||||
issues, data, _ = v.load_type(root, "template-example@0.1")
|
||||
assert data is not None and not msgs(issues)
|
||||
issues, data, _ = v.load_type(root, "template-example@1.0")
|
||||
assert data is None and any("version" in m for m in msgs(issues))
|
||||
issues, data, _ = v.load_type(root, "nope")
|
||||
assert data is None
|
||||
|
||||
|
||||
def test_template_conforms_to_the_json_schemas():
|
||||
import json
|
||||
import pytest
|
||||
jsonschema = pytest.importorskip("jsonschema")
|
||||
type_schema = json.loads((REPO / "canon/schemas/security-case-type_v0.1.schema.json").read_text())
|
||||
case_schema = json.loads((REPO / "canon/schemas/security-case_v0.1.schema.json").read_text())
|
||||
jsonschema.validate(TYPE, type_schema)
|
||||
jsonschema.validate(CASE, case_schema)
|
||||
bad = copy.deepcopy(CASE)
|
||||
bad["spec"]["surprise"] = True
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
jsonschema.validate(bad, case_schema)
|
||||
|
|
@ -47,9 +47,7 @@ Companion plans: ops-mason `MASON-WP-0010` (materialization) and ops-warden
|
|||
|
||||
```task
|
||||
id: NK-WP-0048-T01
|
||||
status: wait
|
||||
needs_human: true
|
||||
blocking_reason: "The founder reviews and accepts the security-case-types_v0.1 canon proposal."
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "930ac3e9-20ec-5473-98cd-3fe8fddb730f"
|
||||
```
|
||||
|
|
@ -60,6 +58,9 @@ Founder review of `canon/standards/security-case-types_v0.1.md`, including its
|
|||
section 10 open questions. On acceptance the canon status goes from `proposed`
|
||||
to `accepted`.
|
||||
|
||||
2026-10-08: accepted by the founder (in session). Canon status `accepted`;
|
||||
section 10's open questions are carried forward to the first type (T04, T05).
|
||||
|
||||
```task
|
||||
id: NK-WP-0048-T02
|
||||
status: done
|
||||
|
|
@ -96,9 +97,8 @@ the forge), workload-kv-read second (largest payoff), then forge-read-identity.
|
|||
|
||||
```task
|
||||
id: NK-WP-0048-T03
|
||||
status: wait
|
||||
status: done
|
||||
depends_on: [NK-WP-0048-T01]
|
||||
blocking_reason: "The schema and template follow the accepted canon."
|
||||
priority: high
|
||||
state_hub_task_id: "e487975b-73b1-516c-971d-da4cbbb93063"
|
||||
```
|
||||
|
|
@ -116,6 +116,13 @@ Acceptance: the validator rejects missing sections, secret-shaped parameter
|
|||
values, unpinned providers and cases against non-`implemented` types, with tests
|
||||
for each.
|
||||
|
||||
2026-10-08: done. `canon/schemas/security-case-type_v0.1.schema.json` and
|
||||
`security-case_v0.1.schema.json`; `tools/security-case-types/security_case_validator.py`
|
||||
(`validate-type`, `validate-case`, `render`, reusing the scenario composer);
|
||||
`security-case-types/_template/` (a valid, runnable example type over
|
||||
`net-kingdom.local-identity`, with README sections, an example case and tests that
|
||||
travel with every copy). 31 tests pass, including the composer's existing suite.
|
||||
|
||||
```task
|
||||
id: NK-WP-0048-T04
|
||||
status: wait
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue