generated from coulomb/repo-seed
feat(warden): WARDEN-WP-0003 — test coverage, permissions, status --state-dir
- File permissions: os.chmod(cert, 0o600) after every sign in LocalCA and VaultCA; chmod(privkey, 0o600) and chmod(pubkey, 0o644) after generate_keypair - Scorecard: add check_file_permissions() that flags world/group-readable cert and key files; run_scorecard now returns 6 checks - warden status --state-dir: bypasses config loading entirely for operators who have a cert but no warden.yaml installed - tests/test_vault.py: 11 VaultCA unit tests covering success, HTTP 403, RequestError, missing token, missing role, missing pubkey, TTL enforcement, eviction, signatures log, and cert mode 600 - tests/test_ca.py: generate_keypair tests (paths, args, overwrite, error, permissions) and cert mode 600 assertion after sign - tests/test_scorecard.py: file_permissions check tests (pass, fail cert, fail keys dir); scorecard count updated to 6 - tests/test_cli.py: covers sign, issue, status, scorecard, inventory, log, cleanup commands using CliRunner and tmp config/inventory files - tests/test_integration.py: @pytest.mark.integration tests against real ssh-keygen; excluded from default suite via pyproject addopts - pyproject.toml: addopts = "-m 'not integration'", integration marker declared All 100 unit tests pass; 3 integration tests pass; ruff clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,7 @@ class LocalCA(CABackend):
|
||||
_evict_cert(spec.actor_name, self._state_dir)
|
||||
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
|
||||
shutil.copy2(cert_path_tmp, dest)
|
||||
os.chmod(dest, 0o600)
|
||||
|
||||
record = CertRecord(
|
||||
identity=meta["identity"] or spec.identity,
|
||||
@@ -211,4 +212,6 @@ class LocalCA(CABackend):
|
||||
if result.returncode != 0:
|
||||
raise CAError(f"Key generation failed: {result.stderr.strip()}")
|
||||
|
||||
os.chmod(privkey, 0o600)
|
||||
os.chmod(pubkey, 0o644)
|
||||
return privkey, pubkey
|
||||
|
||||
@@ -173,16 +173,22 @@ def issue(
|
||||
def status(
|
||||
actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None,
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||
state_dir_override: Annotated[Optional[Path], typer.Option("--state-dir", help="State dir path (bypasses config)")] = None,
|
||||
) -> None:
|
||||
"""Show certificate status. Exits 1 if any cert is expired."""
|
||||
cfg = _load_cfg()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if state_dir_override is not None:
|
||||
state_dir = state_dir_override
|
||||
else:
|
||||
cfg = _load_cfg()
|
||||
state_dir = cfg.state_dir
|
||||
|
||||
if actor_name:
|
||||
cert_path = cfg.state_dir / f"{actor_name}-cert.pub"
|
||||
cert_path = state_dir / f"{actor_name}-cert.pub"
|
||||
paths = [cert_path] if cert_path.exists() else []
|
||||
else:
|
||||
paths = sorted(cfg.state_dir.glob("*-cert.pub")) if cfg.state_dir.exists() else []
|
||||
paths = sorted(state_dir.glob("*-cert.pub")) if state_dir.exists() else []
|
||||
|
||||
if not paths:
|
||||
msg = (
|
||||
|
||||
@@ -126,6 +126,32 @@ def check_ttl_policy(state_dir: Path, inventory: PrincipalsInventory) -> CheckRe
|
||||
)
|
||||
|
||||
|
||||
def check_file_permissions(state_dir: Path) -> CheckResult:
|
||||
"""Cert files and keys must not be group- or world-readable (mode 600/644)."""
|
||||
if not state_dir.exists():
|
||||
return CheckResult("file_permissions", passed=True, detail="no state dir")
|
||||
|
||||
bad = []
|
||||
for cert_path in state_dir.glob("*-cert.pub"):
|
||||
if cert_path.stat().st_mode & 0o044:
|
||||
bad.append(cert_path.name)
|
||||
|
||||
keys_dir = state_dir / "keys"
|
||||
if keys_dir.exists():
|
||||
for key_path in keys_dir.iterdir():
|
||||
if key_path.stat().st_mode & 0o044:
|
||||
bad.append(f"keys/{key_path.name}")
|
||||
|
||||
return CheckResult(
|
||||
name="file_permissions",
|
||||
passed=len(bad) == 0,
|
||||
detail=(
|
||||
f"world/group readable files: {bad} — run 'chmod 600'" if bad
|
||||
else "all cert/key files have restricted permissions"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[CheckResult]:
|
||||
"""Run all cert-side scorecard checks. Returns list of CheckResult."""
|
||||
return [
|
||||
@@ -134,4 +160,5 @@ def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[Check
|
||||
check_no_expired_certs(state_dir),
|
||||
check_no_stale_certs(state_dir),
|
||||
check_ttl_policy(state_dir, inventory),
|
||||
check_file_permissions(state_dir),
|
||||
]
|
||||
|
||||
@@ -76,6 +76,7 @@ class VaultCA(CABackend):
|
||||
_evict_cert(spec.actor_name, self._state_dir)
|
||||
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
|
||||
dest.write_text(cert_text + "\n")
|
||||
os.chmod(dest, 0o600)
|
||||
|
||||
# Parse metadata by writing to a tempfile and running ssh-keygen -L
|
||||
with tempfile.NamedTemporaryFile(
|
||||
|
||||
Reference in New Issue
Block a user