Skip to content
OpenSystemsLabPublic
forked from theazz/awless-ro

About

Read-only AWS CLI: readable listings, resources by name, relations between them, optional offline graph. Can only Describe/Get/List/Head — enforced by a test. Maintained fork of wallix/awless.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

awless-ro

A command line tool for looking at an AWS account: readable tables instead of JSON, resources by name instead of by id, how they relate to each other, and output you can pipe — JSON from every command, when a program is the one reading. Optionally, a local copy of the account to explore offline.

It cannot change anything. Not by convention: every AWS operation it is capable of calling is a Describe, Get, List or Head, and a test enforces that.

awless-ro listing instances and databases, showing a database and an instance with their relations, completing a name with Tab, scanning security groups, and connecting with ssh to a private instance through a jump host

Why this exists

wallix/awless was a genuinely good CLI, and it has been unmaintained since 2018. It no longer builds: dep instead of modules, an old Go, AWS SDK v1, and a vendor directory full of libraries that have since moved on or been abandoned.

Its two halves aged very differently.

The write half — a templating language, hundreds of create/delete one-liners, a log, revert, a scheduler — is the part you least want to run unmaintained against a live account. Six years of AWS API drift sits between that code and reality, and the failure mode is not a wrong listing, it is a wrong change. It was also most of the maintenance burden.

The read half aged well, and still has no close equivalent. aws ec2 describe-instances gives you JSON about instances; it does not give you a graph where an instance knows its subnet, its VPC, its security groups and what those apply to, queryable offline. Nor does it let you say show my-database instead of pasting an identifier.

So this fork keeps the read half, modernises it, and makes read-only a property of the tool rather than a promise in a README.

That turns out to be the interesting part. A tool that provably cannot write is one you can point at production, hand to someone new, or run in an account you are nervous about — and the guarantee does not depend on how carefully you typed.

What it is good for

Looking up what is in an AWS account from the terminal, instead of clicking through the console: what runs where, how it is configured, and what it is connected to. It uses the AWS profiles you already have.

It works in two ways:

  • Straight from the AWS API. list, search, whoami and tail ask AWS each time, so the answer is current.
  • From a synced local copy. show and inspect work on a graph of the account, which is where the extra information comes from: which subnet and VPC an instance is in, what a security group applies to, what depends on what. They refresh the copy themselves before answering; awless-ro sync fetches the whole account at once.

list, show and inspect also take --local: they then answer from the local copy as it is, without calling AWS — instantly, and offline.

"What is running here, and since when?" A table you can read, sorted and filtered, instead of a page of describe-instances JSON.

awless-ro list instances --sort uptime
awless-ro list instances --columns name,state,type,lifecycle,uptime   # which are spot
awless-ro list instances --filter state=running --filter type=t3
awless-ro list instances --tag Env=Production,Team=Payments
awless-ro list databases -r eu-west-1

"What is this thing, and what is it connected to?" Ask by name. An instance comes with its subnet and VPC, its security groups and key pair, and the volumes attached to it; a security group with everything it applies to; a user with their groups and policies.

awless-ro show web-1
awless-ro show i-0123456789abcdef0
awless-ro show jsmith
awless-ro show sg-0a1b2c3d --siblings

"Is anything left lying around?" Detached volumes, old snapshots, access keys nobody rotated, elastic IPs attached to nothing.

awless-ro list volumes --filter state=available
awless-ro list snapshots --sort created
awless-ro list accesskeys --sort created
awless-ro list elasticips

"What is exposed?" Every security group with the ports it opens and what it is attached to; buckets readable by anyone, or by anyone with an AWS account.

awless-ro inspect -i port_scanner
awless-ro inspect -i open_buckets

"Which AMI should I use?" The current official image for a distribution in this region, from the vendor's own account — not from whoever named an image ubuntu-latest.

awless-ro search images canonical:ubuntu:noble --latest-id
awless-ro search images debian:::arm64
awless-ro search images amazonlinux

"Get me onto that box." SSH to an instance by its name, through a jump host if it is private. The address, the key and the login user come from AWS, so nothing has to be kept in ~/.ssh/config.

awless-ro ssh web-1
awless-ro ssh db-1 --through bastion-1
awless-ro ssh db-1 --through bastion-1 --print-cli    # see the ssh command first

"Who am I in this account?" Before doing anything, check which identity, account and policies the current profile resolves to.

awless-ro whoami
awless-ro whoami --account-only

"What is this stack doing right now?" Follow CloudFormation events or autoscaling activity as they happen.

awless-ro tail stack-events my-stack --follow
awless-ro tail scaling-activities --follow

Scripts and spreadsheets. Every listing can be CSV, TSV, JSON or bare ids, show can print a single value, and every command answers in JSON with --format json (see JSON output).

awless-ro list users --format csv > users.csv
awless-ro list instances --filter state=stopped --ids
ssh ec2-user@$(awless-ro show web-1 --values-for privateip)
awless-ro show web-1 --format json | jq '.parents[].id'

Offline, or on a slow link. Sync once, and list, show and inspect answer instantly from the local copy with --local, without an AWS call — on a plane, behind a VPN that keeps dropping, or when you want to go through an account at your own pace.

awless-ro sync
awless-ro list instances --local
awless-ro show web-1 --local

Several accounts and regions. Your ~/.aws profiles work as they are; switch once, or override a single command.

awless-ro switch staging eu-west-1
awless-ro list instances -p production -r us-east-1

Handing someone a tool. Read-only is a property of the binary (see Security checks), so it is a safe thing to give a new teammate, an auditor, or an on-call engineer who needs to look around production at 3 am.

Security checks

In place now

  • Read-only by construction. Every AWS call goes through a narrow per-service interface and nothing else in the tree holds an SDK client, so the methods on those interfaces are the complete list of operations the binary can perform — currently 61 across 18 services. TestEveryAWSOperationIsARead reflects over them and fails the build if any name is not a Describe, Get, List or Head, or if a field is a concrete client rather than an interface. Reflecting over the struct rather than a hand-written list means a service added later is covered without anyone remembering to.

  • No third-party egress. The binary talks to AWS and nothing else. The upstream pricer inspector, which posted an EC2 inventory to an external host, is gone; the public-IP lookup in whoami uses HTTPS.

  • Secrets stay put. show does not print instance UserData; secrets are read without echo and never logged; state under ~/.awless-ro is 0700, files in it 0600, and nothing executable is written to a shared directory.

  • Audited SSH and key handling. The shared-/tmp script behind ProxyCommand was removed, deprecated unauthenticated PEM decryption replaced, key files with loose permissions refused, and key names from AWS data can no longer escape the keys directory.

  • AMI search pinned to publishing accounts, so a look-alike image name cannot be substituted (whoAMI).

  • CI on every push and pull request: gofmt, go vet, go test -race, go mod verify against go.sum, govulncheck for known vulnerabilities in reachable code, and a check that generated code matches its definitions.

  • Dependency review on pull requests, blocking a new dependency that arrives with a known advisory or a copyleft licence. govulncheck answers a different question — whether vulnerable code is reachable — and misses a bad dependency nothing calls yet.

  • A weekly scan independent of any commit, because an advisory published after the last push would otherwise go unnoticed for as long as the repository is quiet.

  • Releases built by GitHub Actions from the tag, not on a workstation, re-running the test and vulnerability gates first — a tag can be pushed to a commit that never passed CI — and verifying the published checksums against the published files.

  • Supply chain: GitHub Actions pinned by commit SHA, workflow token read-only, Dependabot updates for Go modules and actions, GitHub secret scanning with push protection, Dependabot security updates, and a SHA256SUMS file with every release.

  • The workflows are linted, because they are the only code here with no compiler and no tests, and they are what holds the SHA pins and the least-privilege tokens.

Planned, to run automatically

  • Static analysis (CodeQL) on every pull request.
  • zizmor to audit the workflows for template injection and over-broad permissions, which linting does not look for.
  • OpenSSF Scorecard for the repository's own security posture.
  • Build provenance attestations and an SBOM attached to every release.

Found something exploitable? Please report it through Security → Report a vulnerability, which is private between you and the maintainer, rather than in a public issue.

Install

With Homebrew, on macOS or Linux:

brew install theazz/tap/awless-ro

The formula installs the binary published with each release, checked against its SHA-256, and shell completion. Upgrade with brew upgrade awless-ro.

With Go:

go install github.com/theazz/awless-ro@latest

Or download a binary from releases and verify it:

Platform Archive
macOS, Apple Silicon awless-ro-darwin-arm64.tar.gz
Linux, arm64 (Graviton and other 64-bit ARM) awless-ro-linux-arm64.tar.gz
macOS, Intel awless-ro-darwin-amd64.tar.gz
Linux, x86-64 awless-ro-linux-amd64.tar.gz
Windows, x86-64 awless-ro-windows-amd64.zip
shasum -a 256 -c SHA256SUMS --ignore-missing

Or build from source:

git clone https://github.com/theazz/awless-ro && cd awless-ro
go build -o awless-ro .

make build builds for arm64 by default; make build GOARCH=amd64 for an Intel machine.

Requires Go 1.26. No configuration needed if you already use the AWS CLI: existing ~/.aws/{credentials,config} profiles are picked up, and anything missing is prompted for on first run. With no profile chosen, credentials in AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are used, as are container and instance roles — the AWS SDK's own order — so nothing needs configuring in CI.

State lives in ~/.awless-ro — deliberately not ~/.awless, so an installed upstream awless and this tool cannot overwrite each other's graph, database or keys.

Shell completion

Installed with Homebrew, the completion files are already in place:

  • fish picks them up on its own.
  • zsh and bash read them only if the shell is set up for Homebrew's completions, once per machine. If Tab already completes brew itself, it is; otherwise follow Homebrew's shell completion guide (for zsh, a few lines in ~/.zshrc; for bash, the bash-completion@2 package).

Open a new shell afterwards.

Installed any other way, add it to your shell yourself:

# bash (needs the bash-completion package; eval rather than source <(...),
# which the bash 3.2 shipped with macOS does not support)
echo 'eval "$(awless-ro completion bash)"' >> ~/.bashrc

# zsh
awless-ro completion zsh > "${fpath[1]}/_awless-ro"

# fish
awless-ro completion fish > ~/.config/fish/completions/awless-ro.fish

Completion covers commands and flags, regions and profiles, config keys and their values, and — from the local copy, once you have synced — resource ids and names for show. It never calls AWS: a Tab press does not wait on the network.

Commands

list — 50 resource types across EC2, IAM, S3, RDS, AutoScaling, SNS, SQS, Route53, CloudWatch, CloudWatch Logs, CloudFormation, Lambda, ECS, ECR, ELB, CloudFront and ACM, fetched from AWS when you ask (awless-ro list -h shows them all; ls is an alias).

awless-ro list instances --filter state=running --sort uptime
awless-ro list instances --columns name,state,architecture,lifecycle
awless-ro list volumes --tag-key Dept --format tsv
awless-ro list users --format json

--filter matches any column, case-insensitively, in one of two forms:

Form Matches Example
key=value any column containing the value --filter type=t3 finds t3.micro and t3.large
key==value the whole column value --filter state==Active finds Active and not Inactive

The substring form cannot exclude a longer value that contains the one you asked for, which is what the exact form is for:

awless-ro list accesskeys --filter state==Active   # only the active keys
awless-ro list accesskeys --filter state=active    # also the Inactive ones: "inactive" contains "active"

Both forms compare the value as it is stored rather than the cell as it is printed, so a date column is matched on its full timestamp and not on the 2 days the table shows.

--tag, --tag-key and --tag-value match tags. --columns reaches any property a resource carries, not only the default columns. --format covers table, csv, tsv, json and porcelain; --ids prints one id per line and nothing else, for scripts. -r and -p override the region and profile for one command.

Tables fit the terminal: long values wrap only as much as the width requires, and columns that still do not fit are dropped from the right, with a note saying which. When the output goes to a pipe or a file there is no width to fit, so tables are not wrapped at all: one row per line, values whole, as ps or kubectl get do. --max-width N sets the width explicitly, on a terminal or not, and --max-width 0 turns the limit off; show takes it too. It has no effect on the other formats.

--tag, --tag-key and --tag-value take several values separated by commas (--tag Env=Production,Team=Payments), so a value that itself contains a comma is quoted twice: double quotes for awless-ro, single quotes around them for the shell (bash, zsh and fish alike).

awless-ro list instances --tag '"Environment=Not, tagged"'
awless-ro list instances --tag-value '"Not, tagged"'

Without them, --tag 'Environment=Not, tagged' is read as two tags and refused, and a backslash does not escape the comma.

show — one resource by id, by name, or by @name, with its relations. Names are not unique in AWS, so an ambiguous one lists the candidates.

awless-ro show i-0123456789abcdef0
awless-ro show my-database
awless-ro show web-1 --values-for publicip,keypair

Output is the resource's properties, then its lineage (parents and children), then what it applies to, depends on, and its siblings. --format json gives the same as one object; see JSON output.

search images — official AMIs by vendor, pinned to the publishing account, resolving to the vendor's ordinary image rather than a specialised build.

awless-ro search images canonical --latest-id
awless-ro search images redhat::9
awless-ro search images --owner 123456789012 --name 'my-base-*'

inspect and tail.

awless-ro inspect -i port_scanner     # security groups opening ports, and to what
awless-ro inspect -i open_buckets
awless-ro inspect -i bucket_sizer
awless-ro tail stack-events my-stack --follow

whoami and switch — the identity behind the current profile, and a persistent change of region or profile.

awless-ro whoami
awless-ro switch eu-west-1
awless-ro switch my-profile eu-west-1

ssh — connect to an instance by name, id or IP.

# what would run, without connecting
awless-ro ssh bastion-1 --print-cli
awless-ro ssh db-1 --through bastion-1 --print-cli

# directly to an instance
awless-ro ssh bastion-1                               # over its public IP
awless-ro ssh bastion-1 --private                     # over its private IP (VPN, Direct Connect...)

# to a private instance through a jump host
awless-ro ssh db-1 --through bastion-1
awless-ro ssh db-1 --through bastion-1 --private      # reach the jump host over its private IP

# the same from the synced graph, without calling AWS
awless-ro ssh db-1 --through bastion-1 --local

# another AWS profile
awless-ro -p my-profile ssh db-1 --through bastion-1

# details: which key was found and why
awless-ro -v ssh bastion-1

# force the user and the key
awless-ro ssh ubuntu@web-1 -i ~/.ssh/other-key.pem    # -i takes a path or a key name

# keep a stanza in ~/.ssh/config
awless-ro ssh db-1 --through bastion-1 --print-config >> ~/.ssh/config

Names are the instances' Name tags in AWS, not the Host aliases of your ~/.ssh/config. With --through, the target is always reached on its private IP via the jump host; --private decides how the jump host itself is reached.

awless-ro resolves the address, key and user, checks the connection with its own client, and then hands over to your ssh: ssh -i <key> <user>@<ip>, plus a ProxyCommand for the jump host. That ssh reads ~/.ssh/config as usual, but since it is given an IP, only Host *, IP patterns and Match blocks apply; the options awless-ro passes on the command line take precedence.

Keys are looked up first by the instance's key pair name in ~/.awless-ro/keys and then ~/.ssh, with and without .pem. When no file is named like the key pair, the key is found by matching the fingerprint AWS recorded for the key pair against the keys in those two folders, so a key under any name is used (this needs a synced graph; an encrypted key is matched through its .pub; a key readable by others is refused by this search, as openssh would). Host keys are checked against ~/.ssh/known_hosts (or ~/.awless-ro/known_hosts); an unknown host is asked about once on a terminal, and refused with an explanation when there is no terminal. The login user comes from the instance's image when the image is in the synced graph; otherwise ec2-user, ubuntu and the other usual users are tried in turn, each announced on stderr (trying user ubuntu...), and a hint to give the user with USER@ is printed if none is accepted. Each wrong user is a failed login in the host's auth log, so once you know the user, pass it as USER@, especially on hosts behind fail2ban. --print-cli / --print-config say which user they guessed and never open a connection.

Aliases. Reference a resource by its Name tag anywhere an id is accepted.

JSON output

--format json works with every command but ssh, and prints JSON on stdout and nothing else: progress, warnings and prompts (a first-run region, an MFA code) go to stderr, with or without --silent. A command asked for a format it cannot print, such as whoami --format csv, refuses before doing anything; list alone also takes csv, tsv and porcelain.

Command Prints
list <type> an array of resources, each an object of its properties; --ids makes it an array of ids
list <service> an object of arrays, keyed by resource type ("instances"); with --ids, arrays of ids
show {"type", "properties", "parents", "children", "appliedOn", "dependingOn"}, plus "siblings" with --siblings; --values-for gives an object of the properties asked for
whoami {"account", "arn", "userId", "resource", "resourceType", "resourcePath", "root", "policies"}; policies only for an IAM user whose policies could be read; --*-only gives a one-key object
inspect an array of rows, one object per bucket or security group
tail one object per event, one per line (newline-delimited JSON), oldest first, so --follow streams
config an object of every key and its value; --keys an array; get, set, unset give {"key", "value"}
switch {"profile", "region"}
sync {"profile", "region", "services", "errors"}: per service the count of each resource type, the types left off, and an "error" when it could not be read in full; exits 1 when nothing could be synced
version {"version", "commit", "buildDate", "os", "arch", "buildFor"}, the build fields only when set
search images an array of images, as without --format (it has always printed JSON); --latest-id gives {"id"}

A resource is the same object from list and from show (under "properties"): property names as in --columns, numbers as numbers, dates in RFC 3339, CIDR blocks as text. Related resources are references, {"id", "type", "name"}; ask show for any of them. A command with no results prints [] or {}, never null (inside a firewall rule or a route, an absent list is still null, as it has been since v0.3.0), and the same graph prints the same bytes on every run.

awless-ro list instances --format json | jq -r '.[] | select(.State == "running") | .ID'
awless-ro show my-database --format json | jq '.dependingOn'
awless-ro tail stack-events my-stack --follow --format json | jq -r .status

Offline mode (optional)

awless-ro sync fetches every supported service in parallel and stores the result as a local graph under ~/.awless-ro. After that, list, show, inspect and ssh with --local answer from that copy without calling AWS. A service you lack permission for is reported and skipped; the rest still land.

-> infra: 12 instances, 2 vpcs, 9 securitygroups, 14 networkinterfaces, ...
-> access: 5 users, 40 roles, 61 policies, 4 instanceprofiles, ...
-> storage: 7 buckets, s3objects: off (aws.storage.s3object.sync)

Two resource types are off by default because they cost an API call per parent — one per bucket, one per hosted zone. The sync says so rather than reporting zero of them.

With S3 object sync on, the sync keeps the number and total size of the objects in each bucket, which is what inspect -i bucket_sizer reads, and not the objects themselves, so memory stays flat however much the buckets hold. To keep every object too, for list s3objects --local on small buckets, set aws.storage.s3object.keep true. list s3objects without --local asks AWS directly either way.

You rarely need to run it by hand. show and inspect refresh the copy on their own for the services they touch, and changing region with switch syncs the new one once. The first run does not sync. --no-sync skips that for one command, and awless-ro config set autosync false turns it off for good.

What was removed

Gone, and not coming back:

  • the templating language and .aws template files
  • every create, update, delete, attach, detach, start, stop command
  • awless log and awless revert
  • the scheduler integration (wallix/awless-scheduler)
  • awless web and the embedded HTTP server
  • the pricer inspector, which posted an inventory of your EC2 instances to a third-party host whose domain was not registered when this fork was made

Differences you will notice if you used awless

  • Binary and state directory are awless-ro / ~/.awless-ro.
  • Version restarts at v0.1.0. Continuing upstream's v0.1.11 would imply a compatibility that does not exist.
  • Failures exit non-zero. Upstream discarded the error from the root command, so everything exited 0.
  • --ids prints ids only. It used to print each resource's name as well, on its own line, which a script cannot tell apart from an id.
  • AMI vendor coreos and centos are gone: CoreOS Container Linux reached end of life in 2020, and the account publishing CentOS Stream images could not be verified from a source belonging to the project or to AWS. Shipping an unverified account id is the mistake the whoAMI attack relies on.

The full accounting is in CHANGELOG.md.

Status and caveats

The tool is checked against live AWS accounts, which is where the defects that matter turn up: unit tests verify that the code does what it was meant to, and the live checks verify that what it was meant to do matches how AWS actually behaves. Nine defects were found that way, and none of them was visible to a test. If you point it at your account and something looks wrong, that finding is useful: please open an issue.

go test ./... -race is green and no test needs AWS credentials.

Versioning

Releases follow Semantic Versioning 2.0.0 and are tagged vMAJOR.MINOR.PATCH. The public API is the command line: commands, flags, output formats (csv, tsv, json, porcelain, --ids) and exit codes.

  • PATCH — bug fixes that do not change documented behaviour.
  • MINOR — new commands, flags, resource types or services, backwards compatible.
  • MAJOR — anything that breaks a documented command, flag or output format.

While the version is 0.y.z, a breaking change bumps the minor version instead, as SemVer allows. Every release has an entry in CHANGELOG.md.

Contributing

Feature requests and pull requests with improvements are very welcome — open an issue to suggest something or to discuss a change before writing it. Bugs get fixed as time allows, on a best-effort basis. The one thing that will not be accepted is anything that makes the tool write: see the scope section in CONTRIBUTING.

CONTRIBUTING.md covers the layout, how code generation works, and the supply-chain rules for dependencies.

Licence and provenance

Apache 2.0, inherited from wallix/awless — copyright and licence retained, see NOTICE. This is a fork of wallix/awless at commit 44e892b4, and its history is preserved in full in this repository.

About

Read-only AWS CLI: readable listings, resources by name, relations between them, optional offline graph. Can only Describe/Get/List/Head — enforced by a test. Maintained fork of wallix/awless.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages