Rip out compensating-controls framework #359

Merged
eblume merged 2 commits from rip-out-compensating-controls into main 2026-05-22 21:08:55 -07:00
9 changed files with 68 additions and 515 deletions
Showing only changes of commit 3e2c481034 - Show all commits

C1: drop CC: prefixes from mutelist entries; remove CC tooling

Strips the "CC: <id>." prefix from every Description field in the
Prowler mutelist YAML files (and the statement field in trivyignore).
Each entry's free-form description now stands on its own.

Deletes compensating-controls.yaml (the CC registry) and the
review-compensating-controls mise task. Updates
review-compliance-reports to drop CC references from docstrings,
panel text, and table titles. Node verification logic is unchanged.
Erich Blume 2026-05-22 20:09:39 -07:00

View file

@ -6,48 +6,48 @@ Mutelist:
"apiserver_always_pull_images_plugin":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: single-user-cluster, local-registry. Only the operator has cluster access; all images pulled from private zot registry."
Description: "Only the operator has cluster access; all images pulled from private zot registry."
"apiserver_audit_log_maxage_set":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail."
Description: "Alloy/Loki provides pod-level audit trail."
"apiserver_audit_log_maxbackup_set":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail."
Description: "Alloy/Loki provides pod-level audit trail."
"apiserver_audit_log_maxsize_set":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail."
Description: "Alloy/Loki provides pod-level audit trail."
"apiserver_audit_log_path_set":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail."
Description: "Alloy/Loki provides pod-level audit trail."
"apiserver_deny_service_external_ips":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: tailscale-network-isolation. No external IPs routable; cluster only reachable via tailnet."
Description: "No external IPs routable; cluster only reachable via tailnet."
"apiserver_disable_profiling":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet."
Description: "Profiling endpoint unreachable from public internet."
"apiserver_encryption_provider_config_set":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: tailscale-network-isolation, single-user-cluster. Etcd not network-exposed; only operator has node access."
Description: "Etcd not network-exposed; only operator has node access."
"apiserver_kubelet_cert_auth":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: tailscale-network-isolation. Kubelet API not exposed outside the node; minikube auto-generates certificates."
Description: "Kubelet API not exposed outside the node; minikube auto-generates certificates."
"apiserver_request_timeout_set":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: tailscale-network-isolation. API server only reachable via tailnet; DoS risk limited to trusted clients."
Description: "API server only reachable via tailnet; DoS risk limited to trusted clients."
"apiserver_service_account_lookup_true":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: single-user-cluster. Only operator manages service accounts; no revoked tokens in circulation."
Description: "Only operator manages service accounts; no revoked tokens in circulation."
"apiserver_strong_ciphers_only":
Regions: ["*"]
Resources: ["^kube-apiserver-minikube$"]
Description: "CC: tailscale-network-isolation. API server traffic encrypted by WireGuard at the network layer."
Description: "API server traffic encrypted by WireGuard at the network layer."

View file

@ -6,12 +6,12 @@ Mutelist:
"controllermanager_disable_profiling":
Regions: ["*"]
Resources: ["^kube-controller-manager-minikube$"]
Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet."
Description: "Profiling endpoint unreachable from public internet."
"scheduler_profiling":
Regions: ["*"]
Resources: ["^kube-scheduler-minikube$"]
Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet."
Description: "Profiling endpoint unreachable from public internet."
"kubelet_tls_cert_and_key":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: tailscale-network-isolation, single-user-cluster. Kubelet API not exposed outside node; minikube auto-generates certificates."
Description: "Kubelet API not exposed outside node; minikube auto-generates certificates."

View file

