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
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue