A public storage bucket rarely starts out that way on purpose. Someone sets an ACL to test a static site, a Terraform module ships with a permissive default, or a CDN origin gets pointed at a bucket without an origin access control in front of it. Six months later nobody remembers the bucket exists, and it is sitting in a leaked-data report. Finding every public bucket across every account, subscription, and project you own is a mechanical problem, not a mysterious one, and it is worth solving with a repeatable script rather than a one-time console click-through.
Enumerating Buckets Across Every Account, Subscription, and Project
The first failure mode is scope, not detection. Most public-bucket incidents happen in an account nobody was actively watching: a sandbox, an acquired subsidiary, a contractor’s project that was never offboarded. Before checking a single ACL, build the full list of places buckets could exist.
- AWS: pull every account ID from AWS Organizations with
aws organizations list-accounts --query 'Accounts[].Id' --output text, then assume a read-only role into each one. Do not rely on a single account’s bucket list. - Azure: enumerate subscriptions with
az account list --query '[].id' -o tsv. Subscriptions under a different management group are easy to miss if you only check the ones in your default context. - Google Cloud: list projects with
gcloud projects list --format='value(projectId)', including projects outside your primary organization node if any exist from mergers or old free-tier signups.
If you manage more than a handful of accounts, a per-account loop calling the vendor CLI is the honest baseline. It is slow but it is complete, and completeness is the entire point of this exercise. Cross-check the resulting account count against your billing console or CSPM inventory; a mismatch usually means an orphaned account with no active IAM users but a bucket still standing.
Four Ways a Bucket Becomes Public: ACL, Bucket Policy, Org Setting, CDN Origin
On AWS specifically, “public” is not one setting, it is four independent mechanisms that can each override each other. You have to check all four or you will miss real exposure.
| Mechanism | Where it lives | How to check | Typical fix |
|---|---|---|---|
| Bucket ACL | Per-object or per-bucket grant to AllUsers or AuthenticatedUsers group URI | aws s3api get-bucket-acl --bucket NAME, look for the global groups URI |
Remove the grant, disable ACLs entirely with bucket ownership enforced |
| Bucket policy | JSON resource policy attached to the bucket | aws s3api get-bucket-policy-status --bucket NAME --query PolicyStatus.IsPublic |
Restrict the Principal, add explicit Deny for non-VPC or non-corporate CIDR ranges |
| Account-level block | S3 Block Public Access setting, account or bucket scope | aws s3api get-public-access-block --bucket NAME |
Set all four block flags to true unless there is a documented exception |
| CDN origin exposure | CloudFront distribution pointed at the bucket without an Origin Access Control | Check distribution origin config for OAC/OAI; try the direct S3 URL for the object | Attach OAC, then re-lock the bucket policy to only allow the CloudFront service principal |
The CDN case is the one teams miss most often, because the bucket itself can look locked down while the object is still reachable through the direct S3 endpoint. If https://bucket-name.s3.amazonaws.com/object-key returns content with no authentication, the front door does not matter.
Azure Blob and Google Cloud Storage: The Same Mistake, Different Vocabulary
Azure and GCP replicate the same failure pattern with their own terms. On Azure, the storage account has an allowBlobPublicAccess property, and each container independently has a public access level of Blob, Container, or Off. A storage account with the account-level flag enabled and a single container left at “Container” access exposes a full listing and every blob inside it anonymously. Check it with:
az storage account list --query "[].{name:name, allowBlobPublicAccess:allowBlobPublicAccess}" -o table
az storage container list --account-name ACCOUNT --auth-mode login --query "[].{name:name, publicAccess:properties.publicAccess}" -o table
On Google Cloud Storage, there is no separate ACL toggle to hunt for if the bucket uses uniform bucket-level access, which simplifies the check to one question: does the IAM policy grant roles/storage.objectViewer or broader to the special members allUsers or allAuthenticatedUsers. Run:
gcloud storage buckets get-iam-policy gs://BUCKET --format=json | jq '.bindings[] | select(.members[]? | test("allUsers|allAuthenticatedUsers"))'
Any non-empty result on that query is a bucket readable by anyone on the internet, full stop, regardless of what the console’s summary badge says.
CLI and API Recipes for a One-Shot Inventory
Point-in-time checks are fine for an initial sweep, but the real value comes from output you can diff day over day, so a new public grant shows up as a one-line change instead of getting lost in a wall of console screenshots.
AWS
Loop every account and bucket, write structured JSON, and sort keys before writing so diffs are clean:
aws s3api list-buckets --query 'Buckets[].Name' --output text | tr 't' 'n' | while read b; do aws s3api get-bucket-policy-status --bucket "$b" --query 'PolicyStatus.IsPublic' --output text 2>/dev/null | xargs -I{} echo "{"bucket":"$b","public":{}}"; done | jq -s 'sort_by(.bucket)' > buckets-$(date +%F).json
Commit that file to a private git repo on a daily cron. A diff that adds "public":true for a bucket that was previously false is your alert, no separate alerting pipeline required for a first pass.
Azure
Resource Graph gives you a cross-subscription view in a single query instead of looping per subscription:
az graph query -q "Resources | where type =~ 'microsoft.storage/storageaccounts' | project name, subscriptionId, allowBlobPublicAccess" --output json
Google Cloud
for p in $(gcloud projects list --format='value(projectId)'); do gcloud storage buckets list --project="$p" --format='value(name)' 2>/dev/null; done | while read bkt; do gcloud storage buckets get-iam-policy "gs://$bkt" --format=json; done > gcs-iam-snapshot.json
For any of the three clouds, third-party tools such as ScoutSuite, Prowler, or cloudsplaining will run equivalent checks with less scripting, at the cost of less control over exactly what gets flagged and why. Either path, land the output somewhere versioned. An asset inventory that already tracks every bucket as a discovered asset makes this diff automatic instead of a cron job you have to maintain yourself.
Separating Intentionally Public Assets from Accidental Exposure
Not every public bucket is a finding. Static website hosting buckets, public software mirrors, and open datasets are supposed to be reachable with no authentication. Treating every hit from the scripts above as an incident will get the report ignored within a week. Apply a short set of criteria before triaging anything as accidental exposure:
- Does the bucket name or tag explicitly indicate a public purpose (
-public-,-cdn-,website-assets)? If not, treat as suspicious by default. - Does object listing return keys that look like backups, database dumps, logs, or credentials (
.sql,.env,.pem,backup-)? Any match is high severity regardless of naming. - Is the bucket referenced by a known CDN distribution as its origin, with the CDN itself enforcing access controls? If yes, the direct bucket exposure may still be a gap, but severity is lower than an unreferenced bucket with the same setting.
- Does the account or project owner have a documented reason on file (a ticket, a README, an infrastructure-as-code comment)? No documentation means it goes to the owner for confirmation before you close it as intentional.
This is where a lot of manual bucket audits fall apart: the person running the script is not the person who can say whether a given bucket is supposed to be open, and without a routing mechanism the finding sits in a spreadsheet. Attack surface tooling that assigns each newly discovered external asset to an owner, such as the workflow behind attack surface management, shortens that loop from a spreadsheet ping to a tracked task with a deadline. Buckets holding anything resembling customer or cardholder data also intersect directly with audit scope; a compliance mapping view that ties an exposed bucket to the specific control it violates, GDPR Article 32 or PCI DSS Requirement 1, turns “we found a public bucket” into a finding an auditor can act on without a follow-up meeting.
Preventing Recurrence with Account-Level Block Settings and Org Policies
Detection without prevention means you run this audit again in three months and find a new bucket. Each provider has an account or organization-wide switch that stops the mistake before it happens, and none of them require touching individual buckets after the fact.
On AWS, enable S3 Block Public Access at the account level for every account in the organization: aws s3control put-public-access-block --account-id ACCOUNT_ID --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true. Pair it with a Service Control Policy that denies any action attempting to disable that setting, so an individual account admin cannot quietly turn it back off.
On Azure, set allowBlobPublicAccess to false at the storage account level as the default, and enforce it organization-wide with an Azure Policy definition set to Deny for any storage account created with the property set to true. New accounts that violate the policy fail creation instead of getting flagged after the fact.
On Google Cloud, apply the organization policy constraint constraints/storage.publicAccessPrevention set to enforced at the organization node, and pair it with constraints/storage.uniformBucketLevelAccess also enforced, which removes legacy ACLs as an escape hatch entirely. Both constraints inherit down to every project and every bucket created afterward, including ones created by teams that have never heard of this audit.
Platforms built for continuous discovery pick up a policy drift like this the moment it happens rather than waiting for the next scheduled sweep; SITEY, an autonomous vulnerability management platform, re-checks external-facing storage configuration on each scan cycle and flags a bucket the moment a block-public-access setting gets removed, instead of relying on someone to rerun a script. Whether you build the recurring check yourself or run it through tooling, the underlying discipline is the same: enumerate everywhere, check all four exposure mechanisms per cloud, diff the output daily, and make the safe default impossible to opt out of by accident.
About SITEY
SITEY is an autonomous vulnerability management platform. It discovers, validates, prioritizes, remediates and re-tests vulnerabilities through an eight-phase automated pipeline, unifying output from 17 integrated scanners. SITEY is self-hosted: it runs in your own infrastructure and your findings are stored there. Outbound connections are limited to licence activation and the optional services you enable, such as an AI provider, CVE enrichment and patch catalogues. Pricing is 599 USD per month or 5,999 USD for a perpetual lifetime license. See pricing or how the platform works.