Skip to main content

AWS Spend Guardrail Line Check

If spend runs away tonight, what actually stops it? A read-only inventory of every AWS Budget, every attached action, and what each one is really allowed to deny.

IntermediateMonitoring5 minutes for one accountAWS

Standardized doc shell (hero + metadata + quick path). Custom content below remains page-specific.

Download Tool Files
Blaze
Blaze says:Read the approval model column before anything else. A budget action set to MANUAL is not a control, it is a notification with extra steps. It sits in STANDBY until somebody wakes up and approves it, which is exactly the hour you needed it to act on its own.

Standard run path

Structured quick-reference sections for prerequisites, installation, usage, and troubleshooting.

Prerequisites

  • Python 3.9 or newer
  • boto3 and botocore, installed with pip install boto3
  • Credentials through the normal boto3 chain: a named profile, environment variables, or an instance or container role
  • No local write access required unless you ask for --output-csv or --output-json

Permissions, and what you lose without each one

The script degrades instead of failing. Every optional permission you withhold turns a measured answer into an honestly labelled unknown, never into a false pass.

PermissionNeeded forWithout it
budgets:DescribeBudgetsThe inventory itselfNothing runs
budgets:DescribeNotificationsForBudgetNaming alert-only budgetsAlert counts read as zero
budgets:DescribeBudgetActionsForAccount and ...ForBudgetFinding attached actionsEvery budget looks alert-only
sts:GetCallerIdentityResolving the account idNothing runs
iam:GetPolicy and iam:GetPolicyVersionReading what an IAM action deniesThe row reads "action attached, contents not evaluated"
organizations:DescribePolicyReading what an SCP deniesSame honest fallback, and this one only resolves from the management account
budgets:DescribeBudgetActionHistoriesWhether an action has ever firedHistory reads unreadable, never zero
ce:GetCostAndUsageThe gap list from your own billThe gap list is absent, and nothing generic is substituted for it
organizations:ListAccounts and sts:AssumeRoleOrganization modeThe run covers one account and says so

Every call is a describe, get or list. There is no write path in this script, so there is no write path to disable.

Standard run path

  1. 1Run it against one account first and read the VERDICT line under each budget. That is the whole answer in one sentence per budget.
  2. 2Check the approval model on every action. AUTOMATIC fires on its own; MANUAL waits for a human, which at 02:00 means it waits.
  3. 3Look for any row reading "action attached, contents not evaluated" and fix the permission or read that policy by hand. An unread control is not a control.
  4. 4Add --leak-list once you want the gap list derived from your own bill rather than a generic one. It costs one cent per Cost Explorer request.
  5. 5Add --org only when you have confirmed the role name and its trust policy, because budgets are per account and a single-account run is a single-account answer.
bash
python aws_spend_guardrail_line_check.py --profile myprofile
bash
python aws_spend_guardrail_line_check.py --profile myprofile --leak-list
bash
python aws_spend_guardrail_line_check.py --profile management \
  --org --org-role OrganizationAccountAccessRole
bash
python aws_spend_guardrail_line_check.py --profile myprofile \
  --output-csv guardrails.csv --output-json guardrails.json

Reading the verdict

One line per budget, and only four things it can say

The verdict is the sentence you paste into the ticket. Everything above it is the evidence.

VerdictWhat it means
no notifications and no actions, this budget is decorationIt exists in the console and does nothing at all
alerting only, nothing stops anythingSomebody will get an email. Nothing will stop
action attached but every one is MANUALThe control is real and it waits for a human to approve it before anything happens
N automatic actions attached: denies ...Something fires on its own, and this is the exact list of action strings it denies

A fifth outcome exists and it is deliberate: when an automatic action is attached but its policy could not be read, the verdict says coverage is unknown rather than guessing either way.

  • AWS Budgets data refreshes up to three times a day, typically 8 to 12 hours apart, so the spend figures in the output are not live.

What this tool does not tell you

  • It does not simulate policy evaluation. It reports the Deny statements it finds, and whether a specific API call is denied at runtime still depends on the full policy chain including permission boundaries, session policies and other SCPs.
  • It does not price anything and claims no savings. It measures control coverage.
  • It cannot tell you a Deny stops billing. A deny on resource creation does not stop metered consumption by resources that already exist.
  • It does not evaluate a policy it could not read. That case is labelled, not inferred.

The most common false pass

A budget with an action attached looks protected in a screenshot. If that action is MANUAL, or if its policy denies three action strings that have nothing to do with your top billable line, the screenshot is the only protection you have. That gap is the reason this script reads the policy instead of trusting the attachment.

Troubleshooting

  • AWS Budgets uses a single global endpoint, so the script pins its Budgets client to us-east-1. If you have patched that pin out, it will fail anywhere no default region is configured.
  • Rows reading "contents not evaluated" are a permissions result, not a bug. Grant iam:GetPolicy and iam:GetPolicyVersion, and run SCP resolution from the management account.
  • If the gap list is absent, check ce:GetCostAndUsage and that Cost Explorer is enabled for the account. The script reports the reason rather than substituting a generic list.
  • In organization mode, accounts you cannot assume into are listed as errors at the end of the report and the exit code is 2. They are never silently skipped.

Why this exists

Search for whether AWS will stop spending at a number you choose and you get forum threads. The honest answer needs three qualifiers, and once you add them the answer changes: there is no account-wide, customer-chosen, dollar-denominated cap that AWS will enforce for you. What AWS does sell is a budget that can trigger an action, and the distance between those two things is where estates get surprised.

This script closes that distance for one account at a time. It does not tell you what AWS offers in general, which is what the hard-cap inventory post is for. It tells you what your account has wired right now, with the policy documents read rather than assumed.

What it measures

Every budget, and every notification on it

Type, limit, period, actual and forecasted spend, and the notification count. A budget with notifications and no actions is named alert-only rather than left looking protective.

Automatic versus manual, per action

AWS exposes an approval model on each budget action. AUTOMATIC fires on its own. MANUAL waits for a human. This is the single field that separates a control from a notification, and it is the first thing the report prints.

What the attached policy actually denies

For an IAM action it fetches the default policy version and parses the document. For a service control policy it fetches the content from Organizations. Both come back as a list of real denied action strings, not a guess based on the attachment existing.

Whether the action has ever fired

The action history answers a question a console screenshot cannot: has this thing ever actually executed? If the history cannot be read, the answer is unknown, never no.

Your gap list, from your own bill

With --leak-list it pulls your top billable usage types from Cost Explorer and classifies each one against the denies your own automatic actions carry. A generic list of usual suspects would be identical for every account, which is why this one is not generic.

Read-only by construction

Every call is a describe, get or list. There is no flag to disable, no dry-run mode to remember and no mutating path to review, because none exists in the source.

The gap list, and why it has to come from your bill

Every write-up of this problem ends with the same three examples: data transfer, S3 requests, Lambda invocations. They are fine examples and they are also the same three for everybody, which makes them a reading recommendation rather than a finding about your account.

So the gap list here is built the other way round. It takes your top billable usage types this month, takes the union of denied action strings from the actions that actually fire without a human, and classifies each billed line against them. A line comes back in one of five states: no creation action exists to deny it at all, no automatic deny exists in the account, a broad deny matches, a creation-scoped deny matches, or the service is not in the script's prefix table and needs a human read. That last state is included on purpose. A tool that quietly maps an unrecognised service to "not covered" is inventing a finding.

Even a broad deny that matches your biggest line does not remove the charge. It stops new resources. Everything already running keeps billing at the same rate, which is why the classifier names the scope instead of printing a pass.

What the run costs

AWS Budgets read operations are not documented as chargeable. Neither the AWS Budgets pricing page nor the AWS Cost Management pricing overview states a per-request charge for the Budgets describe operations, so this page claims no charge is documented rather than claiming the run is free.

Cost Explorer is different and it is documented: each ce:GetCostAndUsage request is billed at 0.01 USD. That is why the gap list sits behind an explicit --leak-list flag, why the report prints how many Cost Explorer requests it made and what they cost, and why --max-ce-pages exists to bound it. A single-page run costs one cent.

How it was tested

Verified on 5 August 2026 without any live AWS account, and the specifics matter more than the word verified. All four Budgets operations it calls were confirmed to exist in the botocore service model and to have paginators, and the script routes all four through a paginator. The Budgets client was confirmed to resolve to the single global endpoint when pinned to us-east-1, which is why the pin is not optional. A 62-check offline suite drove the real code paths against canned API responses using botocore Stubber, which validates fixtures against the live service model, covering policy parsing, the automatic-versus-manual verdicts, AccessDenied degradation, the gap-list classifier, CSV output and the failure exits. An AST walk over the source confirms no mutating AWS operation is reachable.

Not verified: nothing was run against a real AWS account. Live response shapes at scale, cross-account assume-role behaviour and Cost Explorer service-name coverage in a real bill are all untested. Treat the first run as the read-only dry run it is and check the output against the console. The full disclosure, the checksum and the minimum access list are recorded in the tool's trust metadata.

Read the inventory this came from

The script answers the question for your account. The post answers it for the three clouds, using each vendor's own limitation text, including the handful of real enforced caps that do exist and why none of them is the account-wide cap people assume they are buying.

What Actually Stops Cloud Spend

What to do next

Pick the path that fits where you are right now.

Trust & run-safety metadata

Key execution details for AWS Spend Guardrail Line Check so users know what they are downloading or running before they act.

Need verification guidance? See Security & Trust and Responsible Disclosure.

Read-only / reportingDirect downloadExplicit + inferred metadata

Maintainer

CloudCostChefs

Last Updated

August 5, 2026

Last Tested

August 5, 2026

Minimum Access

Read-only. Required: budgets:DescribeBudgets, budgets:DescribeNotificationsForBudget, budgets:DescribeBudgetActionsForAccount, budgets:DescribeBudgetActionsForBudget, sts:GetCallerIdentity. Optional and additive: iam:GetPolicy and iam:GetPolicyVersion plus organizations:DescribePolicy to resolve what an action denies, budgets:DescribeBudgetActionHistories for execution history, ce:GetCostAndUsage for the gap list, organizations:ListAccounts and sts:AssumeRole for organization mode.

Execution Type

Python 3.9+ CLI script (boto3)

Version

2026-08-05

SHA256 Checksum

a523c6135919b76489cd1d9f67f2f4cad55b2309fcf944920281411a29700274

Verification Notes

Verified on August 5, 2026 without any live AWS account. What was checked: the script compiles under Python 3.14; all four AWS Budgets operations it calls exist in the botocore service model and every one has a paginator, and the script routes all of them through a paginator; the AWS Budgets client resolves to the single global endpoint budgets.amazonaws.com when pinned to us-east-1, which is why the pin is not optional; iam:GetPolicyVersion requires an explicit VersionId, so iam:GetPolicy runs first for the default version, and the returned policy document is URL-encoded and decoded accordingly; ce:GetCostAndUsage has no paginator and is therefore driven by a manual NextPageToken loop with a request cap. A 62-check offline suite drove the real code paths against canned API responses using botocore Stubber, which validates fixtures against the live service model, and covered policy parsing (list form, single statement, string and list actions, and NotAction kept separate from Action), the AUTOMATIC versus MANUAL verdicts, AccessDenied degradation, the gap-list classifier, CSV output and the argument and credential failure exits. An AST walk over the source confirms no mutating AWS operation is reachable. Not verified: nothing was run against a real AWS account, so the shape of live responses at scale, cross-account assume-role behavior and Cost Explorer service-name coverage in a real bill are all untested. Treat the first run as a read-only dry run and check the output against the console.

Safe Usage Checklist

  • Read-only by construction. Every API call is a describe, get or list, and there is no write path to disable.
  • A row that reads "action attached, contents not evaluated" means the policy could not be read, not that the control is absent. Grant iam:GetPolicy and iam:GetPolicyVersion, or read that policy by hand, before drawing a conclusion.
  • The script reports Deny statements. It does not simulate policy evaluation, so whether a specific API call is denied at runtime still depends on the full policy chain including permission boundaries, session policies and other SCPs.
  • A Deny on resource creation does not stop metered consumption by resources that already exist. That gap is what the gap list is for, and it is the reason this tool claims no savings.

Quick start (fast path)

Minimal steps to safely get value from this tool without reading the entire page first.

Estimated time: 5 minutes for one accountDifficulty: IntermediateAccess: Review / read-only
  1. 1. Confirm scope and permissions

    Use least privilege and test in a non-production scope first. Minimum access hint: Read-only. Required: budgets:DescribeBudgets, budgets:DescribeNotificationsForBudget, budgets:DescribeBudgetActionsForAccount, budgets:DescribeBudgetActionsForBudget, sts:GetCallerIdentity. Optional and additive: iam:GetPolicy and iam:GetPolicyVersion plus organizations:DescribePolicy to resolve what an action denies, budgets:DescribeBudgetActionHistories for execution history, ce:GetCostAndUsage for the gap list, organizations:ListAccounts and sts:AssumeRole for organization mode..

  2. 2. Get the tool package / source

    Download Tool Files and review the files before running.

    Download
  3. 3. Check prerequisites

    • Python 3.9 or newer
    • boto3 and botocore (pip install boto3)
    • Credentials via the normal boto3 chain: named profile, environment variables, or an instance or container role
  4. 4. Run safely and review output

    Verified on August 5, 2026 without any live AWS account. What was checked: the script compiles under Python 3.14; all four AWS Budgets operations it calls exist in the botocore service model and every one has a paginator, and the script routes all of them through a paginator; the AWS Budgets client resolves to the single global endpoint budgets.amazonaws.com when pinned to us-east-1, which is why the pin is not optional; iam:GetPolicyVersion requires an explicit VersionId, so iam:GetPolicy runs first for the default version, and the returned policy document is URL-encoded and decoded accordingly; ce:GetCostAndUsage has no paginator and is therefore driven by a manual NextPageToken loop with a request cap. A 62-check offline suite drove the real code paths against canned API responses using botocore Stubber, which validates fixtures against the live service model, and covered policy parsing (list form, single statement, string and list actions, and NotAction kept separate from Action), the AUTOMATIC versus MANUAL verdicts, AccessDenied degradation, the gap-list classifier, CSV output and the argument and credential failure exits. An AST walk over the source confirms no mutating AWS operation is reachable. Not verified: nothing was run against a real AWS account, so the shape of live responses at scale, cross-account assume-role behavior and Cost Explorer service-name coverage in a real bill are all untested. Treat the first run as a read-only dry run and check the output against the console. Start with a small sample scope, then expand once results look correct.