Relationships
#363 bucket-policy StateSchema.PolicyDocument declared z.string() but CloudControl returns it as a parsed object
Opened by jentz · 5/16/2026· Shipped 5/18/2026
Summary
@swamp/aws/s3/bucket-policy's StateSchema declares
PolicyDocument: z.string().optional(), but AWS CloudControl's
GetResource returns the field as a parsed object (the policy JSON
already deserialized into {Version, Statement, ...}). The persisted
state on disk matches the AWS response shape, so any consumer that
safeParses state against the published schema misclassifies the
bucket as having no policy.
Where
// .swamp/pulled-extensions/@swamp/aws/s3/models/bucket_policy.ts:33-36
const StateSchema = z.object({
Bucket: z.string(),
PolicyDocument: z.string().optional(), // <- but AWS returns this as an object
}).passthrough();The GlobalArgsSchema (line 28) and InputsSchema (line 44) correctly
declare PolicyDocument: z.string() — at write-time you supply JSON as
a string. The mismatch is specifically in StateSchema, which describes
what AWS gives back.
Verified unchanged in the latest published 2026.04.23.3 after
swamp extension update @swamp/aws/s3.
Reproduction
- Pull
@swamp/aws/s3(swamp extension pull @swamp/aws/s3). - Create a bucket-policy instance with placeholder globalArgs and run
getagainst any bucket that has a policy (e.g. a TF-state bucket with aDenyInsecureTransportstatement). - Read the persisted state via
cat .swamp/data/@swamp/aws/s3/bucket-policy/<id>/<bucket>/<v>/raw.
Actual on-disk shape we observed:
{
"Bucket": "xyz-iac-026090512999",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Condition": { "Bool": { "aws:SecureTransport": "false" } },
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::xyz-iac-026090512999/*",
"arn:aws:s3:::xyz-iac-026090512999"
],
"Effect": "Deny",
"Principal": "*",
"Sid": "DenyInsecureTransport"
}
]
}
}PolicyDocument is an object, not a string.
Expected
A consumer running
StateSchema.safeParse(JSON.parse(<raw bytes>)) should succeed and
return the policy in a usable form.
Actual
safeParse fails with expected string, received object on
PolicyDocument. Strict consumers treat the bucket as having no
policy and emit spurious findings (this was the actual reason our
first end-to-end TLS-only audit incorrectly flagged a properly-
configured bucket).
Impact
Any third-party extension that depends on
@swamp/aws/s3/bucket-policy state via its declared schema will
misread reality. Affects audit reports, drift detection, anything
reasoning about the policy contents. The miss is silent —
safeParse reports a validation error but consumers that fall back
to null/undefined (the documented shape) treat it as a missing
policy rather than as a schema bug.
Workaround
Union schema with normalization in the consumer:
const BucketPolicyStateSchema = z.object({
Bucket: z.string(),
PolicyDocument: z.union([
z.string(),
z.record(z.string(), z.unknown()),
]).optional(),
}).passthrough();Then JSON.parse the string shape if encountered. The tolerance is
safe even after upstream is fixed.
Suggested fix
Change StateSchema.PolicyDocument to match the response shape:
const StateSchema = z.object({
Bucket: z.string(),
PolicyDocument: z.record(z.string(), z.unknown()).optional(),
}).passthrough();GlobalArgsSchema and InputsSchema should stay as z.string()
since that's the write-side contract — only StateSchema reflects
what AWS returns.
If you prefer to keep tolerance for the historical string shape on
disk (in case anyone has older state), a union
(z.union([z.string(), z.record(...)])) is also reasonable.
Environment
- swamp
20260516.045246.0-sha.e6eda98d @swamp/aws/s32026.04.23.3(verified bug present)- AWS region
eu-west-1, CloudControl path - macOS 25.4.0 / Deno bundled with swamp
Upstream repository: https://github.com/systeminit/swamp-extensions
Environment
- Extension:
@swamp/aws/s3@2026.04.23.3 - swamp:
20260516.045246.0-sha.e6eda98d - OS:
darwin(aarch64) - Deno:
2.7.14+19bd3d8 - Shell:
/bin/zsh
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.