A Kubernetes RBAC and pod-security attack-path analyzer. It reads your manifests and tells you who can become cluster-admin, and how.
Bastion is offline. It reads files — a manifest directory, helm template
output, or a kubectl get -o yaml export. It never uses a kubeconfig, never
calls an API server, and makes no network request at scan time. That is both a
safety property and the reason its output is deterministic enough to gate a
merge on.
Same cluster. A linter sees seven disconnected warnings; Bastion sees the one sentence that matters.
Kubernetes RBAC grants compose in ways nobody reviews for. create pods in a
namespace is not "can start a pod" — it is "can become any ServiceAccount in
this namespace", because a pod specification names the account it runs as.
Escalation is therefore a graph problem, not a checklist problem, and a linter
that examines one Role at a time can never see it.
Every one of those four grants is individually defensible. The build pipeline needs to run test pods. On-call needs to exec into pods. The platform controller needs to provision RBAC for new tenants. No per-resource linter flags any of them, and together they are a route from a CI token to the whole cluster.
Bastion explains each hop, cites a file and a line, and tells you which rule to delete.
pip install bastion-k8sFrom source:
git clone https://github.com/mk12002/Bastion
cd Bastion
pip install -e ".[dev]"Runnable examples (a vulnerable manifest set, the Python API, the diff gate) are
in examples/.
With Docker (offline, non-root; mount your manifests read-only):
docker build -t bastion:local .
docker run --rm -v "$PWD/manifests:/work:ro" bastion:local scan /work --format sarif --stdoutRequires Python 3.11+. One runtime dependency: ruamel.yaml, for line numbers.
# Scan a manifest tree; writes a self-contained HTML report
bastion scan ./manifests
# Every format at once
bastion scan ./manifests --format all --out ./bastion-report
# The question people actually ask
bastion paths ./manifests --from ci-runner --to cluster-admin
# Gate a merge
bastion scan ./manifests --format sarif --fail-on P0
# Does this pull request create a NEW route to cluster-admin?
bastion diff ./base-branch ./pull-request --fail-on-new-pathHelm charts must be rendered first — Bastion refuses to guess at unresolved templates:
helm template ./chart > /tmp/rendered.yaml && bastion scan /tmp/rendered.yamlThe chain in the diagram above comes from
tests/fixtures/chain/, a fixture you can
scan yourself. Here is the real output of bastion paths against it:
Read as prose:
app/ci-runnercan create pods inapp. A pod specification may name any ServiceAccount in its own namespace, so ci-runner can schedule a pod that runs asapp/debuggerand use that account's projected API token. Evidence:chain.yaml:57, RoleBindingci-runner-pods.app/debuggercan exec into pods, cluster-wide. Executing a process insideDeployment core/controllergives that process the pod's mounted ServiceAccount token. Evidence:chain.yaml:84, ClusterRoleBindingdebugger-can-exec.- That Deployment runs as
core/controller-sa, and projects its token into every container. Evidence:chain.yaml:115. core/controller-sacan create ClusterRoleBindings. A ClusterRoleBinding may reference any ClusterRole, includingcluster-admin. Evidence:chain.yaml:144, ClusterRoleBindingcontroller-manages-rbac.
Note what step 4 also says: "Bastion computed that the subject does not already
hold cluster-admin-equivalent permissions and lacks the escalate verb, so
Kubernetes' privilege-escalation prevention would refuse this binding — unless
permissions outside the scanned manifests grant them." Bastion marks that edge
MEDIUM confidence, and because a chain is only as strong as its weakest link, the
whole path is MEDIUM. It does not tell you it is certain when it is not — and it
now states the prevention outcome as a computed result, not a guess.
The full JSON, HTML and SARIF for this scan are committed under
docs/samples/ — open
chain-report.html in a browser.
| The chain fixture | The hardened fixture |
The idea that makes the report readable at the top: an isolated misconfiguration ranks below an identical misconfiguration that is provably part of a chain.
Take a privileged DaemonSet — securityContext.privileged: true, the worst
pod-security finding there is. Bastion has two fixtures containing a
byte-identical workload manifest:
| Fixture | Its ServiceAccount | Priority |
|---|---|---|
crosslink/off-path |
holds no permissions | P2 |
crosslink/on-path |
can create pods cluster-wide | P1 |
Same manifest, same severity, different priority — because in one of them the
account the pod runs as is two hops from every Secret in the cluster. That is the
difference between linting and graph reasoning, and
tests/test_podsec_crosslink.py asserts it.
The posture score is a documented, integer-only formula printed in every report:
score = round(100 / (1 + penalty / 100))
where the penalty sums 30 points for a 1–2 hop critical path, 18 at 3–4, 10 at
5+, scaled by confidence, plus 10/5/2/0.5 per P0/P1/P2/P3 finding. The curve
saturates rather than clamping — a comprehensively compromised cluster scores
in the low teens, not a flat zero — so the number stays useful for tracking
whether a fix actually helped. No weights are hidden. (An earlier linear version
clamped six of nine real charts to 0; see docs/CALIBRATION.md.)
A live-cluster scanner tells you what is already deployed. By then the change has shipped. Bastion compares the base branch against the pull request:
- name: Bastion — block new escalation paths
run: |
git worktree add /tmp/base "$GITHUB_BASE_REF"
bastion diff /tmp/base . --fail-on-new-pathOr use the reusable Action, which installs Bastion and runs the gate for you:
- uses: mk12002/Bastion@v0.1.0
with:
base: /tmp/base # omit `base` to run a full scan with `fail-on: P0` insteadThe diff is identified by fingerprint, not by file and line, so reformatting a file or adding a comment does not report phantom findings. That property is tested — a diff that cries wolf is a diff the team switches off.
Or gate on absolute state:
- run: bastion scan ./manifests --format sarif --out ./out --fail-on P0
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ./out/bastion-report.sarif.jsonMore recipes — GitLab, Jenkins, pre-commit, Helm charts, PR comments, and notes
on tuning the gate so it does not get switched off:
docs/CI_RECIPES.md.
bastion scan <path>
--format {json,html,sarif,all} default: html
--out DIR default: ./bastion-report
--fail-on {P0,P1,P2,P3} exit 1 at or above this priority
--max-depth N path search depth, default 6
--namespace NS repeatable; restrict scope
--ignore-system skip system: subjects (default on)
--no-ignore-system include them
--quiet / --stdout / --no-gitignore
bastion paths <path> --from SUBJECT --to {cluster-admin,node,secrets,any}
bastion diff <baseline> <candidate> [--fail-on-new-path] [--fail-on-new P0..P3]
bastion rules list [--json]
bastion version
Exit codes: 0 clean, 1 gate tripped, 2 usage error.
--from accepts whichever spelling you have to hand: ci-runner,
app/ci-runner, or system:serviceaccount:app:ci-runner.
Run bastion rules list for the full catalog, each entry with a false-positive
note — a tool that never tells you when it might be wrong trains you to ignore
it.
Escalation (E) — permission-derived, the product
| Signal | ||
|---|---|---|
| E1 | create pods (or a controller) → become any SA in the namespace |
HIGH |
| E2 | create pods with no admission policy in the input → node breach |
HIGH |
| E3 | pods/exec, pods/attach, ephemeral containers → inherit a pod's identity |
HIGH |
| E4 | get/list/watch secrets → tokens and credentials |
HIGH |
| E5 | impersonate users/groups/SAs → direct identity assumption |
CRITICAL |
| E6 | create bindings, or the bind/escalate verbs → self-grant |
CRITICAL |
| E7 | update/patch roles → rewrite your own permissions |
CRITICAL |
| E8 | update/patch workload controllers → run as their SA |
HIGH |
| E9 | create serviceaccounts/token → mint another identity's token |
HIGH |
| E10 | nodes/proxy → the kubelet API → every pod on the node |
CRITICAL |
| E11 | Create + approve a CSR → a client cert for any user or group | CRITICAL |
| E12 | Write admission webhooks → rewrite every object admitted | CRITICAL |
| E13 | Write the provider's identity-mapping ConfigMap in kube-system |
CRITICAL |
| E14 | create persistentvolumes → a hostPath PV → the node filesystem |
HIGH |
Pod security (P1–P8) privileged containers, host namespaces and
shareProcessNamespace, hostPath mounts, privilege escalation, capabilities,
writable root filesystems, token automount, missing limits. All of them rise in
priority when the workload's ServiceAccount is on a path.
Isolation (N1–N3) missing NetworkPolicy, allow-all selectors, unrestricted egress. Hygiene (H1–H5) wildcards, cluster-admin bindings, default ServiceAccount use, dangling bindings, anonymous grants.
Read these before you rely on a clean scan. They are in the report too, and in
docs/THREAT_MODEL.md.
- Manifests are not a cluster. RBAC that exists in your cluster but not in the files you scanned is invisible to Bastion. If you scan one directory of a repository, bindings defined elsewhere do not exist as far as it is concerned.
- Admission enforcement is assumed absent unless it is in the input. Bastion
parses
pod-security.kubernetes.io/enforcelabels, Kyverno policies (mode and target) and Gatekeeper Constraints, and silences E2 only for a policy it can prove blocks a privileged pod — an enforcing pod-security policy, not an audit-mode or unrelated one. It still cannot see a webhook deployed outside the scanned files, does not parse Kyverno namespace selectors, and does not evaluate CELValidatingAdmissionPolicy; those are the false-negative-safe direction (E2 keeps firing). Rule E2's MEDIUM confidence exists for exactly this reason, the assumption is printed in every report, and the scope limits are logged indocs/ROADMAP.md. - Aggregated ClusterRoles are resolved only from the provided manifests. In a real cluster the aggregation controller sees ClusterRoles Bastion does not.
- Paths are permission-derived possibility, not observed activity. Bastion reports the path. It never walks it, and it contains no code that could.
- Conjunctions are approximated. Some escalations need two permissions at once (E11 needs create and approve; E14 needs a PV and a pod). Bastion's graph is an ordinary digraph, so it collapses these into one edge whose justification names both preconditions and whose confidence reflects whether both were found. A hypergraph would model it exactly; this does not.
- A clean scan is evidence, not proof.
- Real-chart paths are short and often inherent. Calibrated against 9 real
public Helm charts (368 manifests), most paths are 1–2 hops, and many are
admission controllers that legitimately need the permission. Bastion names and
explains the route; whether to act is your call. See
docs/CALIBRATION.md.
Bastion belongs upstream of admission control, not instead of it. Kyverno and Gatekeeper stop a deployment; Bastion is a report that helps you review one.
| Offline / pre-merge | Attack paths | Cross-linkage | Per-edge justification | |
|---|---|---|---|---|
| Bastion | yes | yes | yes | yes |
| KubeHound | no (live cluster) | yes, 26 edge types | partial | via Gremlin queries |
| rbac-police | partial (collected JSON) | no, per-identity | no | policy descriptions |
| KubiScan | no (live cluster) | no | no | no |
| rbac-tool | no (live cluster) | visualisation only | no | no |
| Trivy / Checkov / kube-score | yes | no | no | n/a |
KubeHound sees runtime truth Bastion can only infer, and its container-escape
taxonomy is deeper. rbac-police's policies are user-extensible in Rego; Bastion's
are Python. rbac-tool generates least-privilege Roles; Bastion only tells you
which rule to delete. Full honest write-up, including which of their ideas were
adopted here: docs/PRIOR_ART.md.
Measured head-to-head. Run on nine real public Helm charts, kube-score
produced 408 findings and zero about RBAC or escalation paths — it lints pods
one at a time. Bastion found 47 escalation paths on the same input, including
argo-cd/argocd-application-controller → cluster-admin and
cert-manager/cert-manager-cainjector → cluster-admin. Full method and numbers:
docs/COMPARISON.md.
flowchart LR
subgraph parsers["parsers/"]
L[loader<br/>line numbers] --> RB[rbac]
L --> WL[workloads]
L --> NW[network]
end
subgraph core["core/"]
G[privilege graph<br/>deterministic BFS]
RK[risk model<br/>path-aware scoring]
end
subgraph rules["rules/"]
E[escalation E1–E14]
P[podsec P1–P8]
N[isolation N1–N3]
H[hygiene H1–H5]
end
subgraph emitters["emitters/"]
J[JSON]
HT[HTML + inline SVG]
SA[SARIF 2.1.0]
end
RB --> G
WL --> G
NW --> G
G --> E --> G
G --> P
G --> RK
E --> RK
RK --> J
RK --> HT
RK --> SA
style G fill:#eaf4ec,stroke:#3f8f57
style RK fill:#e8effb,stroke:#2f5fbf
Parse → build the privilege graph → derive escalation edges → find shortest
paths to critical terminals → score with graph context → emit. Dependency
direction is one-way: parsers, rules and emitters all depend on core;
core depends on nothing internal.
Two design decisions worth knowing:
- The graph is in-house, on the standard library. No third-party graph library guarantees stable iteration order across versions, and unstable path ordering breaks CI diffs and erodes trust. Bastion sorts nodes and edges by an explicit composite key before every expansion.
- Path finding is breadth-first over simple paths. Cycles are harmless by
construction rather than by a separate detector, results arrive shortest-first,
and three bounds (
--max-depth, alternates per terminal, a global expansion budget) mean a pathological input degrades into a truncated-but-honest report rather than a hang.
Determinism is tested across all three formats, including path ordering.
CONTRIBUTING.md covers adding an escalation rule, adding a
parser kind, and the inert-fixture law — fixtures are genuinely
misconfigured declarative manifests, never exploit tooling, enforced by
tests/test_inertness.py on every commit.
pip install -e ".[dev]"
pytest # 530+ tests
ruff check . && mypy src/bastion
python tools/generate_docs_assets.py # after changing report renderingGood first issues: add an escalation rule, add a controller kind to
POD_SPEC_PATHS, or improve a false-positive note.
Bastion reads untrusted input, so its own hardening is part of the job. Each
guarantee below is enforced by a test in
tests/test_hardening.py, run in CI on every commit:
- No code execution. Safe YAML loader (no
!!python/objectRCE); an AST check banseval/exec/subprocess; a payload test asserts nothing runs. - No network, ever. An AST check bans socket/HTTP/kubernetes imports, and a CI job runs a full scan with sockets disabled.
- Bounded resources. 20 MB/file and 100k-file caps; a YAML alias bomb is verified bounded; deep nesting is caught as malformed, not a crash; regexes are proven non-backtracking.
- No report injection. Every user value is HTML-escaped in HTML/SVG; JSON and
SARIF unicode-escape
< > &so they are safe even embedded in a web UI. - Secrets never leak. Parsers never read Secret
data/stringData; output is scrubbed of tokens and base64 blobs as a second layer. - No path escape. Directory symlinks are not followed; non-regular files are
skipped; writes go only to the chosen
--outdirectory.
Full detail in docs/THREAT_MODEL.md; reporting policy in
SECURITY.md.
If you find an escalation path in a real project's shipped manifests, disclose it privately to those maintainers. Do not publish an exploit and do not name a project on an unverified finding.
docs/VISION.md |
What Bastion is, who it is for, and explicitly what it is not |
docs/PRIOR_ART.md |
Honest comparison with existing tools, and which of their ideas were adopted |
docs/THREAT_MODEL.md |
Bastion's threat model for itself, and how each guarantee is enforced |
SECURITY.md |
Vulnerability reporting policy and the hardening guarantees |
docs/CI_RECIPES.md |
Gate recipes for GitHub, GitLab, Jenkins, pre-commit |
docs/CALIBRATION.md |
Results of calibrating against 9 real public Helm charts |
docs/COMPARISON.md |
Reproducible head-to-head vs kube-score and checkov |
docs/ROADMAP.md |
Honest ledger of remaining gaps, model approximations, and deliberate boundaries |
CHANGELOG.md |
Release history, including known gaps |
docs/samples/ |
Real JSON, HTML and SARIF output you can open |
examples/ |
Copy-paste-runnable manifests, Python API, and CI snippets |
CONTRIBUTING.md |
Adding a rule, adding a kind, the inert-fixture law |
CODE_OF_CONDUCT.md |
Contributor Covenant |
A rendered documentation site (MkDocs Material) is configured in
mkdocs.yml and publishes to GitHub Pages via the docs workflow.
Metadata for citation lives in CITATION.cff — GitHub renders a
Cite this repository button from it. Each tagged release is archived on
Zenodo and issued a DOI; the process (and where the DOI badge goes) is in
RELEASING.md.
Kumar, M. Bastion: a Kubernetes RBAC and pod-security attack-path analyzer.
https://github.com/mk12002/Bastion
Apache-2.0. See LICENSE.
Bastion reports escalation paths. It never walks them.