Skip to content

Bastion

A Kubernetes RBAC and pod-security attack-path analyzer. It reads your manifests and tells you who can become cluster-admin, and how.

CI Python 3.11+ License: Apache 2.0

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.

Left: a per-resource linter reports seven disconnected warnings. Right: Bastion reports one connected path from a CI ServiceAccount to cluster-admin, with every step labeled.

Same cluster. A linter sees seven disconnected warnings; Bastion sees the one sentence that matters.


The problem, in three sentences

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.


What it produces

Four-step escalation path: ServiceAccount app/ci-runner creates a pod as app/debugger, which execs into Deployment core/controller, which runs as core/controller-sa, which can create ClusterRoleBindings and so reach cluster-admin

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.


Install

pip install bastion-k8s

From 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 --stdout

Requires Python 3.11+. One runtime dependency: ruamel.yaml, for line numbers.

Quickstart

# 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-path

Helm charts must be rendered first — Bastion refuses to guess at unresolved templates:

helm template ./chart > /tmp/rendered.yaml && bastion scan /tmp/rendered.yaml

The worked example

The 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:

Terminal output of bastion paths showing the four-step chain with a justification and file:line evidence for every edge

Read as prose:

  1. app/ci-runner can create pods in app. A pod specification may name any ServiceAccount in its own namespace, so ci-runner can schedule a pod that runs as app/debugger and use that account's projected API token. Evidence: chain.yaml:57, RoleBinding ci-runner-pods.
  2. app/debugger can exec into pods, cluster-wide. Executing a process inside Deployment core/controller gives that process the pod's mounted ServiceAccount token. Evidence: chain.yaml:84, ClusterRoleBinding debugger-can-exec.
  3. That Deployment runs as core/controller-sa, and projects its token into every container. Evidence: chain.yaml:115.
  4. core/controller-sa can create ClusterRoleBindings. A ClusterRoleBinding may reference any ClusterRole, including cluster-admin. Evidence: chain.yaml:144, ClusterRoleBinding controller-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.


Scoring: paths change priority

Summary card: posture score 56 of 100, with P0 and P1 findings and 3 critical paths Summary card: posture score 100 of 100, no findings, no escalation paths
The chain fixtureThe 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.)


In CI: the diff gate

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:

Terminal output of bastion diff showing three newly introduced escalation paths, a posture score drop from 89 to 56, and a failed gate

- name: Bastion — block new escalation paths
  run: |
    git worktree add /tmp/base "$GITHUB_BASE_REF"
    bastion diff /tmp/base . --fail-on-new-path

Or 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` instead

The 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.json

More 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.


Commands

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.


The rule set

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.


Limitations

Read these before you rely on a clean scan. They are in the report too, and in docs/THREAT_MODEL.md.

  1. 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.
  2. Admission enforcement is assumed absent unless it is in the input. Bastion parses pod-security.kubernetes.io/enforce labels, 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 CEL ValidatingAdmissionPolicy; 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 in docs/ROADMAP.md.
  3. Aggregated ClusterRoles are resolved only from the provided manifests. In a real cluster the aggregation controller sees ClusterRoles Bastion does not.
  4. Paths are permission-derived possibility, not observed activity. Bastion reports the path. It never walks it, and it contains no code that could.
  5. 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.
  6. A clean scan is evidence, not proof.
  7. 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.


How it compares

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.

9 real Helm charts scanned. 0 RBAC findings from a popular linter. 47 escalation paths found by Bastion.


How it works

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
Loading

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

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 rendering

Good first issues: add an escalation rule, add a controller kind to POD_SPEC_PATHS, or improve a false-positive note.


Security

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/object RCE); an AST check bans eval/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 --out directory.

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.


Documentation

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.

Citing Bastion

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

License

Apache-2.0. See LICENSE.

Bastion reports escalation paths. It never walks them.

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages