WARDEN-WP-0026 T06: rotation guidance registry + warden rotate-guide
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

- 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:
tegwick 2026-07-16 14:40:30 +02:00
parent ac09f21ad3
commit c3eb59ea04
9 changed files with 385 additions and 12 deletions

View file

@ -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

View file

@ -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
# ---------------------------------------------------------------------------