Add compensating controls framework and date-based report dirs (#320)

## Summary

- Add `compensating-controls.yaml` tracking 9 named controls that justify suppressed security findings
- Update all Prowler mutelist descriptions with `CC: <id>` references to named controls
- Add `mise run review-compensating-controls` task — surfaces stalest control with all codebase references
- Add [[review-compensating-controls]] how-to doc
- Organize Prowler and Kingfisher reports into `YYYY-MM-DD` subdirectories

### Compensating controls

| ID | Mitigates |
|----|-----------|
| `single-user-cluster` | Image cache abuse, RBAC breadth, system pod privileges |
| `tailscale-network-isolation` | Profiling endpoints, weak TLS, debug ports |
| `local-registry` | AlwaysPullImages gap |
| `sso-gated-admin-tools` | ArgoCD wildcard RBAC |
| `operator-managed-pods` | Tailscale proxy pod security settings |
| `ephemeral-privileged-jobs` | Prowler hostPID exposure |
| `trusted-ci-only` | Forgejo runner DinD |
| `init-container-isolation` | Grafana root init container |
| `observability-stack-audit` | Missing apiserver audit logging |

## Test plan

- [ ] `mise run review-compensating-controls` shows table and references
- [ ] `kubectl kustomize argocd/manifests/prowler/` renders correctly
- [ ] Sync prowler and kingfisher, verify next scan writes to dated subdirectory
- [ ] Grep for `CC:` in mutelist files — every muted finding should have at least one

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #320
This commit is contained in:
Erich Blume 2026-03-30 17:44:11 -07:00
commit 4059b3d27b
13 changed files with 516 additions and 77 deletions

View file

@ -0,0 +1,229 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"]
# ///
#MISE description="Review the most stale compensating control"
#USAGE flag "--limit <limit>" default="10" help="Number of controls to show in the table"
"""Review compensating controls by staleness.
Reads ``compensating-controls.yaml`` and sorts by ``last-reviewed``.
Shows a staleness table, then displays the most stale control with all
references found in the codebase.
After reviewing, update the control entry:
last-reviewed: YYYY-MM-DD
Usage: mise run review-compensating-controls [--limit 10]
"""
import subprocess
import sys
from datetime import date
from pathlib import Path
from typing import Annotated
import typer
import yaml
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
CONTROLS_FILE = Path(__file__).parent.parent / "compensating-controls.yaml"
REPO_ROOT = Path(__file__).parent.parent
def load_controls(path: Path) -> list[dict]:
data = yaml.safe_load(path.read_text())
return data.get("controls", [])
def parse_date(raw) -> date | None:
if raw is None:
return None
if isinstance(raw, date):
return raw
try:
return date.fromisoformat(str(raw))
except ValueError:
return None
def find_references(control_id: str) -> list[str]:
"""Find all files referencing a control ID using ripgrep."""
try:
result = subprocess.run(
["rg", "--no-heading", "-n", control_id, str(REPO_ROOT)],
capture_output=True,
text=True,
timeout=10,
)
lines = result.stdout.strip().splitlines()
# Exclude the controls file itself and this script
return [
ln
for ln in lines
if "compensating-controls.yaml" not in ln
and "review-compensating-controls" not in ln
]
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
def main(
limit: Annotated[
int, typer.Option(help="Number of controls to show in the table")
] = 10,
) -> None:
console = Console()
today = date.today()
if not CONTROLS_FILE.exists():
console.print(
f"[bold red]Controls file not found:[/bold red] {CONTROLS_FILE}"
)
raise typer.Exit(code=1)
controls = load_controls(CONTROLS_FILE)
# Parse dates and build sortable entries
entries: list[tuple[dict, date | None]] = []
for ctrl in controls:
reviewed = parse_date(ctrl.get("last-reviewed"))
entries.append((ctrl, reviewed))
# Sort: never-reviewed first, then oldest
entries.sort(key=lambda e: (e[1] is not None, e[1] or date.min))
never_reviewed = sum(1 for _, r in entries if r is None)
# --- Summary panel ---
console.print()
console.print(
Panel(
f"[bold]{len(entries)}[/bold] compensating controls, "
f"[bold red]{never_reviewed}[/bold red] never reviewed",
title="[bold]Compensating Control Review Queue[/bold]",
border_style="cyan",
)
)
console.print()
# --- Staleness table ---
table = Table(show_header=True, header_style="bold")
table.add_column("#", justify="right")
table.add_column("Control ID")
table.add_column("Last Reviewed", justify="right")
table.add_column("Age (days)", justify="right")
table.add_column("Refs", justify="right")
for i, (ctrl, reviewed) in enumerate(entries[:limit], 1):
control_id = ctrl["id"]
refs = len(find_references(control_id))
if reviewed is None:
table.add_row(
str(i),
f"[red]{control_id}[/red]",
"[red]never[/red]",
"[red]—[/red]",
str(refs),
)
else:
age = (today - reviewed).days
style = "yellow" if age > 90 else ""
id_str = f"[{style}]{control_id}[/{style}]" if style else control_id
date_str = f"[{style}]{reviewed}[/{style}]" if style else str(reviewed)
age_str = f"[{style}]{age}[/{style}]" if style else str(age)
table.add_row(str(i), id_str, date_str, age_str, str(refs))
remaining = len(entries) - limit
if remaining > 0:
table.add_row("", f"[dim]… {remaining} more[/dim]", "", "", "")
console.print(table)
console.print()
# --- Most stale control detail ---
if not entries:
console.print("[bold red]No controls found![/bold red]")
raise typer.Exit(code=1)
top_ctrl, top_reviewed = entries[0]
control_id = top_ctrl["id"]
refs = find_references(control_id)
detail_lines = [
f"[bold cyan]{control_id}[/bold cyan]",
f"[dim]Last reviewed: {top_reviewed or 'never'}[/dim]",
"",
f"[bold]Description:[/bold] {top_ctrl.get('description', '').strip()}",
]
notes = top_ctrl.get("notes", "").strip()
if notes:
detail_lines.append(f"[bold]Notes:[/bold] {notes}")
console.print(
Panel(
"\n".join(detail_lines),
title="[bold]Up For Review[/bold]",
border_style="green",
)
)
console.print()
# --- References ---
if refs:
ref_table = Table(
show_header=True, header_style="bold", title="References in codebase"
)
ref_table.add_column("File", style="cyan")
ref_table.add_column("Line")
for ref in refs:
# rg output: file:line:content
parts = ref.split(":", 2)
if len(parts) >= 3:
filepath = parts[0].replace(str(REPO_ROOT) + "/", "")
line_no = parts[1]
content = parts[2].strip()
ref_table.add_row(f"{filepath}:{line_no}", content)
else:
ref_table.add_row(ref, "")
console.print(ref_table)
else:
console.print(
f"[yellow]No references to '{control_id}' found in the codebase.[/yellow]"
)
console.print()
# --- Review checklist ---
checklist = [
"[bold]Verification:[/bold]\n",
f"• {notes}\n" if notes else "",
"\n[bold]Review each reference:[/bold]\n",
"• For each muted finding referencing this control, confirm:\n",
" 1. The risk the original check guards against\n",
" 2. That this control actually mitigates that risk\n",
" 3. That the control is still in effect (not degraded or bypassed)\n",
"\n[bold]After review:[/bold]\n",
f"• Update compensating-controls.yaml: [cyan]last-reviewed: {today}[/cyan]\n",
"• If the control is no longer valid, either:\n",
" - Fix the underlying finding and remove the mute, or\n",
" - Document a new/updated compensating control\n",
"• Commit the change",
]
console.print(
Panel(
"".join(checklist),
title="[bold yellow]Review Guidance[/bold yellow]",
border_style="yellow",
)
)
if __name__ == "__main__":
typer.run(main)