✓ Human-authored analysis; AI used for formatting and proofreading.
At some point, someone added a bucket policy to ping-api-production. A Shopify S3 bucket backing the iOS Ping API that granted s3:GetObject and s3:ListBucket to Principal: *. Everyone on the internet. Read and list.
The same bucket was tagged data-classification: internal and Environment: production.
A security researcher found it, listed the contents, and filed HackerOne #1021906.
The Tag Contradiction
The bucket configuration contained two statements that directly contradicted each other:
"tags": {
"data-classification": "internal",
"Environment": "production",
"Owner": "mobile-engineering",
"ManagedBy": "manual"
}
"policy": {
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::ping-api-production",
"arn:aws:s3:::ping-api-production/*"
]
}
The tag says internal. The policy says everyone.
One of these was written by the team that owns the data and understands its sensitivity. The other was written or inherited from a template at bucket creation time and never reviewed against the classification. Security controls are supposed to catch the gap between organizational intent and infrastructure reality.
Why This Keeps Happening
The bucket was ManagedBy: manual. No Terraform, CloudFormation or PR review when the policy was added. No drift detection when the tags were updated to reflect the correct data classification.
The sequence is common: a developer creates a bucket quickly, adds a public read policy to serve mobile assets, tags it correctly as internal production data, and moves on. The policy stays. The tags stay. Nobody reviews whether they are consistent.
In a CSPM dashboard, this surfaces as "public S3 bucket: ping-api-production" which is indistinguishable from a bucket intentionally hosting public static assets. There is no signal that the data-classification tag contradicts the access policy. There is no temporal evidence showing how long the contradiction has existed.
The Five Gaps on One Asset
Looking at the full bucket state, ping-api-production had five independent control failures:
| Gap | State |
|---|---|
| Public access |
public_read: true, public_list: true
|
| Block Public Access | All four settings disabled |
| Access logging | Disabled — no record of who accessed what |
| Versioning | Disabled — deleted objects unrecoverable |
| Transport encryption | Not enforced — HTTP requests accepted |
Any one of these would be a finding. Together on a production bucket tagged internal, they describe a bucket that was never hardened after initial creation.
The System Invariant
The invariant:
S3 buckets must not allow public read or list access.
Its mirror. What becomes possible when it is false:
Any unauthenticated user can browse and download the contents of a production iOS API bucket containing internal data.
The tag data-classification: internal is the owning team's statement of intent. The bucket policy with Principal: * is the actual infrastructure state. Stave evaluates the infrastructure state. The intent is preserved in the snapshot as context for the reviewer.
What Stave Detects
id: CTL.S3.PUBLIC.001
name: No Public S3 Buckets
predicate:
any:
- field: properties.storage.access.public_read
op: eq
value: true
- field: properties.storage.access.public_list
op: eq
value: true
The control fires on either public_read: true or public_list: true. The Shopify Ping bucket had both.
The E2E Test
This report is one of 28 end-to-end tests in Stave's test suite. The test reconstructs the exact bucket configuration from the report as a snapshot, runs stave apply, and compares the output byte-for-byte against a golden file.
./stave apply \
--controls testdata/e2e/e2e-h1-shopify-1021906/controls \
--observations testdata/e2e/e2e-h1-shopify-1021906/observations \
--max-unsafe 168h \
--eval-time 2026-01-11T00:00:00Z
The test uses two snapshots. One at T1 (vulnerable state) and one at T2 (still vulnerable, 10 days later) to verify the temporal evidence. The expected output shows 240 hours of exposure against a 168-hour SLA, status OVERDUE, exit code 3.
The test is not claiming Stave would detect a public S3 bucket in general. It is proving that the specific control predicate, evaluated against a snapshot reconstructed from the specific Shopify Ping configuration, produces the specific expected output. The golden file is the evidence.
The Compound Finding
Running the full S3 control set against this bucket produces five findings, not one:
-
CTL.S3.PUBLIC.001— public read and list access -
CTL.S3.PUBLIC.ACCESS.BLOCK.001— Block Public Access not enabled -
CTL.S3.LOGGING.001— access logging disabled -
CTL.S3.VERSIONING.001— versioning disabled -
CTL.S3.TRANSPORT.001— transport encryption not enforced
Each is a separate control gap. Each has its own SLA clock. The combination on a single production asset the compound chain model captures. They are five conditions that together describe a bucket with no detection, recovery or access control.
Remediation
# Remove the public policy
aws s3api delete-bucket-policy --bucket ping-api-production
# Enable all four Block Public Access settings
aws s3api put-public-access-block \
--bucket ping-api-production \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true
# Enable access logging
aws s3api put-bucket-logging \
--bucket ping-api-production \
--bucket-logging-status '{
"LoggingEnabled": {
"TargetBucket": "company-access-logs",
"TargetPrefix": "ping-api-production/"
}
}'
The longer-term fix is migrating to Terraform (ManagedBy: terraform). All policy changes go through pull request review. CTL.S3.PUBLIC.001 runs in CI where any bucket with public access blocks the merge.
Checklist
- No S3 bucket has
Principal: *in its bucket policy - Account-level Block Public Access enabled as a safety net
- All S3 buckets have access logging enabled
- Manually managed buckets (
ManagedBy: manual) audited and migrated to IaC -
CTL.S3.PUBLIC.001runs in CI on every infrastructure change
The tag said internal. The policy said everyone. One of them was wrong.
HackerOne #1021906 Shopify ping-api-production bucket. Stave E2E test e2e-h1-shopify-1021906 reconstructs the vulnerable configuration and verifies detection against a golden file. Stave is an open-source infrastructure invariant verifier.
Top comments (0)