WARDEN-WP-0026 T06: rotation guidance registry + warden rotate-guide
- routing model: RotationGuide (method rotate|re-establish, steps, owner, automatable), RouteEntry.rotation + has_rotation + vends_secret. - catalog parser: validate rotation block; secret-material screen gains a prose-safe mode (high-entropy detector only) so authored steps aren't tripped by substrings like "s."/"exists.". - CLI: `warden rotate-guide <id>` (human + --json); route show --json now carries has_rotation + rotation. - scorecard: catalog_rotation_coverage — every active secret-vending lane must carry a rotation block (SSH/login/pointer lanes exempt). Promotion checklist criterion 9. - data: rotation blocks for all 7 active vending lanes + the draft railiance-backup lane (re-establish: age keypair regen + re-encrypt). - fix pre-existing collision: bare `npm` keyword on forgejo-admin -> forgejo-npm so "npm token" routes to the generic lane (restores test_access expectations). - tests: rotation parse/coverage/prose-screen/CLI in tests/test_routing.py; scorecard count 6 -> 7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ac09f21ad3
commit
c3eb59ea04
9 changed files with 385 additions and 12 deletions
|
|
@ -448,3 +448,86 @@ def test_every_entry_has_reviewed_date():
|
|||
assert re.match(r"^\d{4}-\d{2}-\d{2}$", entry.reviewed), (
|
||||
f"{entry.id}: reviewed must be YYYY-MM-DD, got {entry.reviewed!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rotation / re-establishment guidance registry (WARDEN-WP-0026 T06)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from warden.scorecard import check_catalog_rotation_coverage
|
||||
|
||||
|
||||
def test_every_active_vending_lane_has_rotation_guidance():
|
||||
"""Coverage gate: an active lane that vends a secret must say how to renew it."""
|
||||
catalog = load_catalog(_repo_catalog())
|
||||
missing = [e.id for e in catalog.entries if e.is_active and e.vends_secret and not e.has_rotation]
|
||||
assert not missing, f"active vending lanes lacking rotation guidance: {missing}"
|
||||
|
||||
|
||||
def test_scorecard_rotation_coverage_check_passes_on_repo_catalog():
|
||||
result = check_catalog_rotation_coverage()
|
||||
assert result.passed, result.detail
|
||||
|
||||
|
||||
def test_non_vending_lanes_are_exempt_from_rotation():
|
||||
"""SSH (issue), login, and pointer-only lanes carry no rotation block."""
|
||||
catalog = load_catalog(_repo_catalog())
|
||||
assert catalog.get("ssh-cert-host-access").vends_secret is False # issue lane
|
||||
assert catalog.get("key-cape-oidc-login").vends_secret is False # login lane
|
||||
assert catalog.get("ops-bridge-tunnel").vends_secret is False # pointer only
|
||||
|
||||
|
||||
def test_rotation_block_parses_fields():
|
||||
catalog = load_catalog(_repo_catalog())
|
||||
rot = catalog.get("forgejo-admin-api-token").rotation
|
||||
assert rot is not None
|
||||
assert rot.method in ("rotate", "re-establish")
|
||||
assert rot.owner == "railiance-platform"
|
||||
assert rot.steps and all(isinstance(s, str) for s in rot.steps)
|
||||
|
||||
|
||||
def test_re_establish_method_on_backup_lane():
|
||||
catalog = load_catalog(_repo_catalog())
|
||||
rot = catalog.get("railiance-backup-offsite-lane").rotation
|
||||
assert rot is not None and rot.method == "re-establish"
|
||||
|
||||
|
||||
def test_invalid_rotation_method_rejected(tmp_path):
|
||||
entry = dict(ROUTED_ENTRY, rotation={"method": "renew", "owner": "x", "steps": ["a"]})
|
||||
with pytest.raises(CatalogError, match="rotation.method"):
|
||||
load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, entry]))
|
||||
|
||||
|
||||
def test_rotation_steps_screened_for_pasted_token(tmp_path):
|
||||
"""A high-entropy pasted token in prose is rejected; ordinary prose is allowed."""
|
||||
leak = dict(ROUTED_ENTRY, rotation={
|
||||
"method": "rotate", "owner": "x",
|
||||
"steps": ["set the value to ghp_" + "aB3dE5" * 6], # mixed alnum → high-entropy run
|
||||
})
|
||||
with pytest.raises(CatalogError, match="high-entropy|secret"):
|
||||
load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, leak]))
|
||||
|
||||
|
||||
def test_rotation_prose_allows_ordinary_sentences(tmp_path):
|
||||
"""Words like 'exists.' must not trip the terse 's.' prefix screen."""
|
||||
ok = dict(ROUTED_ENTRY, rotation={
|
||||
"method": "rotate", "owner": "railiance-platform",
|
||||
"steps": ["Rotate per the concrete workload's entry when one exists."],
|
||||
})
|
||||
catalog = load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, ok]))
|
||||
assert catalog.get("openbao-api-key").rotation.steps
|
||||
|
||||
|
||||
def test_rotate_guide_cli_json():
|
||||
result = runner.invoke(app, ["rotate-guide", "forgejo-admin-api-token", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["method"] == "rotate"
|
||||
assert payload["owner"] == "railiance-platform"
|
||||
assert payload["steps"]
|
||||
|
||||
|
||||
def test_rotate_guide_cli_ssh_lane_is_graceful():
|
||||
# SSH renewal is re-issuance, not a static rotation — exit 0, not an error.
|
||||
result = runner.invoke(app, ["rotate-guide", "ssh-cert-host-access"])
|
||||
assert result.exit_code == 0
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ def test_run_scorecard_clean(tmp_path):
|
|||
)
|
||||
results = run_scorecard(tmp_path, inv)
|
||||
assert all(r.passed for r in results)
|
||||
assert len(results) == 6
|
||||
# cert-side checks + catalog_rotation_coverage (WP-0026 T06)
|
||||
assert len(results) == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue