Read-Only by Construction: The Guard in Front of Every AWS Call

My launch post says the scanner "cannot change anything."
That is a big claim from one person you've never met, about a tool you're about to point at your AWS account. The fair reaction is: how do you know?
I'd rather you not take my word for it. So here is the mechanism: a small guard that sits in front of every AWS call the scanner makes. You can copy the pattern for anything you ship that touches someone else's cloud.
"Read-only" is usually a promise
Look at how most tools earn the word:
- The docs say it.
- The IAM role you create for them only allows reads.
The second one is real protection. But it protects you from the vendor's permissions, not from the vendor's code. If the role is wrong, or a later release asks for more, the promise and the behavior drift apart and nobody notices until something is deleted.
I wanted a second, independent layer that lives in the code path itself. If the tool is ever wrong about what it's about to do, the call should die on the way out.
One chokepoint
Every boto3 client call, for every AWS service, ends up in one method: BaseClient._make_api_call in botocore. That is the chokepoint. Wrap it and you see every operation by name, before any request is signed or sent.
The guard in the scanner is a context manager around that method. Trimmed:
READ_ONLY_PREFIXES = ("Describe", "List", "Get", "Search", "Lookup")
@contextmanager
def read_only_guard(blocked_services=frozenset()):
log = GuardLog(calls=Counter(), violations=[], blocked=Counter())
original = botocore.client.BaseClient._make_api_call
def guarded(client, operation_name, api_params):
service = client.meta.service_model.service_name
if not operation_name.startswith(READ_ONLY_PREFIXES):
log.violations.append(f"{service}:{operation_name}")
raise ReadOnlyViolation(
f"refused non-read AWS call {service}:{operation_name}"
)
log.calls[service] += 1
return original(client, operation_name, api_params)
botocore.client.BaseClient._make_api_call = guarded
try:
yield log
finally:
botocore.client.BaseClient._make_api_call = original
(The real version also handles blocked_services, which I'll get to.)
That's the whole idea. Four things in it are doing the work.
1. An allow-list, not a deny-list. The guard doesn't try to list every dangerous verb (Put, Create, Delete, Update, Modify, Start, Stop, Tag, and whatever AWS invents next year). It lists the five prefixes that mean "read" and refuses everything else. A new write operation is blocked by default without anyone remembering to add it.
2. Refuse before send. The check runs before the original method, so a refused call never leaves the machine. It doesn't matter what the credentials would have allowed.
3. The refusal is loud, twice. This is the easy part to miss. The scan's detectors catch their own exceptions, because one failing check shouldn't kill a scan of 20 services. That means a raised ReadOnlyViolation can be swallowed and logged as a warning, and the scan would carry on looking fine. So the guard also appends every refusal to log.violations, and the caller checks that list when each region finishes. If it is not empty, the whole scan fails:
if violations:
raise ReadOnlyViolation(
f"refused non-read AWS call(s): {', '.join(sorted(set(violations)))}"
)
A safety check that a try/except can quietly turn into a warning is not a safety check. There is a test for exactly this: a refused write in any region fails the whole scan.
4. Scoped, and always restored. The patch exists only inside the with block and is undone in finally. Nothing else in the process keeps a patched client.
The same hook does a second job
Because the guard sees every call by service and operation, it counts them. The JSON output includes the number of calls made to each service, so you can see what the scan actually did against your account.
It also answers one service locally. The Cost Explorer checks cost $0.01 per request on AWS's side, about $0.13 per scan, so --no-cost-explorer adds ce to blocked_services. Those calls are never sent. The guard answers them with AccessDenied, exactly what the scanner would see if your role had no Cost Explorer permission, and the scan falls back the same way. They are counted separately from violations, because skipping a paid read is not a safety event.
What it does not protect you from
This is the part that matters for trust, so, plainly:
- It trusts AWS's naming. The guard is a prefix allow-list. An operation called
Get...is allowed because its name says it is a read, and that is a rule about names, not a proof about behavior. Some read-named operations do have side effects: STSGetSessionTokenmints temporary credentials, and Cognito Identity'sGetIdcan generate a new identity ID. Neither changes your infrastructure, but the guard cannot tell the difference, and it would not catch aGet...that did. The scan's JSON output lists the calls it actually made per service, which is how you audit what ran. - It guards the scanner's own process. It wraps botocore inside the same Python process as the code it protects. It stops mistakes: a detector that tries to be helpful, a future change, a bad merge. It is not a sandbox against hostile code, which could remove the patch. That is why the code is source-available (FSL-1.1-ALv2): the guard is a few lines you can read before you run anything.
- Read-only is not "reads nothing." Reads can still return sensitive data if the credentials allow it. Only give the scanner a profile that can see what you're comfortable having it see.
- It is not a substitute for a read-only role. Use a read-only IAM role anyway. The guard and the role fail in different ways, which is the point of having both: the role stops the guard's bugs, and the guard stops the role's drift.
One more check sits outside the guard. When I recorded every network connection during a full scan, it was 48 hosts, all of them *.amazonaws.com. Nothing is sent to CloudWise. The guard answers "will it change anything?" and the network capture answers "who is it talking to?"
Copy this for your own tool
If you ship something that runs inside a customer's cloud account, the pattern generalizes:
- Find the one function every call goes through.
- Allow-list the safe operations by name. Refuse everything else by default.
- Refuse before the request is sent, not after.
- Record the refusal somewhere an exception handler can't swallow it, and fail the run.
- Scope the hook to the work, and restore it in
finally. - Keep the role read-only too. Two layers that fail differently beat one.
Try it
The scanner is free and runs on your machine, with your own credentials. It checks 20 AWS services, and --format json lists how many calls it made to each.
pipx install cloudcostwise
cloudcostwise scan --profile my-profile --regions all
Free, read-only, a few minutes: cloudcostwise.io. The guard lives in runtime.py in the repo. If you can find a way past it, open an issue. I'd rather hear it from you.
Stop wasting money on AWS
CloudWise monitors 45 AWS services and finds waste automatically. Free forever.
Start Free Scan →