@ -17,9 +17,8 @@ Mutelist:
- "^kindnet-"
- "^storage-provisioner$"
Description: >-
CC: tailscale-network-isolation. Control-plane and networking
pods require hostNetwork by design. Host network itself is
only reachable via tailnet.
Control-plane and networking pods require hostNetwork by design.
Host network itself is only reachable via tailnet.
"core_minimize_privileged_containers":
Regions: ["*"]
Resources:
@ -31,7 +30,6 @@ Mutelist:
# Forgejo runner
- "^forgejo-runner-"
Description: >-
CC: single-user-cluster, operator-managed-pods, trusted-ci-only.
kube-proxy: system pod, single-user cluster. ts-*/ingress-*:
Tailscale operator-managed. forgejo-runner: DinD limited to
trusted private forge repos.
@ -49,25 +47,24 @@ Mutelist:
- "^nameserver-"
- "^ingress-"
Description: >-
CC: single-user-cluster, operator-managed-pods. System pods
managed by minikube and Tailscale operator; seccomp profiles
set by upstream. Single-user cluster limits exploit surface.
System pods managed by minikube and Tailscale operator;
seccomp profiles set by upstream. Single-user cluster limits
exploit surface.
"core_minimize_hostPID_containers":
Regions: ["*"]
Resources:
- "^prowler-"
Description: >-
CC: ephemeral-privileged-jobs. Prowler CIS scanner requires
hostPID for file permission checks. Runs as CronJob with
7-day TTL, not a persistent workload.
Prowler CIS scanner requires hostPID for file permission
checks. Runs as CronJob with 7-day TTL, not a persistent
workload.
"core_minimize_root_containers_admission":
Regions: ["*"]
Resources:
- "^grafana-"
Description: >-
CC: init-container-isolation. Root limited to init-chown-data
container; all runtime containers run as UID 472 with caps
dropped.
Root limited to init-chown-data container; all runtime
containers run as UID 472 with caps dropped.
"core_minimize_containers_added_capabilities":
Regions: ["*"]
Resources:
@ -77,10 +74,9 @@ Mutelist:
# Grafana init-chown-data
- "^grafana-"
Description: >-
CC: single-user-cluster, init-container-isolation. System
pods: capabilities required by function (minikube-managed).
Grafana: CHOWN limited to init phase; runtime containers
drop ALL.
System pods: capabilities required by function
(minikube-managed). Grafana: CHOWN limited to init phase;
runtime containers drop ALL.
"core_minimize_containers_capabilities_assigned":
Regions: ["*"]
Resources:
@ -88,5 +84,4 @@ Mutelist:
- "^kindnet-"
- "^grafana-"
Description: >-
CC: single-user-cluster, init-container-isolation. See
core_minimize_containers_added_capabilities.
See core_minimize_containers_added_capabilities.

View file

@ -1,7 +1,7 @@
# Node-level and RBAC checks that Prowler reports as MANUAL because it
# cannot evaluate them from inside a pod. Compensated by automated
# verification in `mise run review-compliance-reports`, which SSHes into
# the minikube node and checks each condition directly every week.
# cannot evaluate them from inside a pod. Verified out-of-band by the
# node-verification block in `mise run review-compliance-reports`, which
# SSHes into the minikube node and checks each condition directly.
Mutelist:
Accounts:
"*":
@ -9,51 +9,51 @@ Mutelist:
"etcd_unique_ca":
Regions: ["*"]
Resources: ["^etcd-minikube$"]
Description: "CC: node-config-automated-verification. Etcd CA fingerprint verified different from cluster CA by review-compliance-reports."
Description: "Etcd CA fingerprint verified different from cluster CA by review-compliance-reports."
"kubelet_conf_file_ownership":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports."
Description: "File ownership verified root:root by review-compliance-reports."
"kubelet_conf_file_permissions":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. File permissions verified 600 by review-compliance-reports."
Description: "File permissions verified 600 by review-compliance-reports."
"kubelet_config_yaml_ownership":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports."
Description: "File ownership verified root:root by review-compliance-reports."
"kubelet_config_yaml_permissions":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. File permissions verified 644 by review-compliance-reports."
Description: "File permissions verified 644 by review-compliance-reports."
"kubelet_service_file_ownership_root":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports."
Description: "File ownership verified root:root by review-compliance-reports."
"kubelet_service_file_permissions":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. File permissions verified 644 by review-compliance-reports."
Description: "File permissions verified 644 by review-compliance-reports."
"kubelet_disable_read_only_port":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. readOnlyPort absence (defaults to 0) verified by review-compliance-reports."
Description: "readOnlyPort absence (defaults to 0) verified by review-compliance-reports."
"kubelet_event_record_qps":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. eventRecordQPS absence (defaults to 5) verified by review-compliance-reports."
Description: "eventRecordQPS absence (defaults to 5) verified by review-compliance-reports."
"kubelet_manage_iptables":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification. makeIPTablesUtilChains absence (defaults to true) verified by review-compliance-reports."
Description: "makeIPTablesUtilChains absence (defaults to true) verified by review-compliance-reports."
"kubelet_strong_ciphers_only":
Regions: ["*"]
Resources: ["^kubelet-config$"]
Description: "CC: node-config-automated-verification, tailscale-network-isolation. Go default ciphers used; all traffic WireGuard-encrypted via tailnet."
Description: "Go default ciphers used; all traffic WireGuard-encrypted via tailnet."
"rbac_cluster_admin_usage":
Regions: ["*"]
Resources:
- "^cluster-admin$"
- "^kubeadm:cluster-admins$"
- "^minikube-rbac$"
Description: "CC: node-config-automated-verification, single-user-cluster. Only built-in/minikube cluster-admin bindings present; verified by review-compliance-reports."
Description: "Only built-in/minikube cluster-admin bindings present; verified by review-compliance-reports."

View file

@ -13,9 +13,8 @@ Mutelist:
# ArgoCD
- "^argocd-"
Description: >-
CC: single-user-cluster, sso-gated-admin-tools. Built-in
K8s roles: only operator can bind them. ArgoCD: requires
broad access but is SSO-gated via Authentik OIDC.
Built-in K8s roles: only operator can bind them. ArgoCD:
requires broad access but is SSO-gated via Authentik OIDC.
"rbac_minimize_pod_creation_access":
Regions: ["*"]
Resources:
@ -26,14 +25,12 @@ Mutelist:
# CloudNativePG operator
- "^cnpg-manager$"
Description: >-
CC: single-user-cluster. Built-in K8s roles and CNPG
operator. Only the operator can assign these roles; no
untrusted users have cluster access.
Built-in K8s roles and CNPG operator. Only the operator can
assign these roles; no untrusted users have cluster access.
"rbac_minimize_service_account_token_creation":
Regions: ["*"]
Resources:
- "^system:"
Description: >-
CC: single-user-cluster. kube-controller-manager requires
token creation for SA management. Only operator manages
service accounts.
kube-controller-manager requires token creation for SA
management. Only operator manages service accounts.

View file

@ -14,26 +14,24 @@ misconfigurations:
paths:
- "argocd/manifests/external-secrets/rbac.yaml"
statement: >-
CC: operator-purpose-bound-rbac. external-secrets-operator's entire
function is to read and synthesize Secret objects; ClusterRole over
secrets is its purpose. Both the controller and cert-controller are
external-secrets-operator's entire function is to read and
synthesize Secret objects; ClusterRole over secrets is its
purpose. Both the controller and cert-controller are
upstream-defined.
- id: KSV-0041
paths:
- "argocd/manifests/kube-state-metrics/rbac.yaml"
- "argocd/manifests/kube-state-metrics-ringtail/rbac.yaml"
statement: >-
CC: kube-state-metrics-metadata-only. KSM exposes only Secret
metadata (name, namespace, type, labels), never the data field.
list/watch on secrets is required for kube_secret_info /
kube_secret_labels metrics.
KSM exposes only Secret metadata (name, namespace, type, labels),
never the data field. list/watch on secrets is required for
kube_secret_info / kube_secret_labels metrics.
- id: KSV-0114
paths:
- "argocd/manifests/external-secrets/rbac.yaml"
statement: >-
CC: operator-purpose-bound-rbac. cert-controller manages the
external-secrets validating webhook configurations to inject its
own rotating CA bundle. RBAC is scoped to two named webhooks
(secretstore-validate, externalsecret-validate) via resourceNames;
KSV-0114 doesn't see the resourceNames restriction so reports the
full ClusterRole.
cert-controller manages the external-secrets validating webhook
configurations to inject its own rotating CA bundle. RBAC is
scoped to two named webhooks (secretstore-validate,
externalsecret-validate) via resourceNames; KSV-0114 doesn't see
the resourceNames restriction so reports the full ClusterRole.

View file

@ -1,210 +0,0 @@
# Compensating Controls
#
# Documents controls that mitigate risks from suppressed or accepted security
# findings. Referenced by security tools (Prowler mutelist, Kingfisher config,
# etc.) via "CC: <id>" in finding descriptions or suppression notes.
#
# Used by `mise run review-compensating-controls` to surface stale controls.
#
# Fields:
# id - kebab-case unique identifier, referenced from tool configs
# description - what the control actually does to mitigate risk
# created - date (YYYY-MM-DD) the control was documented
# last-reviewed - date (YYYY-MM-DD) or null
# notes - optional context
controls:
- id: single-user-cluster
description: >-
Only the cluster operator (eblume) has kubectl access. No untrusted
users can create pods, access cached images, or bind RBAC roles.
created: 2026-03-30
last-reviewed: 2026-04-01
notes: >-
Verify by checking kubeconfig distribution and Tailscale ACLs.
If additional users gain cluster access, re-evaluate all findings
muted under this control.
- id: tailscale-network-isolation
description: >-
Cluster is not internet-exposed. All access requires Tailscale
identity with ACL enforcement. Profiling endpoints, debug ports,
and control-plane APIs are unreachable from the public internet.
created: 2026-03-30
last-reviewed: 2026-04-06
notes: >-
Verify with 'tailscale serve status --json' on indri and review
Tailscale ACLs in pulumi/tailscale/. Only tag:flyio-target services
are publicly routable.
- id: local-registry
description: >-
Operator-built services use a private zot registry
(registry.ops.eblu.me) for supply-chain control. Remaining
images are pulled from public registries without stored
credentials. No shared registry secrets are cached on cluster
nodes.
created: 2026-03-30
last-reviewed: 2026-04-12
notes: >-
Verify by checking image prefixes in kustomization.yaml files.
Known external-image categories: (1) upstream apps not yet
mirrored — immich, ollama, frigate, frigate-notify, valkey;
(2) infrastructure components — tailscale operator/proxy,
external-secrets, 1password-connect, forgejo-runner, docker
DinD, nvidia-device-plugin; (3) utility base images — busybox,
alpine (grafana init containers). Track upstream versions in
service-versions.yaml. Goal is to progressively mirror these
into zot.
- id: sso-gated-admin-tools
description: >-
ArgoCD requires SSO authentication via Authentik OIDC. Wildcard
RBAC roles are mitigated by requiring authenticated identity
before any API access.
created: 2026-03-30
last-reviewed: 2026-04-14
notes: >-
Verify Authentik OIDC provider config for ArgoCD and that
anonymous access is disabled. Check ArgoCD --auth-token isn't
leaked. The workflow-bot API key account is scoped to sync/get
only.
- id: operator-managed-pods
description: >-
Tailscale operator manages proxy pod specs (ts-*, ingress-*,
operator-*, nameserver-*). Pod security settings are set by the
operator, not user manifests. Operator is tracked in
service-versions.yaml and regularly updated.
created: 2026-03-30
last-reviewed: 2026-04-21
notes: >-
Verify operator version is current via 'mise run service-review'.
Check Tailscale changelog for security fixes. If operator adds
seccomp support, remove these mutes. As of 2026-04-21: still no
default seccomp on operator-generated pods (upstream issue #7359
open). A ProxyClass + generic device plugin can downgrade proxies
from privileged to NET_ADMIN+NET_RAW and set seccompProfile —
potential future remediation to remove the seccomp mute without
waiting for upstream defaults.
- id: ephemeral-privileged-jobs
description: >-
Prowler CIS scanner runs as a CronJob with 7-day TTL
auto-deletion, not as a persistent privileged workload. hostPID
exposure is time-bounded to scan duration (~20s).
created: 2026-03-30
last-reviewed: 2026-04-29
notes: >-
Verify TTL is set in cronjob.yaml. Check that no persistent
pods run with hostPID on the scanned cluster (indri). The
alloy-tracing DaemonSet on ringtail also uses hostPID but is
out of scope — Prowler only scans indri. Tracked in Todoist:
"prowler scan against ringtail" — once that lands, the
DaemonSet's hostPID+privileged posture will surface as a CIS
finding and need its own CC or remediation.
- id: trusted-ci-only
description: >-
Forgejo runner only executes workflows from repos on the private
forge (forge.ops.eblu.me). No external or untrusted repos can
trigger privileged CI jobs.
created: 2026-03-30
last-reviewed: 2026-05-01
notes: >-
Verification: (1) Runner config (argocd/manifests/forgejo-runner/
config.yaml) connects only to https://forge.ops.eblu.me/. (2) Forge
app.ini has DISABLE_REGISTRATION=true and ALLOW_ONLY_EXTERNAL_REGISTRATION
=true (ansible/roles/forgejo/defaults/main.yml) — no untrusted users
can sign up or create repos. The runner registers at instance scope
(repo_id=0/owner_id=0 in action_runner table), but the instance itself
is closed, so no per-repo allow-list is needed. Re-evaluate if the
forge ever opens to additional users or if the runner is repointed
to an external forge.
- id: init-container-isolation
description: >-
Root privileges and added capabilities (CHOWN) are limited to
init containers that run once at pod startup. All runtime
containers run as non-root (UID 472) with all capabilities
dropped.
created: 2026-03-30
last-reviewed: 2026-05-04
notes: >-
Verify by inspecting grafana deployment.yaml securityContext
for both init and runtime containers. If fsGroup alone can
handle PVC ownership, remove init-chown-data and this control.
Retirement deferred until grafana lands on ringtail's k3s
(see [[indri-k8s-migration]]) — storage backend will change,
and removing init-chown-data right before that migration
trades a real safety net for marginal cleanup. Revisit
post-migration.
- id: node-config-automated-verification
description: >-
Prowler reports certain node-level checks as MANUAL because it runs
inside a pod and cannot evaluate kubelet file permissions, kubelet
config arguments, etcd CA separation, or cluster-admin RBAC bindings.
The review-compliance-reports script SSHes into the minikube node
weekly and programmatically verifies each condition, failing loudly
if any check deviates from expected values.
created: 2026-04-14
last-reviewed: 2026-04-14
notes: >-
Verification runs as part of 'mise run review-compliance-reports'.
If minikube node is unreachable, all checks report as FAIL. If new
MANUAL findings appear in Prowler, add corresponding verification
logic to the script and update the mutelist.
- id: operator-purpose-bound-rbac
description: >-
Operators whose entire function is to manage a sensitive resource
legitimately need RBAC over that resource. external-secrets-operator
manages Secret objects (its purpose) and the cert-controller mutates
its own ValidatingWebhookConfigurations to inject rotating CA bundles.
Risk is bounded by: (1) the operator code being upstream open-source
and reviewed; (2) RBAC scoped to specific named webhooks where
possible; (3) supply chain controls on the operator image (mirrored
to local registry, version tracked in service-versions.yaml).
created: 2026-04-27
last-reviewed: 2026-04-27
notes: >-
Verify by checking that the operators in question still match their
stated purpose (i.e. external-secrets is still the only consumer of
these ClusterRoles) and that upstream hasn't published advisories
for credential-handling bugs. Re-evaluate if a non-secrets-managing
ClusterRole appears under this control.
- id: kube-state-metrics-metadata-only
description: >-
kube-state-metrics holds list/watch on Secrets cluster-wide but only
exposes Secret object *metadata* (name, namespace, type, creation
timestamp, labels) via the kube_secret_info / kube_secret_labels
metrics. Secret data fields are never read into KSM's exposed
metrics by upstream design. Mitigation rests on KSM's metric
schema, the version pin in service-versions.yaml, and the metrics
endpoint being reachable only on the cluster network.
created: 2026-04-27
last-reviewed: 2026-04-27
notes: >-
Verify by inspecting the /metrics endpoint output for any series
that include secret data (only *_info and *_labels metrics should
reference secrets, and labels should be limited to user-applied
labels — never the data:). Re-evaluate on KSM version bumps.
- id: observability-stack-audit
description: >-
Alloy collects pod logs and ships them to Loki, providing an
audit trail for cluster activity. Compensates for missing
apiserver audit logging which neither minikube (indri) nor
k3s (ringtail) configures by default.
created: 2026-03-30
last-reviewed: 2026-05-11
notes: >-
Verify Alloy DaemonSet is running on each cluster (alloy-k8s on
minikube, alloy-ringtail on k3s) and Loki is receiving logs.
Note this is weaker than native apiserver audit logs — it
captures pod stdout/stderr, not API request-level auditing.
Consider enabling apiserver audit logging on k3s post-migration
(`--audit-log-path` / `--audit-policy-file`) — minikube made it
hard, k3s makes it straightforward.

