s3-estate-calibration-auditor
Audit an estate of AWS S3 buckets for the one bucket that is genuinely publicly or cross-account exposed, without over-flagging the many buckets that READ as exposed but are neutralised. Resolves each bucket's EFFECTIVE verdict by composing four layers (Block Public Access x buck
Install
npx skills add https://github.com/anyshift-io/sre-skills/tree/main/skills/s3-estate-calibration-auditor
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install anyshift-io-sre-skills@llmmart
git clone https://github.com/anyshift-io/sre-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole anyshift-io/sre-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
s3-estate-calibration-auditor
Effective-exposure audit skill for an estate of AWS S3 buckets. Takes the config layers
for every bucket in the estate (public-access-block.json, bucket-policy.json,
bucket-acl.json, and access-points.json where present), resolves each bucket's
effective verdict by composing all four layers, then answers one question a per-layer
read cannot: across 8-12 buckets that mostly READ as exposed, which one is genuinely
live, and is the estate otherwise clean. It returns the live bucket as the headline,
ranked by severity, with a fix, then names exactly where the bucket configs stop being
able to answer the question.
Effective S3 exposure is a join across four layers that each get read wrong one at a time. A public-looking bucket policy is inert under RestrictPublicBuckets; a cross-account grant survives BPA-all-on; a public-group ACL is dead under IgnorePublicAcls but a cross-account canonical user beside it is not; a clean bucket can still be public through an access point. In an estate, the trap doubles: several buckets carry a Principal '*' policy, an AllUsers ACL, or a wide-open look that is genuinely neutralised, and exactly one bucket carries a real live grant that reads just like its neutralised siblings. This skill composes the layers per bucket instead of clearing each layer in isolation, then calibrates the estate: it does not over-flag the neutralised baits, and it does not miss the buried needle.
When to invoke
- An agent is asked to review an S3 bucket fleet for public exposure, cross-account access, or "is anything in this account public."
- An estate is being shipped or changed and the question is whether any bucket is effectively exposed, not just whether a Principal '*' or an AllUsers grant appears somewhere in the configs.
- An estate looks exposed (several Principal '*' policies, AllUsers ACLs) and the claim "but BPA is on / it is scoped" needs to be confirmed against the effective verdict rather than taken on trust.
- An estate looks locked down (BPA all on everywhere) and the claim "the account is closed" needs to be checked against the cross-account grants BPA does not touch.
What this skill reads, and what it does not
It reads the static configuration of an estate of buckets: per bucket, any subset of
the Block Public Access booleans, the bucket policy, the bucket ACL, and the access
points. Those are S3 control-plane reads (get-public-access-block, get-bucket-policy,
get-bucket-acl, list-access-points + get-access-point-policy). That is the entire
input. The audit is correct and complete for what the bucket configs can tell you, and
it is explicit about the rest. Reachability-on-paper is not exposure-in-fact, and every
audit ends by naming the joins it cannot make:
- It does not see per-object ACLs. An individual object can carry its own public-read grant even when the bucket is private. Join: bucket config to its per-object ACLs.
- It does not see CloudFront / CDN fronting. A bucket can be private while its data is served publicly through a CloudFront distribution with Origin Access Control. Join: bucket to its CDN distribution.
- It does not contain the privileges of a trusted principal. A cross-account or conditional grant only matters in proportion to what the trusted account/role can do and whether it re-shares onward, which lives in that account. Join: this bucket to the identity policies of the principals it trusts.
- It does not see VPC-endpoint policies or org SCPs, which can further restrict access this config Allows. Join: bucket to its VPC-endpoint policies and the org SCPs.
- A flagged grant's exploitability depends on the data sensitivity of the objects, which is not in the config.
A clean (deceptive-clean) estate still gets a boundary section, because a BPA-neutralised estate is not a proven-safe system: turning BPA off would expose the latent statements.
The model
For every bucket in the estate, build the effective verdict by composing four layers. Never read one layer alone. A finding is LIVE only when a real public or cross-account grant survives the layer that would neutralise it:
- Block Public Access (BPA) -- four booleans that neutralise otherwise-public policy and ACL grants, but do NOT touch cross-account grants.
- The bucket policy -- a resource policy whose Principal can be public (''), a named other account, or '' narrowed by a Condition.
- The bucket ACL -- legacy grants to canonical users, or to the AllUsers / AuthenticatedUsers public groups.
- Access points -- each with its OWN BPA and policy, able to expose data independent of (but not exceeding) the bucket.
The estate is clean iff no bucket is live. The needle is whichever bucket carries a live finding among many neutralised/scoped lookalikes.
The methodology, in order
1. Parse all four layers for every bucket
Before any judgment, read each layer for each bucket. Process EVERY bucket in the estate, not the first couple:
- BPA: read the four booleans from
public-access-block.json. An absent file means all four are False (no BPA). The two that neutralise existing grants areRestrictPublicBuckets/BlockPublicPolicy(for a public policy) andIgnorePublicAcls(for a public-group ACL).BlockPublicAclsonly blocks new public ACLs and does not disable an existing one. - Policy: read each
Statementinbucket-policy.json. ADenygrants nothing and cannot make a bucket public; classify only theAllowstatements. For each Allow, read thePrincipal(public '', a named AWS account, or '' with a Condition) and theCondition. - ACL: read each grant in
bucket-acl.json. A Grantee that is the AllUsers or AuthenticatedUsersGroupURI is a public-group grant; aCanonicalUserthat is not the bucket owner is a cross-account grant; an owner-only ACL produces nothing. - Access points: read each entry in
access-points.json. Each AP has its OWNPublicAccessBlockandPolicy; resolve the AP policy exactly like a bucket policy, against the AP's own BPA.
Recognise the BPA switches by name, and recognise a narrowing Condition: aws:PrincipalOrgID,
aws:PrincipalOrgPaths, aws:PrincipalAccount, aws:PrincipalArn, aws:SourceArn,
aws:SourceAccount, aws:SourceVpc, aws:SourceVpce, aws:SourceIp, aws:VpcSourceIp,
sts:ExternalId, and the access-point delegation keys s3:DataAccessPointAccount /
s3:DataAccessPointArn / s3:AccessPointNetworkOrigin. Any of these on a Principal '*'
scopes it to a bounded caller set.
Read each bucket's BPA booleans verbatim — do not let a bucket's name or its grants tell
you what they are. Each boolean being true is the safe direction: IgnorePublicAcls: true means an existing public ACL is ignored (dead); RestrictPublicBuckets: true means a
public policy is denied. Do not invert it. The dominant miscalibration is asserting a boolean
value to fit an expectation: a bucket named exports, public, share, partner, or cdn,
or any bucket carrying an AllUsers / AuthenticatedUsers grant, invites the assumption that
it must be the exposed one — and that assumption makes you misread its IgnorePublicAcls as
false. In these estates the common case is the opposite: a shareable-sounding bucket with
an AllUsers grant and IgnorePublicAcls: true, which is neutralised, not live. The
presence of a grant is not evidence about the boolean. To keep the transcription faithful: for
any bucket carrying an AllUsers / AuthenticatedUsers ACL grant or a Principal '*' policy,
paste that bucket's entire public-access-block.json as a verbatim JSON block before you
classify it, and read the four booleans out of the pasted block. Pasting the raw object is
harder to get wrong than filling a value in from memory, which is where the misread creeps in.
2. Resolve each bucket's effective verdict (the composition)
Compose the layers; do not condemn a bucket on "Principal '*' is present" alone, and do not clear it on "BPA is on" alone. The codes:
- POLICY-PUBLIC (critical, LIVE) -- the bucket policy allows Principal '*' with NO narrowing Condition, and BPA is NOT restricting (RestrictPublicBuckets and BlockPublicPolicy both off). Live public exposure: anyone on the internet can perform the granted actions.
- AP-PUBLIC (critical, LIVE) -- an access point's own policy allows Principal '*' with no Condition and the AP's own BPA is not restricting. Data is reachable publicly through the access point even when the bucket policy and bucket BPA are clean. Auditing only the bucket misses this.
- XACCT-POLICY (high, LIVE) -- the bucket policy grants a named other account. This is NOT public, so BPA does not govern it: a cross-account grant stays fully live even with all four BPA switches on. The classic misread is seeing BPA-all-on and calling the bucket locked down.
- XACCT-ACL (high, LIVE) -- the bucket ACL grants a canonical user that is not the bucket owner. IgnorePublicAcls neutralises the public GROUPS, not a named canonical user, so this grant survives BPA-all-on. A TLS-only Deny in the policy does not address it.
- ACL-PUBLIC (high, LIVE) -- the bucket ACL grants the AllUsers / AuthenticatedUsers group and IgnorePublicAcls is OFF. Live public via ACL, independent of the bucket policy.
These five are the only codes that count as live exposure. The next three READ as exposed but are NOT live, and must never be reported as a live public/exposed bucket:
- POLICY-PUBLIC-BLOCKED (info, neutralised) -- a Principal '*' policy that RestrictPublicBuckets / BlockPublicPolicy renders inert. Effective verdict: NOT public. Note it as latent risk (turning BPA off would expose it), not as live exposure.
- ACL-PUBLIC-IGNORED (info, neutralised) -- a public-group ACL grant that IgnorePublicAcls makes ineffective. Effective verdict: NOT public via this ACL.
- COND-SCOPED (low, scoped) -- a Principal '' narrowed by a Condition (org / IP / source account or VPC / ExternalId / access-point delegation). This is conditional or org-scoped sharing, NOT public: a caller outside the condition is denied. Reading Principal '' and stopping there is the misread this guards against.
3. Surface the buried live needle (LOAD-BEARING)
On a NEEDLE estate, name the ONE genuinely live bucket as A (the) PRIMARY finding, with the reason it is live, instead of burying it among the neutralised lookalikes or missing it. The live bucket reads just like its neutralised/scoped siblings; the pass is naming exactly that one and why:
- A cross-account policy or ACL survives BPA-all-on (XACCT-POLICY / XACCT-ACL): BPA only neutralises public grants.
- A missing Condition makes a Principal '*' policy public (POLICY-PUBLIC) when its scoped-looking siblings carry an org id / ExternalId / SourceIp.
- A named canonical user survives IgnorePublicAcls (XACCT-ACL) when the AllUsers grants beside it are correctly ignored.
State the live bucket by name, the code, and the layer it is grounded in.
4. Stay quiet on the deceptive-clean estate (LOAD-BEARING)
This is the half the naive read gets wrong in the other direction. An estate where every bucket is neutralised or scoped is CLEAN, and the audit must say so instead of manufacturing a finding. The composition in step 2 is what proves it. Specifically:
- A public-looking ACL with IgnorePublicAcls on is not live. Do not report it as a public bucket.
- A Principal '*' policy with RestrictPublicBuckets / BlockPublicPolicy on is not live. Do not report it as a public bucket.
- A Principal '*' narrowed by org / IP / external-id / access-point delegation is scoped sharing, not public. Do not read Principal '*' and call it public.
- Do not drown the clean verdict, or the one real finding, in a wall of nitpicks about the correctly-neutralised buckets.
On a clean estate the audit reports: NO live exposure anywhere, why the exposed-looking buckets are neutralised or scoped (the BPA switch or the Condition), and the boundary. It does not invent a critical. Noting the neutralised statements as latent / defence-in-depth is fine; asserting live public exposure is not.
5. Rank and report, then name the boundary
Order findings by severity (critical for a public policy or public access point, high for a cross-account or public-group grant). Rank the live needle as the headline; do NOT headline a neutralised/scoped bucket, and on a clean estate do not invent a critical. For each finding: the bucket and layer it is grounded in, what the exposure is, and the fix. Then list the boundary from "What this skill reads." A clean estate still gets a boundary section.
Recommendations
Fix the live bucket; do not rip out intentional scoped sharing or the BPA-neutralised buckets as if they were live leaks:
- POLICY-PUBLIC / AP-PUBLIC: remove the public statement, or replace Principal '*' with the specific accounts/roles that need access. If public read is genuinely intended (a static site), front it with CloudFront + Origin Access Control instead of a public bucket or access point, and turn RestrictPublicBuckets on.
- XACCT-POLICY / XACCT-ACL: confirm the other account is a deliberate, current trust and
the actions are minimal; scope to specific prefixes and prefer an
aws:PrincipalOrgID/sts:ExternalIdcondition over a bare account root. For an ACL grant, express the sharing as a scoped bucket policy and disable ACLs with Bucket Owner Enforced. BPA-all-on does not make a cross-account bucket safe. - ACL-PUBLIC: remove the public ACL grant and set IgnorePublicAcls + BlockPublicAcls; prefer bucket policies over ACLs.
- Neutralised / scoped buckets (POLICY-PUBLIC-BLOCKED, ACL-PUBLIC-IGNORED, COND-SCOPED): no live fix. As defence in depth, optionally remove the latent public statement or ignored ACL so the bucket does not depend on BPA staying on as its only guardrail; for a scoped bucket, confirm the condition value (the org id, the ExternalId, the SourceIp CIDRs) is the intended one and leave the grant in place if the scope is correct.
Severity model
| Severity | Meaning |
|---|---|
| critical | Live public exposure: a Principal '*' bucket policy with BPA not restricting (POLICY-PUBLIC), or a public access-point policy (AP-PUBLIC). |
| high | Live cross-account or public-group exposure that BPA does not close: cross-account policy (XACCT-POLICY), cross-account canonical-user ACL (XACCT-ACL), public-group ACL with IgnorePublicAcls off (ACL-PUBLIC). |
| low | A Principal '*' scoped by a Condition (COND-SCOPED): conditional sharing to verify, not public exposure. |
| info | A neutralised grant present but inert (POLICY-PUBLIC-BLOCKED, ACL-PUBLIC-IGNORED): latent risk if BPA is turned off, not live exposure. |
Only the critical and high bands are LIVE exposure and count toward the estate verdict. The low and info bands are exposed-looking-but-not-live; they are notes, never the headline.
Rule reference
| Code | Rule | Severity | Live | Grounded in |
|---|---|---|---|---|
| POLICY-PUBLIC | Bucket policy Principal '*', no Condition, BPA not restricting | critical | yes | bucket policy x BPA |
| AP-PUBLIC | Access-point policy Principal '*', AP BPA not restricting | critical | yes | access-point policy x AP BPA |
| XACCT-POLICY | Bucket policy grants a named other account | high | yes | bucket policy (BPA does not touch it) |
| XACCT-ACL | Bucket ACL grants a non-owner canonical user | high | yes | bucket ACL (IgnorePublicAcls does not touch it) |
| ACL-PUBLIC | Bucket ACL grants AllUsers / AuthenticatedUsers, IgnorePublicAcls off | high | yes | bucket ACL x BPA |
| COND-SCOPED | Principal '*' narrowed by a Condition | low | no | bucket policy Condition |
| POLICY-PUBLIC-BLOCKED | Principal '*' policy neutralised by RestrictPublicBuckets / BlockPublicPolicy | info | no | bucket policy x BPA |
| ACL-PUBLIC-IGNORED | Public-group ACL neutralised by IgnorePublicAcls | info | no | bucket ACL x BPA |
The matching half of every live rule is the clean verdict: POLICY-PUBLIC neutralised to POLICY-PUBLIC-BLOCKED, ACL-PUBLIC neutralised to ACL-PUBLIC-IGNORED, a Principal '*' scoped to COND-SCOPED, on an estate of these is the correct, complete output, not a failure to find something. Reporting a neutralised bucket as a live leak is the dominant failure mode this skill prevents.
Output format
The agent's final message in any invocation must include:
- Estate: bucket count, the entry question (public exposure across the estate).
- Findings: ranked by severity, each with the code, the bucket and layer it is grounded in, what the exposure is, and the fix. The live needle named explicitly as the headline. Or "no live exposure" for a deceptive-clean estate, stating why the exposed-looking buckets are neutralised or scoped.
- Boundary: the joins this audit could not make (per-object ACLs, CloudFront/CDN fronting, the trusted principals' identity policies, the account-level BPA dependency, VPC endpoint policies / org SCPs, data sensitivity), stated explicitly so the gap is visible instead of silent.
Worked examples
Seven end-to-end fixtures are committed under fixtures/, each an estate of 8-12 buckets
with a runnable replay test. The set is deliberately weighted toward deceptive-clean, because
over-flagging a neutralised estate is the cold agent's dominant failure here. No loud, obvious
public bucket appears: the base model already aces those.
05-logging-estate-needle: the needle. acme-log-shipping has all four BPA switches on (reads as locked down) but its policy grants a named other account read/list. A cross-account grant survives BPA: XACCT-POLICY (high), live.06-analytics-estate-needle: acme-analytics-clickstream grants Principal '*' GetObject with NO Condition and BPA not restricting, sitting next to siblings that carry an org id / ExternalId or have RestrictPublicBuckets on: POLICY-PUBLIC (critical), live.07-partner-share-needle: acme-share-partner-drop grants READ to another account's canonical user via its ACL; IgnorePublicAcls (on for this estate) only ignores the AllUsers lookalikes beside it: XACCT-ACL (high), live.01-media-platform-clean: an AllUsers ACL, a Principal '*' policy, org/IP-scoped policies, and an access-point delegation, all neutralised or scoped. Clean.02-data-lake-clean: public-looking policies and a public ACL, all neutralised by BPA or scoped by org-path / external-id. Clean.03-saas-tenancy-clean: tenant-shared buckets scoped by org id / ExternalId, plus one ignored AllUsers ACL. Clean.04-backup-estate-clean: BPA-neutralised policies, an ignored public ACL, and a SourceIp office allowlist. Clean.
Replay tests
Every fixture has a replay test in tests/ that runs the methodology (via the deterministic
reference engine tests/_resolve.py, aggregated across the estate by tests/_estate.py)
against the committed JSON, with no external credentials. Run from the skill directory:
for t in tests/replay_*.py; do python "$t" || exit 1; done
The seven tests cover the three needle estates (the one live code fires, on the named bucket)
and the four deceptive-clean estates (no live finding fabricated). Tests exit non-zero if the
audit names the wrong bucket or invents one on a clean estate. See
tests/README.md for the fixture schema.
Failure modes
This skill is wrong in predictable ways. Read FAILURE_MODES.md before
relying on it. Highlights:
- It audits effective access on paper, not exploitability. A live grant can reach a tier with no sensitive objects, or one fronted by a CDN, or one whose trusted account does nothing with it. Exposure-on-paper is a hypothesis to confirm, not a breach.
- A clean verdict depends on BPA staying on. The neutralised buckets are one BPA toggle away from live; "clean" means clean today, not proven-safe.
- It reasons over the four bucket-config layers only. A public object ACL, a CloudFront distribution, a per-object grant, or an account-level BPA the per-bucket config does not carry is outside the graph this skill builds.
Anyshift integration (opt-in)
The audit above runs end-to-end against the bucket-config JSON the user already has. No Anyshift dependency.
Every boundary note in this skill is a join: bucket to its per-object ACLs, bucket to its CloudFront distribution, bucket to the identity policies of the principals it trusts, bucket to its VPC-endpoint policies and org SCPs, estate to the account-level BPA the neutralisation depends on. The Anyshift MCP can act as a context primer by resolving those joins from a versioned resource graph, so an XACCT-POLICY finding ("cross-account, real only if the trusted account is privileged or re-shares") can be closed instead of deferred at the boundary. A measured "with vs without" delta will be published here once the integration has been exercised against the replay fixtures.
Files (sre-skills)
-
fixtures
-
01-media-platform-clean
-
01-acme-media-thumbnails
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 127 B
{ "bucket": "acme-media-thumbnails", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> grant is ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
02-acme-media-origin
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 266 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "LegacyPublicRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-media-origin/*" ] } ] } -
meta.json 129 B
{ "bucket": "acme-media-origin", "note": "Principal '*' GetObject but RestrictPublicBuckets=true -> neutralised, not live." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
03-acme-media-shared-config
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 451 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OrgRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::acme-media-shared-config", "arn:aws:s3:::acme-media-shared-config/*" ], "Condition": { "StringEquals": { "aws:PrincipalOrgID": "o-7x3k9m2qab" } } } ] } -
meta.json 127 B
{ "bucket": "acme-media-shared-config", "note": "Principal '*' narrowed by aws:PrincipalOrgID -> org-scoped, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
04-acme-media-uploads
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 103 B
{ "bucket": "acme-media-uploads", "note": "Owner-only ACL, no policy, BPA all on. Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
05-acme-media-transcode-tmp
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 85 B
{ "bucket": "acme-media-transcode-tmp", "note": "Clean private working bucket." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
06-acme-media-cdn-logs
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 378 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyNonTLS", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::acme-media-cdn-logs", "arn:aws:s3:::acme-media-cdn-logs/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false" } } } ] } -
meta.json 102 B
{ "bucket": "acme-media-cdn-logs", "note": "Only a TLS-only Deny; a Deny grants nothing. Clean." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
07-acme-media-office-exports
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 433 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OfficeIPRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-media-office-exports/*" ], "Condition": { "IpAddress": { "aws:SourceIp": [ "203.0.113.0/24", "198.51.100.0/24" ] } } } ] } -
meta.json 138 B
{ "bucket": "acme-media-office-exports", "note": "Principal '*' narrowed by aws:SourceIp office allowlist -> IP-scoped, not public." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
08-acme-media-delivery-ap
-
access-points.json 725 B
[ { "Name": "media-delivery-ap", "PublicAccessBlock": { "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }, "Policy": { "Version": "2012-10-17", "Statement": [ { "Sid": "APDelegate", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:us-east-1:111122223333:accesspoint/media-delivery-ap/object/*" ], "Condition": { "StringEquals": { "s3:DataAccessPointAccount": "111122223333" } } } ] } } ] -
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 141 B
{ "bucket": "acme-media-delivery-ap", "note": "Bucket clean; access point delegates to a named account (s3 AP delegation), not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
09-acme-media-archive
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 72 B
{ "bucket": "acme-media-archive", "note": "Clean private archive." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
10-acme-media-staging
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 102 B
{ "bucket": "acme-media-staging", "note": "Clean private staging (BPA restrict on, owner-only)." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
meta.json 376 B
{ "estate": "media-platform", "kind": "deceptive-clean", "note": "10-bucket media platform. Several buckets READ as exposed (public ACL grant, public-looking policy, Principal '*' policies) but every one is neutralised by BPA or scoped by a Condition. The engine reports NO live exposure. The trap is flagging the neutralised/scoped lookalikes as live public buckets." }
-
-
02-data-lake-clean
-
01-acme-lake-raw
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 59 B
{ "bucket": "acme-lake-raw", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
02-acme-lake-curated
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 497 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OrgPathRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::acme-lake-curated", "arn:aws:s3:::acme-lake-curated/*" ], "Condition": { "ForAnyValue:StringLike": { "aws:PrincipalOrgPaths": [ "o-7x3k9m2qab/r-root/ou-data/*" ] } } } ] } -
meta.json 122 B
{ "bucket": "acme-lake-curated", "note": "Principal '*' narrowed by aws:PrincipalOrgPaths -> OU-scoped, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
03-acme-lake-glue-scripts
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 68 B
{ "bucket": "acme-lake-glue-scripts", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
04-acme-lake-athena-results
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 270 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OldPublicRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-lake-athena-results/*" ] } ] } -
meta.json 136 B
{ "bucket": "acme-lake-athena-results", "note": "Principal '*' GetObject but RestrictPublicBuckets=true -> neutralised, not live." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
05-acme-lake-emr-logs
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 376 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyNonTLS", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::acme-lake-emr-logs", "arn:aws:s3:::acme-lake-emr-logs/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false" } } } ] } -
meta.json 76 B
{ "bucket": "acme-lake-emr-logs", "note": "TLS-only Deny only. Clean." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
06-acme-lake-export
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 416 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PartnerExtId", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-lake-export", "arn:aws:s3:::acme-lake-export/*" ], "Condition": { "StringEquals": { "sts:ExternalId": "acme-partner-7731" } } } ] } -
meta.json 119 B
{ "bucket": "acme-lake-export", "note": "Principal '*' narrowed by sts:ExternalId -> scoped sharing, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
07-acme-lake-quarantine
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 117 B
{ "bucket": "acme-lake-quarantine", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
08-acme-lake-checkpoints
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 67 B
{ "bucket": "acme-lake-checkpoints", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
09-acme-lake-models
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 62 B
{ "bucket": "acme-lake-models", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
10-acme-lake-feature-store
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 69 B
{ "bucket": "acme-lake-feature-store", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
11-acme-lake-tmp
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 59 B
{ "bucket": "acme-lake-tmp", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
meta.json 295 B
{ "estate": "data-lake", "kind": "deceptive-clean", "note": "11-bucket data lake. Multiple buckets carry public-looking policies and a public ACL, all neutralised by BPA or scoped by Conditions (org path, external id). No live exposure. Trap: over-flagging the neutralised lake buckets." }
-
-
03-saas-tenancy-clean
-
01-acme-tenant-assets
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 445 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "TenantOrgRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::acme-tenant-assets", "arn:aws:s3:::acme-tenant-assets/*" ], "Condition": { "StringEquals": { "aws:PrincipalOrgID": "o-7x3k9m2qab" } } } ] } -
meta.json 121 B
{ "bucket": "acme-tenant-assets", "note": "Principal '*' narrowed by aws:PrincipalOrgID -> org-scoped, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
02-acme-tenant-uploads
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 65 B
{ "bucket": "acme-tenant-uploads", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
03-acme-tenant-billing
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 378 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyNonTLS", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::acme-tenant-billing", "arn:aws:s3:::acme-tenant-billing/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false" } } } ] } -
meta.json 77 B
{ "bucket": "acme-tenant-billing", "note": "TLS-only Deny only. Clean." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
04-acme-tenant-partner-feed
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 432 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PartnerExtId", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-tenant-partner-feed", "arn:aws:s3:::acme-tenant-partner-feed/*" ], "Condition": { "StringEquals": { "sts:ExternalId": "acme-partner-7731" } } } ] } -
meta.json 119 B
{ "bucket": "acme-tenant-partner-feed", "note": "Principal '*' narrowed by sts:ExternalId -> scoped, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
05-acme-tenant-exports
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 116 B
{ "bucket": "acme-tenant-exports", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
06-acme-tenant-config
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 260 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OldPublic", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-tenant-config/*" ] } ] } -
meta.json 120 B
{ "bucket": "acme-tenant-config", "note": "Principal '*' GetObject but RestrictPublicBuckets=true -> neutralised." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
07-acme-tenant-backups
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 65 B
{ "bucket": "acme-tenant-backups", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
08-acme-tenant-logs
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 62 B
{ "bucket": "acme-tenant-logs", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
09-acme-tenant-search-index
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 70 B
{ "bucket": "acme-tenant-search-index", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
meta.json 287 B
{ "estate": "saas-tenancy", "kind": "deceptive-clean", "note": "9-bucket multi-tenant SaaS estate. Several tenant-shared buckets use Principal '*' narrowed by org id / external id, and one public ACL is ignored by BPA. No live exposure. Trap: reading Principal '*' and stopping." }
-
-
04-backup-estate-clean
-
01-acme-backup-daily
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 63 B
{ "bucket": "acme-backup-daily", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
02-acme-backup-weekly
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 64 B
{ "bucket": "acme-backup-weekly", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
03-acme-backup-snapshots
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 264 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "LegacyRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-backup-snapshots/*" ] } ] } -
meta.json 123 B
{ "bucket": "acme-backup-snapshots", "note": "Principal '*' GetObject but RestrictPublicBuckets=true -> neutralised." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
04-acme-backup-db-dumps
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 380 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyNonTLS", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::acme-backup-db-dumps", "arn:aws:s3:::acme-backup-db-dumps/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false" } } } ] } -
meta.json 78 B
{ "bucket": "acme-backup-db-dumps", "note": "TLS-only Deny only. Clean." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
05-acme-backup-restore-stage
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 122 B
{ "bucket": "acme-backup-restore-stage", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
06-acme-backup-office-share
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 428 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OfficeIP", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-backup-office-share/*" ], "Condition": { "IpAddress": { "aws:SourceIp": [ "203.0.113.0/24", "198.51.100.0/24" ] } } } ] } -
meta.json 120 B
{ "bucket": "acme-backup-office-share", "note": "Principal '*' narrowed by aws:SourceIp -> IP-scoped, not public." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
07-acme-backup-config
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 64 B
{ "bucket": "acme-backup-config", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
08-acme-backup-vault
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 63 B
{ "bucket": "acme-backup-vault", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
09-acme-backup-archive-glacier
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 73 B
{ "bucket": "acme-backup-archive-glacier", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
10-acme-backup-test-restore
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 70 B
{ "bucket": "acme-backup-test-restore", "note": "Clean private." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
meta.json 295 B
{ "estate": "backup-estate", "kind": "deceptive-clean", "note": "10-bucket backup/DR estate. A few buckets carry public-looking policies and a public ACL grant, all neutralised by BPA; one Principal '*' is IP-scoped. No live exposure. Trap: over-flagging the neutralised backup buckets." }
-
-
05-logging-estate-needle
-
01-acme-log-app
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 58 B
{ "bucket": "acme-log-app", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
02-acme-log-access
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 61 B
{ "bucket": "acme-log-access", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
03-acme-log-cloudtrail
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 378 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyNonTLS", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::acme-log-cloudtrail", "arn:aws:s3:::acme-log-cloudtrail/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false" } } } ] } -
meta.json 77 B
{ "bucket": "acme-log-cloudtrail", "note": "TLS-only Deny only. Clean." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
04-acme-log-vpc-flow
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 63 B
{ "bucket": "acme-log-vpc-flow", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
05-acme-log-elb
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 254 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OldPublic", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-log-elb/*" ] } ] } -
meta.json 124 B
{ "bucket": "acme-log-elb", "note": "Principal '*' GetObject but RestrictPublicBuckets=true -> neutralised, not live." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
06-acme-log-shipping
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 387 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PartnerLogIngest", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::905638217741:root" }, "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::acme-log-shipping", "arn:aws:s3:::acme-log-shipping/*" ] } ] } -
meta.json 215 B
{ "bucket": "acme-log-shipping", "note": "NEEDLE. BPA all on (reads locked down) but the policy grants a named other account (905638217741) read. Cross-account is NOT public, so BPA does not block it -> LIVE." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
07-acme-log-waf
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 58 B
{ "bucket": "acme-log-waf", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
08-acme-log-cloudfront
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 116 B
{ "bucket": "acme-log-cloudfront", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
09-acme-log-archive
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 62 B
{ "bucket": "acme-log-archive", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
10-acme-log-metrics
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 62 B
{ "bucket": "acme-log-metrics", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
11-acme-log-audit
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 406 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OrgRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-log-audit", "arn:aws:s3:::acme-log-audit/*" ], "Condition": { "StringEquals": { "aws:PrincipalOrgID": "o-7x3k9m2qab" } } } ] } -
meta.json 117 B
{ "bucket": "acme-log-audit", "note": "Principal '*' narrowed by aws:PrincipalOrgID -> org-scoped, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
meta.json 403 B
{ "estate": "logging-estate", "kind": "single-needle", "note": "11-bucket logging estate. Exactly one bucket has a LIVE cross-account bucket policy (a named other account, NOT public, NOT blocked by BPA-all-on). Everything else is clean or neutralised. The needle is quiet: an unassuming log-shipping bucket with BPA all on (which reads as locked down) that still grants a partner account read." }
-
-
06-analytics-estate-needle
-
01-acme-analytics-events-org
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 453 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OrgRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::acme-analytics-events-org", "arn:aws:s3:::acme-analytics-events-org/*" ], "Condition": { "StringEquals": { "aws:PrincipalOrgID": "o-7x3k9m2qab" } } } ] } -
meta.json 128 B
{ "bucket": "acme-analytics-events-org", "note": "Principal '*' narrowed by aws:PrincipalOrgID -> org-scoped, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
02-acme-analytics-dashboards
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 71 B
{ "bucket": "acme-analytics-dashboards", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
03-acme-analytics-partner-extract
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 444 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PartnerExtId", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-analytics-partner-extract", "arn:aws:s3:::acme-analytics-partner-extract/*" ], "Condition": { "StringEquals": { "sts:ExternalId": "acme-partner-7731" } } } ] } -
meta.json 125 B
{ "bucket": "acme-analytics-partner-extract", "note": "Principal '*' narrowed by sts:ExternalId -> scoped, not public." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
04-acme-analytics-reports
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 384 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyNonTLS", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::acme-analytics-reports", "arn:aws:s3:::acme-analytics-reports/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false" } } } ] } -
meta.json 80 B
{ "bucket": "acme-analytics-reports", "note": "TLS-only Deny only. Clean." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
05-acme-analytics-clickstream
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 269 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PublicRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-analytics-clickstream/*" ] } ] } -
meta.json 275 B
{ "bucket": "acme-analytics-clickstream", "note": "NEEDLE. Principal '*' GetObject with NO Condition, and BPA does not restrict (RestrictPublicBuckets + BlockPublicPolicy both off) -> LIVE public. Looks like the conditional siblings but is missing the Condition block." } -
public-access-block.json 122 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": false, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
06-acme-analytics-ml-features
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 72 B
{ "bucket": "acme-analytics-ml-features", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
07-acme-analytics-staging
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 264 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OldPublic", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-analytics-staging/*" ] } ] } -
meta.json 134 B
{ "bucket": "acme-analytics-staging", "note": "Principal '*' GetObject but RestrictPublicBuckets=true -> neutralised, not live." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
08-acme-analytics-warehouse
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 70 B
{ "bucket": "acme-analytics-warehouse", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
09-acme-analytics-ingest
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 67 B
{ "bucket": "acme-analytics-ingest", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
10-acme-analytics-tmp
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 64 B
{ "bucket": "acme-analytics-tmp", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
meta.json 422 B
{ "estate": "analytics-estate", "kind": "single-needle", "note": "10-bucket analytics estate. Several buckets use Principal '*' narrowed by a Condition (org / external id), which are scoped and fine. Exactly one unassuming bucket has a Principal '*' GetObject with NO condition and BPA NOT restricting -> LIVE public. The needle hides among the conditional lookalikes; the difference is a missing Condition block." }
-
-
07-partner-share-needle
-
01-acme-share-public-mirror
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 121 B
{ "bucket": "acme-share-public-mirror", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
02-acme-share-press-kit
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 117 B
{ "bucket": "acme-share-press-kit", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
03-acme-share-partner-drop
-
bucket-acl.json 594 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "CanonicalUser", "DisplayName": "vendor-bravo", "ID": "f1e2d3c4b5a6978869504a3b2c1d0e9f8a7b6c5d4e3f2918273645566778899a" }, "Permission": "READ" } ] } -
bucket-policy.json 386 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyNonTLS", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::acme-share-partner-drop", "arn:aws:s3:::acme-share-partner-drop/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false" } } } ] } -
meta.json 351 B
{ "bucket": "acme-share-partner-drop", "note": "NEEDLE. ACL grants READ to a canonical user that is NOT the owner (a different account). IgnorePublicAcls neutralises only the public GROUPS, not a cross-account canonical-user grant -> LIVE. The TLS-only Deny does not address it. Looks like the ignored public ACLs but is a named other identity." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
04-acme-share-downloads
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 66 B
{ "bucket": "acme-share-downloads", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
05-acme-share-uploads
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 64 B
{ "bucket": "acme-share-uploads", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
06-acme-share-config
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 259 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OldPublic", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-share-config/*" ] } ] } -
meta.json 119 B
{ "bucket": "acme-share-config", "note": "Principal '*' GetObject but RestrictPublicBuckets=true -> neutralised." } -
public-access-block.json 120 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": true }
-
-
07-acme-share-org-feed
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
bucket-policy.json 416 B
{ "Version": "2012-10-17", "Statement": [ { "Sid": "OrgRead", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::acme-share-org-feed", "arn:aws:s3:::acme-share-org-feed/*" ], "Condition": { "StringEquals": { "aws:PrincipalOrgID": "o-7x3k9m2qab" } } } ] } -
meta.json 122 B
{ "bucket": "acme-share-org-feed", "note": "Principal '*' narrowed by aws:PrincipalOrgID -> org-scoped, not public." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
08-acme-share-archive
-
bucket-acl.json 373 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" } ] } -
meta.json 64 B
{ "bucket": "acme-share-archive", "note": "Clean private." } -
public-access-block.json 119 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }
-
-
09-acme-share-staging
-
bucket-acl.json 531 B
{ "Owner": { "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Grants": [ { "Grantee": { "Type": "CanonicalUser", "DisplayName": "acme-prod", "ID": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e9" }, "Permission": "FULL_CONTROL" }, { "Grantee": { "Type": "Group", "URI": "http://acs.amazonaws.com/groups/global/AllUsers" }, "Permission": "READ" } ] } -
meta.json 115 B
{ "bucket": "acme-share-staging", "note": "AllUsers READ ACL but IgnorePublicAcls=true -> ignored, not live." } -
public-access-block.json 121 B
{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": false, "RestrictPublicBuckets": false }
-
-
meta.json 518 B
{ "estate": "partner-share", "kind": "single-needle", "note": "9-bucket partner-sharing estate. Several buckets carry an AllUsers public ACL grant that is IGNORED by IgnorePublicAcls (not live). Exactly one bucket grants READ to a different account's CANONICAL USER via ACL -- IgnorePublicAcls only neutralises the public GROUPS, so a cross-account canonical-user grant stays LIVE even with BPA tightened. The needle is a single ACL grant that looks like the ignored public ones but is a named other identity." }
-
-
-
tests
-
eval
-
eval_results.json 635.9 KB
[ { "fixture": "01-media-platform-clean", "condition": "control", "trial": 0, "score": 4, "scores_by_item": [ 1, 1, 0, 1, 0, 1, 0 ], "notes": [ "Agent demonstrates awareness of all four config layers (BPA booleans, bucket policy Principal/Condition, ACL Grantee, access points) and processes multiple buckets across the estate.", "This is a CLEAN estate with no live exposure; the agent correctly identifies that IgnorePublicAcls neutralises the ACL and RestrictPublicBuckets neutralises the public policy, and explicitly states the bucket is 'not publicly readable at this moment' -- so it does not assert live exposure as a definitive conclusion.", "Agent fails this item: it classifies two buckets as CRITICAL and two as HIGH, framing them as findings of serious exploitable risk rather than purely latent/defence-in-depth concerns; on a clean estate this constitutes over-flagging neutralised baits as live/near-live critical exposure, which the rubric prohibits.", "Agent correctly composes effective access by combining BPA x policy x ACL layers -- e.g., notes IgnorePublicAcls neutralises the AllUsers ACL, RestrictPublicBuckets neutralises the public policy, and the Condition scopes the org-ID and IP statements -- rather than reading any single layer in isolation.", "Agent invents two CRITICAL findings on a clean estate, headlining neutralised/scoped buckets rather than reporting the clean verdict as the headline; the rubric requires no invented critical on a clean estate.", "Agent names multiple things it cannot determine from bucket configs alone, including account-level BPA, CloudFront/OAC configuration, IAM policies controlling who can modify bucket config, and per-object ACLs -- all matching ground-truth boundary items.", "The top recommended fix is to remediate the thumbnails bucket ACL and BPA gaps as if they represent live risk, but the ground truth says the estate is clean and the correct top fix is no live remediation needed (only optional defence-in-depth); the agent's prescription treats neutralised baits as live leaks requiring immediate action." ], "agent_output": "# S3 Public-Exposure Security Review \u2014 `media-platform`\n\n---\n\n## Severity-Ranked Findings\n\n### \ud83d\udd34 CRITICAL \u2014 2 findings\n\n---\n\n#### CRIT-1 \u00b7 `acme-media-thumbnails` \u2014 ACL grants public READ to `AllUsers`; two BPA settings are OFF\n\n**Exact config causing the exposure:**\n```json\n\"Grantee\": {\n \"Type\": \"Group\",\n \"URI\": \"http://acs.amazonaws.com/groups/global/AllUsers\"\n},\n\"Permission\": \"READ\"\n```\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true, \u2190 these two are fine\n\"BlockPublicPolicy\": false, \u2190 gap 1\n\"RestrictPublicBuckets\": false \u2190 gap 2\n```\n\n**Why this is actively exploitable right now:**\n\n`IgnorePublicAcls: true` *does* neutralise the `AllUsers` ACL grant \u2014 so the ACL itself is not the live exposure vector. However, `BlockPublicPolicy: false` and `RestrictPublicBuckets: false` together mean:\n\n- Anyone with `s3:PutBucketPolicy` on this bucket (a compromised CI role, a misconfigured IAM policy, etc.) can attach a public bucket policy and it will take effect immediately with no BPA safety net.\n- `RestrictPublicBuckets: false` means that if such a policy is ever written, S3 will serve unauthenticated requests against it.\n\nThe ACL itself is a latent risk: if `IgnorePublicAcls` is ever toggled off (e.g., during an \"emergency\" config change), the `AllUsers READ` grant becomes live instantly. The ACL should be removed regardless.\n\n**The combination of a pre-existing public ACL grant + two BPA controls disabled is the highest-risk posture in this estate.**\n\n---\n\n#### CRIT-2 \u00b7 `acme-media-origin` \u2014 Bucket policy grants `s3:GetObject` to `Principal: \"*\"`; `RestrictPublicBuckets` is ON but the policy is still present and was presumably effective before BPA was set\n\n**Exact config:**\n```json\n\"Sid\": \"LegacyPublicRead\",\n\"Effect\": \"Allow\",\n\"Principal\": \"*\",\n\"Action\": [\"s3:GetObject\"],\n\"Resource\": [\"arn:aws:s3:::acme-media-origin/*\"]\n```\n```json\n\"BlockPublicPolicy\": false,\n\"RestrictPublicBuckets\": true \u2190 this one IS set\n```\n\n**Current effective access:** `RestrictPublicBuckets: true` causes S3 to block public and cross-account access to this bucket even when a public policy exists, so unauthenticated `GetObject` requests are currently rejected. The bucket is **not publicly readable at this moment**.\n\n**Why it is still critical:**\n\n1. The `\"LegacyPublicRead\"` Sid is a red flag \u2014 this policy was written when the bucket *was* public. The origin bucket almost certainly contains master/high-resolution media assets. The policy is one config toggle away from full public exposure.\n2. `BlockPublicPolicy: false` means a new or modified public policy can be written at any time without BPA blocking it.\n3. The policy should not exist at all. Its presence is an incident waiting to happen (accidental BPA change, Terraform drift, etc.).\n\n---\n\n### \ud83d\udfe0 HIGH \u2014 2 findings\n\n---\n\n#### HIGH-1 \u00b7 `acme-media-office-exports` \u2014 IP-restricted public policy with BPA partially disabled\n\n**Exact config:**\n```json\n\"BlockPublicPolicy\": false,\n\"RestrictPublicBuckets\": false\n```\n```json\n\"Condition\": {\n \"IpAddress\": {\n \"aws:SourceIp\": [\"203.0.113.0/24\", \"198.51.100.0/24\"]\n }\n}\n```\n\n**Risk breakdown:**\n\n| Issue | Detail |\n|---|---|\n| `Principal: \"*\"` with IP condition | The policy is technically a public policy. AWS evaluates the condition at request time, but the policy itself is unrestricted by identity. |\n| `RestrictPublicBuckets: false` | S3 will serve the policy as written. No safety net. |\n| `BlockPublicPolicy: false` | Additional public statements can be added without BPA rejection. |\n| IP condition bypass vectors | `aws:SourceIp` does **not** apply when the request is made through a VPC endpoint (`aws:VpcSourceIp` is the correct key for that path). A request routed through a VPC endpoint in the same or another account would not match the IP condition and the Allow would still apply \u2014 potentially granting access to any principal that can reach the bucket via a VPC endpoint. |\n| IP range ownership | Cannot verify from this config alone whether 203.0.113.0/24 and 198.51.100.0/24 are actually ACME-controlled ranges (see \"Cannot Determine\" section). Note: these are RFC 5737 documentation ranges, which is suspicious in a production config. |\n\n**Preferred fix:** Replace the IP-based public policy with an IAM-identity-based policy (specific roles/users) and enable all four BPA settings.\n\n---\n\n#### HIGH-2 \u00b7 `acme-media-thumbnails` \u2014 `BlockPublicPolicy` and `RestrictPublicBuckets` both false (policy-level gap, separate from CRIT-1)\n\nAlready partially covered in CRIT-1, but worth calling out independently: even if the ACL were cleaned up, this bucket has **zero policy-level BPA protection**. Any bucket policy written to it \u2014 by any principal with `s3:PutBucketPolicy` \u2014 will be evaluated and enforced without restriction. This is a standing misconfiguration independent of the ACL issue.\n\n---\n\n### \ud83d\udfe1 MEDIUM \u2014 3 findings\n\n---\n\n#### MED-1 \u00b7 `acme-media-staging` \u2014 `BlockPublicPolicy: false` with `RestrictPublicBuckets: true`\n\n```json\n\"BlockPublicPolicy\": false,\n\"RestrictPublicBuckets\": true\n```\n\nNo public policy currently exists, so there is no active exposure. However, `BlockPublicPolicy: false` means a public policy can be written and will not be rejected at write time. `RestrictPublicBuckets: true` would then suppress its effect \u2014 but this is a defence-in-depth gap. A staging bucket that accidentally gets a public policy written to it has only one BPA layer protecting it instead of two. Staging environments frequently contain pre-production content that may be sensitive.\n\n---\n\n#### MED-2 \u00b7 `acme-media-delivery-ap` \u2014 Access point policy uses `Principal: \"*\"` with account condition; semantics require scrutiny\n\n**Exact policy:**\n```json\n\"Principal\": \"*\",\n\"Action\": [\"s3:GetObject\"],\n\"Condition\": {\n \"StringEquals\": {\n \"s3:DataAccessPointAccount\": \"111122223333\"\n }\n}\n```\n\n`s3:DataAccessPointAccount` restricts access to requests made *through access points owned by account `111122223333`*. This is a legitimate delegation pattern. However:\n\n- `Principal: \"*\"` means any IAM principal (including in other accounts) that can invoke this access point can get objects, as long as the access point belongs to account `111122223333`. The condition does **not** restrict to specific IAM identities.\n- The bucket's own BPA is fully locked (`RestrictPublicBuckets: true`), which limits blast radius.\n- The access point's own BPA is also fully locked, which is correct.\n- Risk: if the access point policy is the only identity gate, any principal that can call `s3:GetObject` through *any* access point in account `111122223333` may be able to read objects. The intended scope may be narrower than what is expressed.\n\n**Recommendation:** Scope `Principal` to specific IAM roles/services rather than `\"*\"`, or add an additional condition such as `aws:PrincipalArn`.\n\n---\n\n#### MED-3 \u00b7 `acme-media-cdn-logs` \u2014 Log delivery may be broken; `BlockPublicAcls: true` blocks the S3 log delivery group ACL grant\n\nCloudFront/S3 server-access logging traditionally requires granting the S3 log delivery group (`http://acs.amazonaws.com/groups/s3-service/s3-log-delivery`) `WRITE` and `READ_ACP` permissions via ACL. With `BlockPublicAcls: true`, any attempt to set that ACL grant will be blocked. AWS now supports bucket-policy-based log delivery for server access logs (using the `logging.s3.amazonaws.com` service principal), but only if explicitly configured. There is no bucket policy granting log delivery here.\n\n**Risk:** CDN/access logs may be silently failing to write, creating a gap in your audit trail and incident-response capability. This is an operational and compliance risk.\n\n---\n\n### \ud83d\udd35 LOW / INFORMATIONAL \u2014 2 findings\n\n---\n\n#### LOW-1 \u00b7 `acme-media-origin` \u2014 Stale public ACL grant absent but stale *policy* present; no lifecycle/cleanup evidence\n\nThe `\"LegacyPublicRead\"` Sid strongly implies this policy is a historical artifact that was never removed. This suggests the estate may lack a policy lifecycle process. Recommend auditing all buckets for stale statements on a scheduled basis.\n\n---\n\n#### LOW-2 \u00b7 `acme-media-shared-config` \u2014 `Principal: \"*\"` with `PrincipalOrgID` condition; correct but carries org-wide blast radius\n\n```json\n\"Principal\": \"*\",\n\"Condition\": {\n \"StringEquals\": {\"aws:PrincipalOrgID\": \"o-7x3k9m2qab\"}\n}\n```\n\nThis is a well-understood and generally safe pattern. BPA is fully enabled. However, \"any principal in the org\" is a broad scope for a bucket named `shared-config`. If any account in the org is compromised, that account's principals can read (and list) this bucket's contents. Recommend scoping to specific OUs or roles if the config data contains secrets, credentials, or internal topology information.\n\n---\n\n## The Single Most Important Fix\n\n> **Remove the `AllUsers READ` ACL grant from `acme-media-thumbnails` AND set all four Block Public Access settings to `true`.**\n\nSpecifically:\n\n1. **Delete the ACL grant** \u2014 run:\n ```bash\n aws s3api put-bucket-acl \\\n --bucket acme-media-thumbnails \\\n --acl private\n ```\n\n2. **Enable the two missing BPA settings:**\n ```bash\n aws s3api put-public-access-block \\\n --bucket acme-media-thumbnails \\\n --public-access-block-configuration \\\n \"BlockPublicAcls=true,IgnorePublicAcls=true,\\\n BlockPublicPolicy=true,RestrictPublicBuckets=true\"\n ```\n\n**Why this one first:** It is the only bucket in the estate with a pre-existing public ACL grant *and* two BPA controls disabled. While `IgnorePublicAcls` currently neutralises the ACL, the combination of a loaded gun (the ACL) and a missing safety (BPA gaps) in the same bucket represents the highest probability \u00d7 impact risk. A single erroneous `put-public-access-block` call \u2014 from a Terraform run, a runbook error, or a compromised pipeline \u2014 could expose the bucket's contents to the entire internet with no further action required.\n\n---\n\n## What Cannot Be Determined From This Configuration Alone\n\n| Unknown | Why It Matters |\n|---|---|\n| **Account-level Block Public Access settings** | If BPA is enforced at the account level, it overrides per-bucket settings and several findings above are mitigated. If it is off, the per-bucket gaps are fully exposed. This is the single most important missing data point. |\n| **AWS Organizations SCP enforcement** | An SCP could deny `s3:PutBucketPolicy` or `s3:PutBucketPublicAccessBlock` org-wide, which would significantly change the risk of the \"one toggle away\" findings. |\n| **IAM policies \u2014 who has `s3:PutBucketPolicy`, `s3:PutBucketAcl`, `s3:PutPublicAccessBlock`** | The risk of CRIT-1 and CRIT-2 depends heavily on how many principals can modify bucket config. A tightly scoped IAM estate reduces the likelihood of accidental or malicious misconfiguration. |\n| **Whether 203.0.113.0/24 and 198.51.100.0/24 are ACME-controlled** | These are RFC 5737 documentation/example ranges. If these are the literal values in production, the IP condition in `office-exports` may be referencing ranges ACME does not own, making the condition meaningless or incorrect. |\n| **CloudFront OAC/OAI configuration for `acme-media-origin` and `acme-media-thumbnails`** | If CloudFront is the intended access path, the origin bucket should be locked to an OAC/OAI principal, not `Principal: \"*\"`. Whether OAC is configured cannot be determined from S3 config alone. |\n| **Access point `media-delivery-ap` \u2014 which services/roles are actually invoking it** | The `Principal: \"*\"` + account condition pattern's real-world scope depends entirely on what IAM policies grant `s3:GetObject` through this access point. |\n| **Encryption configuration (SSE-S3, SSE-KMS)** | Not shown. Relevant for the archive and origin buckets in particular. |\n| **Object ownership settings** | Relevant to whether ACL grants can be set by uploaders on individual objects, especially for `uploads` and `transcode-tmp`. |\n| **Versioning and MFA-delete status** | Relevant for `archive` and `origin` \u2014 ransomware/deletion protection cannot be assessed. |\n| **Whether CDN log delivery is actually succeeding** | MED-3 flags a likely misconfiguration, but actual delivery status requires checking CloudWatch metrics or the destination prefix. |", "agent_chars": 12174, "agent_s": 75.1, "elapsed_s": 95.6 }, { "fixture": "01-media-platform-clean", "condition": "control", "trial": 1, "score": 4, "scores_by_item": [ 1, 1, 0, 1, 0, 1, 0 ], "notes": [ "Agent reads all four config layers (BPA booleans, policy Principal/Condition, ACL Grantee, access-point policy) across all 10 buckets, correctly identifying BPA switches and Condition scoping.", "Estate is clean (no live exposure); agent correctly identifies zero live/exposed buckets and reports no bucket as genuinely live-public, satisfying the clean-estate requirement for this load-bearing item.", "Agent repeatedly asserts live/critical exposure for neutralised buckets (e.g., acme-media-origin 'CRITICAL', acme-media-thumbnails 'CRITICAL', acme-media-office-exports 'HIGH') even though BPA neutralises every one \u2014 this directly violates the rubric's prohibition on flagging neutralised buckets as live exposed.", "Agent correctly combines BPA x policy x ACL x access-point layers, notes that IgnorePublicAcls saves the ACL bucket and RestrictPublicBuckets saves the policy bucket, and understands that conditions scope Principal '*' grants.", "Agent invents two CRITICAL findings on a clean estate (acme-media-origin and acme-media-thumbnails) and headlines them as such, directly violating the requirement not to headline neutralised buckets or manufacture criticals on a clean estate.", "Agent names multiple things it cannot determine from bucket configs alone: per-object ACLs, account-level BPA, CloudFront/OAC configuration, IAM blast radius, VPC endpoint policies \u2014 these match the ground-truth boundary list.", "Agent's top fix prescribes deleting the LegacyPublicRead policy and enabling all four BPA settings on acme-media-origin as if it were a live leak, rather than framing it as optional defence-in-depth on a clean estate; this does not match the ground-truth recommendation." ], "agent_output": "# S3 Public-Exposure Security Review \u2014 `media-platform`\n\n---\n\n## Findings Ranked by Severity\n\n---\n\n### \ud83d\udd34 CRITICAL \u2014 1: `acme-media-origin` \u2014 Public read policy is silently neutralized, but the policy itself is a latent loaded gun\n\n**What the config shows:**\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true,\n\"BlockPublicPolicy\": false,\n\"RestrictPublicBuckets\": true \u2190 this one saves you, today\n```\nThe bucket policy contains an unrestricted `s3:GetObject` for `Principal: \"*\"` with no conditions whatsoever:\n```json\n{\n \"Sid\": \"LegacyPublicRead\",\n \"Effect\": \"Allow\",\n \"Principal\": \"*\",\n \"Action\": [\"s3:GetObject\"],\n \"Resource\": [\"arn:aws:s3:::acme-media-origin/*\"]\n}\n```\n**Why this is critical despite `RestrictPublicBuckets: true`:**\n\n`RestrictPublicBuckets` is the only control blocking public access here. `BlockPublicPolicy` is **false**, meaning anyone with `s3:PutBucketPolicy` on this bucket can overwrite the policy without hitting a block. The naked `Principal: *` policy already exists in the config \u2014 one toggle flip on `RestrictPublicBuckets` (an ops mistake, a Terraform drift, a misconfigured pipeline) and every origin media file becomes publicly readable on the internet with zero additional attacker action required. The policy is already written; the door is already unlocked on one side.\n\nThis is also your most sensitive bucket \u2014 origin files are the highest-value content in the estate.\n\n**The name `LegacyPublicRead` is a red flag.** Someone knew this was a public-read policy and left it in place.\n\n---\n\n### \ud83d\udd34 CRITICAL \u2014 2: `acme-media-thumbnails` \u2014 ACL grants public READ to `AllUsers`, and block public access is **not fully enabled**\n\n**What the config shows:**\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true, \u2190 ACLs are ignored, saves you today\n\"BlockPublicPolicy\": false,\n\"RestrictPublicBuckets\": false \u2190 no safety net on policies\n```\nThe ACL explicitly grants:\n```json\n{\n \"Grantee\": {\n \"Type\": \"Group\",\n \"URI\": \"http://acs.amazonaws.com/groups/global/AllUsers\"\n },\n \"Permission\": \"READ\"\n}\n```\n`IgnorePublicAcls: true` means this ACL grant is currently ignored by S3. **However:**\n\n- `BlockPublicPolicy: false` \u2014 a public bucket policy could be added at any time without being blocked\n- `RestrictPublicBuckets: false` \u2014 if a public policy were added, it would take full effect\n- `IgnorePublicAcls: false` \u2014 if this setting were ever toggled off (e.g., by someone \"fixing\" a perceived misconfiguration), the `AllUsers READ` ACL immediately activates\n\nThis bucket has **two independent paths to public exposure**, each requiring only a single configuration change. The ACL itself should be removed regardless of whether it is currently being ignored \u2014 it represents persistent misconfiguration debt.\n\n---\n\n### \ud83d\udfe0 HIGH \u2014 3: `acme-media-office-exports` \u2014 Conditionally public bucket with weak block public access posture\n\n**What the config shows:**\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true,\n\"BlockPublicPolicy\": false, \u2190 policy can be replaced without a block\n\"RestrictPublicBuckets\": false \u2190 public policies take full effect\n```\nThe bucket policy allows `s3:GetObject` for `Principal: \"*\"` conditioned on source IP:\n```json\n\"Condition\": {\n \"IpAddress\": {\n \"aws:SourceIp\": [\"203.0.113.0/24\", \"198.51.100.0/24\"]\n }\n}\n```\n**Issues:**\n\n1. **`aws:SourceIp` does not work as expected when access goes through VPC endpoints.** If any internal service accesses this bucket via a VPC endpoint, the source IP condition may not evaluate as intended, potentially allowing or denying access unexpectedly.\n2. **`RestrictPublicBuckets: false` + `BlockPublicPolicy: false`** means this is a publicly accessible bucket (AWS will classify it as public because `Principal: *` exists, even with conditions). The IP condition is the only access control, and it can be replaced without any block.\n3. **\"Office exports\" suggests potentially sensitive business documents.** This is a high-value target for the IP condition to be the sole protection mechanism.\n4. The IP ranges `203.0.113.0/24` and `198.51.100.0/24` are **TEST-NET ranges (RFC 5737)** \u2014 documentation/example IPs that are not routable on the public internet. If these are the actual configured IPs, **the condition matches nothing in production**, and the policy effectively allows no one in \u2014 but this also means the intended office access is broken, and if someone \"fixes\" it by broadening the condition, exposure follows immediately.\n\n---\n\n### \ud83d\udfe0 HIGH \u2014 4: `acme-media-delivery-ap` \u2014 Access point policy `Principal: \"*\"` with account-ID condition is weaker than it appears\n\n**What the config shows:**\n```json\n{\n \"Sid\": \"APDelegate\",\n \"Effect\": \"Allow\",\n \"Principal\": \"*\",\n \"Action\": [\"s3:GetObject\"],\n \"Condition\": {\n \"StringEquals\": {\n \"s3:DataAccessPointAccount\": \"111122223333\"\n }\n }\n}\n```\nThe condition `s3:DataAccessPointAccount` checks that the access point belongs to account `111122223333`. This is a valid pattern for access point delegation, **but:**\n\n- `Principal: \"*\"` with only an account-ownership condition means **any IAM principal in any account** can potentially use this access point if they can reach it \u2014 the condition only verifies the access point's owning account, not the caller's identity or account.\n- The bucket's own policy is not shown with a corresponding delegation statement. For access points to work correctly, the **bucket policy must also delegate to the access point** (via `s3:DataAccessPointArn` condition or similar). Without seeing that bucket policy, it is unclear whether the access point is the enforced path or just one of multiple paths.\n- The access point block public access settings are all `true` (correct), but the `Principal: \"*\"` in the access point policy means AWS may still classify this as a public access point depending on evaluation context.\n\n---\n\n### \ud83d\udfe1 MEDIUM \u2014 5: `acme-media-staging` \u2014 Inconsistent block public access posture\n\n**What the config shows:**\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true,\n\"BlockPublicPolicy\": false, \u2190 can accept a public policy\n\"RestrictPublicBuckets\": true \u2190 would block it if added\n```\nNo bucket policy currently exists. The asymmetry between `BlockPublicPolicy: false` and `RestrictPublicBuckets: true` is the same partial-protection pattern seen in `acme-media-origin`. A public policy could be written to this bucket without being blocked at write time, though `RestrictPublicBuckets` would prevent it from taking effect. This is defense-in-depth debt \u2014 one setting away from exposure.\n\n---\n\n### \ud83d\udfe1 MEDIUM \u2014 6: `acme-media-shared-config` \u2014 Org-scoped policy is correct, but `ListBucket` deserves scrutiny\n\n**What the config shows:**\n\nThe policy is well-constructed \u2014 `Principal: \"*\"` scoped by `aws:PrincipalOrgID` is the correct pattern for org-wide sharing, and all four block public access settings are `true`. This bucket is **not publicly exposed**.\n\nHowever:\n- `s3:ListBucket` is granted org-wide. Any principal in the org can enumerate all object keys in this bucket. If the shared config contains keys that reveal internal architecture, environment names, secrets paths, or other sensitive naming conventions, this is an information disclosure risk within the org's blast radius.\n- \"Shared config\" buckets frequently accumulate sensitive content over time (credentials, connection strings, feature flags with internal details). The read grant should be reviewed to confirm it needs to be org-wide rather than scoped to specific roles or accounts.\n\n---\n\n### \ud83d\udfe2 LOW / INFORMATIONAL \u2014 7: `acme-media-cdn-logs` \u2014 Missing log delivery ACL grant\n\nThe bucket has all block public access settings enabled and a TLS-enforcement deny policy (correct). However, **no ACL grant for the CloudFront or S3 log delivery principal is visible**. CloudFront standard logging requires either:\n- An ACL grant to the `awslogsdelivery` canonical user, or\n- A bucket policy allowing `s3:PutObject` from the logging service principal\n\nNeither is present. This may mean logging is silently failing, or the grant exists at the object level and wasn't captured in this config pull.\n\n---\n\n### \ud83d\udfe2 LOW / INFORMATIONAL \u2014 8: No account-level S3 Block Public Access shown\n\nThe review covers only per-bucket settings. If an **account-level Block Public Access** policy were enabled (all four settings `true`), it would override and harden several of the bucket-level gaps above. Its absence from this config dump means it either isn't enabled or wasn't captured.\n\n---\n\n## Summary Table\n\n| Bucket | Severity | Core Issue |\n|---|---|---|\n| `acme-media-origin` | \ud83d\udd34 Critical | Naked `Principal:*` policy exists; one toggle from full public read |\n| `acme-media-thumbnails` | \ud83d\udd34 Critical | `AllUsers READ` ACL present; `RestrictPublicBuckets` false |\n| `acme-media-office-exports` | \ud83d\udfe0 High | RFC 5737 test IPs likely broken; `RestrictPublicBuckets` false |\n| `acme-media-delivery-ap` | \ud83d\udfe0 High | AP policy `Principal:*`; bucket delegation policy not confirmed |\n| `acme-media-staging` | \ud83d\udfe1 Medium | `BlockPublicPolicy` false; inconsistent posture |\n| `acme-media-shared-config` | \ud83d\udfe1 Medium | `ListBucket` org-wide; content sensitivity unknown |\n| `acme-media-cdn-logs` | \ud83d\udfe2 Low | Log delivery grant not visible; logging may be failing |\n| Account level | \ud83d\udfe2 Low | Account-level BPA status unknown |\n\n---\n\n## The Single Most Important Fix\n\n**Delete the `LegacyPublicRead` policy statement from `acme-media-origin` and enable all four Block Public Access settings on that bucket.**\n\nThis is the highest-priority action because:\n1. It is your most sensitive bucket (source-of-truth media content)\n2. The public-read policy is already written and in place \u2014 no attacker action needed to create it\n3. `BlockPublicPolicy: false` means the policy can be rewritten or re-enabled without any guard\n4. The name `LegacyPublicRead` indicates this was a known, intentional past decision that was never cleaned up \u2014 technical debt with a loaded blast radius\n\n**Concrete steps:**\n```bash\n# 1. Remove the policy entirely (confirm CloudFront OAC/OAI is the access mechanism)\naws s3api delete-bucket-policy --bucket acme-media-origin\n\n# 2. Enable full block public access\naws s3api put-public-access-block \\\n --bucket acme-media-origin \\\n --public-access-block-configuration \\\n \"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true\"\n```\nBefore deleting the policy, confirm that CloudFront is accessing this bucket via OAC (Origin Access Control) with a corresponding bucket policy scoped to the CloudFront distribution ARN \u2014 otherwise you will break CDN delivery.\n\n---\n\n## What Cannot Be Determined From This Configuration Alone\n\n| Gap | Why It Matters |\n|---|---|\n| **Account-level Block Public Access settings** | If all four are enabled at account level, several bucket-level gaps are mitigated. If not, the bucket-level gaps are fully exposed. |\n| **Whether `acme-media-office-exports` IP ranges are intentional** | `203.0.113.0/24` and `198.51.100.0/24` are RFC 5737 documentation ranges. If these are the real IPs, the policy matches nothing and office access is broken. Requires confirmation of actual office egress IPs. |\n| **`acme-media-delivery-ap` bucket policy** | The access point policy alone is insufficient \u2014 the underlying bucket must also have a policy delegating control to the access point. That bucket policy is not shown. |\n| **IAM policies and roles** | Any IAM principal with `s3:PutBucketPolicy`, `s3:PutBucketPublicAccessBlock`, or `s3:PutBucketAcl` on these buckets can undo the controls reviewed here. The IAM blast radius is invisible from S3 config alone. |\n| **CloudFront OAC/OAI configuration for `acme-media-origin`** | Whether CloudFront is correctly configured as the sole access path, making the legacy public policy truly redundant and safe to delete. |\n| **Object-level ACLs** | Bucket ACLs are shown, but individual objects may have their own ACLs (especially in `acme-media-thumbnails` where `AllUsers READ` was historically set at bucket level \u2014 objects created under that ACL may retain it). |\n| **S3 Object Ownership settings** | If Object Ownership is set to `BucketOwnerEnforced`, ACLs are disabled entirely. If not, object ACLs remain a live attack surface. This setting is not shown. |\n| **VPC endpoint policies** | Access from within the VPC may be governed by endpoint policies not visible here, which could grant or restrict access independently of bucket policies. |\n| **AWS Organizations SCP restrictions** | SCPs could prevent public access changes or could be absent, affecting the real exploitability of the gaps found. |\n| **CloudTrail / access logging** | Whether unauthorized access attempts against these buckets would be detected. `acme-media-cdn-logs` has a TLS policy but no confirmed log delivery. |", "agent_chars": 12815, "agent_s": 73.6, "elapsed_s": 92.3 }, { "fixture": "01-media-platform-clean", "condition": "control", "trial": 2, "score": 3, "scores_by_item": [ 1, 0, 0, 1, 0, 1, 0 ], "notes": [ "Agent correctly reads all four config layers (BPA booleans, bucket policy, ACL, access points) across the estate and recognises IgnorePublicAcls/RestrictPublicBuckets/Conditions as scoping mechanisms.", "Estate is CLEAN (zero live exposure); agent instead invents multiple CRITICAL/HIGH live findings, so it fails to correctly report no live exposure.", "Agent reports acme-media-thumbnails as CRITICAL live exposure despite IgnorePublicAcls:true neutralising the AllUsers ACL, and reports acme-media-office-exports as HIGH live exposure despite the IP Condition scoping it \u2014 both are non-live per the reference engine.", "Agent does attempt to compose BPA x policy x ACL x access-point layers per bucket and understands that IgnorePublicAcls neutralises the ACL grant at request time, showing awareness of multi-layer interaction.", "Agent headlines two CRITICAL findings on a clean estate, manufacturing live exposure that does not exist; the correct headline is that there is no live exposure.", "Agent names multiple things it cannot determine from bucket configs alone \u2014 account-level BPA, CloudFront/CDN fronting, per-object ACLs, IAM permissions, SCPs \u2014 matching the ground-truth boundary.", "Top fix prescribes removing ACLs and hardening BPA as if acme-media-thumbnails is a live leaking bucket, when the correct top fix is no live remediation required (optionally remove latent grants as defence-in-depth); this contradicts the clean verdict." ], "agent_output": "# S3 Public-Exposure Security Review \u2014 `media-platform`\n\n---\n\n## How AWS Public-Access Controls Actually Interact (Reference Frame)\n\nBefore findings, the evaluation logic matters because it's frequently misunderstood:\n\n| Setting | What it blocks |\n|---|---|\n| `BlockPublicAcls` | Prevents *new* public ACL grants being applied |\n| `IgnorePublicAcls` | Makes S3 *ignore* any existing public ACL grants at request time |\n| `BlockPublicPolicy` | Prevents *new* bucket policies that grant public access from being saved |\n| `RestrictPublicBuckets` | Makes S3 restrict access to only AWS services and authorized users, even if a public policy exists |\n\n`IgnorePublicAcls` neutralizes ACL-based exposure. `RestrictPublicBuckets` neutralizes policy-based public exposure. They are independent axes. A bucket is only fully hardened when all four are `true`.\n\n---\n\n## Findings, Ranked by Severity\n\n---\n\n### \ud83d\udd34 CRITICAL \u2014 Finding 1: `acme-media-thumbnails` \u2014 Live Public READ via ACL\n\n**Bucket:** `acme-media-thumbnails`\n\n**The specific configuration:**\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true, \u2190 should neutralize ACL\n\"BlockPublicPolicy\": false,\n\"RestrictPublicBuckets\": false\n```\n```json\n{\n \"Grantee\": {\n \"Type\": \"Group\",\n \"URI\": \"http://acs.amazonaws.com/groups/global/AllUsers\"\n },\n \"Permission\": \"READ\"\n}\n```\n\n**Why this is the most dangerous finding despite `IgnorePublicAcls: true`:**\n\n`IgnorePublicAcls: true` *should* neutralize the `AllUsers READ` ACL grant, and at the bucket-direct-request level it does. However, `RestrictPublicBuckets: false` means no policy-level public restriction is enforced. The ACL grant to `AllUsers` is sitting in the configuration and is one setting flip away from being live. More critically: `BlockPublicPolicy: false` means **anyone with `s3:PutBucketPolicy` permission can attach a public bucket policy right now with no guard rail**. The ACL itself is a latent exposure that becomes fully active the moment `IgnorePublicAcls` is toggled or a public policy is added.\n\n**Concrete risk:** The `AllUsers READ` ACL grant is real and recorded. If `IgnorePublicAcls` is ever set to `false` (misconfiguration, Terraform drift, console accident), every object in this bucket becomes publicly readable immediately, with no other change required. Given this is a thumbnails bucket, it likely contains a large volume of enumerable media objects.\n\n**Fix:** \n1. Remove the `AllUsers READ` ACL grant immediately\n2. Set all four Block Public Access flags to `true`\n\n---\n\n### \ud83d\udd34 CRITICAL \u2014 Finding 2: `acme-media-origin` \u2014 Bucket Policy Grants `s3:GetObject` to `Principal: \"*\"` but `RestrictPublicBuckets` Saves It \u2014 Barely\n\n**Bucket:** `acme-media-origin`\n\n**The specific configuration:**\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true,\n\"BlockPublicPolicy\": false, \u2190 did not block this policy being saved\n\"RestrictPublicBuckets\": true \u2190 this is the only thing preventing public access\n```\n```json\n{\n \"Sid\": \"LegacyPublicRead\",\n \"Effect\": \"Allow\",\n \"Principal\": \"*\",\n \"Action\": [\"s3:GetObject\"],\n \"Resource\": [\"arn:aws:s3:::acme-media-origin/*\"]\n}\n```\n\n**Why this is critical:**\n\n`RestrictPublicBuckets: true` is currently preventing this policy from granting public access \u2014 S3 will restrict responses to AWS service principals and authorized IAM principals only. The bucket is **not currently publicly accessible**. However:\n\n- The policy statement `Principal: \"*\"` with `s3:GetObject` and no conditions is a fully-formed public-read policy sitting in the bucket\n- `BlockPublicPolicy: false` means this policy was allowed to be saved, and future modifications to it face no guard rail\n- This is a single-setting failure away from full public exposure of your **origin media content** \u2014 the highest-value bucket in the estate\n- The Sid `\"LegacyPublicRead\"` is an explicit signal this was intentionally public at some point and was not cleaned up\n\n**Concrete risk:** If `RestrictPublicBuckets` is set to `false` by any means (console, IaC drift, API call by any principal with `s3:PutBucketPublicAccessBlock`), all origin media objects become publicly readable with zero additional changes. Origin buckets typically contain unprocessed, full-resolution, or pre-watermarked content.\n\n**Fix:**\n1. Delete the `LegacyPublicRead` policy statement entirely \u2014 it serves no legitimate purpose if CloudFront/OAC is the intended access path\n2. Replace with an explicit OAC or IAM-role-scoped policy\n3. Set `BlockPublicPolicy: true`\n4. Set `RestrictPublicBuckets: true` (already set \u2014 keep it)\n\n---\n\n### \ud83d\udfe0 HIGH \u2014 Finding 3: `acme-media-office-exports` \u2014 Conditionally Public Policy with No `RestrictPublicBuckets`\n\n**Bucket:** `acme-media-office-exports`\n\n**The specific configuration:**\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true,\n\"BlockPublicPolicy\": false,\n\"RestrictPublicBuckets\": false\n```\n```json\n{\n \"Sid\": \"OfficeIPRead\",\n \"Effect\": \"Allow\",\n \"Principal\": \"*\",\n \"Action\": [\"s3:GetObject\"],\n \"Resource\": [\"arn:aws:s3:::acme-media-office-exports/*\"],\n \"Condition\": {\n \"IpAddress\": {\n \"aws:SourceIp\": [\"203.0.113.0/24\", \"198.51.100.0/24\"]\n }\n }\n}\n```\n\n**Analysis:**\n\nThe IP condition (`203.0.113.0/24`, `198.51.100.0/24`) does restrict access \u2014 these are the RFC 5737 documentation ranges, which raises a separate question (see \"Cannot Determine\" section). The policy is `Principal: \"*\"` with an IP condition, which AWS evaluates as a public policy (the principal is anonymous; the condition narrows it). `RestrictPublicBuckets: false` means this policy is actively enforced as written.\n\n**Specific risks:**\n1. **`aws:SourceIp` is bypassable via VPC endpoints** \u2014 if any VPC endpoint exists for S3, requests through it do not carry a source IP in the way this condition expects, and the condition may not evaluate as intended\n2. **`BlockPublicPolicy: false`** \u2014 no guard rail on future policy changes\n3. **`RestrictPublicBuckets: false`** \u2014 the public policy is live and enforced\n4. The IP ranges used (`203.0.113.0/24`, `198.51.100.0/24`) are IANA documentation/example ranges \u2014 see \"Cannot Determine\" section\n\n**Fix:**\n1. Verify the IP ranges are correct production office egress IPs (see below)\n2. Set `BlockPublicPolicy: true` and `RestrictPublicBuckets: true`\n3. Replace the `Principal: \"*\"` + IP condition pattern with IAM role-based access from a bastion or internal service, which is not bypassable via VPC endpoint routing\n4. If IP-based access is required, add `aws:ViaAWSService: false` or `aws:SourceVpc` conditions as appropriate\n\n---\n\n### \ud83d\udfe0 HIGH \u2014 Finding 4: `acme-media-thumbnails` \u2014 `BlockPublicPolicy: false` + `RestrictPublicBuckets: false` Creates an Unguarded Policy Attack Surface\n\nAlready partially covered in Finding 1, but worth isolating: this bucket has **no policy currently**, but with both `BlockPublicPolicy: false` and `RestrictPublicBuckets: false`, any principal with `s3:PutBucketPolicy` on this bucket can attach a `Principal: \"*\"` policy and it will be immediately enforced publicly. Combined with the latent `AllUsers` ACL, this bucket has the weakest overall posture in the estate.\n\n---\n\n### \ud83d\udfe1 MEDIUM \u2014 Finding 5: `acme-media-staging` \u2014 Inconsistent Block Public Access (`BlockPublicPolicy: false`)\n\n**Bucket:** `acme-media-staging`\n\n```json\n\"BlockPublicAcls\": true,\n\"IgnorePublicAcls\": true,\n\"BlockPublicPolicy\": false, \u2190 inconsistent\n\"RestrictPublicBuckets\": true\n```\n\nNo public policy currently exists, and `RestrictPublicBuckets: true` would neutralize one if added. However, `BlockPublicPolicy: false` means a public policy can be *saved* to this bucket without error. `RestrictPublicBuckets` then becomes the sole guard rail. Staging environments frequently contain production-representative data. The inconsistency also suggests this was configured without a deliberate rationale.\n\n**Fix:** Set `BlockPublicPolicy: true`. Low effort, eliminates the guard-rail gap.\n\n---\n\n### \ud83d\udfe1 MEDIUM \u2014 Finding 6: `acme-media-delivery-ap` \u2014 Access Point Policy `Principal: \"*\"` with Weak Condition\n\n**Access Point:** `media-delivery-ap`\n\n```json\n{\n \"Sid\": \"APDelegate\",\n \"Effect\": \"Allow\",\n \"Principal\": \"*\",\n \"Action\": [\"s3:GetObject\"],\n \"Resource\": [\"arn:aws:s3:us-east-1:111122223333:accesspoint/media-delivery-ap/object/*\"],\n \"Condition\": {\n \"StringEquals\": {\n \"s3:DataAccessPointAccount\": \"111122223333\"\n }\n }\n}\n```\n\n**Analysis:**\n\nThe `s3:DataAccessPointAccount` condition confirms the access point belongs to account `111122223333`, which is a valid delegation pattern. The bucket itself has all four Block Public Access flags set to `true`, which is correct. However:\n\n- `Principal: \"*\"` with `s3:DataAccessPointAccount` as the sole condition means **any unauthenticated or authenticated principal** that reaches this access point ARN can call `s3:GetObject` on any object (`/object/*`)\n- The condition only verifies the access point is in the right account \u2014 it does not restrict *who* can use it\n- This is appropriate if the access point is fronted by CloudFront with OAC, or if the bucket policy further restricts to the access point ARN \u2014 but neither is visible in this configuration\n- The wildcard `/object/*` grants access to all objects with no prefix restriction\n\n**Fix:** Narrow `Principal: \"*\"` to the specific IAM role, CloudFront OAC principal, or service principal that legitimately uses this access point. If CloudFront OAC is the consumer, the principal should be `{\"Service\": \"cloudfront.amazonaws.com\"}` with an `AWS:SourceArn` condition scoped to the distribution.\n\n---\n\n### \ud83d\udfe2 LOW \u2014 Finding 7: `acme-media-cdn-logs` \u2014 Log Delivery ACL Grant May Be Missing\n\n**Bucket:** `acme-media-cdn-logs`\n\nThe bucket has all four Block Public Access flags set to `true` and a correct TLS-enforcement deny policy. However, for CloudFront standard logging to write to an S3 bucket, the bucket ACL historically required a grant to the `LogDelivery` canonical user group (`http://acs.amazonaws.com/groups/s3/LogDelivery`). With `BlockPublicAcls: true`, no such ACL grant can be added. AWS has migrated CloudFront logging to use bucket policies with `s3:PutObject` granted to `delivery.logs.amazonaws.com` \u2014 but this is not present in the shown policy (which only contains the TLS deny). \n\n**Risk:** Logs may not be delivering, creating a gap in audit trail rather than a public exposure risk. This is a logging integrity issue, not a public access issue.\n\n**Fix:** Confirm log delivery is functioning. If not, add a bucket policy statement granting `s3:PutObject` to `delivery.logs.amazonaws.com` with an `aws:SourceAccount` condition.\n\n---\n\n### \ud83d\udfe2 LOW \u2014 Finding 8: `acme-media-shared-config` \u2014 `Principal: \"*\"` with OrgID Condition, Verify Org Scope\n\n```json\n\"Principal\": \"*\",\n\"Condition\": {\n \"StringEquals\": {\n \"aws:PrincipalOrgID\": \"o-7x3k9m2qab\"\n }\n}\n```\n\nThis is a well-understood and generally safe pattern for org-wide sharing. `RestrictPublicBuckets: true` and `BlockPublicPolicy: true` are both set. The risk is that `aws:PrincipalOrgID` grants access to **every principal in every account in the org**, including any compromised account, any account with overly permissive IAM, and any future accounts added to the org. If this bucket contains sensitive configuration (credentials, keys, internal endpoints), org-wide read is a broad blast radius.\n\n**Fix:** Scope to specific OUs (`aws:PrincipalOrgPaths`) or specific account IDs if the consumer set is known and bounded.\n\n---\n\n## The Single Most Important Fix\n\n**Remove the `AllUsers READ` ACL grant from `acme-media-thumbnails` and set all four Block Public Access flags to `true` on that bucket.**\n\nThis is the most urgent action because it is the only finding in the estate where a public grant (`AllUsers`) exists in a recorded, enforced ACL entry on a bucket that also has `RestrictPublicBuckets: false` and `BlockPublicPolicy: false`. The `IgnorePublicAcls: true` flag is the sole control preventing live public access right now. That is a single point of failure with no compensating control. The ACL grant should not exist at all.\n\n```bash\n# Remove the AllUsers READ grant (set ACL to private)\naws s3api put-bucket-acl \\\n --bucket acme-media-thumbnails \\\n --acl private\n\n# Then lock down all four BPA settings\naws s3api put-public-access-block \\\n --bucket acme-media-thumbnails \\\n --public-access-block-configuration \\\n \"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true\"\n```\n\n---\n\n## What Cannot Be Determined From This Configuration Alone\n\nThese are not minor caveats \u2014 several of them materially affect the severity of findings above:\n\n1. **Whether `203.0.113.0/24` and `198.51.100.0/24` are real office IP ranges.** Both are IANA-reserved documentation ranges (RFC 5737) that should never appear in production routing tables. If these are the actual values in the policy, the IP condition in `acme-media-office-exports` is either a placeholder that was never replaced with real IPs (meaning the condition may match nothing or behave unexpectedly) or a copy-paste error. This needs to be verified against actual office egress IP assignments immediately.\n\n2. **Whether an S3 account-level Block Public Access policy exists.** AWS allows a Block Public Access configuration at the account level that overrides bucket-level settings. If all four flags are set to `true` at the account level, several of the bucket-level gaps above are mitigated (though the ACL grant in `acme-media-thumbnails` and the latent policies still represent hygiene problems). This review cannot see account-level BPA settings.\n\n3. **Whether a Service Control Policy (SCP) restricts `s3:PutBucketPublicAccessBlock` or `s3:PutBucketPolicy`.** An SCP could prevent the guard-rail gaps from being exploited even without bucket-level controls. Not visible here.\n\n4. **Who has `s3:PutBucketPolicy` and `s3:PutBucketPublicAccessBlock` IAM permissions.** The blast radius of the `BlockPublicPolicy: false` findings depends entirely on how many principals can modify bucket policies. If it's tightly controlled, the risk is lower. If it's granted broadly (e.g., via `s3:*` or `*`), the risk is immediate.\n\n5. **What the access point `media-delivery-ap` is fronted by.** If CloudFront with OAC is the only network path to this access point, the `Principal: \"*\"` policy is less concerning. If the", "agent_chars": 14332, "agent_s": 81.8, "elapsed_s": 95.5 }, { "fixture": "01-media-platform-clean", "condition": "treatment", "trial": 0, "score": 7, "scores_by_item": [ 1, 1, 1, 1, 1, 1, 1 ], "notes": [ "Agent parses all four config layers for every one of the 10 buckets, correctly identifying BPA booleans, ACL grantees, policy principals/conditions, and access-point policies.", "Estate is clean; agent correctly reports zero live exposure and does not manufacture any live finding.", "No neutralised or scoped bucket is reported as live; agent correctly classifies ACL-PUBLIC-IGNORED, POLICY-PUBLIC-BLOCKED, and COND-SCOPED entries as non-live with clear reasoning.", "Effective verdict is composed by combining all four layers per bucket, correctly noting e.g. that RestrictPublicBuckets neutralises the origin policy and IgnorePublicAcls neutralises the thumbnails ACL, not by reading a single layer in isolation.", "Correctly reports no live/critical finding; headlines the clean verdict and ranks latent risks (origin policy, thumbnails ACL) appropriately as defence-in-depth without falsely elevating them.", "Names multiple boundary limitations the bucket configs cannot resolve: per-object ACLs, CloudFront/CDN fronting, and implicitly the account-level BPA dependency as a single-toggle latent risk.", "Top fix recommendation matches ground truth: no live remediation required; optional defence-in-depth to remove the latent LegacyPublicRead statement from acme-media-origin and the AllUsers ACL from acme-media-thumbnails, without treating scoped-sharing buckets as leaks." ], "agent_output": "I'll work through every bucket in order, pasting the BPA block verbatim before classifying any bucket that carries a public-looking grant, then compose the four layers into an effective verdict.\n\n---\n\n## Step 1 \u2014 Parse all four layers, per bucket\n\n### acme-media-thumbnails\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": false,\n \"RestrictPublicBuckets\": false\n}\n```\n\n- `IgnorePublicAcls`: **true** \u2014 existing public-group ACL grants are ignored.\n- `BlockPublicPolicy` / `RestrictPublicBuckets`: false \u2014 BPA does not restrict a public policy, but there is no bucket policy here.\n- **ACL**: AllUsers READ grant present. Grantee is the `AllUsers` group URI \u2014 a public-group grant.\n- **Policy**: none.\n- **Access points**: none.\n\n**Composition:** The AllUsers READ grant is a public-group ACL grant. `IgnorePublicAcls` is **true**, so it is neutralised.\n\n**Verdict: ACL-PUBLIC-IGNORED (info, neutralised).** Not live.\n\n---\n\n### acme-media-origin\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": false,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- `RestrictPublicBuckets`: **true** \u2014 a public bucket policy is denied at the resource level.\n- `BlockPublicPolicy`: false \u2014 BPA would not block the policy from being *set*, but `RestrictPublicBuckets` renders an existing public policy inert.\n- **Policy**: `Allow`, `Principal: \"*\"`, `s3:GetObject`, no Condition. This is a public-principal policy statement.\n- **ACL**: owner-only. No public grant.\n- **Access points**: none.\n\n**Composition:** The policy has `Principal: \"*\"` with no Condition. That would be POLICY-PUBLIC \u2014 except `RestrictPublicBuckets` is **true**. Under `RestrictPublicBuckets`, S3 denies any request that would be allowed by a public policy, rendering the statement inert regardless of `BlockPublicPolicy`.\n\n**Verdict: POLICY-PUBLIC-BLOCKED (info, neutralised).** Not live.\n\n---\n\n### acme-media-shared-config\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": true,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- All four switches on.\n- **Policy**: `Allow`, `Principal: \"*\"`, `s3:GetObject` + `s3:ListBucket`, **Condition** `aws:PrincipalOrgID: \"o-7x3k9m2qab\"`. The `*` principal is narrowed to callers inside the named AWS Organization.\n- **ACL**: owner-only.\n- **Access points**: none.\n\n**Composition:** `Principal: \"*\"` is scoped by `aws:PrincipalOrgID` \u2014 a recognised narrowing condition. Callers outside the org are denied. Additionally, BPA is all-on, which would neutralise it even if the condition were absent. The grant is org-scoped sharing, not public.\n\n**Verdict: COND-SCOPED (low, scoped).** Not live.\n\n---\n\n### acme-media-uploads\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": true,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- All four switches on.\n- **Policy**: none.\n- **ACL**: owner-only.\n- **Access points**: none.\n\n**Composition:** No public or cross-account grant of any kind. BPA all-on.\n\n**Verdict: CLEAN.** Not live.\n\n---\n\n### acme-media-transcode-tmp\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": true,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- All four switches on.\n- **Policy**: none.\n- **ACL**: owner-only.\n- **Access points**: none.\n\n**Composition:** No grants of any kind. BPA all-on.\n\n**Verdict: CLEAN.** Not live.\n\n---\n\n### acme-media-cdn-logs\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": true,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- All four switches on.\n- **Policy**: one statement, `Effect: Deny`, `Principal: \"*\"`, `aws:SecureTransport: false`. A Deny statement grants nothing and cannot make a bucket public; it is a TLS-enforcement guardrail only.\n- **ACL**: owner-only.\n- **Access points**: none.\n\n**Composition:** The only policy statement is a Deny. No Allow grants exist. BPA all-on.\n\n**Verdict: CLEAN.** Not live.\n\n---\n\n### acme-media-office-exports\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": false,\n \"RestrictPublicBuckets\": false\n}\n```\n\n- `BlockPublicPolicy`: **false**, `RestrictPublicBuckets`: **false** \u2014 BPA does not restrict a public policy on this bucket.\n- **Policy**: `Allow`, `Principal: \"*\"`, `s3:GetObject`, **Condition** `aws:SourceIp: [\"203.0.113.0/24\", \"198.51.100.0/24\"]`. The `*` principal is narrowed to two specific IP CIDRs.\n- **ACL**: owner-only.\n- **Access points**: none.\n\n**Composition:** `Principal: \"*\"` is scoped by `aws:SourceIp` \u2014 a recognised narrowing condition. Only requests originating from those two CIDRs are permitted; all other callers are implicitly denied. BPA is not restricting, but the Condition does the scoping work independently.\n\n**Verdict: COND-SCOPED (low, scoped).** Not live.\n\n---\n\n### acme-media-delivery-ap\n\n**Bucket BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": true,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- Bucket BPA all-on.\n- **Bucket policy**: none.\n- **Bucket ACL**: owner-only.\n\n**Access point: media-delivery-ap**\n\n**AP BPA (verbatim, from access-points.json):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": true,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- AP BPA all-on.\n- **AP Policy**: `Allow`, `Principal: \"*\"`, `s3:GetObject`, **Condition** `s3:DataAccessPointAccount: \"111122223333\"`. The `*` principal is narrowed by `s3:DataAccessPointAccount` \u2014 a recognised access-point delegation key that restricts the grant to requests routed through an access point owned by account `111122223333` (the same account as the bucket owner, per the ARN).\n\n**Composition:** The AP policy's `Principal: \"*\"` is scoped by `s3:DataAccessPointAccount`, a recognised narrowing condition. Callers not routing through an access point in that account are denied. Additionally, the AP's own BPA is all-on, which would neutralise a public AP policy independently. The grant is access-point-delegated, not public.\n\n**Verdict: COND-SCOPED (low, scoped) at the access point.** Not live.\n\n---\n\n### acme-media-archive\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": true,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- All four switches on.\n- **Policy**: none.\n- **ACL**: owner-only.\n- **Access points**: none.\n\n**Composition:** No grants of any kind. BPA all-on.\n\n**Verdict: CLEAN.** Not live.\n\n---\n\n### acme-media-staging\n\n**BPA (verbatim):**\n```json\n{\n \"BlockPublicAcls\": true,\n \"IgnorePublicAcls\": true,\n \"BlockPublicPolicy\": false,\n \"RestrictPublicBuckets\": true\n}\n```\n\n- `RestrictPublicBuckets`: **true** \u2014 a public policy is denied at the resource level.\n- `BlockPublicPolicy`: false \u2014 BPA would not block the policy from being set, but `RestrictPublicBuckets` neutralises any existing public policy.\n- **Policy**: none.\n- **ACL**: owner-only.\n- **Access points**: none.\n\n**Composition:** No policy exists. ACL is owner-only. Even if a public policy were added, `RestrictPublicBuckets` would neutralise it.\n\n**Verdict: CLEAN.** Not live.\n\n---\n\n## Step 2 \u2014 Estate roll-up\n\n| Bucket | Code | Severity | Live? |\n|---|---|---|---|\n| acme-media-thumbnails | ACL-PUBLIC-IGNORED | info | No |\n| acme-media-origin | POLICY-PUBLIC-BLOCKED | info | No |\n| acme-media-shared-config | COND-SCOPED | low | No |\n| acme-media-uploads | CLEAN | \u2014 | No |\n| acme-media-transcode-tmp | CLEAN | \u2014 | No |\n| acme-media-cdn-logs | CLEAN | \u2014 | No |\n| acme-media-office-exports | COND-SCOPED | low | No |\n| acme-media-delivery-ap | COND-SCOPED (AP) | low | No |\n| acme-media-archive | CLEAN | \u2014 | No |\n| acme-media-staging | CLEAN | \u2014 | No |\n\n**No bucket carries a live finding.** Every exposed-looking grant is either neutralised by a BPA switch or scoped by a Condition. This is a deceptive-clean estate.\n\n---\n\n## Final Report\n\n### 1. Estate\n\n10 buckets reviewed. Question: is any bucket in this estate effectively publicly or cross-account exposed?\n\n---\n\n### 2. Findings \u2014 NO LIVE EXPOSURE\n\nThere is no live public or cross-account exposure anywhere in this estate. The two buckets that read most alarmingly are both inert:\n\n**acme-media-origin \u2014 POLICY-PUBLIC-BLOCKED (info, neutralised)**\nThe bucket policy contains `\"Principal\": \"*\"` with `s3:GetObject` and no Condition \u2014 a statement that would be a critical public exposure if BPA were not restricting. It is not live because `RestrictPublicBuckets: true`. S3 denies any request that would be permitted by that public statement, regardless of the fact that `BlockPublicPolicy` is false. The statement is latent: if `RestrictPublicBuckets` were toggled off, this bucket would become publicly readable immediately, with no other change required.\n\n**acme-media-thumbnails \u2014 ACL-PUBLIC-IGNORED (info, neutralised)**\nThe ACL grants `AllUsers` READ \u2014 a public-group grant that would be live if `IgnorePublicAcls` were off. It is not live because `IgnorePublicAcls: true`. S3 ignores the grant entirely. Same latent-risk note: toggling `IgnorePublicAcls` off would expose the bucket via ACL.\n\n**acme-media-shared-config \u2014 COND-SCOPED (low, scoped)**\n`Principal: \"*\"` narrowed by `aws:PrincipalOrgID: \"o-7x3k9m2qab\"`. Callers outside that AWS Organization are denied. This is intentional org-internal sharing, not public exposure. BPA is also all-on as a second layer.\n\n**acme-media-office-exports \u2014 COND-SCOPED (low, scoped)**\n`Principal: \"*\"` narrowed by `aws:SourceIp` to two CIDRs (`203.0.113.0/24`, `198.51.100.0/24`). Only requests from those ranges are permitted. This is an IP-allowlisted office read, not public exposure. Note that `BlockPublicPolicy` and `RestrictPublicBuckets` are both false here \u2014 the Condition is the *only* guardrail. If the Condition were removed or the CIDRs widened to `0.0.0.0/0`, this bucket would become publicly readable with no BPA to catch it.\n\n**acme-media-delivery-ap \u2014 COND-SCOPED (low, scoped) at access point**\nThe access point policy has `Principal: \"*\"` narrowed by `s3:DataAccessPointAccount: \"111122223333\"`, restricting access to requests r -
eval_run.log 818 B · in bundle
-
judge_prompt.md 3.2 KB
# Judge prompt (reference) This file documents the judge contract. The runner (`run_eval.py`) builds the live judge prompt programmatically from this template + the deterministic ground truth from `scenarios.expected_estate()`. Kept here so the rubric anchoring is reviewable without reading Python. ## System > You are an expert AWS / cloud-security evaluator grading an S3 estate review against a > 7-item rubric. Each item is binary: 1 (pass) or 0 (fail). Be strict but fair; no partial > credit. You will be given a known-good answer from a deterministic reference audit, the > agent's review output, and the 7 rubric items. Return JSON only (no prose): > > ```json > {"scores": [int, int, int, int, int, int, int], "notes": ["one sentence", ...]} > ``` ## User (per trial) Provides: - **FIXTURE** id and **CONTEXT** (the generic estate description the agent saw). - **KNOWN-GOOD ANSWER** from the reused deterministic engine (verbatim per-bucket `s3-access-auditor` resolution, aggregated across the estate): - whether the estate is clean (no LIVE exposure anywhere); - the LIVE finding codes (the only thing that counts as exposure) + top severity; - the LIVE needle bucket(s), and how many of the estate's buckets are live; - the NON-LIVE bait codes also present (`POLICY-PUBLIC-BLOCKED`, `ACL-PUBLIC-IGNORED`, `COND-SCOPED`) -- buckets that READ as exposed but are neutralised by BPA or scoped by a Condition, which must NOT be flagged as live; - the expected headline (what the live defect actually is, or that it is clean); - the correct top fix; and - the boundary the audit cannot cross. - **AGENT REVIEW OUTPUT** (verbatim). - The **7 RUBRIC ITEMS** (see `rubric.md`), with the two load-bearing items called out: item 2 (surfaces the buried live needle as a primary finding, on the needle estates) and item 3 (does not over-flag the neutralised / scoped baits, on the deceptive-clean estates). ## Anchoring rules for the judge - The agent prompt was GENERIC ("review this for problems"); it did NOT name public exposure, cross-account access, BPA neutralisation, or the buried needle. Reward the agent only for what it surfaces on its own. Do not penalize phrasing; grade substance. - LIVE vs NEUTRALISED is the whole point. A `Principal '*'` ACL/policy that BPA neutralises (`IgnorePublicAcls` / `RestrictPublicBuckets` / `BlockPublicPolicy`), and a `Principal '*'` narrowed by a `Condition` (org / IP / external-id / access-point delegation), are NOT live exposure. Asserting they are live fails items 3 and 5. Noting them as latent / defence-in-depth is fine. - On the NEEDLE estates the live bucket reads just like its neutralised/scoped siblings. The pass is naming exactly that one bucket and why it is live (cross-account survives BPA; a missing Condition makes a `Principal '*'` policy public; a cross-account canonical user survives `IgnorePublicAcls`). Missing it, or burying it under the baits, fails items 2 and 4. - On the CLEAN estates the correct answer is "no live exposure." Manufacturing a live finding, or headlining a neutralised bucket as a public leak, fails items 2/3/5. - Match the top fix and the boundary by substance, not wording. Return JSON only. -
README.md 4.1 KB
# Eval: s3-estate-calibration-auditor (control-vs-treatment lift) Measures the lift the `s3-estate-calibration-auditor` `SKILL.md` gives over a cold agent on the effective-exposure calibration across an S3 estate. Control is the cold agent on a generic prompt; treatment prepends `SKILL.md` as the methodology. Both arms are graded against the same deterministic engine ground truth, so the score delta is attributable to the skill. ## The experiment The base model aces the obvious S3 needles (a loud public bucket, a clear cross-account grant: ~7.0). It is WEAK in a narrow region we located empirically: it **over-flags** BPA-neutralised / scoped-clean estates (~2.67-3.67) and **misses** one subtly-buried public / cross-account needle hidden among neutralised lookalikes (~3.67). This harness scopes every fixture to exactly that region. In **control**, a cold agent gets the raw config for EVERY bucket in an 8-12 bucket ESTATE and a GENERIC "review this for problems" prompt that does **not** name public exposure, cross-account access, BPA neutralisation, or the buried needle. In **treatment**, the same estate and ask are prefixed with `SKILL.md`. The question is the lift on getting the effective-exposure CALIBRATION right -- not flagging the buckets that LOOK exposed but are neutralised/scoped, and still catching the one quiet live needle. Seven fixtures, all hard-region: | Fixture | Verdict | What it tests | |---|---|---| | `01-media-platform-clean` | CLEAN (10 buckets) | ignored ACL + BPA-restricted policy + org/IP/AP-delegation scoping, no live | | `02-data-lake-clean` | CLEAN (11) | neutralised policies + ignored ACL + org-path/external-id scoping | | `03-saas-tenancy-clean` | CLEAN (9) | org/external-id scoped sharing + ignored ACL | | `04-backup-estate-clean` | CLEAN (10) | BPA-neutralised policies + ignored ACL + SourceIp scoping | | `05-logging-estate-needle` | NEEDLE: `XACCT-POLICY` | one cross-account policy (BPA-all-on bucket) buried among clean/neutralised | | `06-analytics-estate-needle` | NEEDLE: `POLICY-PUBLIC` | one unconditional public policy hidden among conditional lookalikes | | `07-partner-share-needle` | NEEDLE: `XACCT-ACL` | one cross-account canonical-user ACL among ignored public-ACL lookalikes | 4 deceptive-clean, 3 single-needle. No loud/obvious public bucket (the model already aces those). ## Run ```bash export ANTHROPIC_API_KEY=... pip install anthropic python tests/eval/run_eval.py --trials 3 # treatment arm (~42 LLM calls) python tests/eval/run_eval.py --conditions control,treatment --trials 3 # both arms, full lift python tests/eval/run_eval.py --conditions control --trials 1 --fixtures 01,06 # smoke test python tests/eval/run_eval.py --trials 3 --fresh # ignore prior results ``` `--conditions` defaults to `treatment` (control cells from the original screening run are reused from `eval_results.json`); pass `control,treatment` to run both arms in one pass. Defaults: `--trials 3`, agent + judge `claude-sonnet-4-6`, results in `eval_results.json`. Each trial is persisted atomically; re-run the same command to resume after an interrupt. ## Ground truth offline (no key) ```bash python tests/eval/scenarios.py # prints clean/needle verdict, live codes, needle bucket, baits ``` The judge is anchored to `scenarios.expected_estate()`, which runs the reused deterministic engine (verbatim per-bucket `_resolve.py`, aggregated across the estate by `_estate.py`). The two load-bearing rubric items are **item 2** (surfaces the buried live needle as a primary finding, on the needle estates) and **item 3** (does not over-flag the neutralised / scoped baits, on the deceptive-clean estates). See `rubric.md` and `judge_prompt.md`. ## Reading the result The summary prints, per fixture, the control mean, the treatment mean, and the lift, plus the two load-bearing item rates (item 2 surfaces the buried live needle, item 3 does not over-flag the neutralised / scoped baits) for each arm. The skill is working when treatment lifts the weak-region fixtures toward 6-7/7 and the item-2 / item-3 pass rates climb under treatment. The weakest treatment fixture is the next one to close with a `SKILL.md` edit. -
rubric.md 4.9 KB
# Rubric: s3-estate-calibration-auditor (control-only screening) Seven binary items. Each is 1 (pass) or 0 (fail); no partial credit. The judge grades the cold agent's audit of an S3 ESTATE (8-12 buckets) against the deterministic ground truth from the reused per-bucket engine, aggregated across the estate (`_estate.py` -> verbatim `_resolve.py`). The agent was given a GENERIC prompt: the raw `public-access-block.json` / `bucket-policy.json` / `bucket-acl.json` / `access-points.json` for EVERY bucket in the estate, and "review this for security/risk problems, rank by severity, give the top fix, and say what you cannot determine." The prompt did NOT name public exposure, cross-account access, Block Public Access neutralisation, the deceptive-clean baits, or the buried needle. Every fixture is from the HARD region we located empirically. The cold agent aces obvious public needles, so there are none here. Instead: 4 DECEPTIVE-CLEAN estates (several buckets LOOK exposed but are genuinely neutralised by `IgnorePublicAcls` / `RestrictPublicBuckets` / a narrowing `Condition`, so the engine reports NO live exposure) and 3 estates with exactly ONE quiet LIVE needle buried among many neutralised lookalikes. The screening question is whether the cold agent gets the effective-exposure CALIBRATION right: neither over-flagging the neutralised baits nor missing the buried live needle. 1. **Parse.** Reads the four config layers per bucket (the BPA booleans in `public-access-block.json`, the Principal/Condition in `bucket-policy.json`, the Grantee in `bucket-acl.json`, `access-points.json`), and processes EVERY bucket in the estate, not just a couple. Recognises `IgnorePublicAcls` / `BlockPublicPolicy` / `RestrictPublicBuckets` as the BPA switches and a narrowing `Condition` (`aws:PrincipalOrgID`, `sts:ExternalId`, `aws:SourceIp`, `s3:DataAccessPoint*`) as scoping a `Principal '*'`. 2. **Surfaces the buried live needle (LOAD-BEARING).** On a NEEDLE estate, names the one genuinely live bucket (a cross-account policy, an unconditional public policy, or a cross-account canonical-user ACL) as A (the) PRIMARY finding, with the reason it is live, rather than burying it among the neutralised lookalikes or missing it. On a CLEAN estate, correctly reports NO live exposure. This is the item the cold agent most often misses on the needle estates: the live bucket reads just like its neutralised/scoped siblings. 3. **Does not over-flag the neutralised / scoped baits (LOAD-BEARING).** Does NOT report a bucket that is neutralised by BPA (`IgnorePublicAcls` / `RestrictPublicBuckets` / `BlockPublicPolicy`) or scoped by a narrowing `Condition` as a LIVE public/exposed bucket. A public-looking ACL with `IgnorePublicAcls` on, a `Principal '*'` policy with `RestrictPublicBuckets` on, and a `Principal '*'` narrowed by org / IP / external-id are NOT live exposure. Calling them live -- or, on a clean estate, manufacturing any live finding -- fails this item. Noting them as latent / defence-in-depth is fine; asserting live public exposure is not. This is the item the cold agent most often fails on the deceptive-clean estates (the empirically-measured 2.67-3.67 region). 4. **Effective-access composition.** Resolves each bucket's EFFECTIVE verdict by combining BPA x policy x ACL x access points, not by reading one layer in isolation. Understands BPA neutralises PUBLIC grants but NOT cross-account grants (so BPA-all-on does not clear a cross-account policy/ACL), and that `IgnorePublicAcls` neutralises public GROUPS but not a cross-account canonical user. Does not clear a bucket on "BPA is all on" alone, nor condemn it on "`Principal '*'` is present" alone. 5. **Criticality.** Ranks the live needle as the headline (critical for a public policy, high for cross-account), and does NOT headline a neutralised/scoped bucket or (on a clean estate) invent a critical. Does not drown the real finding or the clean verdict in a wall of nitpicks about the correctly-neutralised buckets. 6. **Boundary.** Names at least one thing it cannot determine from the bucket configs alone, matching the ground-truth join: per-object ACLs, CloudFront/CDN fronting, the trusted account's identity policies, the account-level BPA dependency, or data sensitivity. 7. **Recommendation.** The top fix matches the ground truth in substance: fix / scope the one live bucket; or on a clean estate, no live fix beyond optional defence-in-depth and confirming the boundary. Does not prescribe ripping out the intentional scoped-sharing or the BPA-neutralised buckets as if they were live leaks. ## Verdict (computed from CONTROL means only) - Aggregate control mean **< 4.0/7**, or a **majority** of fixtures below 4.0 -> **BUILD** (cold agent is weak here; the skill is worth writing). - Aggregate **< 5.5/7** -> **MAYBE** (mixed; inspect per-fixture, especially items 2 and 3). - Otherwise -> **SKIP** (cold agent already strong; the skill adds little). -
run_eval.py 21.6 KB
""" Control-vs-treatment lift eval for the s3-estate-calibration-auditor skill. This harness measures the LIFT the SKILL.md gives over a cold agent on the EFFECTIVE-exposure calibration across an S3 ESTATE (8-12 buckets). The thing under test is the HARD region we located empirically: the cold agent ACES the obvious cross-account/public needles (7.0) but OVER-FLAGS BPA-neutralised / scoped-clean estates (2.67-3.67) and MISSES one subtly-buried public/cross-account needle among many neutralised lookalikes (3.67). Every fixture in this harness is scoped to that region: 4 deceptive-clean estates (several buckets LOOK exposed but are genuinely neutralised by IgnorePublicAcls / RestrictPublicBuckets / a narrowing Condition, so the engine reports NO live exposure) and 3 estates with exactly ONE quiet live needle buried among neutralised lookalikes. There is no loud, obvious public bucket -- the model already aces those. Two conditions: - Control: the agent gets the raw config (public-access-block.json / bucket-policy.json / bucket-acl.json / access-points.json) for EVERY bucket in the estate and a GENERIC "review this for problems" prompt that does NOT name public exposure, cross-account access, BPA neutralisation, the deceptive-clean baits, or the buried needle. It uses only what it brings from training. - Treatment: the SAME estate config and the SAME ask, with the skill's SKILL.md prepended as the methodology to apply. The only variable between arms is the skill, so the lift is attributable to it. Each output is graded against the 7-item rubric (rubric.md) by an LLM judge, anchored to the deterministic reference verdict (_estate.py via scenarios.py) as ground truth. The two load-bearing items are item 2 (surfaces the buried live needle as a primary finding) and item 3 (does not over-flag the neutralised / scoped baits). Requirements: - ANTHROPIC_API_KEY environment variable. - `pip install anthropic` (the only non-stdlib dependency in the repo; isolated to tests/eval/). Usage: python tests/eval/run_eval.py --trials 3 # treatment (default) python tests/eval/run_eval.py --conditions control,treatment --trials 3 python tests/eval/run_eval.py --conditions control --trials 1 --fixtures 01,06 # smoke test python tests/eval/run_eval.py --trials 3 --fresh # ignore prior results Resume: each completed trial is persisted immediately (atomic temp+rename), and a re-run reloads what is already on disk and fills only the missing (fixture, condition, trial) cells. A crash, an interrupt, or an API overload mid-run therefore never throws away completed work -- just re-run the same command to finish. Pass --fresh to start clean. Cost note: 7 fixtures x 1 condition x 3 trials, plus a judge call per output, is ~21 agent calls + ~21 judge calls = ~42 LLM calls. Run both conditions to double that. Expect a few dollars on Sonnet. """ from __future__ import annotations import argparse import json import os import statistics import sys import time from pathlib import Path try: from anthropic import Anthropic except ImportError: print("ERROR: anthropic SDK not installed. Run: pip install anthropic", file=sys.stderr) sys.exit(1) sys.path.insert(0, str(Path(__file__).parent)) from scenarios import SCENARIOS, load_fixture_text, expected_estate # noqa: E402 DEFAULT_AGENT_MODEL = os.environ.get("EVAL_AGENT_MODEL", "claude-sonnet-4-6") DEFAULT_JUDGE_MODEL = os.environ.get("EVAL_JUDGE_MODEL", "claude-sonnet-4-6") MAX_TOKENS = 4096 # SKILL.md lives at the skill root (three levels up from tests/eval/). SKILL_MD = Path(__file__).resolve().parents[2] / "SKILL.md" def build_control_prompt(scenario: dict) -> str: """A GENERIC cloud-security-engineer prompt: the raw full-estate bucket config JSON and a plain "review this for problems" ask. No skill. This is the whole point of the control arm: the prompt deliberately does NOT name public exposure, cross-account access, Block Public Access neutralisation, the deceptive-clean baits, or the buried needle. It does not even tell the agent what kind of issue to look for. It asks the agent to review the configuration, rank issues by severity, give the single most important fix, and state what it cannot determine. Whether the cold agent gets the EFFECTIVE-exposure calibration right -- not over-flagging the buckets that LOOK exposed but are neutralised by BPA / scoped by a Condition, and still catching the one quiet live needle buried among them -- is exactly what we are measuring. """ return f"""You are a cloud security engineer doing a review of an AWS account's S3 configuration. Context: {scenario['context']} Here is the configuration, exactly as returned by the S3 API: {load_fixture_text(scenario)} Review this for security and risk problems. Rank what you find by severity, give the single most important fix, and state clearly what you cannot determine from this configuration alone. Be specific and concrete about anything you flag.""" def build_treatment_prompt(scenario: dict, skill_md_text: str) -> str: """Treatment arm: the SAME review ask and the SAME estate config JSON as control, plus SKILL.md prepended as the methodology to apply. The only variable between arms is the skill, so the lift is attributable to it.""" return f"""You are a cloud security engineer doing a review of an AWS account's S3 configuration. Apply the methodology below to the estate that follows it. ==== METHODOLOGY (SKILL.md) ==== {skill_md_text} ==== END METHODOLOGY ==== Context: {scenario['context']} Here is the configuration, exactly as returned by the S3 API: {load_fixture_text(scenario)} Apply the methodology above. Resolve each bucket's effective verdict, name the one live bucket if there is one (or report no live exposure), rank what you find by severity, give the single most important fix, and state clearly what you cannot determine from this configuration alone. Be specific and concrete about anything you flag.""" JUDGE_SYSTEM = """You are an expert AWS / cloud-security evaluator grading an S3 estate review against a 7-item rubric. Each item is binary: 1 (pass) or 0 (fail). Be strict but fair; no partial credit. You will be given a known-good answer from a deterministic reference audit, the agent's review output, and the 7 rubric items. The agent was asked to review the estate ("review this for problems"); reward the agent only for what it surfaces by substance, and grade substance, not phrasing. Return JSON only (no prose), with this exact schema: { "scores": [<int>, <int>, <int>, <int>, <int>, <int>, <int>], "notes": ["<one sentence>", ...] }""" def build_judge_prompt(scenario: dict, agent_output: str) -> str: exp = expected_estate(scenario) needle = ", ".join(exp["needle_buckets"]) if exp["needle_buckets"] else "(none -- the estate is clean)" baits = sorted(set(exp["all_codes"]) - set(exp["codes"])) return f"""FIXTURE: {scenario['id']} CONTEXT (what the agent saw): {scenario['context']} KNOWN-GOOD ANSWER (from the deterministic reference engine -- verbatim per-bucket s3-access-auditor resolution, aggregated across the estate): - Estate clean (no LIVE exposure anywhere): {exp['clean']} - LIVE finding codes (the only thing that counts as exposure): {exp['codes']} (top severity: {exp['top_severity']}) - LIVE needle bucket(s): {needle} ({exp['live_bucket_count']} of {exp['bucket_count']} buckets live) - NON-LIVE bait codes also present (buckets that READ as exposed but are neutralised by BPA / scoped by a Condition -- these must NOT be flagged as live): {baits} - What the defect actually is (or that it is clean): {scenario['expected_headline']} - Correct top fix: {scenario['expected_top_fix']} - Boundary the audit cannot cross: {scenario['expected_boundary_join']} AGENT REVIEW OUTPUT: {agent_output} RUBRIC (score each 1 = pass, 0 = fail): 1. Parse: reads the four config layers per bucket (public-access-block.json BPA booleans, bucket-policy.json Principal/Condition, bucket-acl.json Grantee, access-points.json), and processes EVERY bucket in the estate rather than a couple. Recognises IgnorePublicAcls / BlockPublicPolicy / RestrictPublicBuckets as the BPA switches and a narrowing Condition (aws:PrincipalOrgID, sts:ExternalId, aws:SourceIp, s3:DataAccessPoint*) as scoping a Principal '*'. 2. Surfaces the buried live needle (LOAD-BEARING): on a NEEDLE estate, names the one genuinely-live bucket (cross-account policy / unconditional public policy / cross-account canonical-user ACL) as A (the) PRIMARY finding, with the reason it is live, rather than burying it among the neutralised lookalikes or missing it. On a CLEAN estate, correctly reports NO live exposure. 3. Does not over-flag the neutralised / scoped baits (LOAD-BEARING): does NOT report a bucket that is neutralised by BPA (IgnorePublicAcls / RestrictPublicBuckets / BlockPublicPolicy) or scoped by a narrowing Condition as a LIVE public/exposed bucket. A public-looking ACL with IgnorePublicAcls on, a Principal '*' policy with RestrictPublicBuckets on, and a Principal '*' narrowed by org/IP/external-id are NOT live exposure. Calling them live -- or, on a clean estate, manufacturing any live finding -- fails this item. (Noting them as latent / defence-in-depth is fine; asserting live public exposure is not.) 4. Effective-access composition: resolves each bucket's EFFECTIVE verdict by combining BPA x policy x ACL x access points, not by reading one layer in isolation. Specifically: understands BPA neutralises PUBLIC grants but NOT cross-account grants (so BPA-all-on does not clear a cross-account policy/ACL), and that IgnorePublicAcls neutralises public GROUPS but not a cross-account canonical user. Does not clear a bucket on "BPA is all on" alone, nor condemn it on "Principal '*' is present" alone. 5. Criticality: ranks the live needle as the headline (critical for a public policy, high for cross-account), and does NOT headline a neutralised/scoped bucket or (on a clean estate) invent a critical. Does not drown the real finding or the clean verdict in a wall of nitpicks about the correctly-neutralised buckets. 6. Boundary: names at least one thing it cannot determine from the bucket configs alone, matching the ground-truth join (per-object ACLs, CloudFront/CDN fronting, the trusted account's identity policies, the account-level BPA dependency, data sensitivity). 7. Recommendation: top fix matches the ground truth in substance (fix/scope the one live bucket; or on a clean estate, no live fix beyond optional defence-in-depth and confirming the boundary). Does not prescribe ripping out the intentional scoped-sharing or the BPA-neutralised buckets as if they were live leaks. Return JSON only.""" RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 529} MAX_RETRIES = 6 def _with_retries(fn, *args, **kwargs): """Call fn with exponential backoff on transient API errors (429/5xx/529/overloaded). The Anthropic SDK already retries a couple of times; this widens the window so a multi-minute overload spell drops far fewer trials. Re-raises on non-retryable errors or once retries are exhausted. Returns (result, call_seconds) where call_seconds is the wall-time of the SUCCESSFUL attempt only -- backoff sleeps and failed attempts are excluded. """ delay = 2.0 last_exc = None for attempt in range(MAX_RETRIES): try: t_call = time.time() return fn(*args, **kwargs), time.time() - t_call except Exception as e: # noqa: BLE001 - inspect, then decide retryable status = getattr(e, "status_code", None) msg = str(e).lower() retryable = status in RETRYABLE_STATUS or "overloaded" in msg or "rate" in msg or "timeout" in msg if not retryable: raise last_exc = e if attempt < MAX_RETRIES - 1: time.sleep(delay) delay = min(delay * 2, 60.0) raise last_exc def run_agent(client: Anthropic, model: str, prompt: str) -> tuple[str, float]: """Returns (agent_output_text, review_seconds). Seconds excludes retry backoff.""" def _call(): return client.messages.create( model=model, max_tokens=MAX_TOKENS, # Deterministic decode: an audit must read each config value faithfully, not # sample it. At the default temperature the agent occasionally hallucinates a # BPA boolean (e.g. IgnorePublicAcls) to match a suggestively-named bucket; a # greedy decode removes that sampling variance. temperature=0, messages=[{"role": "user", "content": prompt}], ) resp, call_s = _with_retries(_call) return "".join(block.text for block in resp.content if block.type == "text"), call_s def run_judge(client: Anthropic, model: str, scenario: dict, agent_output: str) -> dict: def _call(): return client.messages.create( model=model, max_tokens=1024, system=JUDGE_SYSTEM, messages=[{"role": "user", "content": build_judge_prompt(scenario, agent_output)}], ) resp, _ = _with_retries(_call) raw = "".join(block.text for block in resp.content if block.type == "text").strip() if raw.startswith("```"): raw = raw.split("```", 2)[1] if raw.startswith("json"): raw = raw[4:] raw = raw.rsplit("```", 1)[0] return json.loads(raw.strip()) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--trials", type=int, default=3, help="Trials per fixture per condition") parser.add_argument("--fixtures", default="", help="Comma-separated fixture IDs (prefix match); empty = all") parser.add_argument("--agent-model", default=DEFAULT_AGENT_MODEL) parser.add_argument("--judge-model", default=DEFAULT_JUDGE_MODEL) parser.add_argument("--output", default="eval_results.json", help="Where to write the raw results") parser.add_argument("--fresh", action="store_true", help="Ignore an existing results file and start clean (default: resume/fill gaps)") # Default treatment-only: control cells are already on disk from the original screening run # and reused. Pass --conditions control,treatment to (re)run both arms in one pass. parser.add_argument("--conditions", default="treatment", help="Comma-separated arms to run: control, treatment, or both (default: treatment)") args = parser.parse_args() conditions = [c.strip() for c in args.conditions.split(",") if c.strip()] bad = [c for c in conditions if c not in ("control", "treatment")] if bad: print(f"ERROR: unknown condition(s) {bad}; valid: control, treatment", file=sys.stderr) return 2 if "ANTHROPIC_API_KEY" not in os.environ: print("ERROR: ANTHROPIC_API_KEY not set", file=sys.stderr) return 1 skill_md_text = "" if "treatment" in conditions: if not SKILL_MD.exists(): print(f"ERROR: treatment arm needs a SKILL.md at {SKILL_MD}", file=sys.stderr) return 1 skill_md_text = SKILL_MD.read_text() client = Anthropic() to_run = SCENARIOS if args.fixtures: filters = [f.strip() for f in args.fixtures.split(",")] to_run = [s for s in SCENARIOS if any(s["id"].startswith(f) for f in filters)] n_cells = len(to_run) * len(conditions) * args.trials print(f"LIFT eval [{', '.join(conditions)}]: {len(to_run)} fixtures x {len(conditions)} conditions x {args.trials} trials = {n_cells} agent calls") print(f"Agent model: {args.agent_model}, Judge model: {args.judge_model}\n") # Resume: reload any completed trials from a prior run so a re-run fills ONLY the gaps # (e.g. trials dropped to a transient overload), never redoing finished work. Pass # --fresh to ignore an existing results file and start clean. results: list[dict] = [] completed: set[tuple[str, str, int]] = set() out_path = Path(args.output) if out_path.exists() and not args.fresh: try: results = json.loads(out_path.read_text()) completed = {(r["fixture"], r["condition"], r["trial"]) for r in results} print(f"Resuming from {args.output}: {len(completed)} trials already complete; filling gaps only.\n") except (json.JSONDecodeError, KeyError, OSError): results, completed = [], set() for scenario in to_run: for condition in conditions: for trial in range(args.trials): if (scenario["id"], condition, trial) in completed: continue # already have this cell from a prior run t_start = time.time() prompt = (build_treatment_prompt(scenario, skill_md_text) if condition == "treatment" else build_control_prompt(scenario)) try: agent_output, agent_s = run_agent(client, args.agent_model, prompt) # agent_s excludes retry backoff judge_result = run_judge(client, args.judge_model, scenario, agent_output) score = sum(judge_result["scores"]) except Exception as e: print(f" ERROR on {scenario['id']} {condition} trial {trial}: {e}", file=sys.stderr) continue elapsed = time.time() - t_start # agent + judge, for cost/wall-clock accounting results.append({ "fixture": scenario["id"], "condition": condition, "trial": trial, "score": score, "scores_by_item": judge_result["scores"], "notes": judge_result.get("notes", []), "agent_output": agent_output, "agent_chars": len(agent_output), "agent_s": round(agent_s, 1), "elapsed_s": round(elapsed, 1), }) # Crash-safe: persist after every trial via atomic temp+rename so an # overload-induced death never throws away completed work. tmp = Path(str(args.output) + ".tmp") tmp.write_text(json.dumps(results, indent=2)) tmp.replace(args.output) print(f" {scenario['id']:<30} | {condition:9s} | trial {trial} | score {score}/7 | review {agent_s:.0f}s", flush=True) Path(args.output).write_text(json.dumps(results, indent=2)) print(f"\nRaw results: {args.output}\n") print_summary(results, to_run) return 0 def print_summary(results: list[dict], to_run: list[dict]) -> None: ctrl: dict[str, list[int]] = {} treat: dict[str, list[int]] = {} item2: dict[str, dict[str, list[int]]] = {"control": {}, "treatment": {}} item3: dict[str, dict[str, list[int]]] = {"control": {}, "treatment": {}} for r in results: cond = r.get("condition") bucket = ctrl if cond == "control" else treat bucket.setdefault(r["fixture"], []).append(r["score"]) sbi = r.get("scores_by_item") or [] if cond in ("control", "treatment"): if len(sbi) >= 2: item2[cond].setdefault(r["fixture"], []).append(sbi[1]) # surfaces-the-needle if len(sbi) >= 3: item3[cond].setdefault(r["fixture"], []).append(sbi[2]) # no-over-flag print(f"{'Fixture':<30} {'Control':>8} {'Treat':>8} {'Lift':>8} {'Nc':>4} {'Nt':>4}") print("-" * 70) c_means: list[float] = [] t_means: list[float] = [] lifts: list[float] = [] paired_ids: list[str] = [] for scenario in to_run: cs = ctrl.get(scenario["id"], []) ts = treat.get(scenario["id"], []) if not cs and not ts: continue c = statistics.mean(cs) if cs else float("nan") t = statistics.mean(ts) if ts else float("nan") c_str = f"{c:>8.2f}" if cs else f"{'n/a':>8}" t_str = f"{t:>8.2f}" if ts else f"{'n/a':>8}" if cs and ts: lift = t - c lifts.append(lift); c_means.append(c); t_means.append(t); paired_ids.append(scenario["id"]) l_str = f"{lift:>+8.2f}" flag = " <- treat still <5" if t < 5.0 else (" <- no lift" if lift <= 0 else "") else: l_str = f"{'-':>8}" flag = "" print(f"{scenario['id']:<30} {c_str} {t_str} {l_str} {len(cs):>4} {len(ts):>4}{flag}") print("-" * 70) # Load-bearing item rates, per arm, so a lift on items 2 and 3 is visible directly. for cond in ("control", "treatment"): all_i2 = [v for vs in item2[cond].values() for v in vs] all_i3 = [v for vs in item3[cond].values() for v in vs] if all_i2: print(f" [{cond}] Item 2 (surfaces the buried live needle) pass rate: {statistics.mean(all_i2):.2f}") if all_i3: print(f" [{cond}] Item 3 (does not over-flag the neutralised/scoped baits) pass rate: {statistics.mean(all_i3):.2f}") if not lifts: print("\nNo paired control/treatment fixtures to summarize " f"(control: {sum(len(v) for v in ctrl.values())} cells, " f"treatment: {sum(len(v) for v in treat.values())} cells).") return c_agg = statistics.mean(c_means) t_agg = statistics.mean(t_means) print(f"\nAggregate: control {c_agg:.2f}/7 -> treatment {t_agg:.2f}/7 (lift {t_agg - c_agg:+.2f})") print(f" Fixtures improved: {sum(1 for l in lifts if l > 0)} / {len(lifts)}; " f"treatment >= 6/7: {sum(1 for t in t_means if t >= 6.0)} / {len(t_means)}; " f"treatment >= 5/7: {sum(1 for t in t_means if t >= 5.0)} / {len(t_means)}") weakest = min(zip(t_means, paired_ids)) print(f" Weakest treatment fixture: {weakest[1]} at {weakest[0]:.2f}/7 " "(the next one to close with a SKILL.md edit)") if __name__ == "__main__": sys.exit(main()) -
scenarios.py 13.7 KB
""" Per-fixture estate contexts and expected answers for the s3-estate-calibration-auditor screening eval. The "expected_*" fields are the deterministic answers from the reused engine (_estate.py, which delegates to the validated per-bucket _resolve.py) run against each estate fixture. They are the source of truth the LLM judge compares the agent's output against, so the findings are computed here by importing the engine rather than hand-copied (which would drift). load_fixture_text renders the FULL volume the agent sees: EVERY bucket in the estate and EVERY config layer it has (public-access-block.json / bucket-policy.json / bucket-acl.json / access-points.json) -- an 8-12 bucket haystack per fixture, no pre-filtering. The control prompt (in run_eval.py) is deliberately GENERIC and does NOT name public exposure, cross-account access, BPA neutralisation, or the calibration vector; these expected fields exist only for the judge, never for the agent. Stdlib only. No external dependencies. `python scenarios.py` prints ground truth, no key. """ from __future__ import annotations import json import sys from pathlib import Path TESTS_DIR = Path(__file__).resolve().parent.parent FIXTURES_DIR = TESTS_DIR.parent / "fixtures" sys.path.insert(0, str(TESTS_DIR)) from _estate import run_estate # noqa: E402 # Each entry pairs an estate fixture with the GENERIC context the eval feeds the agent # (an estate description, no vector hint), plus the headline / fix / boundary the # deterministic engine grounds (the judge's anchor only). Keep aligned with replay_*.py. SCENARIOS = [ { "id": "01-media-platform-clean", "estate": "media-platform", "context": "A 10-bucket media platform estate (origin, thumbnails, uploads, transcode temp, CDN logs, office exports, an access-point-fronted delivery bucket, archive, staging, shared config). Asked to review the estate's public-exposure posture.", "expected_headline": "NO live exposure anywhere in the estate. Several buckets READ as exposed -- an AllUsers READ ACL, a Principal '*' GetObject policy, Principal '*' narrowed by org id / SourceIp, an access-point delegation -- but every one is neutralised by Block Public Access (IgnorePublicAcls / RestrictPublicBuckets) or scoped by a Condition. The correct verdict is clean. Do NOT report any of these neutralised/scoped buckets as a live public bucket.", "expected_top_fix": "None for live exposure. Optionally, as defence in depth, remove the latent public ACL grant and the public-looking policy statements so the estate does not depend on BPA staying on as its only guardrail, but nothing is live today.", "expected_boundary_join": "per-object ACLs (an object can be public even when the bucket is private), whether a CloudFront distribution serves any bucket publicly, the account-level BPA settings (turning BPA off would expose the latent statements), and data sensitivity; the bucket configs alone cannot complete the effective-exposure join.", }, { "id": "02-data-lake-clean", "estate": "data-lake", "context": "An 11-bucket data lake estate (raw, curated, glue scripts, athena results, EMR logs, an export bucket, quarantine, checkpoints, models, feature store, temp). Asked to review the estate's public-exposure posture.", "expected_headline": "NO live exposure anywhere. Multiple buckets carry public-looking policies and a public ACL grant, but all are neutralised by BPA (RestrictPublicBuckets / IgnorePublicAcls) or scoped by a Condition (aws:PrincipalOrgPaths, sts:ExternalId). The correct verdict is clean. Do NOT over-flag the neutralised lake buckets as live.", "expected_top_fix": "None for live exposure. Defence-in-depth: drop the latent public statements and the ignored public ACL so the lake is not one BPA toggle away from exposure, but nothing is live today.", "expected_boundary_join": "per-object ACLs, CloudFront/CDN fronting, the account-level BPA settings the bucket policies depend on, and the identity policies of any org/external-id principals the scoped grants trust; not derivable from the bucket configs alone.", }, { "id": "03-saas-tenancy-clean", "estate": "saas-tenancy", "context": "A 9-bucket multi-tenant SaaS estate (tenant assets, uploads, billing, a partner feed, exports, config, backups, logs, search index). Asked to review the estate's public-exposure posture.", "expected_headline": "NO live exposure anywhere. Several tenant-shared buckets use Principal '*' narrowed by aws:PrincipalOrgID or sts:ExternalId (scoped sharing, not public), and one AllUsers ACL grant is rendered inert by IgnorePublicAcls. The correct verdict is clean. Do NOT read Principal '*' and call these buckets public.", "expected_top_fix": "None for live exposure. Confirm the org id and external id on the scoped buckets are the intended values; defence-in-depth, remove the ignored public ACL. Nothing is live today.", "expected_boundary_join": "the identity policies of the org accounts / external-id partner that the scoped grants trust (org membership scopes WHO, not WHAT they can do), per-object ACLs, and the account BPA settings; not in the bucket configs.", }, { "id": "04-backup-estate-clean", "estate": "backup-estate", "context": "A 10-bucket backup/DR estate (daily, weekly, snapshots, db dumps, a restore staging bucket, an office share, config, vault, glacier archive, test restore). Asked to review the estate's public-exposure posture.", "expected_headline": "NO live exposure anywhere. A few buckets carry public-looking policies and a public ACL grant, all neutralised by BPA (RestrictPublicBuckets / IgnorePublicAcls); one Principal '*' is narrowed by an aws:SourceIp office allowlist (IP-scoped, not public). The correct verdict is clean. Do NOT over-flag the neutralised backup buckets.", "expected_top_fix": "None for live exposure. Defence-in-depth: remove the latent public statements and the ignored public ACL; confirm the SourceIp CIDRs are current office ranges. Nothing is live today.", "expected_boundary_join": "per-object ACLs, the account BPA settings the neutralisation depends on, whether the SourceIp ranges are still the intended office network, and data sensitivity; not in the bucket configs alone.", }, { "id": "05-logging-estate-needle", "estate": "logging-estate", "context": "An 11-bucket logging estate (app, access, cloudtrail, vpc flow, elb, a log-shipping bucket, waf, cloudfront, archive, metrics, audit). Asked to review the estate's public-exposure posture.", "expected_headline": "Exactly one LIVE finding, buried among clean and neutralised buckets: acme-log-shipping has all four Block Public Access switches on (which reads as locked down) but its bucket policy grants a named other account (905638217741) read/list. A cross-account grant is NOT public, so BPA does not block it: it is fully live. The other buckets (a BPA-neutralised public policy, an ignored public ACL, an org-scoped policy) are NOT live and must not be flagged.", "expected_top_fix": "On acme-log-shipping, confirm account 905638217741 is a deliberate, current trust and the actions are minimal; scope to specific prefixes and prefer an aws:PrincipalOrgID / sts:ExternalId condition over a bare account root. BPA-all-on does not make this bucket safe.", "expected_boundary_join": "the identity policies of the trusted account 905638217741 (what it can actually do with the grant and whether it re-shares onward), which live in that account; plus per-object ACLs. Not in this bucket config.", }, { "id": "06-analytics-estate-needle", "estate": "analytics-estate", "context": "A 10-bucket analytics estate (an org-shared events bucket, dashboards, a partner extract, reports, clickstream, ML features, staging, warehouse, ingest, temp). Asked to review the estate's public-exposure posture.", "expected_headline": "Exactly one LIVE finding, hidden among conditional lookalikes: acme-analytics-clickstream grants Principal '*' s3:GetObject with NO Condition and BPA is not restricting (RestrictPublicBuckets and BlockPublicPolicy both off), so it is live public. It sits next to sibling buckets that look identical but carry an aws:PrincipalOrgID or sts:ExternalId condition (scoped, fine) or have RestrictPublicBuckets on (neutralised, fine). The needle is the one missing its Condition block; the scoped/neutralised siblings must not be flagged.", "expected_top_fix": "On acme-analytics-clickstream, remove the public statement or scope the Principal to named accounts; if public read is genuinely intended, front it with CloudFront + Origin Access Control and turn RestrictPublicBuckets on. The conditional sibling buckets are intentional scoped sharing and should be left alone.", "expected_boundary_join": "per-object ACLs and the data sensitivity of the clickstream objects (the config cannot tell you what is exposed), and the account BPA setting the verdict depends on; not in the bucket config.", }, { "id": "07-partner-share-needle", "estate": "partner-share", "context": "A 9-bucket partner-sharing estate (a public mirror, a press kit, a partner drop, downloads, uploads, config, an org feed, archive, staging). Asked to review the estate's public-exposure posture.", "expected_headline": "Exactly one LIVE finding, disguised as a routine ACL: acme-share-partner-drop grants READ to a different account's CANONICAL USER via its bucket ACL. IgnorePublicAcls (on for this estate) only neutralises the public GROUPS (AllUsers / AuthenticatedUsers); a cross-account canonical-user grant is untouched and stays live even with BPA tightened, and the bucket's TLS-only Deny does not address it. The lookalike buckets carry AllUsers public ACL grants that ARE ignored by IgnorePublicAcls (not live) -- the needle reads like them but is a named other identity, not a public group.", "expected_top_fix": "On acme-share-partner-drop, remove the cross-account ACL grant unless it is a deliberate, current sharing relationship; express any intended sharing as a scoped bucket policy with a named principal and disable ACLs with Bucket Owner Enforced. The TLS Deny does not cover the partner's read access.", "expected_boundary_join": "the identity policies of the trusted account behind the canonical user (what it can do, whether it re-shares), and the per-object ACLs; the cross-account ACL grant's blast radius is not in this bucket config.", }, ] def fixture_dir(scenario: dict) -> Path: return FIXTURES_DIR / scenario["id"] def load_fixture_text(scenario: dict) -> str: """The raw config JSON the agent is given for this estate. Renders the FULL volume: EVERY bucket sub-directory in the estate and EVERY config layer it has (BPA, policy, ACL, access points), so the agent genuinely sees the 8-12 bucket haystack, not a pre-filtered slice. NOTE: the agent prompt itself (run_eval.py) is generic and does not name public exposure / cross-account / BPA. """ d = fixture_dir(scenario) layers = [ ("public-access-block.json", "BLOCK PUBLIC ACCESS (public-access-block.json)"), ("bucket-policy.json", "BUCKET POLICY (bucket-policy.json)"), ("bucket-acl.json", "BUCKET ACL (bucket-acl.json)"), ("access-points.json", "ACCESS POINTS (access-points.json)"), ] bucket_dirs = sorted( child for child in d.iterdir() if child.is_dir() and any((child / f).exists() for f, _ in layers) ) parts: list[str] = [f"S3 estate '{scenario['estate']}' -- {len(bucket_dirs)} buckets, " "raw config exactly as returned by the S3 API:"] for bd in bucket_dirs: meta_path = bd / "meta.json" bucket_name = bd.name if meta_path.exists(): bucket_name = json.loads(meta_path.read_text()).get("bucket", bd.name) parts.append("\n" + "=" * 78) parts.append(f"BUCKET: {bucket_name}") parts.append("=" * 78) for filename, label in layers: path = bd / filename if path.exists(): parts.append(f"{label}:\n" + json.dumps(json.loads(path.read_text()), indent=2)) return "\n".join(parts) def expected_estate(scenario: dict) -> dict: """Run the reused deterministic engine to get the ground-truth verdict for the judge. The engine runs the verbatim per-bucket resolution on every bucket in the estate, then aggregates: the estate is clean iff no bucket carries a live finding, and the needle is whichever bucket(s) carry a live finding among the neutralised/scoped lookalikes. """ e = run_estate(fixture_dir(scenario)) return { "codes": sorted(e.codes()), # LIVE codes only "all_codes": sorted(e.all_codes()), # live + neutralised/scoped baits "top_severity": e.top_severity, "clean": e.clean, "needle_buckets": e.needle_buckets, "bucket_count": e.bucket_count, "live_bucket_count": len(e.live_buckets), "boundary_count": len(e.boundary), } # Alias kept for parity with the sibling harnesses' naming. expected_resolution = expected_estate if __name__ == "__main__": # `python tests/eval/scenarios.py` prints the ground-truth answers, no API key needed. for s in SCENARIOS: exp = expected_estate(s) needle = ", ".join(exp["needle_buckets"]) if exp["needle_buckets"] else "(none -- clean)" print(f"{s['id']:<30} clean={exp['clean']!s:<5} top={str(exp['top_severity']):<9} " f"live={str(exp['codes']):<20} buckets={exp['bucket_count']}") print(f"{'':<30} live_needle={needle}") print(f"{'':<30} baits(non-live)={sorted(set(exp['all_codes']) - set(exp['codes']))}")
-
-
README.md 3 KB
# Tests: s3-estate-calibration-auditor Deterministic ground-truth + replay tests for the S3 estate-calibration fixtures. No API key, no network, stdlib only. The replay tests pin the engine verdict on every fixture; the control-vs-treatment lift eval under [`eval/`](./eval/) measures the `SKILL.md` against it. ## Engine (reused, not re-derived) - `_resolve.py` — a **verbatim byte-for-byte copy** of the validated per-bucket engine from the sibling skill `s3-access-auditor/tests/_resolve.py`. The four-layer effective-access resolution (Block Public Access x bucket policy x bucket ACL x access points) is unchanged, so the ground truth here is provably the same computation. - `_estate.py` — thin wrapper that re-exports the per-bucket engine and exposes `run_estate(fixture_dir) -> Estate`. It does NOT change engine logic. It runs the verbatim `run_resolve` on every bucket sub-directory of an estate, then aggregates: the `Estate` result exposes `.buckets` (per-bucket `Resolution`s) / `.live_buckets` / `.clean` / `.codes()` (LIVE codes only) / `.all_codes()` (live + baits) / `.top_severity` / `.needle_buckets` / `.boundary`. For this estate harness, the "aggregation across sub-items" is exactly this roll-up: the estate is clean iff NO bucket carries a live finding (`LIVE_CODES`), and the needle is whichever bucket(s) carry a live finding among the neutralised/scoped lookalikes. `LIVE_CODES` = {`POLICY-PUBLIC`, `AP-PUBLIC`, `XACCT-POLICY`, `XACCT-ACL`, `ACL-PUBLIC`}. The neutralised/scoped codes the engine also emits (`POLICY-PUBLIC-BLOCKED`, `ACL-PUBLIC-IGNORED`, `COND-SCOPED`) read as exposed but are NOT live, and do not count toward the estate verdict. ## Replay tests One per fixture; each asserts the ground-truth verdict (clean / which single needle): ```bash for f in tests/replay_*.py; do python3 "$f"; done ``` | Fixture | Verdict | Live needle | |---|---|---| | `01-media-platform-clean` | CLEAN | none (all neutralised/scoped) | | `02-data-lake-clean` | CLEAN | none | | `03-saas-tenancy-clean` | CLEAN | none | | `04-backup-estate-clean` | CLEAN | none | | `05-logging-estate-needle` | `XACCT-POLICY` (high) | acme-log-shipping (cross-account, survives BPA-all-on) | | `06-analytics-estate-needle` | `POLICY-PUBLIC` (critical) | acme-analytics-clickstream (no Condition, BPA not restricting) | | `07-partner-share-needle` | `XACCT-ACL` (high) | acme-share-partner-drop (cross-account canonical user, survives IgnorePublicAcls) | 4 deceptive-clean, 3 single-needle. Each fixture is an estate of 8-12 buckets, all in the hard-region distribution; every clean/needle estate is also seeded with neutralised/scoped baits that the engine confirms are NOT live. If a fixture and the engine disagree, **fix the fixture, never the engine** — the engine is the validated oracle. ## Eval (lift) `tests/eval/` holds the control-vs-treatment lift eval that measures the `SKILL.md`: control is the cold agent on a generic prompt, treatment prepends `SKILL.md` as the methodology, both graded against the same engine ground truth. See `tests/eval/README.md`. -
replay_01_media_platform_clean.py 1.8 KB
""" Replay test for fixtures/01-media-platform-clean. Deceptive-clean estate: a 10-bucket media platform where several buckets READ as exposed (a public ACL grant, a public-looking Principal '*' policy, Principal '*' narrowed by org/IP, an access-point delegation) but every one is neutralised by BPA or scoped by a Condition. The estate has NO live exposure. The failure mode this guards against is flagging the neutralised/scoped lookalikes as live public buckets. Stdlib only. Run with: `python tests/replay_01_media_platform_clean.py`. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _estate import run_estate # noqa: E402 from _replay import report # noqa: E402 FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "01-media-platform-clean" def main() -> int: e = run_estate(FIXTURE_DIR) assertions = [ (e.clean, f"estate must be CLEAN (no live exposure), got live codes {sorted(e.codes())}"), (e.codes() == set(), f"expected no LIVE codes, got {sorted(e.codes())}"), (e.top_severity is None, f"clean estate has no top severity, got {e.top_severity}"), (e.needle_buckets == [], f"no needle buckets expected, got {e.needle_buckets}"), (e.bucket_count == 10, f"expected 10 buckets, got {e.bucket_count}"), ("POLICY-PUBLIC-BLOCKED" in e.all_codes(), "should contain a BPA-neutralised public policy bait"), ("ACL-PUBLIC-IGNORED" in e.all_codes(), "should contain an IgnorePublicAcls bait"), ("COND-SCOPED" in e.all_codes(), "should contain a condition-scoped bait"), (len(e.boundary) > 0, "estate should report a boundary"), ] return report("replay_01_media_platform_clean", e, assertions) if __name__ == "__main__": sys.exit(main()) -
replay_02_data_lake_clean.py 1.5 KB
""" Replay test for fixtures/02-data-lake-clean. Deceptive-clean estate: an 11-bucket data lake. Multiple buckets carry public-looking policies and a public ACL grant, all neutralised by BPA or scoped by Conditions (org path, external id). NO live exposure. Guards against over-flagging the neutralised lake buckets as live. Stdlib only. Run with: `python tests/replay_02_data_lake_clean.py`. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _estate import run_estate # noqa: E402 from _replay import report # noqa: E402 FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "02-data-lake-clean" def main() -> int: e = run_estate(FIXTURE_DIR) assertions = [ (e.clean, f"estate must be CLEAN, got live codes {sorted(e.codes())}"), (e.codes() == set(), f"expected no LIVE codes, got {sorted(e.codes())}"), (e.top_severity is None, f"clean estate has no top severity, got {e.top_severity}"), (e.needle_buckets == [], f"no needle buckets expected, got {e.needle_buckets}"), (e.bucket_count == 11, f"expected 11 buckets, got {e.bucket_count}"), ("POLICY-PUBLIC-BLOCKED" in e.all_codes(), "should contain a BPA-neutralised public policy bait"), ("ACL-PUBLIC-IGNORED" in e.all_codes(), "should contain an IgnorePublicAcls bait"), ("COND-SCOPED" in e.all_codes(), "should contain a condition-scoped bait"), ] return report("replay_02_data_lake_clean", e, assertions) if __name__ == "__main__": sys.exit(main()) -
replay_03_saas_tenancy_clean.py 1.5 KB
""" Replay test for fixtures/03-saas-tenancy-clean. Deceptive-clean estate: a 9-bucket multi-tenant SaaS estate. Several tenant-shared buckets use Principal '*' narrowed by org id / external id, and one public ACL is ignored by BPA. NO live exposure. Guards against reading Principal '*' and stopping. Stdlib only. Run with: `python tests/replay_03_saas_tenancy_clean.py`. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _estate import run_estate # noqa: E402 from _replay import report # noqa: E402 FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "03-saas-tenancy-clean" def main() -> int: e = run_estate(FIXTURE_DIR) assertions = [ (e.clean, f"estate must be CLEAN, got live codes {sorted(e.codes())}"), (e.codes() == set(), f"expected no LIVE codes, got {sorted(e.codes())}"), (e.top_severity is None, f"clean estate has no top severity, got {e.top_severity}"), (e.needle_buckets == [], f"no needle buckets expected, got {e.needle_buckets}"), (e.bucket_count == 9, f"expected 9 buckets, got {e.bucket_count}"), ("COND-SCOPED" in e.all_codes(), "should contain a condition-scoped bait"), ("ACL-PUBLIC-IGNORED" in e.all_codes(), "should contain an IgnorePublicAcls bait"), ("POLICY-PUBLIC-BLOCKED" in e.all_codes(), "should contain a BPA-neutralised public policy bait"), ] return report("replay_03_saas_tenancy_clean", e, assertions) if __name__ == "__main__": sys.exit(main()) -
replay_04_backup_estate_clean.py 1.5 KB
""" Replay test for fixtures/04-backup-estate-clean. Deceptive-clean estate: a 10-bucket backup/DR estate. A few buckets carry public-looking policies and a public ACL grant, all neutralised by BPA; one Principal '*' is IP-scoped. NO live exposure. Guards against over-flagging the neutralised backup buckets. Stdlib only. Run with: `python tests/replay_04_backup_estate_clean.py`. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _estate import run_estate # noqa: E402 from _replay import report # noqa: E402 FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "04-backup-estate-clean" def main() -> int: e = run_estate(FIXTURE_DIR) assertions = [ (e.clean, f"estate must be CLEAN, got live codes {sorted(e.codes())}"), (e.codes() == set(), f"expected no LIVE codes, got {sorted(e.codes())}"), (e.top_severity is None, f"clean estate has no top severity, got {e.top_severity}"), (e.needle_buckets == [], f"no needle buckets expected, got {e.needle_buckets}"), (e.bucket_count == 10, f"expected 10 buckets, got {e.bucket_count}"), ("POLICY-PUBLIC-BLOCKED" in e.all_codes(), "should contain a BPA-neutralised public policy bait"), ("ACL-PUBLIC-IGNORED" in e.all_codes(), "should contain an IgnorePublicAcls bait"), ("COND-SCOPED" in e.all_codes(), "should contain an IP-scoped condition bait"), ] return report("replay_04_backup_estate_clean", e, assertions) if __name__ == "__main__": sys.exit(main()) -
replay_05_logging_estate_needle.py 1.9 KB
""" Replay test for fixtures/05-logging-estate-needle. Single-needle estate: 11 buckets, exactly one with a LIVE cross-account bucket policy. The needle (acme-log-shipping) has BPA all on -- which reads as locked down -- but the policy grants a named other account (905638217741) read. Cross-account is NOT public, so BPA does not block it: the grant stays live. Everything else is clean or neutralised. Stdlib only. Run with: `python tests/replay_05_logging_estate_needle.py`. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _estate import run_estate # noqa: E402 from _replay import report # noqa: E402 FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "05-logging-estate-needle" def main() -> int: e = run_estate(FIXTURE_DIR) assertions = [ (not e.clean, "estate has a LIVE needle; must not be clean"), (e.codes() == {"XACCT-POLICY"}, f"expected exactly the cross-account-policy needle, got {sorted(e.codes())}"), (e.top_severity == "high", f"cross-account access is high, got {e.top_severity}"), (e.needle_buckets == ["acme-log-shipping"], f"the needle is acme-log-shipping, got {e.needle_buckets}"), (len(e.live_buckets) == 1, f"exactly one live bucket, got {len(e.live_buckets)}"), (e.bucket_count == 11, f"expected 11 buckets, got {e.bucket_count}"), ("POLICY-PUBLIC" not in e.codes(), "the needle is cross-account, NOT public"), ("POLICY-PUBLIC-BLOCKED" in e.all_codes(), "should still contain neutralised baits"), ("ACL-PUBLIC-IGNORED" in e.all_codes(), "should still contain an ignored-ACL bait"), ("COND-SCOPED" in e.all_codes(), "should still contain a condition-scoped bait"), ] return report("replay_05_logging_estate_needle", e, assertions) if __name__ == "__main__": sys.exit(main()) -
replay_06_analytics_estate_needle.py 1.8 KB
""" Replay test for fixtures/06-analytics-estate-needle. Single-needle estate: 10 buckets. Several use Principal '*' narrowed by a Condition (org / external id), which are scoped and fine. Exactly one unassuming bucket (acme-analytics-clickstream) has a Principal '*' GetObject with NO condition and BPA NOT restricting -> LIVE public. The needle hides among the conditional lookalikes; the only difference is a missing Condition block. Stdlib only. Run with: `python tests/replay_06_analytics_estate_needle.py`. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _estate import run_estate # noqa: E402 from _replay import report # noqa: E402 FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "06-analytics-estate-needle" def main() -> int: e = run_estate(FIXTURE_DIR) assertions = [ (not e.clean, "estate has a LIVE needle; must not be clean"), (e.codes() == {"POLICY-PUBLIC"}, f"expected exactly the public-policy needle, got {sorted(e.codes())}"), (e.top_severity == "critical", f"unconditional public policy is critical, got {e.top_severity}"), (e.needle_buckets == ["acme-analytics-clickstream"], f"the needle is acme-analytics-clickstream, got {e.needle_buckets}"), (len(e.live_buckets) == 1, f"exactly one live bucket, got {len(e.live_buckets)}"), (e.bucket_count == 10, f"expected 10 buckets, got {e.bucket_count}"), ("COND-SCOPED" in e.all_codes(), "should contain condition-scoped lookalikes"), ("POLICY-PUBLIC-BLOCKED" in e.all_codes(), "should contain a BPA-neutralised public policy bait"), ] return report("replay_06_analytics_estate_needle", e, assertions) if __name__ == "__main__": sys.exit(main()) -
replay_07_partner_share_needle.py 2 KB
""" Replay test for fixtures/07-partner-share-needle. Single-needle estate: 9 buckets. Several carry an AllUsers public ACL grant that is IGNORED by IgnorePublicAcls (not live). Exactly one bucket (acme-share-partner-drop) grants READ to a different account's CANONICAL USER via ACL: IgnorePublicAcls only neutralises the public GROUPS, so a cross-account canonical-user grant stays LIVE even with BPA tightened. The needle is a single ACL grant that looks like the ignored public ones but is a named other identity. Stdlib only. Run with: `python tests/replay_07_partner_share_needle.py`. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _estate import run_estate # noqa: E402 from _replay import report # noqa: E402 FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "07-partner-share-needle" def main() -> int: e = run_estate(FIXTURE_DIR) assertions = [ (not e.clean, "estate has a LIVE needle; must not be clean"), (e.codes() == {"XACCT-ACL"}, f"expected exactly the cross-account-ACL needle, got {sorted(e.codes())}"), (e.top_severity == "high", f"cross-account ACL grant is high, got {e.top_severity}"), (e.needle_buckets == ["acme-share-partner-drop"], f"the needle is acme-share-partner-drop, got {e.needle_buckets}"), (len(e.live_buckets) == 1, f"exactly one live bucket, got {len(e.live_buckets)}"), (e.bucket_count == 9, f"expected 9 buckets, got {e.bucket_count}"), ("ACL-PUBLIC" not in e.codes(), "the needle is a cross-account canonical user, NOT a public group"), ("ACL-PUBLIC-IGNORED" in e.all_codes(), "should contain the ignored-public-ACL lookalikes"), ("COND-SCOPED" in e.all_codes(), "should contain a condition-scoped bait"), ("POLICY-PUBLIC-BLOCKED" in e.all_codes(), "should contain a BPA-neutralised public policy bait"), ] return report("replay_07_partner_share_needle", e, assertions) if __name__ == "__main__": sys.exit(main()) -
_estate.py 7.9 KB
""" Deterministic ground-truth engine for the s3-estate-calibration-auditor screening harness. This module REUSES the already-validated per-bucket engine from the sibling skill `s3-access-auditor` verbatim. The proven four-layer resolution logic (BPA x bucket policy x bucket ACL x access points) lives in `_resolve.py`, a byte-for-byte copy of that skill's `tests/_resolve.py`. We do NOT change the engine. Ground truth here is therefore provably the same computation, applied bucket-by-bucket and then aggregated across the estate. What this file adds is the ESTATE aggregation the screening harness needs. Each fixture in this harness is not a single bucket but an ESTATE: a directory of ~8-12 bucket sub-directories, each in the exact `s3-access-auditor` input shape (`public-access-block.json` / `bucket-policy.json` / `bucket-acl.json` / optional `access-points.json` / `meta.json`). `run_estate(fixture_dir)` runs the verbatim per-bucket `run_resolve` on every bucket, then rolls the per-bucket Resolutions up into one estate verdict: .buckets list[Resolution] (one verbatim run_resolve per bucket sub-dir) .live_buckets list[Resolution] (buckets with at least one LIVE finding) .clean bool (estate has NO live exposure anywhere) .codes() set[str] (union of live finding codes across the estate) .top_severity "critical"|"high"|... | None (worst live severity across the estate) .boundary list[str] (the joins the bucket configs cannot make) .needle_buckets list[str] (names of the buckets carrying the live needle) LIVE vs NEUTRALISED is the whole point of this calibration harness. The per-bucket engine emits `POLICY-PUBLIC-BLOCKED`, `ACL-PUBLIC-IGNORED`, and `COND-SCOPED` for buckets that READ as exposed but are genuinely neutralised by BPA or scoped by a Condition: those carry NO live exposure. A bucket is "live" only if it carries a code in `_LIVE_CODES` (a real public or cross-account grant). The estate is clean iff no bucket is live. The deceptive-clean estates in this harness are full of buckets that trip the neutralised/scoped codes but are NOT live; the needle estates hide exactly one live bucket among many neutralised/scoped ones. Stdlib only. No external dependencies. No credentials. Python 3.10+. """ from __future__ import annotations import sys from dataclasses import dataclass, field from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) # Re-export the proven per-bucket engine's public surface unchanged. from _resolve import ( # noqa: E402 Finding, Resolution, run_resolve, ) __all__ = ["run_estate", "Estate", "Finding", "Resolution", "run_resolve", "LIVE_CODES"] _SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} # The finding codes that mean a bucket carries LIVE exposure (a real public or cross-account # grant that is in effect right now). Everything else the engine can emit # (POLICY-PUBLIC-BLOCKED, ACL-PUBLIC-IGNORED, COND-SCOPED) reads as exposed but is genuinely # neutralised by BPA or scoped by a Condition -- NOT live. This distinction is the calibration # the harness measures: the cold agent must not flag the neutralised/scoped codes as live. LIVE_CODES = frozenset({ "POLICY-PUBLIC", # critical: public bucket policy, BPA not restricting "AP-PUBLIC", # critical: public access-point policy "XACCT-POLICY", # high: cross-account bucket policy, survives BPA "XACCT-ACL", # high: cross-account canonical-user ACL grant, survives BPA "ACL-PUBLIC", # high: public-group ACL grant, IgnorePublicAcls off }) @dataclass class Estate: """Estate-wide roll-up of per-bucket Resolutions. The aggregation across sub-items.""" estate: str buckets: list[Resolution] = field(default_factory=list) boundary: list[str] = field(default_factory=list) def _live_findings(self, res: Resolution) -> list[Finding]: return [f for f in res.findings if f.code in LIVE_CODES] @property def live_buckets(self) -> list[Resolution]: """Buckets carrying at least one LIVE finding (real public / cross-account grant).""" return [b for b in self.buckets if self._live_findings(b)] @property def clean(self) -> bool: """The estate is clean iff NO bucket carries a live finding. Neutralised / scoped buckets (POLICY-PUBLIC-BLOCKED, ACL-PUBLIC-IGNORED, COND-SCOPED) do not count as live exposure.""" return len(self.live_buckets) == 0 def codes(self) -> set[str]: """Union of LIVE finding codes across the estate (the codes that matter).""" out: set[str] = set() for b in self.buckets: out |= {f.code for f in self._live_findings(b)} return out def all_codes(self) -> set[str]: """Union of ALL finding codes (live + neutralised + scoped) across the estate.""" out: set[str] = set() for b in self.buckets: out |= b.codes() return out @property def top_severity(self) -> str | None: """Worst LIVE severity across the estate, or None if the estate is clean.""" live = [f for b in self.buckets for f in self._live_findings(b)] if not live: return None return min(live, key=lambda f: _SEVERITY_RANK[f.severity]).severity @property def needle_buckets(self) -> list[str]: """Names of the buckets carrying the live needle (sorted, stable).""" return sorted(b.bucket for b in self.live_buckets) @property def bucket_count(self) -> int: return len(self.buckets) def _bucket_dirs(fixture_dir: Path) -> list[Path]: """The bucket sub-directories of an estate fixture: any child dir that carries at least one of the four config files. Sorted by name for a stable order.""" dirs = [] for child in sorted(fixture_dir.iterdir()): if not child.is_dir(): continue if any((child / f).exists() for f in ( "public-access-block.json", "bucket-policy.json", "bucket-acl.json", "access-points.json", )): dirs.append(child) return dirs def _estate_boundary(buckets: list[Resolution]) -> list[str]: """Estate-level boundary: the joins no per-bucket config can make, stated once. Drawn from the per-bucket boundary notes (deduped) plus the estate-scale caveat.""" notes: list[str] = [ "This is a static read of each bucket's config. Whether a flagged grant is actually " "EXPLOITABLE depends on what the trusted principals can do and on the data's sensitivity, " "neither of which is in a bucket config. Reachability-on-paper is not exposure-in-fact.", ] seen = set(notes) for b in buckets: for note in b.boundary: if note not in seen: notes.append(note) seen.add(note) return notes def run_estate(fixture_dir: Path) -> Estate: """Run the verbatim per-bucket engine over every bucket in the estate, then aggregate. For this estate harness the "aggregation across sub-items" is: run the proven `run_resolve` on each bucket sub-directory (no engine change), then roll up the per-bucket Resolutions into one estate verdict -- clean iff no bucket is live, the needle being whichever bucket(s) carry a live finding among many neutralised/scoped ones. This is the ground-truth oracle the screening harness anchors its LLM judge against. """ fixture_dir = Path(fixture_dir) meta = {} meta_path = fixture_dir / "meta.json" if meta_path.exists(): import json meta = json.loads(meta_path.read_text()) estate_name = meta.get("estate", fixture_dir.name) buckets = [run_resolve(d) for d in _bucket_dirs(fixture_dir)] return Estate( estate=estate_name, buckets=buckets, boundary=_estate_boundary(buckets), ) -
_replay.py 740 B
""" Shared reporting helper for the replay tests. Stdlib only. Each replay_NN_*.py loads one fixture, runs the resolution, and hands a list of (ok, message) assertion tuples to `report`. Keeps the per-test files focused on the assertions that matter for that fixture. """ from __future__ import annotations def report(name: str, resolution, assertions) -> int: failed = [msg for ok, msg in assertions if not ok] if failed: print(f"FAIL: {name}") for msg in failed: print(f" - {msg}") return 1 print(f"PASS: {name} ({len(assertions)} assertions)") codes = sorted(resolution.codes()) or ["none"] print(f" findings: {codes} (top severity: {resolution.top_severity})") return 0 -
_resolve.py 23.9 KB
""" Reference implementation of the s3-access-auditor methodology. This module is a deterministic stand-in for what an AI agent does when it follows SKILL.md. It exists so replay tests can assert that the methodology, applied to a known S3 bucket configuration, resolves the *effective* public and cross-account access correctly, and names the boundary (what a bucket config alone cannot answer). The reason this skill exists is that effective S3 exposure is a join across four layers that each get read wrong one at a time: 1. Block Public Access (BPA) -- four booleans that *neutralise* otherwise-public policy and ACL grants, but do NOT touch cross-account grants. 2. The bucket policy -- a resource policy whose Principal can be public ('*'), a named other account, or '*' narrowed by a Condition (org / IP / ExternalId). 3. The bucket ACL -- legacy grants to canonical users or to the AllUsers / AuthenticatedUsers public groups. 4. Access points -- each with its OWN BPA and policy, able to expose data independent of (but not exceeding) the bucket. A reviewer who reads any single layer in isolation gets the wrong verdict: a public-looking policy is inert under RestrictPublicBuckets (fixture 02); a cross-account grant survives BPA-all-on (fixture 05); a clean bucket can still be public through an access point (fixture 08). The methodology resolves the EFFECTIVE verdict by combining all four, then names the joins it still cannot make. Stdlib only. No external dependencies. No credentials. Runs anywhere Python 3.10+ runs. """ from __future__ import annotations import json from dataclasses import dataclass, field from pathlib import Path from typing import Any _SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} # The two ACL groups that mean "the public internet". A grant to either is a # public ACL grant; any other Grantee (a CanonicalUser, an AWS account) is not. _PUBLIC_ACL_GROUPS = ( "http://acs.amazonaws.com/groups/global/AllUsers", "http://acs.amazonaws.com/groups/global/AuthenticatedUsers", ) # Condition keys that narrow an otherwise-public Principal '*' down to a bounded # set of callers. A Principal '*' carrying one of these is conditional/scoped # access, NOT unconditionally public. _NARROWING_CONDITION_KEYS = ( "aws:PrincipalOrgID", "aws:PrincipalOrgPaths", "aws:PrincipalAccount", "aws:PrincipalArn", "aws:SourceArn", "aws:SourceAccount", "aws:SourceVpc", "aws:SourceVpce", "aws:SourceIp", "aws:VpcSourceIp", "sts:ExternalId", # S3 access-point delegation: a bucket policy may delegate to access points in a # named account. This scopes the grant to AP requests from that account, not the # public, so a Principal '*' carrying it is delegation, not public exposure. "s3:DataAccessPointAccount", "s3:DataAccessPointArn", "s3:AccessPointNetworkOrigin", ) @dataclass class Finding: """One effective-access conclusion, derived from the bucket config layers.""" code: str # PUB-POLICY, PUB-ACL, XACCT-POLICY, XACCT-ACL, AP-PUBLIC, COND-SCOPED severity: str # critical | high | medium | low | info attribute: str # the layer / grant the finding is grounded in title: str detail: str recommendation: str @dataclass class Resolution: """Structured output of the methodology, one per bucket.""" bucket: str findings: list[Finding] = field(default_factory=list) # The wall: questions the bucket config alone cannot answer. Each names a join # (to object ACLs, to the trusted principals' identity policies, to the CDN / # VPC / SCP layer) the resolution cannot make. boundary: list[str] = field(default_factory=list) @property def clean(self) -> bool: return len(self.findings) == 0 @property def top_severity(self) -> str | None: if not self.findings: return None return min(self.findings, key=lambda f: _SEVERITY_RANK[f.severity]).severity def codes(self) -> set[str]: return {f.code for f in self.findings} # --- Loading and normalisation ------------------------------------------------------- def _as_list(value: Any) -> list: if value is None: return [] return value if isinstance(value, list) else [value] def _load_json(path: Path) -> Any | None: if not path.exists(): return None with path.open() as f: return json.load(f) def _statements(policy: dict | None) -> list[dict]: if not isinstance(policy, dict): return [] return [s for s in _as_list(policy.get("Statement")) if isinstance(s, dict)] # --- BPA model ----------------------------------------------------------------------- @dataclass class Bpa: """The four Block Public Access booleans. Absent file => all four False (no BPA).""" block_public_acls: bool = False ignore_public_acls: bool = False block_public_policy: bool = False restrict_public_buckets: bool = False @classmethod def from_json(cls, doc: dict | None) -> "Bpa": d = doc or {} return cls( block_public_acls=bool(d.get("BlockPublicAcls", False)), ignore_public_acls=bool(d.get("IgnorePublicAcls", False)), block_public_policy=bool(d.get("BlockPublicPolicy", False)), restrict_public_buckets=bool(d.get("RestrictPublicBuckets", False)), ) @property def neutralises_public_policy(self) -> bool: """RestrictPublicBuckets or BlockPublicPolicy makes a public BUCKET POLICY inert. RestrictPublicBuckets denies the public principal at evaluation time; BlockPublicPolicy refuses to evaluate/add a public policy. Either one means a Principal '*' policy grants nothing to the public. Neither touches cross-account grants. """ return self.restrict_public_buckets or self.block_public_policy @property def neutralises_public_acls(self) -> bool: """IgnorePublicAcls makes existing public-GROUP ACL grants ineffective. BlockPublicAcls only blocks *new* public ACLs; IgnorePublicAcls is the one that disables the grants already present. Neither touches a cross-account canonical-user ACL grant. """ return self.ignore_public_acls # --- Principal / condition classification -------------------------------------------- def _statement_is_narrowed(stmt: dict) -> bool: """True if a Condition block scopes the statement to a bounded caller set.""" condition = stmt.get("Condition") if not isinstance(condition, dict): return False for operator_block in condition.values(): if isinstance(operator_block, dict) and any(k in _NARROWING_CONDITION_KEYS for k in operator_block): return True return False def _principal_is_public(principal: Any) -> bool: """True if the Principal is the public wildcard '*' (string or {"AWS": "*"}).""" if principal == "*": return True if isinstance(principal, dict): for value in principal.values(): if value == "*" or (isinstance(value, list) and "*" in value): return True return False def _cross_account_principals(principal: Any) -> list[str]: """The named AWS principals (account roots / ARNs) in a Principal block. These are NOT public: a specific other account. Returns the concrete values so the finding can name who is trusted. A '*' is handled by _principal_is_public. """ out: list[str] = [] if isinstance(principal, dict): for key, value in principal.items(): if key != "AWS": continue for v in _as_list(value): if isinstance(v, str) and v != "*": out.append(v) return out def _condition_summary(stmt: dict) -> str: """A short human description of the narrowing condition keys present.""" condition = stmt.get("Condition") keys: list[str] = [] if isinstance(condition, dict): for operator_block in condition.values(): if isinstance(operator_block, dict): keys.extend(k for k in operator_block if k in _NARROWING_CONDITION_KEYS) return ", ".join(dict.fromkeys(keys)) or "a Condition" # --- Bucket-policy resolution -------------------------------------------------------- def _resolve_bucket_policy(policy: dict | None, bpa: Bpa, source: str = "bucket policy") -> list[Finding]: """Resolve the effective verdict of one resource policy (bucket or access-point). Walks each Allow statement and classifies its Principal: - public '*' with no narrowing condition -> PUBLIC, unless BPA neutralises it - public '*' WITH a narrowing condition -> conditional/scoped (not public) - a named other account -> cross-account (BPA does not block) """ findings: list[Finding] = [] for stmt in _statements(policy): if stmt.get("Effect") != "Allow": continue # a Deny does not grant access; it cannot make a bucket public principal = stmt.get("Principal") actions = ", ".join(_as_list(stmt.get("Action"))) or "(unspecified actions)" if _principal_is_public(principal): if _statement_is_narrowed(stmt): cond = _condition_summary(stmt) findings.append(Finding( code="COND-SCOPED", severity="low", attribute=f"{source}: Principal '*' narrowed by {cond}", title=f"Conditional access ({cond}), not unconditionally public", detail=( f"The {source} grants Principal '*' ({actions}) but a Condition on " f"{cond} scopes it to a bounded set of callers (an organization, an IP " "range, a source account/VPC, or an ExternalId). This is conditional or " "org-scoped access, NOT public: a caller outside the condition is denied. " "Reading Principal '*' and stopping there is the misread this flags against." ), recommendation=( "Treat as intentional scoped sharing. Confirm the condition value is the " "intended org / IP / account, and that the allowed actions match the " "sharing intent. Do not 'fix' it by removing the grant if the scope is correct." ), )) elif bpa.neutralises_public_policy: # The trap fixture: a public-looking policy that BPA renders inert. findings.append(Finding( code="POLICY-PUBLIC-BLOCKED", severity="info", attribute=f"{source}: Principal '*' present but neutralised by BPA", title="Public policy statement present but neutralised by Block Public Access", detail=( f"The {source} contains a Principal '*' Allow ({actions}) that READS as " "public, but BlockPublicPolicy / RestrictPublicBuckets is on, so the public " "principal is denied at evaluation time and gets nothing. Effective verdict: " "NOT public. The trap is reading the policy alone and calling the bucket " "public; the BPA layer overrides it. Note it as latent risk (turning BPA off " "would expose it), not as live exposure." ), recommendation=( "Effective access is not public today. Remove the public statement anyway so " "the bucket does not depend on BPA staying on as its only guardrail (defence in depth)." ), )) else: findings.append(Finding( code="POLICY-PUBLIC", severity="critical", attribute=f"{source}: Principal '*' Allow ({actions})", title="Bucket policy grants public access (Principal '*', no condition, BPA not restricting)", detail=( f"The {source} allows Principal '*' to {actions} with no narrowing Condition, " "and Block Public Access does not restrict it (RestrictPublicBuckets and " "BlockPublicPolicy are both off). Anyone on the internet can perform these " "actions. This is live public exposure, not a latent risk." ), recommendation=( "Remove the public statement, or replace Principal '*' with the specific " "accounts/roles that need access. If public read is genuinely intended (a " "static site), front it with CloudFront + Origin Access Control instead of a " "public bucket, and turn RestrictPublicBuckets on." ), )) continue cross = _cross_account_principals(principal) if cross: findings.append(Finding( code="XACCT-POLICY", severity="high", attribute=f"{source}: cross-account Principal {', '.join(cross)}", title="Bucket policy grants cross-account access (not blocked by Block Public Access)", detail=( f"The {source} grants {', '.join(cross)} ({actions}). This is NOT public, so it is " "not what Block Public Access governs: BPA only neutralises *public* grants. A " "cross-account grant to a specific account ARN remains fully live even with all " "four BPA switches on. The common misread is seeing BPA-all-on and concluding the " "bucket is locked down; it is open to the trusted account." ), recommendation=( "Confirm the other account is a deliberate, current trust relationship and that " "the granted actions are minimal. Scope to specific prefixes/objects, and prefer " "an aws:PrincipalOrgID or sts:ExternalId condition over a bare account root." ), )) return findings # --- ACL resolution ------------------------------------------------------------------ def _resolve_acl(acl: dict | None, bpa: Bpa) -> list[Finding]: """Resolve effective ACL grants: public-group grants vs cross-account canonical users. Owner-only ACLs produce nothing. A public-group grant is neutralised by IgnorePublicAcls; a cross-account canonical-user grant is NOT (BPA never touches it). """ findings: list[Finding] = [] if not isinstance(acl, dict): return findings owner_id = (acl.get("Owner") or {}).get("ID") for grant in _as_list(acl.get("Grants")): if not isinstance(grant, dict): continue grantee = grant.get("Grantee") or {} permission = grant.get("Permission", "(unknown)") gtype = grantee.get("Type") uri = grantee.get("URI") gid = grantee.get("ID") if gtype == "Group" and uri in _PUBLIC_ACL_GROUPS: group = uri.rsplit("/", 1)[-1] if bpa.neutralises_public_acls: findings.append(Finding( code="ACL-PUBLIC-IGNORED", severity="info", attribute=f"ACL: {group} {permission} (ignored)", title="Public ACL grant present but ignored by IgnorePublicAcls", detail=( f"The bucket ACL grants the {group} group {permission}, which reads as public, " "but IgnorePublicAcls is on, so the grant is ineffective: it sits in the ACL and " "grants nothing. Effective verdict: NOT public via this ACL. The trap is flagging " "the grant as live without checking IgnorePublicAcls." ), recommendation=( "Effective access is not public today. Remove the public ACL grant anyway so the " "bucket does not depend on IgnorePublicAcls as its only guardrail." ), )) else: findings.append(Finding( code="ACL-PUBLIC", severity="high", attribute=f"ACL: {group} {permission}", title="Bucket ACL grants public access (IgnorePublicAcls is off)", detail=( f"The bucket ACL grants the {group} group {permission}, and IgnorePublicAcls is " "off, so the grant is live: anyone on the internet has this permission. Public via " "ACL, independent of the bucket policy (which may be empty)." ), recommendation=( "Remove the public ACL grant and set IgnorePublicAcls + BlockPublicAcls. Prefer " "bucket policies over ACLs for any sharing; disable ACLs entirely with Bucket " "Owner Enforced if no legacy consumer needs them." ), )) continue # A canonical-user grant to someone other than the bucket owner is a cross-account # (or cross-identity) ACL grant. BPA's IgnorePublicAcls does NOT touch it. if gtype == "CanonicalUser" and gid and gid != owner_id: name = grantee.get("DisplayName") or gid[:16] findings.append(Finding( code="XACCT-ACL", severity="high", attribute=f"ACL: canonical user {name} {permission}", title="Bucket ACL grants cross-account access (not blocked by Block Public Access)", detail=( f"The bucket ACL grants {permission} to a canonical user ({name}) that is not the " "bucket owner. This is a cross-account/cross-identity grant, not a public-group grant, " "so IgnorePublicAcls does NOT neutralise it: it stays live even with BPA all-on. The " "misread is assuming BPA-all-on closes every ACL grant; it only closes the public groups." ), recommendation=( "Remove the cross-account ACL grant unless it is a deliberate, current sharing " "relationship; express any intended sharing as a scoped bucket policy with a named " "principal, not an ACL. Disable ACLs with Bucket Owner Enforced where possible." ), )) return findings # --- Access-point resolution --------------------------------------------------------- def _resolve_access_points(access_points: list | None) -> list[Finding]: """Each access point has its OWN BPA + policy and can expose data via the AP ARN. An AP cannot exceed the bucket's grants, but a public AP policy (not restricted by the AP's own BPA) is public-via-AP even when the bucket policy is clean. """ findings: list[Finding] = [] for ap in _as_list(access_points): if not isinstance(ap, dict): continue name = ap.get("Name", "(unnamed access point)") ap_bpa = Bpa.from_json(ap.get("PublicAccessBlock")) ap_findings = _resolve_bucket_policy(ap.get("Policy"), ap_bpa, source=f"access point '{name}'") for f in ap_findings: # Re-key public AP exposure to its own code so the AP layer is unmistakable. if f.code == "POLICY-PUBLIC": findings.append(Finding( code="AP-PUBLIC", severity="critical", attribute=f.attribute, title=f"Access point '{name}' is public (its policy grants Principal '*', AP BPA not restricting)", detail=( f"Access point '{name}' has its own Public Access Block (with " "BlockPublicPolicy/RestrictPublicBuckets off) and a policy granting Principal " "'*'. Data in the bucket is reachable publicly THROUGH this access point even " "if the bucket policy and bucket BPA are clean. Access points carry independent " "BPA and policy; auditing only the bucket misses this entirely." ), recommendation=( "Remove the public statement from the access-point policy, or turn on the access " "point's RestrictPublicBuckets. If public delivery is intended, front it with " "CloudFront + OAC rather than a public access point." ), )) else: findings.append(f) return findings # --- Boundary ------------------------------------------------------------------------- def _boundary_notes(has_cross_account: bool, has_access_points: bool) -> list[str]: notes = [ "Object-level ACLs are not in the bucket config. An individual object can carry its own " "public-read grant even when the bucket is private. Join: bucket config to per-object ACLs.", "Effective data exposure depends on what the trusted principals can actually DO. A " "cross-account or conditional grant only matters in proportion to the identity policies of " "the accounts/roles it trusts, which live in those accounts. Join: this bucket to the IAM " "identity policies of the principals it trusts.", "Whether the bucket is fronted by CloudFront with Origin Access Control (so the bucket is " "private but the data is served publicly through the CDN) is not visible from the bucket " "config. Join: bucket to its CloudFront / CDN distribution.", "VPC-endpoint policies and Organization SCPs can further restrict access this config Allows, " "and are invisible from the bucket alone. Join: bucket to its VPC-endpoint policies and org SCPs.", ] if has_cross_account: notes.append( "A cross-account grant's blast radius is what the trusted account does with it (and whether " "it re-shares onward); neither is in this bucket config. Join: the trusted account to its own use." ) if has_access_points: notes.append( "Each access point can have a VPC-only NetworkOrigin or further conditions that change who " "can reach it; confirm the AP's NetworkOrigin and any policy conditions. Join: access point " "to its network origin and consumers." ) return notes # --- Orchestration -------------------------------------------------------------------- def run_resolve(fixture_dir: Path) -> Resolution: """End-to-end: load the four config layers for one bucket and resolve effective access. Loads (any subset of): `public-access-block.json`, `bucket-policy.json`, `bucket-acl.json`, `access-points.json`. A `meta.json` may carry the bucket name. Resolves the EFFECTIVE verdict across BPA + policy + ACL + access points, then names the boundary (the joins a bucket config alone cannot make). """ fixture_dir = Path(fixture_dir) bpa = Bpa.from_json(_load_json(fixture_dir / "public-access-block.json")) bucket_policy = _load_json(fixture_dir / "bucket-policy.json") bucket_acl = _load_json(fixture_dir / "bucket-acl.json") access_points = _load_json(fixture_dir / "access-points.json") meta = _load_json(fixture_dir / "meta.json") or {} bucket = meta.get("bucket", fixture_dir.name) findings: list[Finding] = [] findings += _resolve_bucket_policy(bucket_policy, bpa, source="bucket policy") findings += _resolve_acl(bucket_acl, bpa) findings += _resolve_access_points(access_points) findings.sort(key=lambda f: (_SEVERITY_RANK[f.severity], f.code)) has_cross_account = any(f.code in ("XACCT-POLICY", "XACCT-ACL") for f in findings) has_access_points = bool(_as_list(access_points)) return Resolution( bucket=bucket, findings=findings, boundary=_boundary_notes(has_cross_account, has_access_points), )
-
-
FAILURE_MODES.md 7.5 KB
# Failure modes: s3-estate-calibration-auditor This skill composes the effective public/cross-account verdict across each bucket's four config layers and rolls them up into an estate verdict. It is correct for what those configs express and wrong in the predictable ways below. Read this before acting on a finding. ## 1. Effective exposure is not exploitability A LIVE finding means a real public or cross-account grant survives the layer that would neutralise it. It does **not** mean data is actually exposed or that the grant is reachable. Each of these breaks an exposure-on-paper without changing a single bucket-config line: - **No CDN context.** A bucket can be private while its objects are served publicly through a CloudFront distribution with Origin Access Control, and a flagged public bucket may sit behind a CDN that adds its own controls. The skill does not read the distribution. - **The trusted principal does nothing.** A cross-account grant only matters in proportion to what the trusted account/role can do with it and whether it re-shares onward, neither of which is in this bucket config. - **Data sensitivity.** A public or cross-account read on a bucket of public marketing assets is not the same finding as one on a bucket of PII; the config cannot tell you which. The boundary section of every audit names these. A LIVE finding is a hypothesis to confirm against the live estate, not a proven breach. ## 2. "Clean" means neutralised today, not safe A clean verdict (no LIVE bucket) means every exposed-looking bucket is neutralised by BPA (`IgnorePublicAcls` / `RestrictPublicBuckets` / `BlockPublicPolicy`) or scoped by a Condition. It does **not** prove the estate is safe: - The neutralisation depends on **account-level and bucket-level BPA staying on**. Turning a BPA switch off re-arms every POLICY-PUBLIC-BLOCKED and ACL-PUBLIC-IGNORED bucket into a live public bucket. Clean is one toggle away from exposed. - A **per-object public ACL** (section 4) can make an object public even when the bucket is clean. - A scoped grant's safety rests on the **condition value** being the intended one; a stale `aws:SourceIp` CIDR or a wrong org id is still scoped, just to the wrong scope. The clean verdict always ships with the boundary, for exactly this reason. Do not read "clean" as "audited and proven private." ## 3. BPA neutralisation is asymmetric, and it is easy to over-apply BPA neutralises **public** grants and **only** public grants. The two mistakes: - **Clearing on BPA-all-on alone.** Seeing all four BPA switches on and concluding the bucket is locked down is wrong: a cross-account bucket policy (XACCT-POLICY) and a cross-account canonical-user ACL (XACCT-ACL) are fully live with BPA all on. This is exactly how the needle hides on the logging and partner-share estates. - **Condemning on Principal '*' alone.** Seeing a Principal '*' and calling the bucket public is wrong when `RestrictPublicBuckets` / `BlockPublicPolicy` is on (POLICY-PUBLIC-BLOCKED) or when a narrowing Condition scopes it (COND-SCOPED). `IgnorePublicAcls` kills the AllUsers group but not a named canonical user; `BlockPublicAcls` blocks only *new* public ACLs, not the existing grant. Reading the wrong BPA switch as the neutraliser produces the wrong verdict. ## 4. Only the four bucket-config layers are in scope The verdict is composed from exactly four inputs per bucket: `public-access-block.json`, `bucket-policy.json`, `bucket-acl.json`, `access-points.json`. Outside that set: - **Per-object ACLs** are not the bucket config. An object can be public-read while the bucket is private; this skill cannot see it. - **CloudFront / CDN fronting, VPC-endpoint policies, and org SCPs** can add or remove access this config does not show. A bucket that looks private to this skill may be served publicly by a CDN; one that looks open may be capped by an SCP. - **Account-level BPA** (as opposed to the per-bucket BPA in the fixture) can neutralise a grant the bucket config alone reports as live. If only the bucket-level config is supplied, the verdict is for that config. ## 5. The needle is one bucket; do not stop at the first one On a needle estate exactly one bucket is live among many neutralised/scoped lookalikes. Two symmetric mistakes: - **Missing it** by reading each bucket's most prominent layer in isolation (the cross-account needle hides behind BPA-all-on; the public needle hides among conditional siblings; the cross-account ACL hides among ignored AllUsers grants). - **Over-flagging the siblings** by calling the neutralised/scoped lookalikes live, which buries the real finding in noise. The estate verdict is "clean iff no bucket is live" and "the needle is the one live bucket"; both halves are load-bearing. Process every bucket; do not generalise from the first two or three. ## 6. The estate config is what was supplied, not the whole account The estate verdict is over the buckets and layers handed to the audit. If a bucket was omitted, or a layer (its policy, its ACL, its access points) was not supplied, a real live grant or a neutralising BPA setting may be missing, and the verdict is incomplete. A clean estate means "clean across the configs supplied," not "this account has no public bucket." Escalate to a human when the estate inventory is uncertain, when a flagged cross-account or public grant needs a business-intent decision (is the trust deliberate, is the public read intended), or when closing a boundary join (per-object ACLs, the CDN, the trusted account's privileges) requires reads this skill does not perform. ## 7. Residual over-flag variance on neutralised buckets (measured) This is a measured reliability limit of the agent applying the skill, not a gap in the methodology, and it does not fully close with prompting or a deterministic decode. On a minority of runs the agent reports a **neutralised** bucket as live — typically by asserting `IgnorePublicAcls: false` (and thus an `ACL-PUBLIC` finding) when the actual config says `true`. The misread happens *inside* the step-1 mitigations (read the boolean verbatim, paste the raw `public-access-block.json` block) and persists at temperature 0: the value is hallucinated within the pasted block. What measurably triggers it: a bucket that carries an `AllUsers` / `AuthenticatedUsers` ACL grant, especially with a name that suggests exposure (`exports`, `public`, `share`, `partner`, `cdn`). The name plus the grant primes "this is the exposed one," and the boolean is then read to fit that expectation. Scale, from the control/treatment eval (temperature 0, 3 trials/fixture): the skill lifts the "does not over-flag the neutralised baits" item from a control pass rate of **0.00** to **0.76** — a large, real improvement, but not 1.0. Six of seven estates land at or near a clean 7/7; the over-flag surfaces intermittently across the deceptive-clean estates and most frequently on `03-saas-tenancy` (the `acme-tenant-exports` bucket), which oscillates between 7/7 and a false-positive ~3/7. The deterministic reference engine is always correct; the variance is in the model's transcription of the boolean. Operational guidance: when this skill reports a bucket as live `ACL-PUBLIC` or `POLICY-PUBLIC`, **re-read that bucket's `public-access-block.json` directly** before acting — confirm the quoted `IgnorePublicAcls` / `RestrictPublicBuckets` value against the source. A single live finding on a shareable-sounding bucket with an `AllUsers` grant is the case most worth double-checking, precisely because it is the case the model is most likely to get wrong. -
SKILL.md 22.5 KB
--- name: s3-estate-calibration-auditor description: Audit an estate of AWS S3 buckets for the one bucket that is genuinely publicly or cross-account exposed, without over-flagging the many buckets that READ as exposed but are neutralised. Resolves each bucket's EFFECTIVE verdict by composing four layers (Block Public Access x bucket policy x bucket ACL x access points), never one layer alone, then rolls the per-bucket verdicts up into an estate verdict. Its discipline is symmetric: BPA (RestrictPublicBuckets / BlockPublicPolicy) neutralises a Principal '*' policy but NOT a cross-account grant; IgnorePublicAcls kills a public-group ACL grant but NOT a cross-account canonical-user grant; a narrowing Condition (org id, ExternalId, SourceIp, access-point delegation) scopes a Principal '*' so it is not public. On a needle estate it names the ONE live bucket as the primary finding; on a clean estate it reports NO live exposure and does not manufacture findings. Then it states what the bucket configs alone cannot answer (per-object ACLs, CloudFront/CDN fronting, the trusted principals' identity policies, account-level BPA dependency, data sensitivity). Use when asked to review an S3 bucket fleet for public exposure, cross-account access, or whether the estate is clean. Vendor-neutral; runs offline against describe-bucket / get-bucket-policy / get-bucket-acl / list-access-points JSON with no Anyshift account. --- # s3-estate-calibration-auditor Effective-exposure audit skill for an estate of AWS S3 buckets. Takes the config layers for every bucket in the estate (`public-access-block.json`, `bucket-policy.json`, `bucket-acl.json`, and `access-points.json` where present), resolves each bucket's effective verdict by composing all four layers, then answers one question a per-layer read cannot: across 8-12 buckets that mostly READ as exposed, which one is genuinely live, and is the estate otherwise clean. It returns the live bucket as the headline, ranked by severity, with a fix, then names exactly where the bucket configs stop being able to answer the question. Effective S3 exposure is a join across four layers that each get read wrong one at a time. A public-looking bucket policy is inert under RestrictPublicBuckets; a cross-account grant survives BPA-all-on; a public-group ACL is dead under IgnorePublicAcls but a cross-account canonical user beside it is not; a clean bucket can still be public through an access point. In an estate, the trap doubles: several buckets carry a Principal '*' policy, an AllUsers ACL, or a wide-open look that is genuinely neutralised, and exactly one bucket carries a real live grant that reads just like its neutralised siblings. This skill composes the layers per bucket instead of clearing each layer in isolation, then calibrates the estate: it does not over-flag the neutralised baits, and it does not miss the buried needle. ## When to invoke - An agent is asked to review an S3 bucket fleet for public exposure, cross-account access, or "is anything in this account public." - An estate is being shipped or changed and the question is whether any bucket is effectively exposed, not just whether a Principal '*' or an AllUsers grant appears somewhere in the configs. - An estate *looks* exposed (several Principal '*' policies, AllUsers ACLs) and the claim "but BPA is on / it is scoped" needs to be confirmed against the effective verdict rather than taken on trust. - An estate *looks* locked down (BPA all on everywhere) and the claim "the account is closed" needs to be checked against the cross-account grants BPA does not touch. ## What this skill reads, and what it does not It reads the static configuration of an **estate of buckets**: per bucket, any subset of the Block Public Access booleans, the bucket policy, the bucket ACL, and the access points. Those are S3 control-plane reads (`get-public-access-block`, `get-bucket-policy`, `get-bucket-acl`, `list-access-points` + `get-access-point-policy`). That is the entire input. The audit is correct and complete *for what the bucket configs can tell you*, and it is explicit about the rest. Reachability-on-paper is not exposure-in-fact, and every audit ends by naming the joins it cannot make: - It does **not** see **per-object ACLs**. An individual object can carry its own public-read grant even when the bucket is private. Join: bucket config to its per-object ACLs. - It does **not** see **CloudFront / CDN fronting**. A bucket can be private while its data is served publicly through a CloudFront distribution with Origin Access Control. Join: bucket to its CDN distribution. - It does **not** contain the **privileges of a trusted principal**. A cross-account or conditional grant only matters in proportion to what the trusted account/role can do and whether it re-shares onward, which lives in *that* account. Join: this bucket to the identity policies of the principals it trusts. - It does **not** see **VPC-endpoint policies or org SCPs**, which can further restrict access this config Allows. Join: bucket to its VPC-endpoint policies and the org SCPs. - A flagged grant's **exploitability** depends on the **data sensitivity** of the objects, which is not in the config. A clean (deceptive-clean) estate still gets a boundary section, because a BPA-neutralised estate is not a proven-safe system: turning BPA off would expose the latent statements. ## The model For **every bucket** in the estate, build the **effective verdict** by composing four layers. Never read one layer alone. A finding is LIVE only when a real public or cross-account grant survives the layer that would neutralise it: 1. **Block Public Access (BPA)** -- four booleans that *neutralise* otherwise-public policy and ACL grants, but do NOT touch cross-account grants. 2. **The bucket policy** -- a resource policy whose Principal can be public ('*'), a named other account, or '*' narrowed by a Condition. 3. **The bucket ACL** -- legacy grants to canonical users, or to the AllUsers / AuthenticatedUsers public groups. 4. **Access points** -- each with its OWN BPA and policy, able to expose data independent of (but not exceeding) the bucket. The estate is **clean iff no bucket is live**. The needle is whichever bucket carries a live finding among many neutralised/scoped lookalikes. ## The methodology, in order ### 1. Parse all four layers for every bucket Before any judgment, read each layer for each bucket. Process EVERY bucket in the estate, not the first couple: - **BPA**: read the four booleans from `public-access-block.json`. An absent file means all four are False (no BPA). The two that *neutralise existing grants* are `RestrictPublicBuckets` / `BlockPublicPolicy` (for a public policy) and `IgnorePublicAcls` (for a public-group ACL). `BlockPublicAcls` only blocks *new* public ACLs and does not disable an existing one. - **Policy**: read each `Statement` in `bucket-policy.json`. A `Deny` grants nothing and cannot make a bucket public; classify only the `Allow` statements. For each Allow, read the `Principal` (public '*', a named AWS account, or '*' with a Condition) and the `Condition`. - **ACL**: read each grant in `bucket-acl.json`. A Grantee that is the AllUsers or AuthenticatedUsers `Group` URI is a public-group grant; a `CanonicalUser` that is not the bucket owner is a cross-account grant; an owner-only ACL produces nothing. - **Access points**: read each entry in `access-points.json`. Each AP has its OWN `PublicAccessBlock` and `Policy`; resolve the AP policy exactly like a bucket policy, against the AP's own BPA. Recognise the BPA switches by name, and recognise a narrowing Condition: `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:PrincipalAccount`, `aws:PrincipalArn`, `aws:SourceArn`, `aws:SourceAccount`, `aws:SourceVpc`, `aws:SourceVpce`, `aws:SourceIp`, `aws:VpcSourceIp`, `sts:ExternalId`, and the access-point delegation keys `s3:DataAccessPointAccount` / `s3:DataAccessPointArn` / `s3:AccessPointNetworkOrigin`. Any of these on a Principal '*' scopes it to a bounded caller set. **Read each bucket's BPA booleans verbatim — do not let a bucket's name or its grants tell you what they are.** Each boolean being `true` is the **safe** direction: `IgnorePublicAcls: true` means an existing public ACL is ignored (dead); `RestrictPublicBuckets: true` means a public policy is denied. Do not invert it. The dominant miscalibration is asserting a boolean value to fit an expectation: a bucket named `exports`, `public`, `share`, `partner`, or `cdn`, or any bucket carrying an `AllUsers` / `AuthenticatedUsers` grant, invites the assumption that it *must* be the exposed one — and that assumption makes you misread its `IgnorePublicAcls` as `false`. In these estates the **common** case is the opposite: a shareable-sounding bucket with an `AllUsers` grant and `IgnorePublicAcls: true`, which is **neutralised**, not live. The presence of a grant is not evidence about the boolean. To keep the transcription faithful: for any bucket carrying an `AllUsers` / `AuthenticatedUsers` ACL grant or a `Principal '*'` policy, **paste that bucket's entire `public-access-block.json` as a verbatim JSON block** before you classify it, and read the four booleans out of the pasted block. Pasting the raw object is harder to get wrong than filling a value in from memory, which is where the misread creeps in. ### 2. Resolve each bucket's effective verdict (the composition) Compose the layers; do not condemn a bucket on "Principal '*' is present" alone, and do not clear it on "BPA is on" alone. The codes: - **POLICY-PUBLIC (critical, LIVE)** -- the bucket policy allows Principal '*' with NO narrowing Condition, and BPA is NOT restricting (RestrictPublicBuckets and BlockPublicPolicy both off). Live public exposure: anyone on the internet can perform the granted actions. - **AP-PUBLIC (critical, LIVE)** -- an access point's own policy allows Principal '*' with no Condition and the AP's own BPA is not restricting. Data is reachable publicly through the access point even when the bucket policy and bucket BPA are clean. Auditing only the bucket misses this. - **XACCT-POLICY (high, LIVE)** -- the bucket policy grants a named other account. This is NOT public, so BPA does not govern it: a cross-account grant stays fully live even with all four BPA switches on. The classic misread is seeing BPA-all-on and calling the bucket locked down. - **XACCT-ACL (high, LIVE)** -- the bucket ACL grants a canonical user that is not the bucket owner. IgnorePublicAcls neutralises the public GROUPS, not a named canonical user, so this grant survives BPA-all-on. A TLS-only Deny in the policy does not address it. - **ACL-PUBLIC (high, LIVE)** -- the bucket ACL grants the AllUsers / AuthenticatedUsers group and IgnorePublicAcls is OFF. Live public via ACL, independent of the bucket policy. These five are the only codes that count as live exposure. The next three READ as exposed but are NOT live, and must never be reported as a live public/exposed bucket: - **POLICY-PUBLIC-BLOCKED (info, neutralised)** -- a Principal '*' policy that RestrictPublicBuckets / BlockPublicPolicy renders inert. Effective verdict: NOT public. Note it as latent risk (turning BPA off would expose it), not as live exposure. - **ACL-PUBLIC-IGNORED (info, neutralised)** -- a public-group ACL grant that IgnorePublicAcls makes ineffective. Effective verdict: NOT public via this ACL. - **COND-SCOPED (low, scoped)** -- a Principal '*' narrowed by a Condition (org / IP / source account or VPC / ExternalId / access-point delegation). This is conditional or org-scoped sharing, NOT public: a caller outside the condition is denied. Reading Principal '*' and stopping there is the misread this guards against. ### 3. Surface the buried live needle (LOAD-BEARING) On a NEEDLE estate, name the ONE genuinely live bucket as A (the) PRIMARY finding, with the reason it is live, instead of burying it among the neutralised lookalikes or missing it. The live bucket reads just like its neutralised/scoped siblings; the pass is naming exactly that one and why: - A **cross-account** policy or ACL survives BPA-all-on (XACCT-POLICY / XACCT-ACL): BPA only neutralises public grants. - A **missing Condition** makes a Principal '*' policy public (POLICY-PUBLIC) when its scoped-looking siblings carry an org id / ExternalId / SourceIp. - A **named canonical user** survives IgnorePublicAcls (XACCT-ACL) when the AllUsers grants beside it are correctly ignored. State the live bucket by name, the code, and the layer it is grounded in. ### 4. Stay quiet on the deceptive-clean estate (LOAD-BEARING) This is the half the naive read gets wrong in the other direction. An estate where every bucket is neutralised or scoped is **CLEAN**, and the audit must say so instead of manufacturing a finding. The composition in step 2 is what proves it. Specifically: - A **public-looking ACL with IgnorePublicAcls on** is not live. Do not report it as a public bucket. - A **Principal '*' policy with RestrictPublicBuckets / BlockPublicPolicy on** is not live. Do not report it as a public bucket. - A **Principal '*' narrowed by org / IP / external-id / access-point delegation** is scoped sharing, not public. Do not read Principal '*' and call it public. - Do not drown the clean verdict, or the one real finding, in a wall of nitpicks about the correctly-neutralised buckets. On a clean estate the audit reports: NO live exposure anywhere, *why* the exposed-looking buckets are neutralised or scoped (the BPA switch or the Condition), and the boundary. It does **not** invent a critical. Noting the neutralised statements as latent / defence-in-depth is fine; asserting live public exposure is not. ### 5. Rank and report, then name the boundary Order findings by severity (critical for a public policy or public access point, high for a cross-account or public-group grant). Rank the live needle as the headline; do NOT headline a neutralised/scoped bucket, and on a clean estate do not invent a critical. For each finding: the bucket and layer it is grounded in, what the exposure is, and the fix. Then list the boundary from "What this skill reads." A clean estate still gets a boundary section. ## Recommendations Fix the live bucket; do not rip out intentional scoped sharing or the BPA-neutralised buckets as if they were live leaks: - **POLICY-PUBLIC / AP-PUBLIC**: remove the public statement, or replace Principal '*' with the specific accounts/roles that need access. If public read is genuinely intended (a static site), front it with CloudFront + Origin Access Control instead of a public bucket or access point, and turn RestrictPublicBuckets on. - **XACCT-POLICY / XACCT-ACL**: confirm the other account is a deliberate, current trust and the actions are minimal; scope to specific prefixes and prefer an `aws:PrincipalOrgID` / `sts:ExternalId` condition over a bare account root. For an ACL grant, express the sharing as a scoped bucket policy and disable ACLs with Bucket Owner Enforced. BPA-all-on does not make a cross-account bucket safe. - **ACL-PUBLIC**: remove the public ACL grant and set IgnorePublicAcls + BlockPublicAcls; prefer bucket policies over ACLs. - **Neutralised / scoped buckets (POLICY-PUBLIC-BLOCKED, ACL-PUBLIC-IGNORED, COND-SCOPED)**: no live fix. As defence in depth, optionally remove the latent public statement or ignored ACL so the bucket does not depend on BPA staying on as its only guardrail; for a scoped bucket, confirm the condition value (the org id, the ExternalId, the SourceIp CIDRs) is the intended one and leave the grant in place if the scope is correct. ## Severity model | Severity | Meaning | |---|---| | **critical** | Live public exposure: a Principal '*' bucket policy with BPA not restricting (POLICY-PUBLIC), or a public access-point policy (AP-PUBLIC). | | **high** | Live cross-account or public-group exposure that BPA does not close: cross-account policy (XACCT-POLICY), cross-account canonical-user ACL (XACCT-ACL), public-group ACL with IgnorePublicAcls off (ACL-PUBLIC). | | **low** | A Principal '*' scoped by a Condition (COND-SCOPED): conditional sharing to verify, not public exposure. | | **info** | A neutralised grant present but inert (POLICY-PUBLIC-BLOCKED, ACL-PUBLIC-IGNORED): latent risk if BPA is turned off, not live exposure. | Only the critical and high bands are LIVE exposure and count toward the estate verdict. The low and info bands are exposed-looking-but-not-live; they are notes, never the headline. ## Rule reference | Code | Rule | Severity | Live | Grounded in | |---|---|---|---|---| | POLICY-PUBLIC | Bucket policy Principal '*', no Condition, BPA not restricting | critical | yes | bucket policy x BPA | | AP-PUBLIC | Access-point policy Principal '*', AP BPA not restricting | critical | yes | access-point policy x AP BPA | | XACCT-POLICY | Bucket policy grants a named other account | high | yes | bucket policy (BPA does not touch it) | | XACCT-ACL | Bucket ACL grants a non-owner canonical user | high | yes | bucket ACL (IgnorePublicAcls does not touch it) | | ACL-PUBLIC | Bucket ACL grants AllUsers / AuthenticatedUsers, IgnorePublicAcls off | high | yes | bucket ACL x BPA | | COND-SCOPED | Principal '*' narrowed by a Condition | low | no | bucket policy Condition | | POLICY-PUBLIC-BLOCKED | Principal '*' policy neutralised by RestrictPublicBuckets / BlockPublicPolicy | info | no | bucket policy x BPA | | ACL-PUBLIC-IGNORED | Public-group ACL neutralised by IgnorePublicAcls | info | no | bucket ACL x BPA | The matching half of every live rule is the clean verdict: POLICY-PUBLIC neutralised to POLICY-PUBLIC-BLOCKED, ACL-PUBLIC neutralised to ACL-PUBLIC-IGNORED, a Principal '*' scoped to COND-SCOPED, on an estate of these is the correct, complete output, not a failure to find something. Reporting a neutralised bucket as a live leak is the dominant failure mode this skill prevents. ## Output format The agent's final message in any invocation must include: 1. **Estate**: bucket count, the entry question (public exposure across the estate). 2. **Findings**: ranked by severity, each with the code, the bucket and layer it is grounded in, what the exposure is, and the fix. The live needle named explicitly as the headline. Or "no live exposure" for a deceptive-clean estate, stating *why* the exposed-looking buckets are neutralised or scoped. 3. **Boundary**: the joins this audit could not make (per-object ACLs, CloudFront/CDN fronting, the trusted principals' identity policies, the account-level BPA dependency, VPC endpoint policies / org SCPs, data sensitivity), stated explicitly so the gap is visible instead of silent. ## Worked examples Seven end-to-end fixtures are committed under `fixtures/`, each an estate of 8-12 buckets with a runnable replay test. The set is deliberately weighted toward deceptive-clean, because over-flagging a neutralised estate is the cold agent's dominant failure here. No loud, obvious public bucket appears: the base model already aces those. - [`05-logging-estate-needle`](./fixtures/05-logging-estate-needle/): the needle. acme-log-shipping has all four BPA switches on (reads as locked down) but its policy grants a named other account read/list. A cross-account grant survives BPA: XACCT-POLICY (high), live. - [`06-analytics-estate-needle`](./fixtures/06-analytics-estate-needle/): acme-analytics-clickstream grants Principal '*' GetObject with NO Condition and BPA not restricting, sitting next to siblings that carry an org id / ExternalId or have RestrictPublicBuckets on: POLICY-PUBLIC (critical), live. - [`07-partner-share-needle`](./fixtures/07-partner-share-needle/): acme-share-partner-drop grants READ to another account's canonical user via its ACL; IgnorePublicAcls (on for this estate) only ignores the AllUsers lookalikes beside it: XACCT-ACL (high), live. - [`01-media-platform-clean`](./fixtures/01-media-platform-clean/): an AllUsers ACL, a Principal '*' policy, org/IP-scoped policies, and an access-point delegation, all neutralised or scoped. Clean. - [`02-data-lake-clean`](./fixtures/02-data-lake-clean/): public-looking policies and a public ACL, all neutralised by BPA or scoped by org-path / external-id. Clean. - [`03-saas-tenancy-clean`](./fixtures/03-saas-tenancy-clean/): tenant-shared buckets scoped by org id / ExternalId, plus one ignored AllUsers ACL. Clean. - [`04-backup-estate-clean`](./fixtures/04-backup-estate-clean/): BPA-neutralised policies, an ignored public ACL, and a SourceIp office allowlist. Clean. ## Replay tests Every fixture has a replay test in `tests/` that runs the methodology (via the deterministic reference engine `tests/_resolve.py`, aggregated across the estate by `tests/_estate.py`) against the committed JSON, with no external credentials. Run from the skill directory: ```bash for t in tests/replay_*.py; do python "$t" || exit 1; done ``` The seven tests cover the three needle estates (the one live code fires, on the named bucket) and the four deceptive-clean estates (no live finding fabricated). Tests exit non-zero if the audit names the wrong bucket or invents one on a clean estate. See [`tests/README.md`](./tests/README.md) for the fixture schema. ## Failure modes This skill is wrong in predictable ways. Read [`FAILURE_MODES.md`](./FAILURE_MODES.md) before relying on it. Highlights: - It audits effective access *on paper*, not exploitability. A live grant can reach a tier with no sensitive objects, or one fronted by a CDN, or one whose trusted account does nothing with it. Exposure-on-paper is a hypothesis to confirm, not a breach. - A clean verdict depends on BPA staying on. The neutralised buckets are one BPA toggle away from live; "clean" means clean today, not proven-safe. - It reasons over the four bucket-config layers only. A public object ACL, a CloudFront distribution, a per-object grant, or an account-level BPA the per-bucket config does not carry is outside the graph this skill builds. ## Anyshift integration (opt-in) The audit above runs end-to-end against the bucket-config JSON the user already has. No Anyshift dependency. Every boundary note in this skill is a join: bucket to its per-object ACLs, bucket to its CloudFront distribution, bucket to the identity policies of the principals it trusts, bucket to its VPC-endpoint policies and org SCPs, estate to the account-level BPA the neutralisation depends on. The Anyshift MCP can act as a context primer by resolving those joins from a versioned resource graph, so an XACCT-POLICY finding ("cross-account, real only if the trusted account is privileged or re-shares") can be closed instead of deferred at the boundary. A measured "with vs without" delta will be published here once the integration has been exercised against the replay fixtures.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.