GCP conditional IAM & tag-based privilege-escalation scanner
Project description
condor-scan
A GCP privilege-escalation scanner that thinks in attack paths, not findings.
Install:
pip install condor-scan· PyPI · Apache-2.0 · Python 3.10+
condor-scan reads an export of your Google Cloud IAM and tells you not just who has dangerous permissions, but who can chain those permissions into full control, which of them an outsider could reach, what the single highest- leverage fix is, and whether you'd even see the attack in your logs.
It started as a scanner for one specific, under-served escalation class — tag-based IAM Conditions abuse — and grew an analysis layer on top that answers the questions a security team actually argues about in triage.
Why this exists
There is no shortage of GCP security scanners. Prowler, ScoutSuite, Forseti, and the commercial CSPM/CIEM platforms all enumerate IAM misconfigurations competently. After using them for a while you notice two recurring frustrations:
They model permissions, not paths. Most tools tell you "principal X holds
iam.serviceAccounts.actAs" as an isolated fact. They rarely tell you that X
can actAs a service account that is itself Owner on the project, which is the
thing that actually matters. Real GCP escalation is almost never a single step;
it's a chain — a leaked key leads to a default service account, which can
actAs a privileged one, which holds an org-level binding. Each link is
unremarkable alone.
They miss tag-based conditional escalation entirely. IAM Conditions let you
grant a role only when a CEL expression holds — for example, only on resources
tagged env=prod. That's a great feature. The problem: if a low-privileged user
can attach that tag themselves, they can satisfy the condition on demand and
collect the role. Mitiga documented this as "Tag Your Way In" in 2026, and
Google's position is that it's a customer-side misconfiguration — which means
it's your job to detect it, and almost nothing does, because almost everything
models only static bindings.
condor-scan is built around modelling the chain, and it treats tag-conditional escalation as a first-class edge in that chain.
What it actually computes
1. The escalation engine (capability closure)
The core is a small, deterministic engine that computes a capability closure. For each principal it starts from the permissions and identities that principal holds, then repeatedly applies escalation rules until nothing new can be reached:
- CONDOR-SETIAMPOLICY — holding
*.setIamPolicylets you bind yourself Owner. Game over in one move. - CONDOR-ROLEUPDATE —
iam.roles.updateon a custom role you hold lets you add any permission to it. - CONDOR-IMPERSONATE — service-account impersonation: via Token Creator
(mint an access token), via key creation, or via
actAscombined with a deploy permission (deploy code that runs as the SA). When you reach an SA, you inherit everything it can do, and the closure keeps going from there. - CONDOR-TAGCONDITION — the tag-based conditional path described above.
Because the closure only ever adds to the set of reachable capabilities and the universe of permissions is finite, it always terminates. Every rule that fires records a step, so the output is the full chain, not just a verdict.
That much is useful but not novel — it's table stakes for a serious tool. The interesting part is what sits on top.
2. Exposure-aware origin analysis — can an outsider reach this?
A principal that can reach Owner is a problem. A principal that can reach Owner and is reachable from the internet is an incident. Those deserve very different priorities, and almost no scanner distinguishes them.
condor-scan models untrusted sources: the public IAM members allUsers and
allAuthenticatedUsers (when they're actually bound to something), plus any
identities you declare as internet-exposed in the export — for instance, the
service account attached to a public Cloud Run service. It then runs the closure
from those sources and marks every escalation finding that originates from, or
is reachable by, an untrusted identity.
The practical payoff: when the report says "2 externally exposed paths to Tier Zero," those two go to the top of the queue regardless of how the raw severity sorted them. This is the initial-access → privilege-escalation linkage that turns a theoretical misconfiguration into a path you can reason about as an attacker would.
3. Choke-point analysis — what do I fix first?
This is the part I'm most pleased with, and the reason the tool earns its keep on a large estate.
Escalation findings are not independent. Cross an organisation with hundreds of principals and you'll find that many distinct escalation chains funnel through the same handful of IAM grants — one over-broad group binding, one shared service account everyone can impersonate. If you remediate blindly down a severity-sorted list, you do a lot of work for little structural gain.
condor-scan attributes every step in every chain back to the specific, remediable grant that enabled it (a binding of role → member on a resource, possibly conditional). It then treats "break every path to Tier Zero" as a minimum set-cover problem over those grants and solves it greedily: repeatedly pick the grant that addresses the most still-unaddressed escalating principals.
Minimum set cover is NP-hard, so this is the standard greedy approximation
(the one with the classic ln(n) bound). I'm deliberately not pretending it's
optimal. What it gives you is the right order to work in: a prioritised
remediation plan where the first item is, provably, the single change that
collapses the most attack paths. In the bundled example, the top choke point is
one group binding that — removed — eliminates escalation for two principals at
once, ahead of five single-principal fixes.
One honest caveat, stated in the tool and again here: removing one grant on a chain may leave an alternate sub-path intact, so you should re-scan after remediating. The ranking is still the correct order; it just isn't a guarantee that one removal fully neutralises a principal.
4. Detection-visibility mapping — would we even see it?
A path that generates no log is more dangerous than one that does, because it defeats both detection and incident response. No misconfiguration scanner I know of tells you this, and it's a genuinely important dimension.
Every escalation primitive is mapped to its MITRE ATT&CK (Cloud) technique and, more usefully, to whether it produces a Cloud Audit Log entry by default. The nuance that matters here is GCP-specific:
- Admin Activity logs are always on, free, and can't be disabled. They cover
configuration-changing calls:
SetIamPolicy,CreateServiceAccountKey, role updates, resource deploys,CreateTagBinding. Escalation that goes through these is visible. - Data Access logs are off by default (BigQuery excepted), billable, and
must be explicitly enabled. They cover most reads — including
GenerateAccessToken,GenerateIdToken, andSignJwton the IAM Service Account Credentials API.
So service-account token impersonation is usually invisible out of the box,
while key creation is logged. And tag-based conditional escalation is invisible
in a subtler way: the conditional binding already exists, so satisfying it by
attaching a tag emits only a CreateTagBinding event — never an IAM
policy-change event. Most SIEM detection content keys on policy changes, so it
simply never fires. condor-scan surfaces these silent paths explicitly as
"detection blind spots" so a SOC knows exactly where to turn on Data Access
logging or write tag-binding detections.
5. Temporal / JIT awareness — is it exploitable right now?
IAM Conditions are often time-bound. A break-glass procedure grants Owner for
four hours; a contractor's access expires at the end of an engagement; a
just-in-time access tool hands out short-lived elevation on demand. All of these
are expressed in CEL against request.time.
A scanner that ignores time gets two things wrong. It reports expired grants as live escalations — a false positive that burns responder time chasing access that no longer exists. And it has no way to flag the case that matters most for live monitoring: a grant that is active right now but only briefly. A daily CSPM sweep can miss a two-hour break-glass window entirely.
condor-scan parses the request.time bounds out of each condition and
classifies every conditional grant relative to an evaluation instant:
- Expired grants are dropped from the closure — they are no longer a path, so they are no longer a finding. (This also quietly fixes a real correctness bug the tool had before: a tag-conditional grant whose window had passed used to be reported as exploitable.)
- Future grants don't count as live escalation but are surfaced separately as scheduled / dormant — latent risk to review before it goes live.
- Active grants are analysed normally, and if the window is short-lived (below a configurable threshold, default 24h) the finding is flagged JIT with its expiry time. There is also a dedicated rule for an active, time-bound direct grant of an escalatory role — the literal break-glass case — which the engine would otherwise not surface at all.
Because the evaluation instant is injectable (--as-of), you can ask forward-
looking questions too: what will be exploitable next Monday at 09:00? The same
export, evaluated at two different instants, gives two different answers — which
is exactly right.
Installation
condor-scan is published on PyPI:
pip install condor-scan
This installs the condor-scan command. To work on the project from a checkout
instead, with the test and lint tooling:
pip install -e ".[dev]"
The analysis core has zero runtime dependencies — it's pure standard
library. That's a deliberate choice for a security tool: the fewer third-party
packages in the dependency tree, the smaller the supply-chain attack surface you
take on by running it. Live Cloud Asset Inventory ingestion is the one optional
extra (pip install ".[cloud]"), since it pulls in the Google client library.
Python 3.10+.
Usage
Scan for escalation chains
condor-scan scan export.json # human-readable table
condor-scan scan export.json --format json # machine-readable, ATT&CK-enriched
condor-scan scan export.json --format sarif # SARIF 2.1.0 for CI dashboards
condor-scan scan export.json --fail-on critical # non-zero exit to gate CI
Run the attack-path posture report
This is the triage / executive view — exposure, the remediation plan, and blind spots in one place:
condor-scan posture export.json
condor-scan posture export.json --format json
condor-scan posture export.json --fail-on-exposed # CI gate on internet-reachable Tier Zero
# Temporal questions: evaluate time-bound conditions at a chosen instant.
condor-scan posture export.json --as-of 2026-12-01T09:00:00Z
condor-scan posture export.json --jit-threshold-hours 4 # what counts as "short-lived"
condor-scan scan export.json --as-of 2026-12-01T09:00:00Z # scan also honours --as-of
Example output against the bundled examples/sample_export.json:
condor-scan - attack-path posture report
================================================
Principals analyzed .............. 10
Escalation findings .............. 8
Can reach Tier Zero .............. 8
Externally exposed -> Tier Zero .. 2
Remediation budget (choke points) 7
Detection blind spots ............ 4
EXTERNALLY EXPOSED PATHS TO TIER ZERO (fix first):
! serviceAccount:frontend@demo-prod.iam.gserviceaccount.com
! serviceAccount:privileged@demo-prod.iam.gserviceaccount.com
PRIORITISED REMEDIATION PLAN (greedy choke-point cover):
1. remove/scope: roles/owner -> group:platform@example.com on .../projects/demo-prod [condition: only-prod-tagged]
-> eliminates escalation for 2 principal(s): user:alice@example.com, user:grace@example.com
...
DETECTION BLIND SPOTS (escalation with no default audit signal):
~ [CONDOR-TAGCONDITION] user:alice@example.com: attach tag to satisfy condition 'only-prod-tagged' ...
~ [CONDOR-IMPERSONATE] user:bob@example.com: impersonate 'privileged@...' via 'iam.serviceAccounts.getAccessToken'
Generate preventive policy
The same logic that detects tag-conditional escalation can run preventively as an OPA / Policy Library constraint at deploy time (Terraform validation, a CI gate) rather than only as an after-the-fact scan:
condor-scan gen-constraints --out-dir ./policy
This emits a Rego constraint template plus an instance compatible with the
Config Validator / Gatekeeper ecosystem that Forseti's policy-library
consumes. It's deliberately decoupled: the constraint stands on its own and can
be adopted by Google's open-source policy tooling without depending on this
project at all.
Getting the input
condor-scan reads a JSON shape close to a Cloud Asset Inventory export:
gcloud asset search-all-iam-policies --scope=organizations/ORG_ID --format=json
You massage that into the documented schema (roles, iam_policies,
tag_bindings, group_members, exposed_principals). See
examples/sample_export.json for a complete, commented-by-example file. The
load_from_cloud_asset_inventory() stub in loaders.py sketches the direct
client integration for when you want to skip the manual export.
The exposed_principals list is how you feed in network reality — your CSPM,
load-balancer config, or Cloud Run/Functions inventory knows which service
accounts sit behind public endpoints; list them here and the exposure analysis
becomes meaningful.
Architecture
loaders.py parse CAI/JSON export ─────────► model.py typed domain objects
│
analysis.py index: per-principal permissions, impersonation maps,
grant provenance, exposure sources
│
rules.py capability-closure engine ────────► findings.py Finding + JSON/SARIF/table
(attributes each step to a remediable grant)
│
graph.py attack-path intelligence: exposure, choke-point
set-cover, detection blind spots ──► PostureReport
│
techniques.py MITRE ATT&CK + audit-log visibility model
temporal.py request.time window parsing + JIT/expiry classification
constraints.py preventive OPA/Rego policy generator
cli.py argparse front-end (scan / posture / gen-constraints)
knowledge.py curated escalation primitives + predefined-role subsets
cel.py conservative tag-condition (CEL) parser
The split is intentional: indexing is pure and cacheable, the engine is pure graph traversal, and the intelligence layer is pure analysis over the engine's output. Each layer is independently unit-testable, which is why the test suite can isolate, say, the greedy set-cover from the closure from the CEL parser.
Limitations (read these — a security tool that oversells its coverage is dangerous)
- The CEL parser is conservative by design. It recognises
resource.matchTag(...)andresource.matchTagId(...)predicates. Other condition types (time, IP, request attributes) are treated as not tag-satisfiable — the low-false-positive default. It is not a full CEL evaluator. - Tag-attach capability is modelled broadly. Holding
resourcemanager.tagValueBindings.createis treated as "can attach tags." Real GCP additionally requirestagValues.useon the specific tag value, so this can over-report; verify a tag-condition finding against the specific tag's IAM before acting. - The role→permission map is a curated escalation-relevant subset, not a full mirror of GCP IAM (which has tens of thousands of permissions). Custom roles are read verbatim from the export. Keeping the map curated is what keeps false positives low and the output auditable.
- Choke-point cover is a greedy approximation, and removing one grant may leave an alternate path — re-scan after remediation. The ranking is the right order to work in, not a uniqueness proof.
- Exposure is only as good as the
exposed_principalsyou provide.allUsers/allAuthenticatedUsersare detected automatically; everything else (public Cloud Run/Functions SAs, etc.) you supply. - Resource-hierarchy inheritance (org → folder → project) is modelled per policy resource, not as full inherited-binding propagation.
- Temporal parsing is limited to
request.timevstimestamp('...'). Other time expressions (durations,request.time.getHours(), recurring windows) are treated as unbounded on that side — the conservative default. Evaluation uses a single instant; it does not reason about recurring schedules.
These are written down on purpose so findings are interpreted correctly rather than trusted blindly.
Development
pip install -e ".[dev]"
make check # the full gate: ruff + mypy --strict + pytest
make test # pytest with coverage
make demo # run the scanner against the bundled example
The project holds itself to: ruff clean, mypy --strict clean across
every module, and a pytest suite (72 tests, ~96% coverage) that exercises
every escalation path with both positive and negative cases, the set-cover
algorithm, the exposure logic, the blind-spot detection, the temporal/JIT
classification, and the CLI end to end.
Releasing (maintainers)
Releases are built with build and uploaded
with twine:
python -m pip install --upgrade build twine
rm -rf dist build *.egg-info
python -m build # produces dist/*.whl and dist/*.tar.gz
twine check dist/* # validate metadata renders on PyPI
twine upload dist/* # authenticate with a PyPI API token
Bump version in pyproject.toml (and __version__ in
src/condor_scan/__init__.py) for each release; PyPI refuses to overwrite an
existing version. Consider testing against TestPyPI first, or wiring GitHub
Actions Trusted Publishing so no token ever leaves CI.
License
Apache-2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file condor_scan-0.3.0.tar.gz.
File metadata
- Download URL: condor_scan-0.3.0.tar.gz
- Upload date:
- Size: 45.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
114b104b17011fef918aa7edff7d6d31d6901da6e8bcd593579a2842493162f5
|
|
| MD5 |
1ab6251863080a73f961d81aa3ff4846
|
|
| BLAKE2b-256 |
6ebf1fdc8f437999e6b9b5009c32285d3b73a78f6af2dbc2cc1136f902553883
|
Provenance
The following attestation bundles were made for condor_scan-0.3.0.tar.gz:
Publisher:
release.yml on rsh1k/condor-scan
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
condor_scan-0.3.0.tar.gz -
Subject digest:
114b104b17011fef918aa7edff7d6d31d6901da6e8bcd593579a2842493162f5 - Sigstore transparency entry: 1990090191
- Sigstore integration time:
-
Permalink:
rsh1k/condor-scan@75e05681ebe9e7069751ae51a33cbd2423d66287 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/rsh1k
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@75e05681ebe9e7069751ae51a33cbd2423d66287 -
Trigger Event:
release
-
Statement type:
File details
Details for the file condor_scan-0.3.0-py3-none-any.whl.
File metadata
- Download URL: condor_scan-0.3.0-py3-none-any.whl
- Upload date:
- Size: 43.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ce55dc41aab1500d0703144af8b997d1ab2218cbef72ec4f66d6bbfa331d442
|
|
| MD5 |
6b98aa21af7fcdfcdafba4798d9edab0
|
|
| BLAKE2b-256 |
44b4aecaabf989e65a091d2f6ae91fe83efb135df3b0bf155caab448c5cde25c
|
Provenance
The following attestation bundles were made for condor_scan-0.3.0-py3-none-any.whl:
Publisher:
release.yml on rsh1k/condor-scan
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
condor_scan-0.3.0-py3-none-any.whl -
Subject digest:
0ce55dc41aab1500d0703144af8b997d1ab2218cbef72ec4f66d6bbfa331d442 - Sigstore transparency entry: 1990090353
- Sigstore integration time:
-
Permalink:
rsh1k/condor-scan@75e05681ebe9e7069751ae51a33cbd2423d66287 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/rsh1k
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@75e05681ebe9e7069751ae51a33cbd2423d66287 -
Trigger Event:
release
-
Statement type: