sar-aws-barnacle
A read-only linter for AWS account waste. Scans for resources you are paying for and not using, prints what they cost you, and never touches a thing.
The problem
Cloud waste is boring, invisible, and continuous. An EBS volume detached during a migration two years ago still bills every hour. A developer's stopped test instance keeps its 200 GiB root volume forever. Nobody notices, because nothing is broken — the bill just quietly grows.
AWS Cost Explorer tells you what you spent. It does not tell you which specific resources were pointless. Existing cleanup tools mostly solve the opposite problem: they delete things, which means nobody is allowed to run them against production.
sar-aws-barnacle sits in the gap. It answers one question — "what am I paying for that nothing is using?" — and answers it with resource IDs, dollar figures, and an exit code you can wire into CI. It has no delete path at all.
Demo
$ sar-aws-barnacle scan --region ap-south-1
sar-aws-barnacle scan · 1 region(s) · account 123456789012
Regions
┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓
┃ Region ┃ ebs-unattached ┃ ebs-unencrypted ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩
│ ap-south-1 │ 2 finding(s) │ clean │
└────────────┴────────────────┴─────────────────┘
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Severity ┃ Check ┃ Region ┃ Resource ┃ Monthly ┃ Detail ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ HIGH │ ebs-unattached│ ap-south-1 │ vol-0a1b2c3d4e5f67890 │ $91.20 │ 1000 GiB gp3 volume in state │
│ │ │ │ │ │ 'available', created 412 days ago│
│ MEDIUM │ ebs-unattached│ ap-south-1 │ vol-0987654321fedcba0 │ $9.12 │ 100 GiB gp3 volume in state │
│ │ │ │ │ │ 'available', created 63 days ago │
└──────────┴───────────────┴────────────┴───────────────────────┴─────────┴──────────────────────────────────┘
Estimated monthly waste: $100.32 ($1,203.84/year)
2 finding(s) across 2 check(s) in 0.84s
Prices: AWS Pricing API (cached)
Install
pipx install sar-aws-barnacle # recommended: isolated, on your PATH
# or
pip install sar-aws-barnacle
The sar-aws-barnacle command is installed on your PATH.
Usage
# Scan your default region using your default credentials
sar-aws-barnacle scan
# A specific region and profile
sar-aws-barnacle scan --region ap-south-1 --profile prod-readonly
# Several regions at once
sar-aws-barnacle scan -r ap-south-1 -r eu-west-1 -r us-east-1
# Every region enabled on the account
sar-aws-barnacle scan --all-regions
# Machine-readable, for pipelines and dashboards
sar-aws-barnacle scan --output json > findings.json
# Only certain checks
sar-aws-barnacle scan --check ebs-unattached
# Hide small change
sar-aws-barnacle scan --min-savings 10
# No network calls for pricing — bundled table only
sar-aws-barnacle scan --no-live-pricing
# What checks exist, and what permissions do they need?
sar-aws-barnacle checks
sar-aws-barnacle iam-policy
Every report opens with a Regions table: one row per region, one column per check, so the whole scan's coverage reads as a single grid — scanned-and-clean included, not just cells with findings, so you can tell "checked, found nothing" apart from "never looked," per check. This is fully automatic: a new check appears as a new column the moment it's registered, with no changes anywhere else. A region passed to -r that isn't actually enabled for the account is skipped before the scan starts rather than attempted and left to fail — it's reported separately as Skipped (not enabled for this account): ... rather than as blank cells, since it was never scanned at all. --all-regions already only ever considers enabled regions, so nothing is skipped there.
Exit codes
Exit codes are a stable interface; changing them is a breaking change.
| Code | Meaning |
|---|---|
0 |
Scan completed; nothing tripped the --fail-on threshold |
1 |
Findings met or exceeded --fail-on |
2 |
One or more checks failed — results are partial |
3 |
Usage error |
By default (--fail-on none) findings are informational and only real errors fail the command. In CI, pick a threshold:
- name: Check for AWS waste
run: sar-aws-barnacle scan --all-regions --fail-on high
Note that errors outrank findings. A scan that could not complete is an unanswered question, not a clean bill of health.
IAM policy
sar-aws-barnacle needs read access and nothing else. This policy is generated from the code — every check declares the API calls it makes, and CI fails if this file drifts from what the code actually calls. Regenerate the exact policy for your selection of checks with sar-aws-barnacle iam-policy.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SarAwsBarnacleReadOnlyScan",
"Effect": "Allow",
"Action": [
"ec2:DescribeRegions",
"ec2:DescribeVolumes",
"sts:GetCallerIdentity"
],
"Resource": "*"
}
]
}
Add pricing:GetProducts if you want live pricing. Without it, sar-aws-barnacle falls back to its bundled price table and says so in the report footer.
If you would rather trust nothing: AWS's own ReadOnlyAccess managed policy is a superset of the above.
Set up read-only access
The policy above is generated, not created — you still need to turn it into something --profile can point at. This only needs whatever access your own AWS credentials already have; sar-aws-barnacle itself never touches IAM.
1. Save the policy and create it
sar-aws-barnacle iam-policy > sar-aws-barnacle-policy.json
aws iam create-policy \
--policy-name sar-aws-barnacle-read-only-scan \
--policy-document file://sar-aws-barnacle-policy.json
2. Attach it to a user or role
A dedicated IAM user with programmatic access is the simplest path for a laptop or a CI runner:
aws iam create-user --user-name sar-aws-barnacle-scanner
aws iam attach-user-policy \
--user-name sar-aws-barnacle-scanner \
--policy-arn arn:aws:iam::<ACCOUNT_ID>:policy/sar-aws-barnacle-read-only-scan
Prefer a role instead if you're scanning from an EC2 instance, a CI job with OIDC, or assuming into other accounts — attach the same policy to the role and skip the rest of this section entirely.
3. Create the access key and wire it straight into a profile
Piping the key from create-access-key into aws configure set means the secret only ever lives in a shell variable, never a file on disk:
CREDS=$(aws iam create-access-key --user-name sar-aws-barnacle-scanner --output json)
aws configure set aws_access_key_id "$(jq -r .AccessKey.AccessKeyId <<<"$CREDS")" \
--profile sar-aws-barnacle-scanner
aws configure set aws_secret_access_key "$(jq -r .AccessKey.SecretAccessKey <<<"$CREDS")" \
--profile sar-aws-barnacle-scanner
aws configure set region ap-south-1 --profile sar-aws-barnacle-scanner
unset CREDS
Requires jq. Without it, fall back to aws configure --profile sar-aws-barnacle-scanner and paste the AccessKeyId / SecretAccessKey from the create-access-key output by hand.
4. Scan with it
sar-aws-barnacle scan --profile sar-aws-barnacle-scanner --region ap-south-1
--profile is actually optional here: if a profile named sar-aws-barnacle-scanner exists in your AWS config, sar-aws-barnacle scan uses it automatically when you don't pass --profile at all. This is a fallback, not a requirement — it only ever applies when that exact profile is present, so it changes nothing for default credentials, an instance role, or CI OIDC.
If this was a one-off audit, clean up afterwards — aws iam delete-access-key and aws iam delete-user (after detaching the policy). Read-only access has nothing to steal, but an unused access key is still a credential not worth leaving around.
Configuration
Optional sar-aws-barnacle.toml, discovered from the working directory upwards. Precedence, highest first: CLI flags → SAR_AWS_BARNACLE_* env vars → config file → defaults.
Create one
# Project-local: only applies when you run the CLI from this directory or below
cp sar-aws-barnacle.toml.example sar-aws-barnacle.toml
# Or a personal default: found no matter which directory you scan from, as
# long as it's somewhere under your home directory (the leading dot is required)
cp sar-aws-barnacle.toml.example ~/.sar-aws-barnacle.toml
Then edit the values you want to change — anything you leave out falls back to its default. sar-aws-barnacle.toml.example is a committed template only; the CLI never reads that file directly, so copying it is required, not optional decoration.
Top-level settings
| Setting | TOML key | Env var | CLI flag | Default | What it does |
|---|---|---|---|---|---|
| Regions | regions |
SAR_AWS_BARNACLE_REGIONS (comma-separated) |
--region / -r |
none — falls back to your profile's default region | Which regions to scan |
| All regions | all_regions |
— | --all-regions |
false |
Scan every region enabled on the account; overrides regions |
| Profile | profile |
SAR_AWS_BARNACLE_PROFILE |
--profile |
sar-aws-barnacle-scanner if configured, else default AWS credentials |
Named AWS CLI profile to authenticate with |
| Output | output |
SAR_AWS_BARNACLE_OUTPUT |
--output / -o |
table |
table or json |
| Fail on | fail_on |
SAR_AWS_BARNACLE_FAIL_ON |
--fail-on |
none |
Minimum severity that exits 1: none, any, low, medium, high |
| Minimum savings | min_monthly_savings |
— | --min-savings |
0 |
Hide findings cheaper than this per month. Unpriced findings are always kept — see Cost honesty |
| Live pricing | live_pricing |
SAR_AWS_BARNACLE_LIVE_PRICING |
--live-pricing / --no-live-pricing |
true |
Query the AWS Pricing API (cached 30 days) vs. the bundled seed table only |
| Max workers | max_workers |
— | — | 8 |
Parallel (check × region) units in the thread pool |
| Checks to run | checks_enabled (array, top-level) |
— | --check / -c |
all registered checks | Restrict the scan to specific check ids |
Two settings have no config-file or env var form at all, CLI flag only: --refresh-prices (bust the price cache) and the pricing cache directory itself, which follows the standard XDG_CACHE_HOME env var and defaults to ~/.cache/sar-aws-barnacle.
Per-check tunables
Each check can expose its own options in a [sar-aws-barnacle.checks.<check-id>] table — note this is a different key (checks, nested) from the top-level checks_enabled array above; one selects which checks run, the other tunes a check that's already running. Unknown keys are ignored, so a config written for an older version stays valid.
For ebs-unattached:
| Key | Default | What it does |
|---|---|---|
high_severity_usd |
20 |
Findings at or above this monthly cost are HIGH instead of MEDIUM |
min_age_days |
0 |
Ignore volumes created more recently than this — useful to avoid flagging churn during an active migration |
[sar-aws-barnacle]
regions = ["ap-south-1", "eu-west-1"]
output = "table"
fail_on = "high"
min_monthly_savings = 5
[sar-aws-barnacle.checks.ebs-unattached]
high_severity_usd = 50
min_age_days = 7
Architecture
cli.py Typer adapter. Parses flags, renders output. No logic.
└── runner.py Fans out (check × region) across a thread pool, isolates failures
├── registry.py Discovers checks; validates their metadata at import
├── context.py The single object a check receives
├── checks/ One file per check. Adding one touches nothing else.
├── pricing/ PriceBook protocol: live API → disk cache → bundled seed
└── output/ Renderer protocol: table, json
Four decisions worth calling out:
Read-only is enforced, not promised. A botocore before-call hook inspects every operation before it goes over the wire and raises on anything that is not a Describe/Get/List. A check that tried to delete something would fail the test suite, not somebody's production account.
Costs carry a confidence level. EXACT, LOWER_BOUND, UPPER_BOUND, or UNKNOWN. This is not decoration. EBS snapshots bill on incremental consumed storage, but the API only reports the source volume's size — so any tool reporting an exact snapshot cost is guessing. An unpriceable resource reports None, never $0.00, and is excluded from the savings total rather than silently deflating it.
Failures degrade the scan, they do not end it. A missing rds:DescribeDBSnapshots permission costs you one check, not the whole report. Errors are collected, rendered, and reflected in exit code 2.
Adding a check touches only two files. A check is a class with five class attributes and a run(ctx) generator. Drop it in checks/, add its test, done — no import list, no registry edit, no CLI change. Third-party packages can ship checks via the sar_aws_barnacle.checks entry-point group.
Adding a check
from sar_aws_barnacle.registry import register
from sar_aws_barnacle.models import Finding, Scope, Severity
@register
class UnassociatedElasticIps:
id = "eip-unassociated"
title = "Unassociated Elastic IPs"
service = "ec2"
scope = Scope.REGIONAL
required_actions = frozenset({"ec2:DescribeAddresses"})
def run(self, ctx):
for address in ctx.client().describe_addresses()["Addresses"]:
if address.get("AssociationId"):
continue
yield Finding(...)
Then run python scripts/generate_iam_policy.py so the published policy covers the new permission.
Roadmap
v0.1 — shipped
- Core engine, registry, read-only guard, pricing, table + JSON output
-
ebs-unattached -
ebs-unencrypted— security/hygiene, not cost-waste; seedocs/DECISIONS.md§3
v0.2 — in progress
-
eip-unassociated— unassociated Elastic IPs -
ec2-stopped— instances stopped longer than N days -
ebs-snapshot-age/rds-snapshot-age— ageing snapshots -
iam-stale-keys— old and never-used access keys
v0.3 — designed, not started
-
ec2-low-cpu— idle running instances via CloudWatch -
sg-unused— security groups attached to no ENI and referenced by no rule -
--fixbehind an explicit opt-in, with a plan/apply split
Considered and rejected for now
- RDS reservation recommendations. Not a hygiene check — it needs weeks of usage history and Cost Explorer's paid API, which does not fit a stateless single-pass scanner. See
docs/DECISIONS.md.
Contributing
See CONTRIBUTING.md. New checks are very welcome; the bar is a test that proves the detection logic and an honest cost confidence level.
Found a security issue? See SECURITY.md — please don't open a public issue for it.
License
MIT — see LICENSE.
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 sar_aws_barnacle-0.1.0.tar.gz.
File metadata
- Download URL: sar_aws_barnacle-0.1.0.tar.gz
- Upload date:
- Size: 62.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
217f097405ccaf1502c227b53f1de29a77621c4260b319bac70c13c90eb5f7bf
|
|
| MD5 |
1d5f55a621ddb30675d6f87e71c4ef95
|
|
| BLAKE2b-256 |
e5902115bb2a236d6383da53fe5da7607a689f7c05f91338551f7cc9223421af
|
Provenance
The following attestation bundles were made for sar_aws_barnacle-0.1.0.tar.gz:
Publisher:
release.yml on achutharaman/sar-aws-barnacle
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sar_aws_barnacle-0.1.0.tar.gz -
Subject digest:
217f097405ccaf1502c227b53f1de29a77621c4260b319bac70c13c90eb5f7bf - Sigstore transparency entry: 2466553167
- Sigstore integration time:
-
Permalink:
achutharaman/sar-aws-barnacle@64c4c750d5960fb07966c17d0c7ce7bc0abde968 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/achutharaman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@64c4c750d5960fb07966c17d0c7ce7bc0abde968 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sar_aws_barnacle-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sar_aws_barnacle-0.1.0-py3-none-any.whl
- Upload date:
- Size: 45.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5586656112a14f3a650056700d7bc759866d77dc7b3ea9c76d95ad6bb1e67350
|
|
| MD5 |
41762bc656da96e4e05a34995457730a
|
|
| BLAKE2b-256 |
46d9019c467c02eb9e5d4ab8ad691a7ebe5500e0ad8ca2ad6eb8d090e4eaa403
|
Provenance
The following attestation bundles were made for sar_aws_barnacle-0.1.0-py3-none-any.whl:
Publisher:
release.yml on achutharaman/sar-aws-barnacle
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sar_aws_barnacle-0.1.0-py3-none-any.whl -
Subject digest:
5586656112a14f3a650056700d7bc759866d77dc7b3ea9c76d95ad6bb1e67350 - Sigstore transparency entry: 2466553191
- Sigstore integration time:
-
Permalink:
achutharaman/sar-aws-barnacle@64c4c750d5960fb07966c17d0c7ce7bc0abde968 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/achutharaman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@64c4c750d5960fb07966c17d0c7ce7bc0abde968 -
Trigger Event:
push
-
Statement type: