aws-enumerator reads the configuration of one AWS account, including the Kubernetes API of its EKS clusters. It
evaluates the IAM and resource policies it collected, and writes a directed attack graph
(analysis/graph.json) together with findings. A local dashboard draws the graph and finds the cheapest paths from
what you control to the account's high-value targets.
The idea comes from BloodHound; this project is not affiliated with it. Use it for authorised offensive assessments: an assumed-breach starting point or a set of leaked keys.
Offensive and observe-only. aws-enumerator is an offensive enumeration tool. It observes the account and maps privilege escalation: who can become whom, how, and what that reaches. It never fixes, hardens or defends anything, and it has no defender mode: no remediation plans, no "fix first" lists, no what-if simulation, no compliance score. Its audit is the operator's audit: where you would be seen, where you are blind, and what the account exposes for you to use.
What it is not:
- Not an exploitation tool. It makes read-only API calls (see the exceptions). It never reads data: no S3 objects, secret or parameter values, or Kubernetes Secret data.
- Not a compromise assessment. It does not read CloudTrail events, logs or security-service findings, so it cannot tell you whether an account has been breached.
- Not a remediation or hardening tool. It does not tell you how to fix the account. The engine keeps each
finding's
remediationfield because the report formats carry it (findings.json, the ASFF export), but the dashboard never shows it. - Not a complete IAM simulator. The graph shows what the collected policies allow, as far as the evaluator models them; see Known limitations. Treat every path as a lead to confirm by hand.
Not the same project: an older, unrelated Go tool on GitHub is also called "aws-enumerator". This project is the Python package
aws_enumerator. It is not published on PyPI, so never runpip install aws-enumeratororpipx install aws-enumerator: install from a release tag, as shown below.
- Authorized use only
- Install
- Docker
- Quick start
- Usage
- How paths work
- Known limitations
- Report layout
- Handling reports
- Required permissions
- OPSEC and detection footprint
- Screenshots
- Documentation
- Project, security and licence
Run aws-enumerator only against accounts you have written permission to assess. The banner and every --help
print this line:
Authorized use only: run only against accounts you have written permission to assess.
Before a run:
- Get written authorisation. It must name the AWS accounts, the regions and the EKS clusters. Kubernetes
collection is on by default, so it queries the API server of every cluster it finds; pass
--no-k8sif the clusters are out of scope. - Agree on data handling and retention. A report is a pre-computed attack map of the account. See docs/report-handling.md.
- Tell the defenders, or agree that you will not. The run is visible in CloudTrail and in the Kubernetes audit logs, and it identifies itself in the user agent unless you opt out. See docs/detection.md.
You need Python 3.10 or newer. Release tags are vX.Y.Z; the current version is 0.3.0
(aws-enumerator --version).
pipx install "git+https://github.com/0xj4f/aws-enumerator.git@v0.3.0"
aws-enumerator --version # aws-enumerator 0.3.0Always pin the install to a tag you have reviewed (@v0.3.0). An install without @<tag> takes whatever is on
main at that moment. pipx resolves the dependencies (boto3, botocore and kubernetes) from the version ranges in
pyproject.toml. For hash-locked dependencies, install from source.
git clone --branch v0.3.0 https://github.com/0xj4f/aws-enumerator.git
cd aws-enumerator
python3 -m venv .venv && . .venv/bin/activate
pip install --require-hashes -r requirements.txt # boto3, botocore and kubernetes, pinned with hashes
pip install --no-deps .
aws-enumerator --version# from the repository root
pip install --require-hashes -r requirements.txt
python -m aws_enumerator --version
python -m aws_enumerator collect --region eu-west-2requirements.txt includes kubernetes. Without it the k8s module is skipped with one warning, and every
cluster's cluster_info.json records auth_status: "dependency_missing". You then get no pods, service accounts,
RBAC or IRSA paths.
To set up a development environment (dev dependencies, tests, pre-commit hooks), see CONTRIBUTING.md.
The image runs as the unprivileged user app (uid 10001). It holds no credentials and writes reports to
/reports (AWS_ENUMERATOR_OUTPUT=/reports). Build it from a release checkout.
BuildKit is required; it is the default builder in current Docker:
git clone --branch v0.3.0 https://github.com/0xj4f/aws-enumerator.git && cd aws-enumerator
docker build --build-arg GIT_SHA="$(git rev-parse --short=12 HEAD)" -t aws-enumerator:0.3.0 .Always run a version tag you built, or an image pinned by digest (image@sha256:...), never a floating tag.
Images in the 0xj4f/aws-enumerator Docker Hub repository are tagged 0.2.N by the current CI, whatever the
package version (see CHANGELOG.md). If you use one, pin it by digest and check that
docker run --rm <image> version prints the version you expect.
Credentials from a profile. Mount ~/.aws read-only and select the profile by name:
mkdir -p -m 700 out
docker run --rm \
--user "$(id -u):$(id -g)" -e HOME=/aws-home \
-v "$HOME/.aws:/aws-home/.aws:ro" -e AWS_PROFILE=example-audit \
-v "$PWD/out:/reports" \
aws-enumerator:0.3.0 collect --region eu-west-2--userlets the container read your0600credential files and writeout/as you.- Docker sets
HOME=/for a uid it does not know, so-e HOME=/aws-hometells the SDK where to find.aws. - The mount is read-only, so the container can neither change your AWS configuration nor refresh SSO tokens. Run
aws sso login --profile example-auditon the host first.
Short-lived credentials in an env file. Write the file with mode 0600 and delete it after the run:
umask 077
aws configure export-credentials --profile example-audit --format env-no-export > aws-run.env
docker run --rm --env-file aws-run.env --user "$(id -u):$(id -g)" -v "$PWD/out:/reports" \
aws-enumerator:0.3.0 collect --region eu-west-2
rm -f aws-run.envYou can also pass variables by name only, for example -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN; Docker then copies the values from your shell. Never put a credential value on the command
line, where it ends up in shell history and in the process list.
Dashboard from the image. Publish the port on the host's loopback only:
docker run --rm -p 127.0.0.1:8000:8000 aws-enumerator:0.3.0 dashboard --host 0.0.0.0 --no-browserThen open http://127.0.0.1:8000/ and load the run's zip or folder from your machine with the page's file picker.
The server never serves report files, so the container needs no report volume.
--host 0.0.0.0is needed inside the container, and the server logs a warning for a non-loopback bind.- The
Hostallow-list accepts127.0.0.1,localhostand[::1]on the bound port. If the host port differs (-p 127.0.0.1:9000:8000), add--allowed-host 127.0.0.1:9000 --allowed-host localhost:9000.
# 1. Which collectors would be denied? (one cheap call per module, visible in CloudTrail)
aws-enumerator preflight --profile example-audit --region eu-west-2
# 2. Collect, analyse, and zip the run. stdout is the run root.
aws-enumerator collect --profile example-audit --region eu-west-2 --zip
# 3. View the graph: open the printed URL and load <run_id>.zip (or the run folder) in the page.
aws-enumerator dashboardBy default every run goes to ~/.aws-enumerator/reports/<account>/<run_id>/. Exit code 2 means the run is
usable but incomplete: some modules were denied or failed, and manifest.json lists them.
aws-enumerator <subcommand> [options]
aws-enumerator --version | -h | --help
| Subcommand | Purpose | AWS credentials |
|---|---|---|
collect |
Enumerate an AWS account and analyse it | yes |
analyze |
(Re-)run the analysis of a run or a legacy report | no |
export |
Write findings or graph exports from analysis/graph.json | no |
merge |
Merge the analysed graphs of runs of different accounts (graph.json 1.1) | no |
dashboard |
Serve the dashboard locally | no |
preflight |
Check the permissions the selected modules need | yes |
permissions |
Print the IAM policy / Kubernetes RBAC the selected modules need | no |
modules |
List the collector registry | no |
package |
Zip (and optionally encrypt/sign) a run | no |
verify |
Check a run's digests | no |
version |
Print the version | no |
aws-enumerator <subcommand> --help lists every option, and docs/reference/cli.md has
the same reference, generated from the parser. Every subcommand also takes -q/--quiet,
-v/--verbose (-vv adds botocore debug output, which includes request parameters), --log-format text|json,
--no-color and --log-file PATH.
| Option | Default | Meaning |
|---|---|---|
--profile NAME |
env / default chain | AWS profile; one session and one credential resolution for the whole run |
--region R / --regions A,B / --all |
the profile's or environment's region | One region, a list, or every enabled region (ec2:DescribeRegions). There is no built-in default region: with none configured, the command exits 64 |
--exclude-regions A,B |
— | With --all only |
--modules A,@GROUP / --skip A,@GROUP |
every default module | Select collectors by name or group (aws-enumerator modules lists them) |
--max-tier 1 |
2 |
Drop the tier-2 modules (lambda, ecs, cloudformation, glue, codebuild), whose APIs return secret-bearing configuration. The collectors drop those values, but tier 1 does not call these APIs at all |
--no-k8s |
Kubernetes on | Do not query the Kubernetes API of EKS clusters |
--k8s-secrets |
off | List Secret metadata (names, never values). Note that list-secrets RBAC can read every value |
--output DIR |
$AWS_ENUMERATOR_OUTPUT, else ~/.aws-enumerator/reports |
Output root. An output inside a git work tree needs --allow-in-repo (exit 64 otherwise) |
--zip |
off | Write <output>/<account>/<run_id>.zip next to the run |
--encrypt age:RCPT|gpg:RCPT / --sign gpg:KEYID|minisign:KEY |
off | Encrypt or sign the zip with an external tool (both imply --zip) |
--redact-identity |
off | Drop the caller ARN and user id from the manifest and logs |
--preflight / --preflight-only |
off | Probe the selected modules first (and record the result), or only probe |
--resume RUN_DIR|RUN_ID |
— | Re-run the units of an existing run that did not finish cleanly |
--no-analysis |
analysis on | Collect only; run aws-enumerator analyze later |
--max-rps N, --rate-limit SVC=RPS, --concurrency N |
client-side rate limiter on, 3 units in parallel | Load control |
--ua-app-id STR / --no-ua-tag |
aws-enumerator/<version>/<run id tail> |
The user-agent app id (see OPSEC) |
Collector tuning flags (--iam-mode, --s3-subresources, --full-dns, --keep-sensitive-metadata,
--scp-file, ...) are listed by aws-enumerator collect --help.
aws-enumerator analyze ~/.aws-enumerator/reports/123456789012/20261015T101500Z-a1b2c3
aws-enumerator export ~/.aws-enumerator/reports/123456789012/20261015T101500Z-a1b2c3 --format sarif
aws-enumerator merge RUN_DIR_OF_ACCOUNT_A RUN_DIR_OF_ACCOUNT_B
aws-enumerator package RUN_DIR --encrypt age:RECIPIENT
aws-enumerator verify RUN_DIR_OR_ZIP
aws-enumerator permissions --format iam > aws-enumerator-policy.jsonexportformats aresarif,csv,md,asffandopengraph. Output goes to<run>/analysis/exports/by default, or to stdout with--output -.mergejoins the analysed graphs of runs of different accounts into<output>/merged/<run_id>/. The export formats,verify,packageand the dashboard all read the result.
| Code | Meaning |
|---|---|
0 |
complete: every selected module ok or skipped, analysis ok or skipped |
2 |
partial: some module partial, denied or failed, or the analysis was partial or failed; preflight found denials |
1 |
fatal: credentials or STS failed, unknown profile, output not writable, token expired during the run, every module failed; verify found a problem |
64 |
usage: bad or conflicting flags, unknown module, no home region, output inside a git work tree, not a report |
130 / 143 |
interrupted by SIGINT / SIGTERM; the manifest is finalised as interrupted first |
stdout carries only the result: the run root for collect, the URL for dashboard, the written path for
export, and so on. The banner, progress, warnings and the end-of-run summary go to stderr, so
run_root="$(aws-enumerator collect ...)" works.
Old invocations. aws-enumerator --region R, --all and --dashboard still work for one release. They print a
deprecation warning and run collect --region R, collect --all or dashboard.
Every edge in graph.json is stored in the attack direction: whoever controls the source gains something on
the target. Grants found in resource policies (bucket, key, secret, queue and similar policies) are normalised to
grantee → resource. Public grants start at a synthetic anonymous node. Path finding, in the dashboard and in the
engine's reference implementation, follows these rules:
-
Directed. It walks an edge only from source to target.
-
Traversable edges only. Each edge type has a traversal class. Only edges marked
traversableare walked:Class Meaning controlGains the target identity, or code execution as it. A path may continue from the target. accessReaches data or a data gate (read, write, decrypt, administer). Paths end at the target. exposureReachable from outside (anonymous or another account) without a credential in this account. structuralMembership, attachment, placement or an encryption dependency. Never an attack step. infoContext: data flow, impact-only actions, incomplete chains. Never an attack step. Two principals that share a group, a policy, a bucket or a cluster are therefore not connected to each other. Code running on a compute resource (an instance, function, task, pod and so on) can be entered only through a
controledge, such as updating its code or connecting to it. -
Weights. A path costs the sum of its edges' stored weights, and
0is a real cost of zero. For example,INSTANCE_ROLEandIS_ADMIN_EQUIVALENTcost 0,CAN_CONNECTcosts 0.5,CAN_ASSUMEcosts 1,CAN_PASS_ROLE_TOcosts 2 andSSRF_TO_IMDScosts 5. A conditional edge (a condition the evaluator could not resolve) costs +1 for a weak condition and +3 for a strong one. The full table of edge types, classes and weights is docs/contracts/traversability-table.md. -
Sources. The graph's default sources are the identity that ran the collection (the operator, taken from the manifest and mapped to its user or role, including assumed-role and SSO sessions) and the entry points: anyone on the internet, external accounts and principals, identity providers and nodes the analysis marked. The dashboard's Attack paths start from those and from the nodes you mark as owned.
-
Targets. The high-value nodes: the account's synthetic
account-adminnode and the admin-equivalent principals. A route ends at the first admin-equivalent principal it reaches, because that principal can already do everything. For a data asset (a bucket, secret, key, table …) open it in Explore to see who reaches it, or use Find a route…. -
Options. "Include conditional steps" decides whether conditional edges may be used. Explore's options (collapse the admin fan-out, hide policies and services, hide context edges) change only what is drawn.
Privilege escalation shows up as edges: CAN_PASS_ROLE_TO, CAN_TAKEOVER, CAN_ESCALATE and
IS_ADMIN_EQUIVALENT lead towards the account-admin node. A finding is attached to the node it is about, of
any type. The dashboard lists every finding, including ones it could not attach to a node.
docs/attack_paths.md is the field guide: starting positions, techniques, and what each edge and finding means.
-
One account per run.
aws-enumerator mergecombines analysed runs of different accounts by node id, so a path can continue into another collected account. Merge does not re-evaluate policies across accounts: each cross-account edge keeps the decision its own account's analysis made. -
Evaluator gaps are disclosed, never hidden. Every
graph.jsonlistsevaluation_gaps. These three are always present:- condition values known only at request time (source IP, MFA, request tags) are not evaluated, so such decisions are conditional;
- session policies are not modelled;
- the identity policies of external principals are not visible, so cross-account edges are conditional.
Others appear when they apply, for example
boundary:doc-missingorkms:key-policy-unknown. -
SCPs and RCPs are applied only when known. The
organizationsmodule can read them only where Organizations allows it (typically the management account or a delegated administrator), or you import them with--scp-file. Otherwise the gapsorg:scp-unknown/org:rcp-unknownsay that they were not applied. -
Only collected resource types exist in the graph. Escalation paths through services aws-enumerator does not collect (for example App Runner, Batch, Bedrock, CodeDeploy, Step Functions) appear at most as low-severity conditional notes.
-
A denied API is a gap, not an empty list. Coverage per module and per call is in
manifest.json,_coverage.jsonand the dashboard's coverage panel. A partial run exits 2. -
Not a compromise-assessment tool. It reads configuration, not activity.
-
Open untrusted reports only in an isolated browser profile. The dashboard renders report strings as text under a strict Content-Security-Policy, but a report from someone else is still hostile input. Serve the dashboard with
aws-enumerator dashboard; browsers refuse its integrity-checked files overfile://. -
Old report layouts (0.2.x
reports/<date>/<account>/...) are not loaded by the dashboard directly. Runaws-enumerator analyze <dir>once, then load the directory.
~/.aws-enumerator/reports/ 0700 ($AWS_ENUMERATOR_OUTPUT or --output)
<account_id>/
<run_id>/ 0700 e.g. 20261015T101500Z-a1b2c3 (UTC timestamp + 6 hex)
manifest.json run status, per-module coverage, options, operator, file digests
HANDLING.txt data-handling notice (written before any collection)
run.log JSON-lines log
SHA256SUMS written last; `shasum -a 256 -c SHA256SUMS` verifies the run
global/ global services, collected once per run
iam/ organizations/ s3/ cloudfront/ waf_cloudfront/ route53/
<region>/ one directory per collected region
vpc/ sg/ ec2/ cloudtrail/ flowlogs/ waf/ eks/ k8s/<cluster>/
secretsmanager/ ssm/ lambda/ kms/ ecs/ cloudformation/ glue/ codebuild/ sagemaker/
sns/ sqs/ ecr/ rds/ dynamodb/ apigateway/ cognito/ ebs/ elb/
guardduty/ securityhub/ config/ accessanalyzer/
analysis/ written once, after every region was collected
graph.json nodes, directed edges, findings, coverage (the dashboard's input)
findings.json summary.json always
diff.json only with --baseline
permission_map.json trust_relationships.json s3_relationships.json ec2_relationships.json
secrets_relationships.json lambda_relationships.json kms_relationships.json
compute_relationships.json messaging_relationships.json newsurface_relationships.json
extra_relationships.json k8s_relationships.json
0.2.x-format relationship files, kept for one release
exports/ `aws-enumerator export` output
<run_id>.zip 0600 with --zip or `aws-enumerator package`
merged/<run_id>/ `aws-enumerator merge` output
- Directories are
0700and files0600. - Every module that ran has a directory with a
_coverage.json, which records the outcome of each call asok,denied,error,throttledorskipped. A module that did not run has no directory. - Data files keep a stable shape: an unknown value is
null, neverfalseor[]. - The module docstrings in
aws_enumerator/collectors/document each module's files.
A run directory or zip is confidential. It is a pre-computed attack map: identities and their effective
permissions, trust relationships, network exposure, resource policies and logging posture. Every run carries
HANDLING.txt, and the manifest has a classification field. Read
docs/report-handling.md for:
- what a report contains and what is deliberately left out;
- file modes;
- encryption (
--encrypt,aws-enumerator package --encrypt) and signatures (--sign); - integrity checks (
aws-enumerator verify,SHA256SUMS); --redact-identity;- retention.
aws-enumerator permissions prints the least-privilege policy the selected modules need. It is built from the
actions each collector declares:
aws-enumerator permissions --format iam # IAM policy JSON
aws-enumerator permissions --format iam --guardrail # plus a Deny statement for data-plane reads
aws-enumerator permissions --format k8s # ClusterRole, aws-auth Role, bindings, access-entry example
aws-enumerator permissions --format markdown # the page docs/permissions.md is generated from
aws-enumerator preflight --region eu-west-2 --mode simulate # iam:SimulatePrincipalPolicy over every actionSelection flags apply to all of these (--modules, --skip, --max-tier, --no-k8s, --k8s-secrets).
--include-optional adds the modules' optional and fallback actions, plus the two actions that
preflight --mode simulate needs.
docs/permissions.md has the policy, the Kubernetes RBAC and the notes per module. The
default policy and ClusterRole are also checked in as
docs/reference/iam-policy.json and
docs/reference/k8s-clusterrole.yaml.
-
CloudTrail. The collectors make Describe/List/Get calls. CloudTrail records them as management events in the target account, but only on trails that log read events. docs/detection.md lists the calls per module, the few calls that are not plain reads, and the Kubernetes audit pattern.
-
User agent. Every AWS request carries
app/aws-enumerator-<version>-<last 6 characters of the run id>. This lets a SOC match the traffic to a run (manifest.jsonholds the run id).--ua-app-id STRsets your own value, and--no-ua-tagsends none. AnAWS_SDK_UA_APP_IDyou have already set is kept. Kubernetes requests carryaws-enumerator/<version>/<run id tail>as their User-Agent. -
Load. These keep the request rate predictable:
- a client-side rate limiter per service and region (
--max-rps,--rate-limit SVC=RPS;--no-rate-limitfor labs); - unit-level concurrency (
--concurrency, default 3); - your own retry settings (
AWS_RETRY_MODE/AWS_MAX_ATTEMPTS, or the profile); - a per-API deny breaker.
--resumere-runs only what did not finish.A scan uses the same shared, account-wide API quotas as every other caller in the account. AWS throttles control-plane calls per account: for Route 53 the quota covers the whole account, and for EC2
Describe*, ECS and Lambda it covers the account in each region. Deploy pipelines, autoscalers and controllers in a busy production account draw on the same quotas, so a scan can get their calls throttled. The limiter caps the tool's own rate from the first call and slows down when the tool is throttled, but it cannot see anyone else's traffic. Against a busy production account, run the scan off-peak, in a window agreed with the account owner, and with a lower rate:--max-rps 2caps every service, and--rate-limit route53=1slows one service further. The run'smanifest.jsonrecords the limiter settings underrun.options.rate_limit. See docs/detection.md for the default rates. - a client-side rate limiter per service and region (
-
Deconfliction. Agree the run id, the source IP and the role session name with the defenders. Use a session name that names the engagement when you assume a role.
These images are generated from a synthetic report: placeholder accounts and demo-* names only.
scripts/screenshots/capture.sh regenerates them offline with a local Google Chrome;
scripts/screenshots/README.md explains how, and has the checklist for any new doc
image.
Brief: the answer first. Who can reach the account administrator and in how many steps, what anyone on the internet reaches, what could detect you, and how complete the collection was.
Explore: one node's neighbourhood (who reaches it, what it reaches) on a snapping grid, with its details, findings, attached policies and trusted services in the drawer below.
Attack paths: every route from an entry point or your foothold to an administrator, one row per foothold. Selecting one opens its kill chain: the steps as cards, with the evidence and the findings on the path.
Map: the kill-chain map, the whole attack surface on one canvas. Ways in, footholds, escalation, the admin-equivalent principals, the account administrator and the data the ways in reach, in ATT&CK order, at most 150 cards (large groups collapse into one card). The key pivots beside it rank the principals the routes run through; hover one to light up its routes. From your foothold narrows it to what you already control reaches. Under More, the Audit is the operator's: where you would be seen (each detection control per region: watching, blind or unknown) and what the account exposes for you to use, by category and severity.
Two more pages list what the routes go through: Principals (every user, role, group and outside principal: admin-equivalent first, then who reaches admin, who can become each one and what it can become) and Data (secrets, keys, buckets, parameters, snapshots …: public or shared ones first, who can read each one). Routes and findings carry MITRE ATT&CK techniques and tactics, and Findings shows one row per rule.
| Document | What it covers |
|---|---|
| docs/attack_paths.md | Field guide: starting positions, techniques, edge types and finding ids |
| docs/permissions.md | IAM policy and Kubernetes RBAC per module (generated) |
| docs/detection.md | CloudTrail and Kubernetes audit footprint, user agent, deconfliction |
| docs/report-handling.md | Classification, contents, encryption, retention |
| docs/reference/ | Generated catalogues: CLI, finding ids, edge types, node types |
| docs/contracts/ | Report layout, manifest, graph.json schema, traversability table, CLI, exports |
| CHANGELOG.md | Changes per release, including the 0.3.0 breaking changes |
| CONTRIBUTING.md | Architecture, adding a service, tests, project rules |
| SECURITY.md | Reporting vulnerabilities, the read-only design, the dashboard threat model |
- Product and command name:
aws-enumerator. Import package:aws_enumerator. Version:0.3.0. - To report a vulnerability, see SECURITY.md. Please do not open a public issue.
- Licence: MIT (LICENSE).



