diff --git a/argocd/manifests/prowler/mutelist/apiserver.yaml b/argocd/manifests/prowler/mutelist/apiserver.yaml index 5a25d4f..fd077e8 100644 --- a/argocd/manifests/prowler/mutelist/apiserver.yaml +++ b/argocd/manifests/prowler/mutelist/apiserver.yaml @@ -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." diff --git a/argocd/manifests/prowler/mutelist/control-plane.yaml b/argocd/manifests/prowler/mutelist/control-plane.yaml index 2056691..d3cc34a 100644 --- a/argocd/manifests/prowler/mutelist/control-plane.yaml +++ b/argocd/manifests/prowler/mutelist/control-plane.yaml @@ -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." diff --git a/argocd/manifests/prowler/mutelist/core-pod-security.yaml b/argocd/manifests/prowler/mutelist/core-pod-security.yaml index c39e0c6..b1e986e 100644 --- a/argocd/manifests/prowler/mutelist/core-pod-security.yaml +++ b/argocd/manifests/prowler/mutelist/core-pod-security.yaml @@ -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. diff --git a/argocd/manifests/prowler/mutelist/manual-node-checks.yaml b/argocd/manifests/prowler/mutelist/manual-node-checks.yaml index 9c8354d..c91a2a6 100644 --- a/argocd/manifests/prowler/mutelist/manual-node-checks.yaml +++ b/argocd/manifests/prowler/mutelist/manual-node-checks.yaml @@ -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." diff --git a/argocd/manifests/prowler/mutelist/rbac.yaml b/argocd/manifests/prowler/mutelist/rbac.yaml index c9c52e4..324809d 100644 --- a/argocd/manifests/prowler/mutelist/rbac.yaml +++ b/argocd/manifests/prowler/mutelist/rbac.yaml @@ -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. diff --git a/argocd/manifests/prowler/mutelist/trivyignore.yaml b/argocd/manifests/prowler/mutelist/trivyignore.yaml index 22c612a..87af966 100644 --- a/argocd/manifests/prowler/mutelist/trivyignore.yaml +++ b/argocd/manifests/prowler/mutelist/trivyignore.yaml @@ -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. diff --git a/compensating-controls.yaml b/compensating-controls.yaml deleted file mode 100644 index 01b3cfd..0000000 --- a/compensating-controls.yaml +++ /dev/null @@ -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: " 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. diff --git a/mise-tasks/review-compensating-controls b/mise-tasks/review-compensating-controls deleted file mode 100755 index e92d302..0000000 --- a/mise-tasks/review-compensating-controls +++ /dev/null @@ -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 " 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) diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports index bcbe090..a9146c8 100755 --- a/mise-tasks/review-compliance-reports +++ b/mise-tasks/review-compliance-reports @@ -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)