View file

@ -1,229 +0,0 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.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)

View file

@ -143,7 +143,10 @@ def _kubectl(args: str, timeout: int = 15) -> subprocess.CompletedProcess:
def run_node_verification(console: Console) -> None:
"""Verify node-level conditions that Prowler reports as MANUAL.
Compensating control: node-config-automated-verification
Prowler runs inside a pod and can't evaluate kubelet file permissions,
kubelet config arguments, etcd CA separation, or cluster-admin RBAC
bindings. We SSH into the minikube node and check each condition here,
failing loudly if any deviates from expected values.
"""
checks: list[tuple[str, str, bool]] = [] # (name, detail, passed)
@ -278,7 +281,7 @@ def run_node_verification(console: Console) -> None:
table = Table(
show_header=True,
header_style="bold",
title="Node Verification (CC: node-config-automated-verification)",
title="Node Verification (out-of-band checks for MANUAL findings)",
)
table.add_column("Check")
table.add_column("Detail")
@ -528,8 +531,8 @@ def summarize_report(
Panel(
f"[bold yellow]{len(latest['unmuted'])} unmuted failure(s) "
f"need triage.[/bold yellow]\n\n"
"For each: remediate or mute "
"(add to mutelist + compensating control).",
"For each: remediate, or add a Resource entry to the "
"matching check in argocd/manifests/prowler/mutelist/.",
title=f"{label} Verdict",
border_style="yellow",
)
@ -653,7 +656,6 @@ def main(
)
# --- Node-level MANUAL check verification ---
# Compensating control: node-config-automated-verification
# These checks verify conditions Prowler reports as MANUAL because it
# runs inside a pod and cannot evaluate them directly.
run_node_verification(console)