<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bala Paranj</title>
    <description>The latest articles on DEV Community by Bala Paranj (@bala_paranj_059d338e44e7e).</description>
    <link>https://dev.to/bala_paranj_059d338e44e7e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3862804%2F7ea6c560-63cb-4daf-a713-450532280b0a.jpg</url>
      <title>DEV Community: Bala Paranj</title>
      <link>https://dev.to/bala_paranj_059d338e44e7e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bala_paranj_059d338e44e7e"/>
    <language>en</language>
    <item>
      <title>What SCPs Can't Verify, Snapshots Can</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:03:41 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/what-scps-cant-verify-snapshots-can-3668</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/what-scps-cant-verify-snapshots-can-3668</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Can an SCP require EKS clusters to use customer-managed KMS keys?&lt;/p&gt;

&lt;p&gt;No. Because the information the SCP needs doesn't exist in the place the SCP can look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request context is incomplete
&lt;/h2&gt;

&lt;p&gt;What happens when someone creates an EKS cluster with encryption:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws eks create-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; production &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--encryption-config&lt;/span&gt; &lt;span class="s1"&gt;'[{
    "provider": {
      "keyArn": "arn:aws:kms:us-east-1:123456789012:key/abc-123"
    },
    "resources": ["secrets"]
  }]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SCP sees this request context. It can evaluate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The action: &lt;code&gt;eks:CreateCluster&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The key ARN: &lt;code&gt;arn:aws:kms:us-east-1:123456789012:key/abc-123&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The condition key: &lt;code&gt;eks:encryptionConfigProviderKeyArns&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SCP can deny if &lt;strong&gt;no&lt;/strong&gt; key ARN is provided. That's straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyEKSWithoutEncryptionKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eks:CreateCluster"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Null"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"eks:encryptionConfigProviderKeyArns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But can the SCP deny if the provided key is AWS-managed instead of customer-managed?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;Look at the key ARN: &lt;code&gt;arn:aws:kms:us-east-1:123456789012:key/abc-123&lt;/code&gt;. It's a string. There's nothing in the ARN that says "customer-managed" or "AWS-managed." Both types have identical ARN formats. The distinction lives in the KMS key's metadata. Specifically the &lt;code&gt;KeyManager&lt;/code&gt; field which is stored on the key object in KMS, not in the request to create an EKS cluster.&lt;/p&gt;

&lt;p&gt;The SCP evaluates the &lt;strong&gt;request.&lt;/strong&gt; The answer is in the &lt;strong&gt;key's metadata.&lt;/strong&gt; The SCP can't cross that boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workaround is a governance band-aid
&lt;/h2&gt;

&lt;p&gt;The community's best workaround: enforce a naming convention on key aliases, then write an SCP that denies if the key alias doesn't match the convention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyGrantOnAWSManagedEKSKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kms:CreateGrant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kms:ViaService"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eks.us-east-1.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ForAnyValue:StringLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kms:ResourceAliases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alias/aws/eks"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This blocks the AWS-managed key specifically (&lt;code&gt;alias/aws/eks&lt;/code&gt;). It works for the narrow case of blocking the default AWS-managed key.&lt;/p&gt;

&lt;p&gt;But it doesn't prove the key that is used is customer-managed. It proves the key isn't the specific AWS-managed key with the &lt;code&gt;alias/aws/eks&lt;/code&gt; alias. If someone creates a key with a different alias, or no alias, or an alias that matches your naming convention but is an AWS-managed key from another service, the SCP passes it through.&lt;/p&gt;

&lt;p&gt;The workaround works when everyone follows the naming convention. "When everyone follows the convention" is the security equivalent of "when everyone remembers to lock the door." It's the same rubber-stamping pattern SOC 2 auditors keep catching. The control looks correct on paper, the underlying property isn't verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  The snapshot contains the truth
&lt;/h2&gt;

&lt;p&gt;Here's what the KMS key looks like in a configuration snapshot, the actual key object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"KeyId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:kms:us-east-1:123456789012:key/abc-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"KeyManager"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CUSTOMER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AWS_KMS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"KeyState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"KeyUsage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ENCRYPT_DECRYPT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EKS secrets envelope encryption"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"CreationDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-03-15T10:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the EKS cluster's encryption configuration in the same snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ClusterName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"EncryptionConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"KeyArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:kms:us-east-1:123456789012:key/abc-123"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"secrets"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTIVE"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;KeyManager&lt;/code&gt; field is right there. &lt;code&gt;"CUSTOMER"&lt;/code&gt; or &lt;code&gt;"AWS"&lt;/code&gt;. Binary. No naming convention needed. No alias enforcement or workaround. The snapshot contains the truth about the key — not the request's claim about the key, but the key's own metadata.&lt;/p&gt;

&lt;p&gt;A snapshot-based verification tool joins the two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the EKS cluster's &lt;code&gt;EncryptionConfig.Provider.KeyArn&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Look up that ARN in the KMS key inventory&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;KeyManager == "CUSTOMER"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;KeyManager == "AWS"&lt;/code&gt;: &lt;strong&gt;FAIL&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;KeyManager == "CUSTOMER"&lt;/code&gt;: &lt;strong&gt;PASS&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If no &lt;code&gt;EncryptionConfig&lt;/code&gt; at all: &lt;strong&gt;FAIL&lt;/strong&gt; (no encryption configured)&lt;/li&gt;
&lt;li&gt;If the key ARN doesn't resolve (key deleted, cross-account, or missing from snapshot): &lt;strong&gt;FAIL&lt;/strong&gt; (can't verify — fail loud, don't assume)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deterministic. Same answer every time. No convention to follow or alias to enforce. The property is verified against the configuration state, not inferred from the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this gap is structural, not accidental
&lt;/h2&gt;

&lt;p&gt;The SCP limitation isn't a bug. It's a consequence of where SCPs operate in the architecture.&lt;/p&gt;

&lt;p&gt;SCPs evaluate &lt;strong&gt;request context&lt;/strong&gt; at API call time. The request context contains what the caller sent. The action, resource ARN, tags the caller attached and the condition keys AWS defined for that API. It does not contain the metadata of resources the request references. When you create an EKS cluster and specify a KMS key ARN, the SCP sees the ARN string. It does not query KMS to check what kind of key that ARN points to. That cross-service metadata lookup doesn't happen during policy evaluation.&lt;/p&gt;

&lt;p&gt;This is by design. SCPs need to evaluate in microseconds. A cross-service lookup (call KMS to check &lt;code&gt;KeyManager&lt;/code&gt; for every key ARN in every request) would add latency, create circular dependencies, and introduce failure modes. AWS made the right engineering decision. The SCP evaluates what's in front of it, fast.&lt;/p&gt;

&lt;p&gt;But that means any property that requires &lt;strong&gt;joining data from two services&lt;/strong&gt; such as the EKS cluster's encryption config AND the KMS key's metadata is structurally invisible to the SCP. The SCP can see either one in isolation. It can't join them.&lt;/p&gt;

&lt;p&gt;A configuration snapshot can. The snapshot contains both services' configuration at the same point in time. The join is a lookup, not a cross-service API call. The EKS cluster says "I use key ARN X." The KMS inventory says "key ARN X has &lt;code&gt;KeyManager: AWS&lt;/code&gt;." The verification engine reads both and produces the verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern generalizes
&lt;/h2&gt;

&lt;p&gt;EKS encryption is one instance. The same structural gap applies anywhere a preventive policy needs to verify a property that lives in a different service's metadata:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3 bucket encryption&lt;/strong&gt; — is the KMS key customer-managed or AWS-managed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RDS encryption&lt;/strong&gt; — same question for database encryption keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EBS volume encryption&lt;/strong&gt; — is the default EBS encryption key a CMK?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda function environment encryption&lt;/strong&gt; — is the KMS key attached to the function a CMK?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets Manager encryption&lt;/strong&gt; — is the secret encrypted with a CMK or the default &lt;code&gt;aws/secretsmanager&lt;/code&gt; key?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQS/SNS encryption&lt;/strong&gt; — is the queue/topic using a CMK?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these requires joining the resource's configuration with the KMS key's metadata. Every one of these is invisible to an SCP for the same structural reason. Every one of these is a deterministic check against a configuration snapshot.&lt;/p&gt;

&lt;p&gt;The pattern: &lt;strong&gt;SCPs verify properties within the request context. Snapshots verify properties across the configuration graph.&lt;/strong&gt; Both are needed. SCPs prevent. They block requests that violate policy. Snapshots verify. They prove the configuration satisfies the property after deployment. The SCP catches the violation at creation time (when it can see enough to evaluate). The snapshot catches the violation at any time (because it can see everything).&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap between prevention and verification
&lt;/h2&gt;

&lt;p&gt;Most organizations treat SCPs as the security mechanism and stop there. "We have an SCP that requires encryption keys." The property they think they've verified: EKS clusters use customer-managed keys. The property they've verified: EKS clusters provide a key ARN. Whether that key ARN points to a customer-managed key is unverified. Because the SCP structurally can't check it.&lt;/p&gt;

&lt;p&gt;This is the gap between prevention and verification. Prevention says "I blocked the bad request." Verification says "the resulting configuration satisfies the property." Prevention operates at request time with partial context. Verification operates on the full configuration with complete context. Prevention is necessary. Verification proves the property holds.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; is an open-source tool that verifies cloud configuration against declared invariants using three formal reasoning engines (CEL, Z3, Soufflé). It reads configuration snapshots without any credentials and joins data across services to verify properties that per-service or per-request controls structurally cannot see. The EKS/KMS check described above is one of 3,000+ controls in the catalog.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloudsecurity</category>
      <category>encryption</category>
      <category>devops</category>
    </item>
    <item>
      <title>An AWS library stored SQS payloads in S3 without encryption for 4 years and nobody's tooling caught it</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:15:27 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/an-aws-library-stored-sqs-payloads-in-s3-without-encryption-for-4-years-and-nobodys-tooling-caught-g06</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/an-aws-library-stored-sqs-payloads-in-s3-without-encryption-for-4-years-and-nobodys-tooling-caught-g06</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In June 2016, a user &lt;a href="https://github.com/awslabs/amazon-sqs-java-extended-client-lib/issues/10" rel="noopener noreferrer"&gt;reported&lt;/a&gt; about the Amazon SQS Extended Client Library. It is an official AWS library that stored large SQS message payloads in S3 without server-side encryption.&lt;/p&gt;

&lt;p&gt;The question: "the messages stored in S3 do not have encryption turned on. For the best HIPAA compliance, I think they should be?"&lt;/p&gt;

&lt;p&gt;The issue sat open for &lt;strong&gt;4 years&lt;/strong&gt;. It was resolved in July 2020 with version 1.1.0, which added SSE-KMS support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The SQS Extended Client Library is used when message payloads exceed the 256KB SQS limit. The library transparently stores the payload in S3 and sends a pointer via SQS. The consuming application retrieves the payload from S3.&lt;/p&gt;

&lt;p&gt;If those payloads contain PHI such as patient records, diagnostic results, insurance claims, they're stored unencrypted in S3. This violates HIPAA §164.312(a)(2)(iv) (encryption and decryption) regardless of how the SQS queue itself is configured.&lt;/p&gt;

&lt;p&gt;The problem is invisible to most tooling because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The bucket exists but isn't in any IaC template.&lt;/strong&gt; The library creates or uses a bucket programmatically. CloudFormation linters like cfn_nag never see it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Config checks the bucket, not the application.&lt;/strong&gt; Config's &lt;code&gt;S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED&lt;/code&gt; rule would flag the bucket but only if Config is enabled, and the finding has no context that the bucket stores SQS message payloads containing PHI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No duration tracking.&lt;/strong&gt; Even if a tool flagged the missing encryption, nobody tracked how long the bucket was non-compliant. The answer: 4 years.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Stave detects
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Missing encryption at rest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# CTL.S3.ENCRYPT.001&lt;/span&gt;
&lt;span class="na"&gt;unsafe_predicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.kind&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucket&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.encryption.at_rest_enabled&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stave's &lt;code&gt;CTL.S3.ENCRYPT.001&lt;/code&gt; fires immediately when the extractor captures a bucket without server-side encryption. The finding includes the HIPAA citation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[FAIL] CONTROLS.001 — high
Compliance: §164.312(a)(2)(iv) — Encryption and Decryption
Finding: Bucket sqs-extended-payloads: server-side encryption is not enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Missing KMS customer-managed key
&lt;/h3&gt;

&lt;p&gt;Even after enabling SSE, if the bucket uses the AWS-managed key (&lt;code&gt;alias/aws/s3&lt;/code&gt;), Stave's &lt;code&gt;CONTROLS.001.STRICT&lt;/code&gt; fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[FAIL] CONTROLS.001.STRICT — critical
Compliance: §164.312(a)(2)(iv) — CMK required for key revocation
Finding: SSE-KMS uses the AWS-managed key. CMK required for breach
response — AWS-managed keys cannot be revoked by the customer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AWS-managed key cannot be disabled during a breach. A customer-managed key can be immediately revoked, rendering all encrypted objects unreadable. For PHI, this distinction is the difference between containment and exposure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duration tracking
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Finding: sqs-extended-payloads
  First unsafe: 2016-06-15T00:00:00Z
  Last seen:    2020-07-29T00:00:00Z
  Duration:     36,000+ hours (threshold: 168 hours)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4 years of non-compliance. 36,000 hours past the 168-hour SLA threshold. No other tool surfaces this. They report "non-compliant right now" without temporal context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compound risk: unencrypted + data classification
&lt;/h3&gt;

&lt;p&gt;If the bucket is tagged &lt;code&gt;data-classification: phi&lt;/code&gt; (as it should be for SQS payloads containing patient data), Stave's &lt;code&gt;CTL.S3.ENCRYPT.004&lt;/code&gt; escalates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[FAIL] CTL.S3.ENCRYPT.004 — high
Sensitive Data Requires KMS Encryption
Finding: Bucket tagged "phi" uses AES256, not SSE-KMS with CMK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classification tag becomes evidence. The organization's own metadata proves the data is sensitive while the encryption is insufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deeper problem: application-created buckets
&lt;/h2&gt;

&lt;p&gt;This case exposes a gap in infrastructure security that most tools miss: &lt;strong&gt;buckets created by application libraries, not by infrastructure teams.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The SQS Extended Client Library creates or references a bucket at runtime. This bucket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Doesn't appear in CloudFormation or Terraform&lt;/li&gt;
&lt;li&gt;Isn't in the infrastructure team's inventory&lt;/li&gt;
&lt;li&gt;May not have the organization's standard bucket policy&lt;/li&gt;
&lt;li&gt;May not have encryption, logging, or Public Access Block&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These shadow buckets are everywhere. They are created by AWS SDKs, CI/CD tools, log aggregators, backup solutions, and application frameworks. They inherit whatever defaults the library uses, not the organization's security baseline.&lt;/p&gt;

&lt;p&gt;Stave catches them because it evaluates &lt;strong&gt;observed state&lt;/strong&gt;. Whatever buckets exist in the AWS account, regardless of how they were created. The extractor captures all buckets. The controls evaluate all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline problem
&lt;/h2&gt;

&lt;p&gt;The most damaging aspect of this case is the 4-year gap between discovery and fix.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;June 2016&lt;/strong&gt;: User reports the issue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2016-2018&lt;/strong&gt;: Community discussion, feature requests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;July 2020&lt;/strong&gt;: Fixed in version 1.1.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During those 4 years, every organization using this library with PHI payloads was non-compliant. No tool tracked the duration. No tool escalated based on how long the gap persisted.&lt;/p&gt;

&lt;p&gt;Stave's duration tracking turns this from "we have a finding" into "we have a finding that has been open for 36,000 hours past the SLA." That temporal evidence changes the conversation with auditors, management, and regulators.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for HIPAA programs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audit application-created buckets&lt;/strong&gt;, not just IaC-managed ones. Libraries create S3 buckets that your infrastructure tools don't see.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Track duration&lt;/strong&gt;, not just state. A 4-year compliance gap is categorically different from a 4-hour one. Duration is the missing dimension in most compliance programs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Require CMK, not just "encryption enabled."&lt;/strong&gt; The AWS-managed key is a false sense of security for breach response. HIPAA's encryption requirement should be interpreted as "encryption you can revoke."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Evaluate observed state.&lt;/strong&gt; Template scanning catches what you intend to deploy. State evaluation catches what actually exists — including the buckets nobody intended to create.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;This is implemented in &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;, an open-source cloud security platform. The kernel evaluates predicates. The YAML encodes the domain insight. Try it: &lt;code&gt;bash examples/demo-ai-security/run.sh&lt;/code&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hipaa</category>
      <category>aws</category>
      <category>s3</category>
      <category>security</category>
    </item>
    <item>
      <title>"Our auditor wants proof of S3 compliance, what do we give them?" Automating HIPAA S3 evidence for your auditor</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sun, 04 Oct 2026 12:35:16 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/our-auditor-wants-proof-of-s3-compliance-what-do-we-give-them-automating-hipaa-s3-evidence-for-34hk</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/our-auditor-wants-proof-of-s3-compliance-what-do-we-give-them-automating-hipaa-s3-evidence-for-34hk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Reddit Question
&lt;/h2&gt;

&lt;p&gt;The thread on r/aws: "Our auditor wants proof of S3 HIPAA compliance. We have AWS Config and SecurityHub, but they want something more structured. What do we give them?"&lt;/p&gt;

&lt;p&gt;The usual answers such as console screenshots, CSV exports, GRC platform PDFs are not what auditors need.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Auditors Want
&lt;/h2&gt;

&lt;p&gt;Auditors want &lt;strong&gt;evidence&lt;/strong&gt;: deterministic, reproducible artifacts traceable to specific HIPAA requirements.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What was evaluated&lt;/strong&gt; — which controls, mapped to which HIPAA sections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it was evaluated&lt;/strong&gt; — a timestamp for the compliance period&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the result was&lt;/strong&gt; — COMPLIANT, NON_COMPLIANT, or AT_RISK with findings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That the evaluation is repeatable&lt;/strong&gt; — same inputs, same output&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How Stave Produces Evidence
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; produces deterministic, machine-readable compliance evidence with HIPAA section citations in every finding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stave evaluate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--controls&lt;/span&gt; controls/s3/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--observations&lt;/span&gt; observations/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--eval-time&lt;/span&gt; 2026-04-08T00:00:00Z &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON output follows the &lt;code&gt;out.v0.1&lt;/code&gt; schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"out.v0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"evaluated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-08T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total_controls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total_assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"compliant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"non_compliant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"security_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NON_COMPLIANT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"asset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phi-reports-bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"control"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CTL.S3.PRESIGNED.001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MEDIUM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UNSAFE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"compliance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hipaa"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"164.312(a)(1)"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Presigned URL access unrestricted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"remediation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Add s3:signatureAge or s3:authType condition"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"risk_signals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"asset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phi-logs-bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"control"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CTL.S3.LOCK.003"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"signal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"approaching-threshold"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Retention period 2200 days, minimum 2190 days"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every finding includes the HIPAA section citation. The auditor can trace each finding to the regulatory requirement it maps to.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI Pipeline Integration
&lt;/h2&gt;

&lt;p&gt;The real power is running Stave in CI on every deployment. Exit codes make this straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/hipaa-compliance.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HIPAA S3 Compliance Check&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;terraform/s3/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;observations/**'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;compliance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build Stave&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd stave &amp;amp;&amp;amp; make build&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Evaluate S3 compliance&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;stave evaluate \&lt;/span&gt;
            &lt;span class="s"&gt;--controls controls/s3/ \&lt;/span&gt;
            &lt;span class="s"&gt;--observations observations/ \&lt;/span&gt;
            &lt;span class="s"&gt;--eval-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \&lt;/span&gt;
            &lt;span class="s"&gt;--format json \&lt;/span&gt;
            &lt;span class="s"&gt;&amp;gt; compliance-report.json&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload evidence artifact&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hipaa-compliance-${{ github.sha }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compliance-report.json&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2190&lt;/span&gt;  &lt;span class="c1"&gt;# 6 years per HIPAA&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit codes drive the pipeline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Pipeline Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;All controls pass&lt;/td&gt;
&lt;td&gt;Deploy proceeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Violations found&lt;/td&gt;
&lt;td&gt;Deploy blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Input error&lt;/td&gt;
&lt;td&gt;Pipeline fails, investigate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Internal error&lt;/td&gt;
&lt;td&gt;Pipeline fails, investigate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every CI run produces a compliance artifact tied to a specific commit SHA. Over time, this creates a continuous compliance trail of evidence for every deployment.&lt;/p&gt;

&lt;p&gt;Stop giving auditors screenshots. Give them JSON reports with HIPAA section citations, produced by a deterministic tool, run on every deployment, stored as build artifacts with 6-year retention. Stave makes compliance evidence a CI artifact that is reproducible, traceable, and machine-readable. When the auditor asks "prove this bucket was compliant on March 15th," you pull the artifact from that date's deployment and hand them the JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this relates to existing compliance mods
&lt;/h2&gt;

&lt;p&gt;For the HIPAA dashboard the auditor will recognize on sight — control-family layout, pass/fail per requirement, framework section labels matching their workpaper — &lt;a href="https://hub.powerpipe.io/mods/turbot/aws_compliance" rel="noopener noreferrer"&gt;&lt;code&gt;turbot/steampipe-mod-aws-compliance&lt;/code&gt;&lt;/a&gt; ships a dedicated HIPAA Security Rule benchmark with the framework's section IDs already mapped to controls. That's the auditor-facing dashboard half of the evidence story. The snapshot-anchored, commit-SHA-tied, 6-year-retention CI artifact this article describes is the &lt;em&gt;machine-readable proof&lt;/em&gt; half — deterministic JSON the auditor can re-run, not a dashboard screenshot they have to take on faith. Both halves serve the same auditor; both halves should ship in the same compliance pipeline. Framework benchmark on the dashboard surface for recognizability, snapshot-anchored verdicts in the artifact store for reproducibility. The two-tool decision matrix: &lt;a href="https://github.com/sufield/stave/blob/main/docs/comparison/aws-compliance-mod.md" rel="noopener noreferrer"&gt;aws-compliance-mod&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hipaa</category>
      <category>aws</category>
      <category>s3</category>
      <category>security</category>
    </item>
    <item>
      <title>Your Lambda Has Admin on S3 and Doesn't Know It</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sat, 03 Oct 2026 12:14:22 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/your-lambda-has-admin-on-s3-and-doesnt-know-it-251i</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/your-lambda-has-admin-on-s3-and-doesnt-know-it-251i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Lambda function reads one file from one bucket. The policy attached to its execution role looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"logs:CreateLogGroup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"logs:CreateLogStream"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"logs:PutLogEvents"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:logs:us-east-1:111122223333:*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CloudWatch Logs statement is correctly scoped with three specific actions on the account's log-group ARN namespace. The developer who wrote this policy knows how to scope a permission set. They wrote the second statement deliberately.&lt;/p&gt;

&lt;p&gt;The first statement is "this works, ship it" reflex. &lt;code&gt;s3:*&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt; is the policy that requires the least time to write and the least time to debug; the function doesn't fail on permission errors during local testing; nobody asks "what S3 actions does this code call?" before merge.&lt;/p&gt;

&lt;p&gt;The Capital One breach was this configuration plus an SSRF on a publicly-reachable web application. The SSRF reached the EC2 metadata endpoint, retrieved temporary credentials for the role attached to the running compute, and used those credentials' &lt;code&gt;s3:*&lt;/code&gt; grant to read 100 million records from buckets the application never intended to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;s3:*&lt;/code&gt; Grants
&lt;/h2&gt;

&lt;p&gt;The S3 service has more than 100 actions. Most teams only think about a handful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;s3:GetObject&lt;/code&gt; — read.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s3:PutObject&lt;/code&gt; — write.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s3:ListBucket&lt;/code&gt; — list.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s3:DeleteObject&lt;/code&gt; — delete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;s3:*&lt;/code&gt; admits all of those, plus all of these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3:PutBucketPolicy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replace the bucket's policy. One call makes any bucket public.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3:PutBucketAcl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Grant the canonical &lt;code&gt;AllUsers&lt;/code&gt; ACL. Same outcome through a different door.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3:DeletePublicAccessBlock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove the safety net so the next misconfiguration sticks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3:PutBucketReplication&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Configure replication of every new object to an attacker-owned bucket in another account.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3:DeleteBucket&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete the bucket. Forensic logs in the bucket vanish.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3:GetBucketLogging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read which buckets have access logging configured.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3:PutBucketLogging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disable bucket access logging silently.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A function that calls &lt;code&gt;s3:GetObject&lt;/code&gt; once gets every one of these on every bucket the account owns. The Lambda's control plane is now an attack surface for the entire account's S3 footprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The System Invariant
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Sensitive IAM actions (&lt;code&gt;s3:*&lt;/code&gt;, &lt;code&gt;kms:Decrypt&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;dynamodb:*&lt;/code&gt;, &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;sts:AssumeRole&lt;/code&gt;, etc.) must scope their &lt;code&gt;Resource&lt;/code&gt;&lt;br&gt;
element to specific ARNs.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Stave's observation schema, the engine pre-computes a boolean by walking each role's attached policy statements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:role/DataProcessorLambdaRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"trusted_services"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lambda.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"policies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"has_resource_wildcard_on_sensitive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"attached_policies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DataProcessorBroadPolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"statements"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"logs:..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:logs:..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;has_resource_wildcard_on_sensitive&lt;/code&gt; is the engine's verdict; &lt;code&gt;attached_policies[].statements&lt;/code&gt; carries the evidence. CEL reads the boolean to fire the control. Z3 reads the statements to enumerate the specific dangerous calls the wildcard admits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stave Control
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CTL.IAM.POLICY.RESOURCE.WILDCARD.001&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sensitive Actions Must Not Use Resource Wildcard&lt;/span&gt;
&lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;span class="na"&gt;unsafe_predicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.identity.policies.has_resource_wildcard_on_sensitive&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One leaf clause. The engine has already done the work of deciding whether any of the role's statements pair a sensitive action with &lt;code&gt;Resource: "*"&lt;/code&gt;. CEL's job is to report it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CEL is Not Enough
&lt;/h2&gt;

&lt;p&gt;The customer reading the finding sees:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your role has a wildcard sensitive action.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;…and asks the follow-up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Which actions? And on which buckets?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;CEL evaluates a state predicate. It does not enumerate the admitted action × resource set. The risk of &lt;code&gt;s3:*&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt; is not "your policy is broad". It is "your role can do &lt;em&gt;these specific dangerous things&lt;/em&gt; on &lt;em&gt;these specific sensitive resources&lt;/em&gt;." &lt;/p&gt;

&lt;h2&gt;
  
  
  The Z3 Witness Model
&lt;/h2&gt;

&lt;p&gt;The companion program at &lt;code&gt;stave/examples/iam-overpermission-wildcard/z3prove/&lt;/code&gt; walks the role's policy statements and encodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 = (s3:GetObject,        app-data-production/input/file.csv)    intended
1 = (s3:PutObject,        app-data-production/output/result.json) intended
2 = (s3:PutBucketPolicy,  customer-pii-bucket)                   DANGEROUS
3 = (s3:DeleteObject,     billing-archives/jan-2026.csv)         DANGEROUS
4 = (s3:PutBucketAcl,     audit-logs-bucket)                     DANGEROUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Go side computes which witnesses each &lt;code&gt;Allow&lt;/code&gt; statement admits &lt;code&gt;s3:*&lt;/code&gt; matches every &lt;code&gt;s3:Foo&lt;/code&gt; action; &lt;code&gt;Resource: "*"&lt;/code&gt; matches every ARN and feeds the admitted-set boolean to Z3. The solver then discharges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unsafe = admitted ∧ dangerous ∧ ¬intended
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output for the broad policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== before (s3:* on *) ===
  policy statements: 2
    [0] Effect=Allow Action=s3:* Resource=*
    [1] Effect=Allow Action=[logs:CreateLogGroup logs:CreateLogStream logs:PutLogEvents] Resource=arn:aws:logs:us-east-1:111122223333:*
  admitted requests: 5 / 5
  intended scope:    [s3:GetObject → app-data-production/input/file.csv
                      s3:PutObject → app-data-production/output/result.json]
  dangerous set:     [s3:PutBucketPolicy → customer-pii-bucket
                      s3:DeleteObject → billing-archives/jan-2026.csv
                      s3:PutBucketAcl → audit-logs-bucket]
  verdict: SAT — witness: s3:PutBucketPolicy on customer-pii-bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five of five witnesses admitted. The solver picks the first dangerous-and-not-intended one as a proof: an attacker with this role's credentials can make &lt;code&gt;customer-pii-bucket&lt;/code&gt; public with a single &lt;code&gt;s3:PutBucketPolicy&lt;/code&gt; call. The customer does not need to read the policy carefully or imagine the attack chain. The witness is the attack chain.&lt;/p&gt;

&lt;p&gt;After the policy is scoped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== after  (scoped actions + ARNs) ===
  policy statements: 3
    [0] Effect=Allow Action=s3:GetObject Resource=arn:aws:s3:::app-data-production/input/*
    [1] Effect=Allow Action=s3:PutObject Resource=arn:aws:s3:::app-data-production/output/*
    [2] Effect=Allow Action=[logs:...] Resource=arn:aws:logs:...
  admitted requests: 2 / 5
  ...
  verdict: UNSAT — no dangerous action admitted outside intended scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UNSAT. Two admitted requests, both intended. No dangerous-and-unintended request remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI Gate
&lt;/h2&gt;

&lt;p&gt;The same policy that grants &lt;code&gt;s3:*&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt; &lt;em&gt;also&lt;/em&gt; contains a correctly-scoped CloudWatch Logs statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"logs:CreateLogGroup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"logs:CreateLogStream"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"logs:PutLogEvents"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:logs:us-east-1:111122223333:*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three specific actions, one regional ARN namespace. The developer knew how to scope a permission. They &lt;em&gt;chose&lt;/em&gt; not to scope S3, because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The function's S3 usage was being iterated on during development; broad permissions kept the test loop fast.&lt;/li&gt;
&lt;li&gt;Scoping S3 requires knowing the exact bucket prefix the function reads, which depends on whether you're in &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, or &lt;code&gt;prod&lt;/code&gt;. The developer was about to template that out, but the deadline arrived first.&lt;/li&gt;
&lt;li&gt;The same broad policy worked the last time the team shipped a Lambda. Nobody filed a follow-up to scope it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pen testers and SOC analysts often blame the developer who wrote this kind of policy. The CloudWatch Logs statement proves they had the skill. What they didn't have was a CI gate that said "you're shipping &lt;code&gt;s3:*&lt;/code&gt;, this fails the build."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Remediation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; {
   "Statement": [
     {
       "Effect": "Allow",
&lt;span class="gd"&gt;-      "Action": "s3:*",
-      "Resource": "*"
&lt;/span&gt;&lt;span class="gi"&gt;+      "Action": "s3:GetObject",
+      "Resource": "arn:aws:s3:::app-data-production/input/*"
+    },
+    {
+      "Effect": "Allow",
+      "Action": "s3:PutObject",
+      "Resource": "arn:aws:s3:::app-data-production/output/*"
&lt;/span&gt;     },
     {
       "Effect": "Allow",
       "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
       "Resource": "arn:aws:logs:us-east-1:111122223333:*"
     }
   ]
 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines turn into seven. The function's behaviour doesn't change; the role's reach does. The CEL predicate goes from &lt;code&gt;has_resource_wildcard_on_sensitive: true&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;; Z3 goes from SAT-with-witness to UNSAT.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prevention Lesson
&lt;/h2&gt;

&lt;p&gt;The fix is three layers, in order of leverage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service Control Policy denying wildcard sensitive actions for compute roles.&lt;/strong&gt; At the organisation level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyWildcardSensitiveActions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"s3:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"kms:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:*"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ArnNotLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/SecurityBootstrap*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/CloudOps*"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SCP refuses to apply any policy of this shape to non-bootstrap roles. The denial is global and silent. The developer's broad-policy attempt fails at policy-attach time, never reaches deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Module-level enforcement in IaC.&lt;/strong&gt; The Terraform / CDK / Pulumi module that creates a Lambda execution role takes an &lt;code&gt;s3_resources: list(string)&lt;/code&gt; parameter. The module composes the statement itself; passing &lt;code&gt;["*"]&lt;/code&gt; is rejected at plan time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI gate on &lt;code&gt;stave apply&lt;/code&gt;.&lt;/strong&gt; The example shipped with this article is the template. A PR that introduces a role with &lt;code&gt;has_resource_wildcard_on_sensitive: true&lt;/code&gt; produces exit 3 from &lt;code&gt;CTL.IAM.POLICY.RESOURCE.WILDCARD.001&lt;/code&gt;. Same predicate, same exit code as the example; the build fails before the PR is mergeable.&lt;/p&gt;

&lt;p&gt;The CI gate  catches policies created &lt;em&gt;outside&lt;/em&gt; IaC. A console click during incident response, an emergency hotfix, a third-party service that creates a role on first install. The other two layers prevent the unsafe shape. The CI gate makes sure the prevention is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Service Control Policy denies wildcard sensitive actions on &lt;code&gt;Resource: "*"&lt;/code&gt; for non-bootstrap roles&lt;/li&gt;
&lt;li&gt;IaC modules for compute-service execution roles take a typed list of allowed resources, not a free-form policy document&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stave apply&lt;/code&gt; runs in CI against the post-deploy observation snapshot; PRs with &lt;code&gt;has_resource_wildcard_on_sensitive: true&lt;/code&gt; fail&lt;/li&gt;
&lt;li&gt;Production roles do not pair &lt;code&gt;s3:*&lt;/code&gt;, &lt;code&gt;kms:*&lt;/code&gt;, &lt;code&gt;dynamodb:*&lt;/code&gt;, or &lt;code&gt;secretsmanager:*&lt;/code&gt; with &lt;code&gt;Resource: "*"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Code review for new Lambda functions reads the &lt;em&gt;role's&lt;/em&gt; policy, not just the &lt;em&gt;function's&lt;/em&gt; code, because what the role &lt;em&gt;can&lt;/em&gt; do matters more than what the function &lt;em&gt;currently&lt;/em&gt; does&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The policy in the &lt;code&gt;before&lt;/code&gt; fixture is honest. It says: "I am too broad." The CEL predicate reads the boolean and agrees; the Z3 witness names the specific dangerous call. The SCP at the org level  makes that combination impossible to ship in the first place. The lesson is "infrastructure should make broad policies hard to write by mistake."&lt;/p&gt;

&lt;h2&gt;
  
  
  The $1.5B Wildcard: When &lt;code&gt;company-frontend-*&lt;/code&gt; Matches Production
&lt;/h2&gt;

&lt;p&gt;The Lambda fixture above uses &lt;code&gt;Resource: "*"&lt;/code&gt;. The CEL predicate sets &lt;code&gt;has_resource_wildcard_on_sensitive: true&lt;/code&gt; and the control fires loudly. That is the easy case.&lt;/p&gt;

&lt;p&gt;The interesting case is the one the boolean misses. In March 2025, attackers stole $1.5 billion in ETH from Bybit's hot wallet. The attack didn't exploit Bybit's infrastructure directly. It exploited Safe{WALLET}'s. A developer at the wallet provider had an IAM policy roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::company-frontend-*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::company-frontend-*/*"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every action is a specific named operation; every resource is scoped to a prefix. The CEL boolean &lt;code&gt;has_resource_wildcard_on_sensitive&lt;/code&gt; is set to &lt;strong&gt;false&lt;/strong&gt;. The engine is reading "scoped" because the ARN isn't the literal &lt;code&gt;*&lt;/code&gt;. The policy looks fine to a heuristic checker.&lt;/p&gt;

&lt;p&gt;It is not fine. The prefix &lt;code&gt;company-frontend-*&lt;/code&gt; matches both &lt;code&gt;company-frontend-dev&lt;/code&gt; (intended) and &lt;code&gt;company-frontend-prod&lt;/code&gt; (not intended). The developer could write to production. The production bucket served the application's JavaScript via CloudFront. Compromise the developer's machine, run a single &lt;code&gt;aws s3 cp app.js s3://company-frontend-prod/app.js&lt;/code&gt;, and every user of the application loads attacker-supplied code on the next page reload.&lt;/p&gt;

&lt;p&gt;This is in the example as &lt;code&gt;fixtures/bybit-pattern-before/&lt;/code&gt;. The CEL control stays silent on it. Z3 finds the witness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== bybit-pattern-before (developer with s3:PutObject on company-frontend-*) ===
  policy statements: 1
    [0] Effect=Allow
        Action=[s3:GetObject s3:PutObject s3:DeleteObject s3:ListBucket]
        Resource=[arn:aws:s3:::company-frontend-*
                  arn:aws:s3:::company-frontend-*/*]
  buckets observed:  2
    - company-frontend-prod   environment=production   served_via=cloudfront
    - company-frontend-dev   environment=development   served_via=

  --- Bybit Pattern: Developer Write to Production S3 ---
  verdict: SAT
  witness: s3:PutObject on arn:aws:s3:::company-frontend-prod/app.js
           (resource pattern matches both dev and prod)
  rationale: environment=production, served_via=cloudfront — modifying
             app.js is a supply chain attack via CloudFront
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Z3 enumerates buckets by integer index, asks for an admitted-by-policy witness whose &lt;code&gt;environment&lt;/code&gt; tag is &lt;code&gt;production&lt;/code&gt;, and reports the prod bucket. The prefix wildcard the heuristic accepted is the same wildcard Z3 makes concrete.&lt;/p&gt;

&lt;h3&gt;
  
  
  The compound finding — undetectable supply chain
&lt;/h3&gt;

&lt;p&gt;Write access to production isn't enough on its own. The attacker needs the write to be invisible. Z3's second query compounds four conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;write_access_to_prod   ∧   no_mfa_condition   ∧
no_ip_condition        ∧   no_object_logging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each is independently not remarkable. Together they describe the Bybit attack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;--- Compound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Undetectable Production Write ---&lt;/span&gt;
  &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SAT&lt;/span&gt;
  &lt;span class="na"&gt;witness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;developer can write to production S3 from any IP,&lt;/span&gt;
           &lt;span class="s"&gt;without MFA, with no CloudTrail data-event record&lt;/span&gt;
  &lt;span class="na"&gt;rationale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write_access=true   no_mfa=true   no_ip=true   no_logging=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A heuristic scanner would issue four separate alerts ("policy is broad", "no MFA condition", "no IP restriction", "data events disabled"). Most of which look like reasonable choices in isolation. Together they are the attack path. Z3 finds the conjunction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is the harder case
&lt;/h3&gt;

&lt;p&gt;The Lambda example fires on the CEL boolean. The Bybit example does not. It caused a $1.5 billion theft. Because the unsafe state is a join across multiple assets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Lambda example&lt;/th&gt;
&lt;th&gt;Bybit example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resource wildcard&lt;/td&gt;
&lt;td&gt;literal &lt;code&gt;*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;prefix &lt;code&gt;*&lt;/code&gt; (after &lt;code&gt;-&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sensitive action&lt;/td&gt;
&lt;td&gt;&lt;code&gt;s3:*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;s3:PutObject&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What CEL sees&lt;/td&gt;
&lt;td&gt;one role asset&lt;/td&gt;
&lt;td&gt;one user + two buckets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What CEL reports&lt;/td&gt;
&lt;td&gt;NON_COMPLIANT&lt;/td&gt;
&lt;td&gt;COMPLIANT (silent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Z3 verdict&lt;/td&gt;
&lt;td&gt;SAT&lt;/td&gt;
&lt;td&gt;SAT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real exposure&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;td&gt;$1.5B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CEL control isn't broken. It's working as designed: it detects what its boolean field encodes, and the boolean encodes "literal Resource:*". The policy that took down Safe{WALLET} doesn't have that. It has a prefix wildcard that, paired with the bucket naming convention, admits the action that mattered.&lt;/p&gt;

&lt;p&gt;This is the case for two-level architecture. The CEL boolean covers the obvious case in milliseconds. The Z3 prover covers the conjunctive case with the same observation file, same asset graph, different reasoning depth. Both fire on fixtures that ship with this article. One catches the easy case; the other catches the case that happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix is one character
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;company-frontend-*&lt;/code&gt; → &lt;code&gt;company-frontend-dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The remediated fixture splits the policy into&lt;br&gt;
two statements: full read/write on dev, read-only on prod.&lt;br&gt;
Z3 reports UNSAT on both queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  --- Bybit Pattern: Developer Write to Production S3 ---
  verdict: UNSAT
  rationale: no production bucket admitted by s3:PutObject

  --- Compound: Undetectable Production Write ---
  verdict: UNSAT
  rationale: write_access=false   no_mfa=true   no_ip=true   no_logging=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One character difference between the policies that enabled the largest cryptocurrency theft in history. The infrastructure question — how do you make that mistake hard to commit by accident is the same one the Lambda example raises. The answer is the same: typed IaC modules, SCPs that deny prefix-wildcard resources on production buckets, and a checker that reasons about which buckets the wildcard admits, not just whether it looks like a wildcard.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/e%20amples/iam-overpermission-wildcard" rel="noopener noreferrer"&gt;&lt;code&gt;iam-overpermission-wildcard&lt;/code&gt;&lt;/a&gt; is two binaries side by side: a CEL evaluation via &lt;code&gt;pkg/stave.Apply&lt;/code&gt; (asserts the unsafe state when the attached policy has a wildcard sensitive action) and a Z3 SAT prover (extracts a concrete dangerous action the policy admits — &lt;code&gt;s3:PutBucketPolicy on customer-pii-bucket&lt;/code&gt; in the demo). The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, without cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>I Ran a Security Scanner Against Mastodon, Discourse, and Chatwoot's AWS Defaults. Here's What I Found.</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:38:32 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/i-ran-a-security-scanner-against-mastodon-discourse-and-chatwoots-aws-defaults-heres-what-i-32pe</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/i-ran-a-security-scanner-against-mastodon-discourse-and-chatwoots-aws-defaults-heres-what-i-32pe</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You deploy a Rails app to AWS. You follow the README. File uploads work. You move on.&lt;/p&gt;

&lt;p&gt;But what just happened to the S3 bucket your app is writing to? Is it encrypted? Is it public? Could someone use it to ransom your data?&lt;/p&gt;

&lt;p&gt;I used &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;, an open-source configuration safety tool, to answer those questions for three of the most popular open-source Rails applications: &lt;strong&gt;Mastodon&lt;/strong&gt; (47K stars), &lt;strong&gt;Discourse&lt;/strong&gt; (43K stars), and &lt;strong&gt;Chatwoot&lt;/strong&gt; (22K stars).&lt;/p&gt;

&lt;p&gt;The results: &lt;strong&gt;47 security findings across three projects&lt;/strong&gt;. Two of the three default to publicly readable S3 buckets. None configure encryption, access logging, or Public Access Block.&lt;/p&gt;

&lt;p&gt;This isn't a vulnerability disclosure. These projects work exactly as documented. The problem is  the documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Methodology
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Clone each project's source code&lt;/li&gt;
&lt;li&gt;Extract AWS configuration from initializers, &lt;code&gt;config/storage.yml&lt;/code&gt;, environment templates, and site settings&lt;/li&gt;
&lt;li&gt;Generate JSON snapshots representing "what a deployer gets if they follow the docs without additional hardening"&lt;/li&gt;
&lt;li&gt;Run Stave's control catalog against those snapshots&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No AWS credentials needed. No running infrastructure. Stave evaluates configuration snapshots offline. It checks what the bucket &lt;em&gt;would&lt;/em&gt; look like based on the application defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding #1: Mastodon Defaults to public-read
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;config/initializers/paperclip.rb&lt;/code&gt; in Mastodon's repo. Line 57:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="ss"&gt;s3_permissions: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'S3_PERMISSION'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'public-read'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't set the &lt;code&gt;S3_PERMISSION&lt;/code&gt; environment variable, every file your Mastodon instance uploads to S3 gets a &lt;code&gt;public-read&lt;/code&gt; ACL. Profile pictures, media attachments, header images are all readable by anyone on the internet.&lt;/p&gt;

&lt;p&gt;This is &lt;em&gt;intentional&lt;/em&gt; for federation. Mastodon needs remote servers to fetch media. But the security implications go beyond serving images:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No safety net.&lt;/strong&gt; Without S3 Public Access Block enabled, there is no account-level guard preventing this bucket or any bucket in your AWS account from being public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanners are watching.&lt;/strong&gt; Automated tools continuously enumerate S3 bucket names. Once your bucket name leaks (it's in every image URL your instance serves), every object key someone can guess is downloadable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACLs override policies.&lt;/strong&gt; Even if you add a restrictive bucket policy later, the object-level &lt;code&gt;public-read&lt;/code&gt; ACL still grants access. The ACL and the policy are evaluated independently either one granting access is sufficient.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stave flagged this as &lt;code&gt;CTL.S3.PUBLIC.001&lt;/code&gt; critical severity, exposure score 100/100.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix for Mastodon deployers:&lt;/strong&gt; Set &lt;code&gt;S3_PERMISSION=''&lt;/code&gt; (empty string) in your environment. Line 75 of the same file disables ACLs entirely when this is set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Paperclip&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Attachment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:s3_permissions&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'S3_PERMISSION'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding #2: Discourse Does the Same Thing, But Through Admin Settings
&lt;/h2&gt;

&lt;p&gt;Discourse doesn't use environment variables for S3 ACLs. It uses &lt;code&gt;SiteSetting&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/site_settings.yml:2629&lt;/span&gt;
&lt;span class="na"&gt;s3_use_acls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;secure_uploads&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;s3_use_acls&lt;/code&gt; is true and &lt;code&gt;secure_uploads&lt;/code&gt; is false, non-secure uploads get a &lt;code&gt;public-read&lt;/code&gt; ACL. The logic lives in &lt;code&gt;lib/file_store/s3_store.rb&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/file_store/s3_store.rb:390&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;s3_bucket_folder_path&lt;/span&gt;
  &lt;span class="c1"&gt;# When secure_uploads is disabled, uploads are public&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same critical finding. Same exposure. Different mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix for Discourse deployers:&lt;/strong&gt; Enable &lt;code&gt;secure_uploads&lt;/code&gt; in admin settings, or disable &lt;code&gt;s3_use_acls&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding #3: Chatwoot Gets One Thing Right
&lt;/h2&gt;

&lt;p&gt;Chatwoot uses Active Storage with S3. Here's their &lt;code&gt;config/storage.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;amazon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;S3&lt;/span&gt;
  &lt;span class="na"&gt;access_key_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= ENV.fetch('AWS_ACCESS_KEY_ID', '') %&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;secret_access_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= ENV.fetch('AWS_SECRET_ACCESS_KEY', '') %&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= ENV.fetch('AWS_REGION', '') %&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= ENV.fetch('S3_BUCKET_NAME', '') %&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No ACL configuration. Active Storage creates private objects by default. Chatwoot is the only project of the three that does NOT default to &lt;code&gt;public-read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result: 15 findings instead of 16.&lt;/strong&gt; The public access finding doesn't fire. Everything else does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15 Findings Every Rails-on-S3 Deployer Shares
&lt;/h2&gt;

&lt;p&gt;All three projects share these findings. They are infrastructure settings that Rails applications never touch and that setup documentation never mentions:&lt;/p&gt;

&lt;h3&gt;
  
  
  Critical
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;What It Means in Plain English&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSE-C not disabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Anyone with &lt;code&gt;s3:PutObject&lt;/code&gt; permission can encrypt your files with their own key. You can't decrypt them. AWS can't help. This is the S3 ransomware vector and it requires zero KMS permissions.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  High
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;What It Means in Plain English&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No account-level Public Access Block&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Any bucket in your AWS account can be made public by a single misconfiguration. PAB is the kill switch that prevents it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No bucket-level Public Access Block&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Same as above, but per-bucket. Without it, a bad bucket policy or ACL change exposes your data with no warning.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No encryption at rest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your files sit unencrypted on AWS disks. If AWS has a physical security incident, your data is readable. Every compliance framework requires this.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No HTTPS enforcement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Without a bucket policy denying &lt;code&gt;aws:SecureTransport=false&lt;/code&gt;, someone could access your bucket over plain HTTP. Your Rails app uses HTTPS, but the S3 API doesn't enforce it by default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No VPC endpoint restriction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your bucket is reachable from any network path on the internet. No IP or VPC condition limits who can even try to connect.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No ownership controls&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ACLs are still active on the bucket. Any uploader can set object-level ACLs that override your bucket policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BlockPublicAcls not enabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Someone can add a &lt;code&gt;public-read&lt;/code&gt; ACL to an object. PAB would reject this at the API level.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BlockPublicPolicy not enabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Someone can add a bucket policy granting &lt;code&gt;Principal: "*"&lt;/code&gt; access. PAB would reject this at the API level.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IgnorePublicAcls not enabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Existing public ACLs are honored. PAB would ignore them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RestrictPublicBuckets not enabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-account access via public policies is unrestricted. PAB would block it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Medium
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;What It Means in Plain English&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No access logging&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You have zero visibility into who accessed your bucket, when, and what they downloaded. If you get breached, you won't know what was taken.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No versioning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;If someone deletes or overwrites a file, it's gone. No recovery. Combined with the ransomware vector above, this means a complete data loss scenario.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No multipart upload cleanup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Incomplete multipart uploads accumulate forever, costing you money. A lifecycle policy cleans these up automatically.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Low
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;What It Means in Plain English&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No governance controls&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No Object Lock, no legal hold, no retention policies. Your files can be deleted by any principal with the right permissions.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why This Happens: The Rails-AWS Gap
&lt;/h2&gt;

&lt;p&gt;Rails has excellent abstractions for storage. Active Storage and Paperclip make file uploads trivial. But they abstract at the &lt;em&gt;application&lt;/em&gt; level what goes into the bucket, not how the bucket is configured.&lt;/p&gt;

&lt;p&gt;The bucket is infrastructure. Infrastructure configuration lives in a different world:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Rails app                  Your AWS account
┌──────────────┐                   ┌──────────────────────┐
│  Paperclip   │── s3:PutObject ──▶│  S3 Bucket           │
│  or Active   │                   │  - No encryption     │
│  Storage     │                   │  - No PAB            │
│              │                   │  - No logging        │
│  "It works!" │                   │  - No versioning     │
└──────────────┘                   │  - SSE-C enabled     │
                                   │  - Public ACLs ok    │
                                   └──────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your app works. Your bucket is a liability.&lt;/p&gt;

&lt;p&gt;This isn't a Rails problem specifically. It's a deployment documentation problem. The gap exists because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rails gems configure the client, not the bucket.&lt;/strong&gt; &lt;code&gt;aws-sdk-s3&lt;/code&gt; sets up the connection. It doesn't run &lt;code&gt;put-bucket-encryption&lt;/code&gt; or &lt;code&gt;put-public-access-block&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Setup docs stop at "uploads work."&lt;/strong&gt; Mastodon's docs explain how to configure S3 credentials. They don't mention encryption, PAB, or access logging.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Terraform and CloudFormation exist, but aren't part of the Rails story.&lt;/strong&gt; Infrastructure-as-code tools handle bucket hardening well. But a developer following a Rails project's README will create a bucket through the AWS console and move on.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security is someone else's job.&lt;/strong&gt; Application developers think the cloud team handles bucket security. Solo deployers don't have a cloud team.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How Stave Works
&lt;/h2&gt;

&lt;p&gt;Stave doesn't scan your AWS account or need credentials. It evaluates JSON snapshots. A structured representations of your infrastructure configuration against a catalog of controls written as CEL (Common Expression Language) predicates.&lt;/p&gt;

&lt;p&gt;For example, the public access control checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;storage.access.public_read eq true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that property is &lt;code&gt;true&lt;/code&gt; in your observation snapshot, the control fires. Each control maps to compliance frameworks (CIS, NIST, PCI DSS, HIPAA, SOC 2) and includes a severity, remediation steps, and an exposure score.&lt;/p&gt;

&lt;p&gt;For this analysis, I extracted configuration defaults from source code and translated them into Stave observation snapshots. The approach is deterministic: given the same source code defaults, Stave will always produce the same findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Should Do
&lt;/h2&gt;

&lt;p&gt;If you're deploying any Rails application to AWS with S3:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right now (5 minutes):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable account-level Public Access Block&lt;/span&gt;
aws s3control put-public-access-block &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--account-id&lt;/span&gt; YOUR_ACCOUNT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public-access-block-configuration&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;BlockPublicAcls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;,IgnorePublicAcls&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;,BlockPublicPolicy&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;,RestrictPublicBuckets&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single command prevents any bucket in your account from being accidentally made public. It's the highest-impact change you can make.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This week:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable default encryption on your bucket&lt;/span&gt;
aws s3api put-bucket-encryption &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; YOUR_BUCKET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server-side-encryption-configuration&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'&lt;/span&gt;

&lt;span class="c"&gt;# Enforce HTTPS&lt;/span&gt;
aws s3api put-bucket-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; YOUR_BUCKET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy&lt;/span&gt; &lt;span class="s1"&gt;'{
    "Version":"2012-10-17",
    "Statement":[{
      "Sid":"EnforceHTTPS",
      "Effect":"Deny",
      "Principal":"*",
      "Action":"s3:*",
      "Resource":["arn:aws:s3:::YOUR_BUCKET","arn:aws:s3:::YOUR_BUCKET/*"],
      "Condition":{"Bool":{"aws:SecureTransport":"false"}}
    }]
  }'&lt;/span&gt;

&lt;span class="c"&gt;# Enable access logging&lt;/span&gt;
aws s3api put-bucket-logging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; YOUR_BUCKET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket-logging-status&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"LoggingEnabled":{"TargetBucket":"YOUR_LOG_BUCKET","TargetPrefix":"s3-access-logs/"}}'&lt;/span&gt;

&lt;span class="c"&gt;# Enable versioning&lt;/span&gt;
aws s3api put-bucket-versioning &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; YOUR_BUCKET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--versioning-configuration&lt;/span&gt; &lt;span class="nv"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If you're running Mastodon:&lt;/strong&gt; Set &lt;code&gt;S3_PERMISSION=''&lt;/code&gt; in your environment. This is the most important single change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're running Discourse:&lt;/strong&gt; Enable &lt;code&gt;secure_uploads&lt;/code&gt; in your admin panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scorecard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Findings&lt;/th&gt;
&lt;th&gt;Worst Finding&lt;/th&gt;
&lt;th&gt;One-Line Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;public-read default&lt;/td&gt;
&lt;td&gt;&lt;code&gt;S3_PERMISSION=''&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discourse&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;public-read via SiteSetting&lt;/td&gt;
&lt;td&gt;Enable &lt;code&gt;secure_uploads&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chatwoot&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;SSE-C ransomware vector&lt;/td&gt;
&lt;td&gt;Account-level PAB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Chatwoot's Active Storage default (private objects) is the safer pattern. If you're building a new Rails app, prefer Active Storage's defaults over configuring ACLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Analysis based on source code defaults only. Actual deployments vary based on operator configuration.&lt;/li&gt;
&lt;li&gt;Observation snapshots represent "follow the docs, change nothing else" deployments.&lt;/li&gt;
&lt;li&gt;Stave's control catalog covers S3 only for this analysis. Discourse's SNS, MediaConvert, and Bedrock integrations were not evaluated.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;public-read&lt;/code&gt; default in Mastodon is documented behavior. Discourse's ACL behavior is configurable. Neither is a vulnerability. Both are deployment defaults that produce a weak security posture.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; is an open-source configuration safety tool. It evaluates infrastructure configuration snapshots against security controls without credentials or API access. Install it with &lt;code&gt;go install github.com/sufield/stave@latest&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>aws</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Deny Policy Blocks Six Privesc Paths. There Are Nine.</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:34:02 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/your-deny-policy-blocks-six-privesc-paths-there-are-nine-4km9</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/your-deny-policy-blocks-six-privesc-paths-there-are-nine-4km9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In July 2022 Edoardo Rosa published a writeup documenting a privilege escalation in AWS where a principal carrying the AWS-managed &lt;code&gt;DataScientist&lt;/code&gt; policy plus &lt;code&gt;AmazonElasticMapReduceFullAccess&lt;/code&gt; could escalate to admin even with an explicit deny policy in place. The deny &lt;code&gt;DemoDenyPrivEscs&lt;/code&gt; in the writeup listed six actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"cloudformation:CreateStack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"cloudformation:UpdateStack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"ec2:RunInstances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"lambda:Create*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"lambda:Update*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"lambda:InvokeFunction"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each one is a known compute-launch path that, combined with &lt;code&gt;iam:PassRole&lt;/code&gt;, lets a principal launch compute running as a different role. The deny list reads like the result of a security review where someone enumerated "ways to launch EC2 with a role" and copied them into a policy.&lt;/p&gt;

&lt;p&gt;The writeup shows that this list is &lt;em&gt;incomplete&lt;/em&gt;. The author found one bypass: &lt;code&gt;autoscaling:CreateLaunchConfiguration&lt;/code&gt; + &lt;code&gt;autoscaling:CreateAutoScalingGroup&lt;/code&gt; by manual inspection of the AWS service catalog. The path works: the launch configuration specifies an admin role as the instance profile, the autoscaling group launches an EC2 with that role, the principal reads IMDS credentials and gains admin.&lt;/p&gt;

&lt;p&gt;This article runs the same configuration through a Z3 SAT solver with a registry of nine known compute-launch vectors. The solver finds &lt;strong&gt;five&lt;/strong&gt; bypasses. After remediation when the deny list is expanded to cover all nine the solver proves the architectural residual: every new compute service AWS adds becomes a new bypass path.&lt;/p&gt;

&lt;p&gt;The deny-list approach to privilege escalation prevention is structurally fragile. The math says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compute-launch vector
&lt;/h2&gt;

&lt;p&gt;A compute-launch vector is any (set of actions) combination that, with &lt;code&gt;iam:PassRole&lt;/code&gt;, results in compute running as a specified role. The known vectors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Action(s)&lt;/th&gt;
&lt;th&gt;PassedToService&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EC2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ec2:RunInstances&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ec2.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lambda:CreateFunction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lambda.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lambda:UpdateFunctionConfiguration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lambda.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFormation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloudformation:CreateStack&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloudformation.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto Scaling&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;autoscaling:CreateLaunchConfiguration&lt;/code&gt; + &lt;code&gt;CreateAutoScalingGroup&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ec2.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ECS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ecs:RunTask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ecs-tasks.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeBuild&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;codebuild:CreateProject&lt;/code&gt; + &lt;code&gt;StartBuild&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;codebuild.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;glue:CreateJob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;glue.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SageMaker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sagemaker:CreateNotebookInstance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sagemaker.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nine vectors. Each one is a different code path inside AWS, but each one ends at the same place: an EC2-like compute environment running with the IAM role you named.&lt;/p&gt;

&lt;p&gt;The writeup's deny list covers four: &lt;code&gt;ec2:RunInstances&lt;/code&gt;, &lt;code&gt;lambda:CreateFunction&lt;/code&gt; (via &lt;code&gt;lambda:Create*&lt;/code&gt;), &lt;code&gt;lambda:UpdateFunctionConfiguration&lt;/code&gt; (via &lt;code&gt;lambda:Update*&lt;/code&gt;), and &lt;code&gt;cloudformation:CreateStack&lt;/code&gt;. Plus &lt;code&gt;cloudformation:UpdateStack&lt;/code&gt; (which doesn't itself launch compute but is included for completeness) and &lt;code&gt;lambda:InvokeFunction&lt;/code&gt; (also not a compute-launch). The five it misses are autoscaling, ECS, CodeBuild, Glue, and SageMaker.&lt;/p&gt;

&lt;p&gt;The author found one of those five. The other four were sitting there untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Z3 setup
&lt;/h2&gt;

&lt;p&gt;The Z3 prover walks the principal's three policies &lt;code&gt;DataScientist&lt;/code&gt;, &lt;code&gt;EMRFullAccess&lt;/code&gt;, &lt;code&gt;DemoDenyPrivEscs&lt;/code&gt; and computes the &lt;em&gt;effective permission set&lt;/em&gt;: actions that appear in at least one Allow statement and no Deny statement. For each of the nine compute-launch vectors, the prover checks whether all required actions are effectively permitted &lt;em&gt;and&lt;/em&gt; &lt;code&gt;iam:PassRole&lt;/code&gt; is allowed for the vector's &lt;code&gt;PassedToService&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vector_available(vector) :=
  ALL action in vector.RequiredActions:
    action in any Allow statement
    AND action not in any Deny statement
  AND iam:PassRole is allowed AND not denied
  AND vector.PassedToService in some PassRole condition
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Z3 query for Finding 1: is there &lt;em&gt;any&lt;/em&gt; &lt;code&gt;vector&lt;/code&gt; for which &lt;code&gt;vector_available(vector)&lt;/code&gt; is true?&lt;/p&gt;

&lt;h2&gt;
  
  
  Three findings on the writeup config
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Finding 1: deny coverage gap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt; &lt;span class="na"&gt;Finding 1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny coverage gap ---&lt;/span&gt;
  &lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;among 9 known compute-launch vectors, is any one&lt;/span&gt;
            &lt;span class="s"&gt;effectively permitted (Allow ∧ ¬Deny)?&lt;/span&gt;
  &lt;span class="s"&gt;vectors available&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 / &lt;/span&gt;&lt;span class="m"&gt;9&lt;/span&gt;
  &lt;span class="na"&gt;verdict:  SAT — witness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;autoscaling — Auto Scaling launch config + group with instance profile&lt;/span&gt;
            &lt;span class="s"&gt;actions&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;autoscaling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;CreateLaunchConfiguration autoscaling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;CreateAutoScalingGroup&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="s"&gt;(deny does not cover these actions; the path is open)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vectors available: 1 / 9&lt;/code&gt; The prover counts the autoscaling vector as the one reachable path. The other available vectors are filtered by the &lt;code&gt;PassedToService&lt;/code&gt; condition: ECS needs &lt;code&gt;ecs-tasks.amazonaws.com&lt;/code&gt; in the condition list, which isn't there; CodeBuild needs &lt;code&gt;codebuild.amazonaws.com&lt;/code&gt;, also missing; same for Glue and SageMaker. The autoscaling vector matches because EMRFullAccess's PassRole condition includes &lt;code&gt;ec2.amazonaws.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But the &lt;em&gt;deny coverage&lt;/em&gt; table separate from the SAT proof shows which actions the deny does and doesn't cover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- Deny coverage analysis ---
  ec2             BLOCKED    : Direct EC2 launch with instance profile
  lambda          BLOCKED    : Create Lambda with execution role
  lambda          BLOCKED    : Update existing Lambda to use different execution role
  cloudformation  BLOCKED    : Create CloudFormation stack with execution role
  autoscaling     NOT BLOCKED : Auto Scaling launch config + group with instance profile
  ecs             NOT BLOCKED : Run ECS task with task role
  codebuild       NOT BLOCKED : Create and start CodeBuild project with service role
  glue            NOT BLOCKED : Create Glue job with execution role
  sagemaker       NOT BLOCKED : Create SageMaker notebook with execution role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five vectors not blocked. The autoscaling one is &lt;em&gt;currently&lt;/em&gt; exploitable because the principal's PassRole condition includes EC2. The other four are not currently exploitable because the PassRole condition list doesn't include their respective services. But that's a fragile gate. If the principal later acquires &lt;code&gt;ec2.amazonaws.com&lt;/code&gt;broader PassRole (say, through a different attached policy that broadens the service list), or if AWS introduces a new service whose role-passing convention reuses &lt;code&gt;ec2.amazonaws.com&lt;/code&gt;, four more vectors become immediately reachable. The deny doesn't block them because the deny doesn't know about them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding 2: PassRole reaches an admin-equivalent role
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- Finding 2: PassRole reaches an admin-equivalent role ---
  query:    is there an admin role whose trust matches the principal's
            PassRole `iam:PassedToService` condition?
  reachable admin roles: 1 / 1
  verdict:  SAT — witness: arn:aws:iam::111122223333:role/demo-EC2Admin
            (trusts [ec2.amazonaws.com]; has AdministratorAccess; PassRole condition
             admits any role trusting one of those services)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EMR policy's PassRole grant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:PassRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:PassedToService"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"elasticmapreduce.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ec2.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Resource: "*"&lt;/code&gt; and a service-only condition. This is the second architectural fragility: the grant scopes &lt;em&gt;which services&lt;/em&gt; the role can be passed to, but not &lt;em&gt;which roles&lt;/em&gt; can be passed. Any role in the account trusting &lt;code&gt;elasticmapreduce.amazonaws.com&lt;/code&gt; or &lt;code&gt;ec2.amazonaws.com&lt;/code&gt; is a passable target. The fixture's &lt;code&gt;demo-EC2Admin&lt;/code&gt; trusts &lt;code&gt;ec2.amazonaws.com&lt;/code&gt; and has &lt;code&gt;AdministratorAccess&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding 3: the compound chain
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt; &lt;span class="na"&gt;Finding 3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;complete privesc chain ---&lt;/span&gt;
  &lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;is there an available compute-launch vector + an admin&lt;/span&gt;
            &lt;span class="s"&gt;role + a trust relationship that all line up?&lt;/span&gt;
  &lt;span class="s"&gt;compound paths&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;verdict:  SAT — witness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vector=autoscaling role=arn:aws:iam::111122223333:role/demo-EC2Admin&lt;/span&gt;
            &lt;span class="s"&gt;chain&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;autoscaling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;CreateLaunchConfiguration autoscaling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;CreateAutoScalingGroup&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s"&gt;→ role with AdministratorAccess assumed by EC2 →&lt;/span&gt;
            &lt;span class="s"&gt;principal reads IMDS credentials → admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conjunction lands. A vector is reachable, an admin role is PassRole-reachable, and the role's trust service matches the vector's PassedToService. Three API calls &lt;code&gt;CreateLaunchConfiguration&lt;/code&gt;, &lt;code&gt;CreateAutoScalingGroup&lt;/code&gt;, then SSH into the launched instance and the principal has admin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The remediated config and the residual
&lt;/h2&gt;

&lt;p&gt;The natural fix: expand the deny list to cover every known compute-launch action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; {
   "Effect": "Deny",
   "Action": [
     "cloudformation:CreateStack",
     "cloudformation:UpdateStack",
     "ec2:RunInstances",
     "lambda:Create*",
     "lambda:Update*",
&lt;span class="gd"&gt;-    "lambda:InvokeFunction"
&lt;/span&gt;&lt;span class="gi"&gt;+    "lambda:InvokeFunction",
+    "autoscaling:CreateLaunchConfiguration",
+    "autoscaling:CreateAutoScalingGroup",
+    "autoscaling:UpdateAutoScalingGroup",
+    "ecs:RunTask",
+    "ecs:CreateService",
+    "codebuild:CreateProject",
+    "codebuild:StartBuild",
+    "glue:CreateJob",
+    "sagemaker:CreateNotebookInstance",
+    "sagemaker:CreateTrainingJob"
&lt;/span&gt;   ],
   "Resource": "*"
 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-run the prover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;========== remediated-config (deny expanded to all 9 known vectors) ==========
--- Finding 1: deny coverage gap ---
  vectors available: 0 / 9
  verdict:  UNSAT — every known compute-launch vector is denied
            (the expanded deny list covers all 9 known vectors today;
             see Finding 2 for the residual structural risk)

--- Finding 2: PassRole reaches an admin-equivalent role ---
  verdict:  SAT — witness: arn:aws:iam::111122223333:role/demo-EC2Admin
            **RESIDUAL** — the remediated config closes today's launch
            vectors but does not scope PassRole by role ARN. Any new
            compute service AWS adds becomes an immediate exploit
            path until the deny list is expanded.

--- Finding 3: complete privesc chain ---
  compound paths: 0
  verdict:  UNSAT — no compound privesc path open
            (Finding 1 closed all launch vectors; without one of those,
             the PassRole reachability in Finding 2 has nowhere to land)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of three queries flipped to UNSAT. Finding 2 remains SAT.&lt;/p&gt;

&lt;p&gt;The remediation &lt;em&gt;works&lt;/em&gt;. Finding 3 is UNSAT, no exploit path is currently open. But Finding 2's persistent SAT is the formal proof that the architecture is &lt;em&gt;structurally&lt;/em&gt; fragile. The principal can still pass an admin role to "any service in the condition list." The principal cannot currently &lt;em&gt;use&lt;/em&gt; that role because all known compute-launch services are denied. The day AWS introduces a new compute service that the deny list doesn't know about and AWS introduces roughly a new service per quarter. Finding 3 flips back to SAT.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural fix the deny list doesn't make
&lt;/h2&gt;

&lt;p&gt;Two ways to scope &lt;code&gt;iam:PassRole&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What the writeup uses (and what the remediation&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// keeps unchanged):&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:PassRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:PassedToService"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"elasticmapreduce.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ec2.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What's structurally sound:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:PassRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:role/EMRClusterRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:role/EMRDataNodeRole"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first form scopes by &lt;em&gt;which services&lt;/em&gt; and delegates the question of "which roles trust those services" to the IAM trust-policy graph, which the operator does not control globally. The second form scopes by &lt;em&gt;which roles&lt;/em&gt; which is explicit and finite.&lt;/p&gt;

&lt;p&gt;The deny list approach is a chase: every time AWS adds a service that supports an instance profile or task role, the deny list grows. The PassRole-by-role approach is a fixed enumeration: the only roles that can ever be passed are the named ones, regardless of what AWS launches next month. Z3's residual SAT on Finding 2 is the formal way of saying "the deny list is the wrong shape; the PassRole grant is the right shape."&lt;/p&gt;

&lt;h2&gt;
  
  
  What pattern-matching tools miss
&lt;/h2&gt;

&lt;p&gt;A heuristic IAM scanner like PMapper detects this specific bypass. Edoardo Rosa's writeup acknowledges PMapper. But a heuristic scanner reports "this principal can launch autoscaling with an admin role". A true positive, the kind of finding a SOC reviewer would action. The reviewer fixes autoscaling and moves on.&lt;/p&gt;

&lt;p&gt;The Z3 prover reports the same finding &lt;em&gt;and&lt;/em&gt; the deny coverage table &lt;em&gt;and&lt;/em&gt; the residual. The reviewer sees:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The autoscaling exploit path is currently active.&lt;/li&gt;
&lt;li&gt;Four other compute-launch vectors are also not blocked (and waiting for a PassRole condition broadening).&lt;/li&gt;
&lt;li&gt;After fixing all five vectors, the underlying PassRole grant is still over-broad. The &lt;em&gt;correct&lt;/em&gt; long-term fix is at the PassRole layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point is the difference between "close the autoscaling hole" and "close the deny-list-architecture issue." The first a heuristic recommends. The second formal verification recommends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No principal has &lt;code&gt;iam:PassRole&lt;/code&gt; with &lt;code&gt;Resource: "*"&lt;/code&gt;. The resource is always a specific role ARN list&lt;/li&gt;
&lt;li&gt;Service Control Policies enforce &lt;code&gt;iam:PassRole&lt;/code&gt; resource scoping for non-admin principals at the org level&lt;/li&gt;
&lt;li&gt;Deny lists are not the primary defense for privilege escalation. They're a defense in depth behind PassRole-by-role&lt;/li&gt;
&lt;li&gt;CI runs &lt;code&gt;stave apply&lt;/code&gt; against post-deploy observation snapshots; the existing &lt;code&gt;CTL.IAM.ESCALATE.PASSROLE.AUTOSCALING.001&lt;/code&gt; and similar per-technique controls fire on regressions&lt;/li&gt;
&lt;li&gt;When AWS launches a new compute service, the review explicitly checks whether &lt;code&gt;iam:PassRole&lt;/code&gt; grants in production policies could now reach the new service's role-pass mechanic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The researcher found one bypass through expert knowledge. Z3 found five. After remediation, the solver is still finding something. A structural feature of the architecture that guarantees more exploits will appear. That's the difference between checking the configuration's current state and checking the configuration's &lt;em&gt;durability&lt;/em&gt;. Pattern matchers do the first. Z3 does both.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/iam-autoscaling-privesc-bypass" rel="noopener noreferrer"&gt;&lt;code&gt;iam-autoscaling-privesc-bypass&lt;/code&gt;&lt;/a&gt; has two binaries side by side: a CEL evaluation via &lt;code&gt;pkg/stave.Apply&lt;/code&gt; (uses the existing &lt;code&gt;CTL.IAM.ESCALATE.PASSROLE.AUTOSCALING.001&lt;/code&gt; per-technique control) and a Z3 SAT prover that walks the principal's effective permission set against a 9-vector compute-launch registry, prints the deny coverage table, and surfaces the residual PassRole-scoping finding that survives remediation. The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, without cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>Why `Resource: bucket/${tenant}/*` is Not Enough</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:02:48 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/why-resource-buckettenant-is-not-enough-l4j</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/why-resource-buckettenant-is-not-enough-l4j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A multi-tenant SaaS that stores customer files in S3 has a predictable architectural choice: one bucket shared across tenants with prefix-based isolation, or one bucket per tenant. Per-tenant buckets cap out around the AWS soft-limit (100 buckets per account); shared-bucket isolation scales further but only works if every layer of the stack respects the prefix invariant.&lt;/p&gt;

&lt;p&gt;Two HackerOne reports describe what happens when a layer doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shopify &lt;a href="https://hackerone.com/reports/94087" rel="noopener noreferrer"&gt;94087&lt;/a&gt;&lt;/strong&gt; — signed S3 object keys allowed &lt;code&gt;../&lt;/code&gt; path traversal. One tenant's signed URL reached another tenant's prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unikrn &lt;a href="https://hackerone.com/reports/254200" rel="noopener noreferrer"&gt;254200&lt;/a&gt;&lt;/strong&gt; — the upload-URL signer didn't enforce per-tenant prefix at all. Tenant A could request a presigned PUT against tenant B's prefix; the signer minted it; S3 honoured it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both reports look like S3 misconfigurations. They aren't. The S3 bucket policy in both cases probably contained the right idea — &lt;code&gt;Resource: arn:aws:s3:::&amp;lt;bucket&amp;gt;/${aws:userid}/*&lt;/code&gt;, or a &lt;code&gt;StringEquals aws:PrincipalTag/tenant_id&lt;/code&gt; condition. The bucket was tenant-aware. &lt;strong&gt;The signer was not.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Layer Boundary
&lt;/h2&gt;

&lt;p&gt;The mental model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client → API server → S3 bucket policy → object
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bucket-level tenant isolation lives in the third box. Tag the principal, condition the policy on the tag, the policy rejects cross-tenant requests at S3's API layer.&lt;/p&gt;

&lt;p&gt;The reality with presigned URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client → API server → app-signer → presigned URL → object (S3 honors signature)
                       ↑
              the prefix-enforcement layer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;S3 sees a validly-signed request and serves the object. It does &lt;strong&gt;not&lt;/strong&gt; re-evaluate the bucket policy against the requesting client's identity. The signature &lt;em&gt;is&lt;/em&gt; the proof of authorisation. Whatever prefix the signer wrote into the URL S3 honours.&lt;/p&gt;

&lt;p&gt;So the tenant-isolation invariant has to be enforced by the signer itself, at signing time, before the URL is minted. That means the signer needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The requesting tenant's identity (from the session).&lt;/li&gt;
&lt;li&gt;A normalised target key (no &lt;code&gt;..&lt;/code&gt;, no extra slashes).&lt;/li&gt;
&lt;li&gt;A check that the target key starts with the requesting tenant's prefix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those three is missing, the signer is the breach surface. The bucket policy could still pass a SOC 2 audit; the signer is the layer where one tenant becomes the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The System Invariant
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every app-signer that mints presigned URLs against a&lt;br&gt;
shared bucket must enforce the tenant prefix and reject&lt;br&gt;
path traversal.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Stave's observation schema has a top-level &lt;code&gt;identities&lt;/code&gt; list (sibling to &lt;code&gt;assets&lt;/code&gt;) that tracks long-lived signing identities. An &lt;code&gt;app_signer&lt;/code&gt; identity carries a &lt;code&gt;purpose&lt;/code&gt; field listing the security-relevant flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"appsigner:s3:acme-uploads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app_signer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"purpose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signs_uploads;enforce_prefix=false;allow_traversal=true"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;enforce_prefix&lt;/code&gt; and &lt;code&gt;allow_traversal&lt;/code&gt; flags are the operational state of the signer, captured by whatever collector observes the signing service. The bucket asset is separate, and tagged with the tenant scheme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme-tenant-data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tenant_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shared"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tenant_prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenants/{tenant_id}/"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two assets, one invariant: &lt;strong&gt;shared-tenant-mode bucket AND permissive signer = unsafe&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stave Control
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CTL.S3.TENANT.ISOLATION.001&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Shared-Bucket Tenant Isolation Must Enforce Prefix&lt;/span&gt;
&lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;span class="na"&gt;unsafe_predicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.kind&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucket&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.tags.tenant_mode&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shared&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.tags.tenant_prefix&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;identities&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;any_match&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type&lt;/span&gt;
            &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_signer&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
            &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;contains&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;appsigner:s3:"&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;purpose&lt;/span&gt;
                &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;contains&lt;/span&gt;
                &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allow_traversal=true"&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;purpose&lt;/span&gt;
                &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;contains&lt;/span&gt;
                &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enforce_prefix=false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The predicate uses &lt;code&gt;any_match&lt;/code&gt;. Stave's quantifier over the &lt;code&gt;identities&lt;/code&gt; list to look for at least one app-signer with either flag in the unsafe state. CEL evaluates this predicate over the static configuration. The verdict: "the signer is permissive."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CEL is Not Enough
&lt;/h2&gt;

&lt;p&gt;CEL detects the unsafe configuration. The natural follow-up question is reachability: &lt;em&gt;given this signer, which tenant-to-tenant request can be made?&lt;/em&gt; That's a search across the &lt;code&gt;(requesting_tenant, target_key)&lt;/code&gt; space, not a fold over property bags.&lt;/p&gt;

&lt;p&gt;Customer-facing impact reports want concrete answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tenant A can request a signed URL for &lt;code&gt;tenants/B/photos/1.jpg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Path traversal works: &lt;code&gt;tenants/A/../B/secret.json&lt;/code&gt; resolves to tenant B's prefix when the signer doesn't normalise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CEL says the configuration is unsafe. Z3 enumerates the specific requests it admits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Z3 Witness Model
&lt;/h2&gt;

&lt;p&gt;The companion program at &lt;code&gt;stave/examples/s3-tenant-prefix-isolation/z3prove/&lt;/code&gt; encodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 = (tenant=A, target="tenants/A/photo.png")        intended
1 = (tenant=A, target="tenants/B/photo.png")        cross-tenant
2 = (tenant=A, target="tenants/A/../B/secret.json") path traversal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The admitted set is parameterised by the signer flags:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signer state&lt;/th&gt;
&lt;th&gt;Admitted set&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enforce_prefix=false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{0, 1, 2}&lt;/code&gt; (everything)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;enforce_prefix=true&lt;/code&gt;, &lt;code&gt;allow_traversal=true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{0, 2}&lt;/code&gt; (own + traversal)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;enforce_prefix=true&lt;/code&gt;, &lt;code&gt;allow_traversal=false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{0}&lt;/code&gt; (own only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The intended set is &lt;code&gt;{0}&lt;/code&gt; — each tenant only legitimately needs their own prefix.&lt;/p&gt;

&lt;p&gt;Solver discharges &lt;code&gt;unsafe = admitted ∧ ¬intended&lt;/code&gt; against the configuration data the fixture provides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== before (signer permissive) ===
  signer purpose: signs_uploads;enforce_prefix=false;allow_traversal=true
  flags: enforce_prefix=false   allow_traversal=true
  admitted set: [tenant=A → tenants/A/photo.png
                 tenant=A → tenants/B/photo.png
                 tenant=A → tenants/A/../B/secret.json]
  intended set: ["tenant=A → tenants/A/photo.png"]
  verdict: SAT — witness request: tenant=A → tenants/B/photo.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SAT. The witness is concrete: &lt;code&gt;tenant=A → tenants/B/photo.png&lt;/code&gt;. That's the request a penetration tester can replay; the audit trail will show tenant A signing the URL through normal API channels; nothing about the bucket itself looks misconfigured.&lt;/p&gt;

&lt;p&gt;After the signer is fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== after  (signer enforced) ===
  signer purpose: signs_uploads;enforce_prefix=true;allow_traversal=false
  flags: enforce_prefix=true   allow_traversal=false
  admitted set: [tenant=A → tenants/A/photo.png]
  intended set: ["tenant=A → tenants/A/photo.png"]
  verdict: UNSAT — every admitted request is intended
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UNSAT. There is no cross-tenant request the signer admits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Pattern Hides
&lt;/h2&gt;

&lt;p&gt;The configuration audits a SOC 2 reviewer runs against the bucket itself look fine. The bucket is private, has no public-read or public-list flag, the bucket policy references &lt;code&gt;${aws:PrincipalTag/tenant_id}&lt;/code&gt; in a Resource condition, the IAM roles for human users are all tenant-tagged. Every layer the auditor examines is tenant-aware.&lt;/p&gt;

&lt;p&gt;The signer is application code. It runs in the API tier. It takes a session, takes a target key, returns a URL. The SOC 2 reviewer looks at it as a function call, not a security boundary. The function is correct. It produces URLs that work. Nobody re-runs the prefix-enforcement check because nobody filed it as a security control to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Remediation
&lt;/h2&gt;

&lt;p&gt;In application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; def sign_upload_url(session, target_key):
&lt;span class="gi"&gt;+    if "/.." in target_key or target_key.startswith("../"):
+        raise SecurityError("path traversal not allowed")
+    expected_prefix = f"tenants/{session.tenant_id}/"
+    if not target_key.startswith(expected_prefix):
+        raise SecurityError(f"key must start with {expected_prefix}")
&lt;/span&gt;     return s3.generate_presigned_url(
         "put_object",
         Params={"Bucket": "acme-tenant-data", "Key": target_key},
         ExpiresIn=900,
     )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two checks: traversal rejection, prefix enforcement. Both run before the signer mints anything. The application is now the layer that holds the tenant-isolation invariant.&lt;/p&gt;

&lt;p&gt;For belt-and-braces enforcement at the bucket layer too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TenantScopedAccess"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:role/UploadProxy"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::acme-tenant-data/tenants/${aws:PrincipalTag/tenant_id}/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalTag/tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${aws:PrincipalTag/tenant_id}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The condition is the keystone. A presigned URL minted with a session tagged &lt;code&gt;tenant_id=A&lt;/code&gt; can never access &lt;code&gt;tenants/B/...&lt;/code&gt;, regardless of what the signer does, because the URL's principal tag locks the resource ARN. This is the defense-in-depth layer; the signer is the primary enforcement layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prevention Lesson
&lt;/h2&gt;

&lt;p&gt;The two reports happened because the signer wasn't audited as a security boundary. Three layers of prevention:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Library default.&lt;/strong&gt; The presigned-URL helper API takes a session &lt;strong&gt;and&lt;/strong&gt; a target key, and the helper itself enforces the tenant prefix. The application code can't construct an unsafe URL because the helper rejects them. This is the highest-leverage layer. Every existing and future upload feature inherits the invariant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI invariant check.&lt;/strong&gt; &lt;code&gt;stave apply&lt;/code&gt; runs against the pre-merge observation snapshot. A signer with &lt;code&gt;enforce_prefix=false&lt;/code&gt; or &lt;code&gt;allow_traversal=true&lt;/code&gt; produces exit 3 from &lt;code&gt;CTL.S3.TENANT.ISOLATION.001&lt;/code&gt;. The fixture shipped with this article is the template with the same predicate, same exit code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource-tag condition on the bucket policy.&lt;/strong&gt; Even if the signer regresses, the bucket policy's &lt;code&gt;Condition&lt;/code&gt; clause on &lt;code&gt;aws:PrincipalTag/tenant_id&lt;/code&gt; rejects cross-tenant requests at S3's request-evaluation layer. This is the safety net, the layer that catches mistakes the primary missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every app-signer wrapping S3 presigned URLs enforces the tenant prefix and rejects traversal patterns&lt;/li&gt;
&lt;li&gt;The presigned-URL helper API takes a session AND a target key (signer has access to both); calls passing arbitrary keys fail at helper boundary&lt;/li&gt;
&lt;li&gt;Bucket policy on shared-tenant buckets carries a &lt;code&gt;Condition&lt;/code&gt; clause on &lt;code&gt;aws:PrincipalTag/tenant_id&lt;/code&gt; that locks the Resource ARN to the requester's tenant&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stave apply&lt;/code&gt; runs in CI against snapshots that include &lt;code&gt;app_signer&lt;/code&gt; identities; PRs introducing a permissive signer fail the gate&lt;/li&gt;
&lt;li&gt;Code review for new tenant-aware features explicitly asks "what does the signer enforce?" not just "what does the bucket policy enforce?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two HackerOne reports differ in product, in industry, in whether the breach was read-only or write-capable. The configuration that exposed them was identical: a shared bucket with a tenant-aware policy, and a signer that didn't share the same awareness. The lesson is that &lt;strong&gt;the bucket policy is not the only layer&lt;/strong&gt;. The application's signing helper is the layer that mints the URLs S3 will honour, and it has to enforce the same invariant the bucket policy enforces or the bucket policy is decorative.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/s3-tenant-prefix-isolation" rel="noopener noreferrer"&gt;&lt;code&gt;stave/examples/s3-tenant-prefix-isolation/&lt;/code&gt;&lt;/a&gt; is two binaries side by side: a CEL evaluation via &lt;code&gt;pkg/stave.Apply&lt;/code&gt; (asserts the unsafe state when the signer is permissive) and a Z3 SAT prover (extracts a concrete cross-tenant request the signer admits but the application never intended). The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>Your S3 bucket is encrypted at rest. It is not encrypted in transit by default.</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:26:30 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/your-s3-bucket-is-encrypted-at-rest-it-is-not-encrypted-in-transit-by-default-48h</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/your-s3-bucket-is-encrypted-at-rest-it-is-not-encrypted-in-transit-by-default-48h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In 2015 a researcher pointed at HackerOne's own attachment bucket and noted that the objects were retrievable over plain HTTP. The data was encrypted at rest. The bucket was private. The objects required authentication. But the HTTP transport itself was unencrypted, and the bucket policy did not deny non-TLS requests. The report became &lt;a href="https://hackerone.com/reports/43280" rel="noopener noreferrer"&gt;HackerOne #43280&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is the configuration gap that survives every "encrypt your data" presentation. Encryption-at-rest is the default. Encryption-in-transit is not. Closing the gap is one bucket policy statement, eight lines of JSON. Not closing it is a HIPAA finding (&lt;code&gt;164.312(e)(2)(ii)&lt;/code&gt;), a PCI-DSS finding (&lt;code&gt;4.2.1&lt;/code&gt;), a SOC 2 finding (&lt;code&gt;CC6.1&lt;/code&gt;), and a CIS AWS finding (&lt;code&gt;2.1.1&lt;/code&gt;) — simultaneously, on the same bucket, every audit cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration that fails
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws:s3:::hackerone-attachments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"public_read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"public_list"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"encryption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"in_transit_enforced"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"controls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"public_access_fully_blocked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"public_access_block"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"block_public_acls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ignore_public_acls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"block_public_policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"restrict_public_buckets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line in this block reads like a well-secured bucket. Public read off. Public list off. Public Access Block fully enabled (all four flags). Anonymous access denied at every layer.&lt;/p&gt;

&lt;p&gt;The vulnerability is one field: &lt;code&gt;in_transit_enforced: false&lt;/code&gt;. The bucket allows HTTPS &lt;em&gt;or&lt;/em&gt; HTTP. Any client that calls a presigned URL, a redirect, or a misconfigured CDN over HTTP gets the object delivered in plaintext over the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  In transit
&lt;/h2&gt;

&lt;p&gt;AWS S3 supports HTTPS on every endpoint. The question is not "can the bucket be reached over HTTPS?" — it can. The question is "does the bucket &lt;em&gt;refuse&lt;/em&gt; HTTP?" and the default answer is no.&lt;/p&gt;

&lt;p&gt;The fix is a single bucket policy statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyInsecureTransport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::hackerone-attachments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::hackerone-attachments/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Bool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:SecureTransport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"false"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aws:SecureTransport&lt;/code&gt; is an IAM condition key. When &lt;code&gt;false&lt;/code&gt;, the request came in over HTTP. The deny statement above blocks every action on the bucket from every principal when the request is not TLS. The deny is unconditional, applies to authenticated and anonymous requests alike, and is the canonical fix referenced by every compliance benchmark.&lt;/p&gt;

&lt;p&gt;Without this statement, the bucket policy does not say "HTTPS required." It says "any transport works." The default-deny logic of IAM does not save you here. &lt;code&gt;s3:GetObject&lt;/code&gt; is explicitly allowed by the bucket policy or by the caller's IAM, and the policy evaluator does not care which TCP socket it arrived on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this hides
&lt;/h2&gt;

&lt;p&gt;The check is fast in audit. The check is invisible in operation. Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 console UX never surfaces it.&lt;/strong&gt; The console encourages you to set up encryption at rest (KMS or SSE-S3), configure replication, configure lifecycle. Transport encryption is a policy concern, and you only see it if you go read the policy. The console's "Permissions" tab does not have an "HTTPS required" checkbox the way it has a "Block public access" checkbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Working HTTPS hides broken HTTP.&lt;/strong&gt; Every SDK uses HTTPS by default. Every console operation uses HTTPS. Every CloudFront-fronted object uses HTTPS. The HTTP path is reachable but rarely traversed by your own code. Until an old SDK, a legacy integration, a custom uploader, or a presigned URL leak takes the HTTP path, nothing visibly breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scanners report compliance, not transport.&lt;/strong&gt; Most scanners flag "encryption at rest enabled?" with a checkmark and move on. The transport-encryption check is a separate rule, often grouped with TLS-version checks on ALB and CloudFront, and is missing from a surprising number of CSPM defaults. CIS AWS v3.0 control 2.1.1 explicitly calls it out and the corresponding policy-as-code rules are present in OPA-libraries but often disabled because they generate noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system invariant
&lt;/h2&gt;

&lt;p&gt;The invariant for the data layer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No S3 bucket may permit object access over a non-TLS transport.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Restated as a fact predicate: for every bucket, &lt;code&gt;storage.encryption.in_transit_enforced&lt;/code&gt; must be &lt;code&gt;true&lt;/code&gt;. The boolean is a projection of "does this bucket have a policy statement denying &lt;code&gt;aws:SecureTransport: false&lt;/code&gt;?" produced once by the extractor, then evaluated by every consumer (audit, gate, dashboard, compliance report) against the same projected value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stave detects
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CTL.S3.ENCRYPT.002&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Transport Encryption Required&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;S3 buckets must enforce HTTPS via a deny policy on aws:SecureTransport=false.&lt;/span&gt;
  &lt;span class="s"&gt;Without this, data transfers occur in plaintext.&lt;/span&gt;
&lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;span class="na"&gt;compliance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hipaa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;164.312(e)(2)(ii)"&lt;/span&gt;
  &lt;span class="na"&gt;cis_aws_v3.0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.1.1"&lt;/span&gt;
  &lt;span class="na"&gt;pci_dss_v4.0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.2.1"&lt;/span&gt;
  &lt;span class="na"&gt;soc2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CC6.1"&lt;/span&gt;
  &lt;span class="na"&gt;nist_800_53_r5&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SC-8"&lt;/span&gt;
&lt;span class="na"&gt;unsafe_predicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.kind&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucket&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.encryption.in_transit_enforced&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two-line predicate. The first line scopes to buckets (the same property block can describe replication targets or website endpoints; this control only applies to the bucket asset). The second line is the actual check: in-transit enforcement must be true.&lt;/p&gt;

&lt;p&gt;The compliance block carries six framework citations. Each is a separately motivated reason this control exists. None of them changes the predicate. The rule is the same regardless of which audit the customer is preparing for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the fixture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;stave &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make build

&lt;span class="c"&gt;# The fixture ships two snapshots (T1 unsafe, T2 fixed). Evaluate T1 alone to&lt;/span&gt;
&lt;span class="c"&gt;# reproduce the original finding.&lt;/span&gt;
&lt;span class="nv"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;testdata/e2e/e2e-h1-hackerone-43280/observations/2015-01-11T000000Z.json &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;

./stave apply &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--controls&lt;/span&gt; testdata/e2e/e2e-h1-hackerone-43280/controls &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--observations&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-unsafe&lt;/span&gt; 168h &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--eval-time&lt;/span&gt; 2015-01-18T00:00:00Z &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-unknown-input&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; json | jq &lt;span class="s1"&gt;'.findings[0]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"control_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CTL.S3.ENCRYPT.002"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"control_severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"asset_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws:s3:::hackerone-attachments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"misconfigurations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"property"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"storage.encryption.in_transit_enforced"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"actual_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"operator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"unsafe_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"remediation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bucket does not enforce HTTPS for data transfers. Requests over plain HTTP transmit data in cleartext."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Add a bucket policy statement that denies all actions when aws:SecureTransport is false. This forces all API calls to use HTTPS."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One field, property path and shell-pasteable fix. The compliance citations live on the control YAML, they show up in the report renderer when the operator asks for "show me findings tagged HIPAA" or "show me findings tagged CIS AWS v3.0."&lt;/p&gt;

&lt;h2&gt;
  
  
  The remediation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api put-bucket-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; hackerone-attachments &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy&lt;/span&gt; &lt;span class="s1"&gt;'{
    "Version": "2012-10-17",
    "Statement": [{
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::hackerone-attachments",
        "arn:aws:s3:::hackerone-attachments/*"
      ],
      "Condition": { "Bool": { "aws:SecureTransport": "false" } }
    }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the bucket already has a policy with other statements (e.g., a CloudFront OAI grant), append the &lt;code&gt;DenyInsecureTransport&lt;/code&gt; statement to the existing policy rather than overwriting. The deny is evaluated before any allow in IAM policy logic, so it composes with any existing allow statements you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it generalises
&lt;/h2&gt;

&lt;p&gt;This is the cheapest possible class of HIPAA-grade finding to close. One bucket policy statement, eight lines of JSON, zero performance cost, no application changes required. The reason it persists in the wild is the same reason most cloud security findings persist: the configuration was set up before the compliance bar existed, never re-audited against the current bar, and the tool the team uses to audit does not check this specific predicate by default.&lt;/p&gt;

&lt;p&gt;The right answer is a presence check at the project layer: every bucket your snapshot extractor finds must have &lt;code&gt;in_transit_enforced: true&lt;/code&gt;, evaluated continuously, in CI, with the failing PR blocked from merging until the policy statement is added. Stave's &lt;code&gt;CTL.S3.ENCRYPT.002&lt;/code&gt; is that presence check, the fixture above is the e2e regression test for it, and the cost of running it on every commit is roughly the cost of one &lt;code&gt;s3:GetBucketPolicy&lt;/code&gt; call per bucket well within the API budget of every audit pipeline.&lt;/p&gt;

&lt;p&gt;HackerOne fixed their own bucket policy in 2015. The class of finding has not disappeared since.&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>Source Code in a Public Bucket: When .git/ Survives Deployment</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Mon, 28 Sep 2026 12:19:09 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/source-code-in-a-public-bucket-when-git-survives-deployment-4nnp</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/source-code-in-a-public-bucket-when-git-survives-deployment-4nnp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A static-site S3 bucket is, by design, public. Marketing copy, infographics, downloadable assets, anyone on the internet can read the contents. The bucket policy admits &lt;code&gt;Principal: "*"&lt;/code&gt; for &lt;code&gt;s3:GetObject&lt;/code&gt;, the CDN serves the URLs, the team treats this as a feature.&lt;/p&gt;

&lt;p&gt;The bug Mozilla shipped to production in &lt;a href="https://hackerone.com/reports/2383486" rel="noopener noreferrer"&gt;HackerOne 2383486&lt;/a&gt; was that the &lt;code&gt;.git/&lt;/code&gt; directory shipped with the marketing content.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://acme-marketing-site.example/.git/HEAD
ref: refs/heads/main

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://acme-marketing-site.example/.git/config
&lt;span class="o"&gt;[&lt;/span&gt;core]
    repositoryformatversion &lt;span class="o"&gt;=&lt;/span&gt; 0
    filemode &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;remote &lt;span class="s2"&gt;"origin"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    url &lt;span class="o"&gt;=&lt;/span&gt; git@github.com:acme/marketing-site.git
&lt;span class="o"&gt;[&lt;/span&gt;user]
    email &lt;span class="o"&gt;=&lt;/span&gt; alice@acme.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.git/HEAD&lt;/code&gt; is two lines. &lt;code&gt;.git/config&lt;/code&gt; is more interesting. It leaks the git remote, the developer's email, and the path of the credential store that was committed once and &lt;code&gt;git rm&lt;/code&gt;'d later. From there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;wget &lt;span class="nt"&gt;--mirror&lt;/span&gt; https://acme-marketing-site.example/.git/
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;acme-marketing-site.example/.git/..
&lt;span class="nv"&gt;$ &lt;/span&gt;git checkout HEAD &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# Full repository history, every branch, every commit.&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--full-history&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'.env'&lt;/span&gt; &lt;span class="s1"&gt;'credentials*'&lt;/span&gt; &lt;span class="s1"&gt;'config/*.yml'&lt;/span&gt;
&lt;span class="c"&gt;# Every secret ever committed, even the ones that were&lt;/span&gt;
&lt;span class="c"&gt;# "removed" in a later commit.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three commands turn a public bucket into a source-code disclosure. Plus the credential set the team thought they had rotated when they &lt;code&gt;git rm&lt;/code&gt;'d the original commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Pattern Recurs
&lt;/h2&gt;

&lt;p&gt;The deployment pipeline looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# In the build server / CI runner&lt;/span&gt;
git clone https://github.com/acme/marketing-site.git build/
&lt;span class="c"&gt;# Build steps run inside build/...&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;build/ s3://acme-marketing-site/ &lt;span class="nt"&gt;--delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build/&lt;/code&gt; is a clone, not an export. It contains &lt;code&gt;.git/&lt;/code&gt;. &lt;code&gt;aws s3 sync&lt;/code&gt; faithfully uploads every file under &lt;code&gt;build/&lt;/code&gt;, including the &lt;code&gt;.git/&lt;/code&gt; subdirectory. The team's mental model is "we sync the build output to S3"; reality is "we sync the build directory plus whatever non-build files are in it."&lt;/p&gt;

&lt;p&gt;The fix is one line. The bug ships because nobody's testing "is &lt;code&gt;.git/&lt;/code&gt; in the published artefacts?" before the &lt;code&gt;aws s3 sync&lt;/code&gt; runs.&lt;/p&gt;

&lt;p&gt;This pattern is broader than a single Mozilla report. Searching public S3 endpoints for &lt;code&gt;.git/HEAD&lt;/code&gt; returns hits across every industry; the count tracks how many teams use &lt;code&gt;git clone&lt;/code&gt; as their deployment-source step. Most of those teams' S3 buckets are public for the same reason as Mozilla's. The site is intentionally public and the same mistake exposes the same artefacts.&lt;/p&gt;

&lt;p&gt;The same shape applies to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.svn/&lt;/code&gt; — older, but recurring on legacy infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; — environment files committed by a developer for local convenience, never rewritten out of history.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_modules/.cache/&lt;/code&gt;, &lt;code&gt;__pycache__/&lt;/code&gt; — sometimes leak module names not intended for public consumption.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.aws/credentials&lt;/code&gt;, &lt;code&gt;.ssh/id_rsa&lt;/code&gt; — the developer-machine artefacts that occasionally end up in a build directory through a misconfigured Dockerfile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;.git/&lt;/code&gt; is the most catastrophic and the source of the failure is the most generic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Layer Boundary Problem
&lt;/h2&gt;

&lt;p&gt;Article on (&lt;code&gt;s3-public-read-policy&lt;/code&gt;) was about buckets that should not be public. This bucket is &lt;em&gt;supposed to be public&lt;/em&gt;. The bucket policy is correct; making the bucket private would break the marketing site.&lt;/p&gt;

&lt;p&gt;The fix is at the &lt;strong&gt;deployment pipeline&lt;/strong&gt; layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; # In the build server / CI runner
 git clone https://github.com/acme/marketing-site.git build/
 # Build steps run inside build/...
&lt;span class="gi"&gt;+rm -rf build/.git
&lt;/span&gt; aws s3 sync build/ s3://acme-marketing-site/ --delete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…or use &lt;code&gt;git archive&lt;/code&gt; (which has no &lt;code&gt;.git/&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git archive &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;tar &lt;/span&gt;HEAD | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xC&lt;/span&gt; build/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…or have the build pipeline write to a separate &lt;code&gt;dist/&lt;/code&gt; directory and sync only that.&lt;/p&gt;

&lt;p&gt;The bucket policy is the right layer for "is this bucket public?" The deployment pipeline is the right layer for "are sensitive paths in the published artefact set?" Conflating them produced the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The System Invariant
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A public bucket must not contain version-control&lt;br&gt;
artefacts (&lt;code&gt;.git/&lt;/code&gt;, &lt;code&gt;.svn/&lt;/code&gt;) or developer-machine&lt;br&gt;
artefacts (&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.aws/credentials&lt;/code&gt;).&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Stave's observation schema, the bucket's content is modelled with two fields under &lt;code&gt;storage.content&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"public_read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"exposed_repo_artifacts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"exposed_paths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;".git/HEAD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;".git/config"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;".git/objects/pack/pack-abc123.pack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;".env"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;exposed_repo_artifacts&lt;/code&gt; is the engine's verdict (a collector inspects the bucket's object listing for the known unsafe path patterns). &lt;code&gt;exposed_paths&lt;/code&gt; is the evidence which is the key set the collector found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stave Control
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CTL.S3.REPO.ARTIFACT.001&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Public Buckets Must Not Expose VCS Artifacts&lt;/span&gt;
&lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;medium&lt;/span&gt;
&lt;span class="na"&gt;unsafe_predicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.access.public_read&lt;/span&gt;
          &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.access.public_list&lt;/span&gt;
          &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.storage.content.exposed_repo_artifacts&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two clauses, both required. Either alone is a non-finding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A private bucket with &lt;code&gt;.git/&lt;/code&gt; in it is &lt;em&gt;fine&lt;/em&gt; (IaC backups can be stored that way deliberately).&lt;/li&gt;
&lt;li&gt;A public bucket with no repo artefacts is the intended static-site case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The combination of public &lt;em&gt;and&lt;/em&gt; repo artefacts present is the one that fires. Severity is &lt;code&gt;medium&lt;/code&gt; because the &lt;em&gt;intended&lt;/em&gt; content of the public bucket is by definition public. The escalation comes from artefact paths revealing content the team didn't intend to publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Z3 Doesn't Help
&lt;/h2&gt;

&lt;p&gt;Same answer as bucket name dangling: this is a &lt;strong&gt;presence check&lt;/strong&gt; at the collector layer, not a reachability question. The collector observes the bucket's object inventory, applies a known-unsafe-paths filter, and emits a boolean. CEL evaluates two booleans.&lt;/p&gt;

&lt;p&gt;A reachability question would look like "given this set of admitted requests, is there one that produces a different intended output?". Here the unsafe state is: "the set of objects in the public bucket contains a key matching a known unsafe pattern." That's a deterministic look-up, not a search.&lt;/p&gt;

&lt;p&gt;The article's value is in the &lt;em&gt;layer-boundary&lt;/em&gt; framing (the bucket policy is the wrong layer to fix this), not in solver mechanics. Some bugs are small predicates with big stories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing The Detection
&lt;/h2&gt;

&lt;p&gt;The example is at &lt;code&gt;stave/examples/s3-dotgit-readable/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go run ./examples/s3-dotgit-readable before
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Captured stdout (&lt;code&gt;expected/before-output.txt&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== before (.git/ exposed) ===
  status: NON_COMPLIANT   total_assets=1   violations=1
  CTL.S3.REPO.ARTIFACT.001 fired on 1 asset(s):
    - arn:aws:s3:::acme-marketing-site   severity=medium   exposure_score=51.10
  assertion: fires=true (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the deployment pipeline is fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== after  (artefacts removed) ===
  status: COMPLIANT   total_assets=1   violations=0
  CTL.S3.REPO.ARTIFACT.001: no findings
  assertion: fires=false (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;public_read&lt;/code&gt; is still &lt;code&gt;true&lt;/code&gt; in the after fixture. The bucket is still a public marketing site. The remediation is at the deployment-pipeline layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Remediation
&lt;/h2&gt;

&lt;p&gt;Three options, escalating in robustness:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A. &lt;code&gt;rm -rf .git&lt;/code&gt; before &lt;code&gt;s3 sync&lt;/code&gt;.&lt;/strong&gt; Smallest change.&lt;br&gt;
Works as long as nobody changes the deployment script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; build/
&lt;span class="c"&gt;# build steps...&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; build/.git
aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;build/ s3://acme-marketing-site/ &lt;span class="nt"&gt;--delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;B. Use &lt;code&gt;git archive&lt;/code&gt; instead of &lt;code&gt;git clone&lt;/code&gt;.&lt;/strong&gt; Stronger — the source has no &lt;code&gt;.git/&lt;/code&gt; directory at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;build/
git archive &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;--remote&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; HEAD | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xC&lt;/span&gt; build/
&lt;span class="c"&gt;# build steps...&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;build/ s3://acme-marketing-site/ &lt;span class="nt"&gt;--delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;C. Two-directory build pipeline.&lt;/strong&gt; Strongest — the build output goes to &lt;code&gt;dist/&lt;/code&gt;, the bucket only sees &lt;code&gt;dist/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; src/
&lt;span class="nb"&gt;cd &lt;/span&gt;src &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ..
&lt;span class="c"&gt;# build/ is the source-of-truth checkout&lt;/span&gt;
&lt;span class="c"&gt;# dist/ is the artefact-only output&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;dist/ s3://acme-marketing-site/ &lt;span class="nt"&gt;--delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option C is the right pattern for any non-trivial pipeline because it forces the team to be explicit about what is and isn't deployable. The artefact set is a separate output, not a filtered view of the source tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prevention Lesson
&lt;/h2&gt;

&lt;p&gt;Three layers prevent recurrence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-deploy artefact scan.&lt;/strong&gt; Before &lt;code&gt;aws s3 sync&lt;/code&gt; runs, the deployment script scans the build directory for known-unsafe paths and aborts if any are present. A small helper, ~20 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;unsafe_paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find dist/ &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'.git'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'.svn'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'.env'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'.aws'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$unsafe_paths&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: unsafe artefacts in dist/:"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$unsafe_paths&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;dist/ s3://...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check runs in the same script that does the deploy, so the gate is in the right place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post-deploy invariant check.&lt;/strong&gt; &lt;code&gt;stave apply&lt;/code&gt; runs against the post-deploy observation snapshot. The observation collector includes a step that probes the bucket for known-unsafe path patterns and emits &lt;code&gt;exposed_repo_artifacts: true/false&lt;/code&gt;. The example shipped with this article is the template with the same predicate, same exit code 3 for any regression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt; + secret scanning.&lt;/strong&gt; Upstream of all of this: a &lt;code&gt;.gitignore&lt;/code&gt; that includes &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;*.key&lt;/code&gt;, &lt;code&gt;credentials*&lt;/code&gt;, and a pre-commit secret-scanner that prevents the secrets from ever reaching &lt;code&gt;.git/&lt;/code&gt; in the first place. This is the layer that handles the "secrets in old commits even after &lt;code&gt;git rm&lt;/code&gt;" failure mode. If the secret was never committed, the &lt;code&gt;.git/&lt;/code&gt; disclosure has nothing of value to reveal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Deployment scripts use &lt;code&gt;git archive&lt;/code&gt; or a separate &lt;code&gt;dist/&lt;/code&gt; directory; &lt;code&gt;git clone&lt;/code&gt; output never reaches &lt;code&gt;aws s3 sync&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Pre-deploy script scans the artefact directory for &lt;code&gt;.git/&lt;/code&gt;, &lt;code&gt;.svn/&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.aws/&lt;/code&gt;, &lt;code&gt;.ssh/&lt;/code&gt; and aborts on any hit&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;stave apply&lt;/code&gt; runs in CI against post-deploy observations; PRs that introduce a public bucket with &lt;code&gt;exposed_repo_artifacts: true&lt;/code&gt; fail the gate&lt;/li&gt;
&lt;li&gt;[ ] Repo-level secret scanning prevents &lt;code&gt;.env&lt;/code&gt; / credential files from being committed in the first place&lt;/li&gt;
&lt;li&gt;[ ] Quarterly bucket inventory check looks for any public bucket with &lt;code&gt;.git/&lt;/code&gt; paths in its key set (catches drift introduced outside the deployment pipeline)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bucket was always going to be public. The marketing site is &lt;em&gt;for the public&lt;/em&gt;. The bug was that public got applied to one more directory than the team meant.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/s3-dotgit-readable" rel="noopener noreferrer"&gt;&lt;code&gt;stave/examples/s3-dotgit-readable/&lt;/code&gt;&lt;/a&gt; is a self-contained Go program that loads two fixture snapshots, runs &lt;code&gt;pkg/stave.Apply&lt;/code&gt;, asserts that &lt;code&gt;CTL.S3.REPO.ARTIFACT.001&lt;/code&gt; fires on the .git-exposed fixture and is silent on the cleaned one, and exits zero when both assertions hold. The remediation in the after fixture leaves &lt;code&gt;public_read: true&lt;/code&gt; on purpose where the bucket is intentionally public; only the artefact paths needed removal. &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>When an Upload Form Becomes a Foothold: Broad S3 Write Scope</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sun, 27 Sep 2026 12:47:03 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/when-an-upload-form-becomes-a-foothold-broad-s3-write-scope-cnb</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/when-an-upload-form-becomes-a-foothold-broad-s3-write-scope-cnb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A signed POST form is the standard pattern for letting a browser upload a file directly to S3 without routing the bytes through your servers. When it works, it works beautifully. When the policy authorising the POST is too broad, every signed URL the server hands out is a write primitive on the entire bucket prefix.&lt;/p&gt;

&lt;p&gt;Four HackerOne reports describe the same shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shopify &lt;a href="https://hackerone.com/reports/93691" rel="noopener noreferrer"&gt;93691&lt;/a&gt;&lt;/strong&gt; — signed upload policy used &lt;code&gt;starts-with $key files/&lt;/code&gt; instead of an exact key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify &lt;a href="https://hackerone.com/reports/98819" rel="noopener noreferrer"&gt;98819&lt;/a&gt;&lt;/strong&gt; — authenticated S3 read paired with broad policy-write scope; the policy could be mutated by anyone authenticated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BCM &lt;a href="https://hackerone.com/reports/764243" rel="noopener noreferrer"&gt;764243&lt;/a&gt;&lt;/strong&gt; — bucket-level write scope too broad; one foothold became a supply-chain implant point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify &lt;a href="https://hackerone.com/reports/94502" rel="noopener noreferrer"&gt;94502&lt;/a&gt;&lt;/strong&gt; — multi-bucket exposure with public read/list/write across several keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four ship the same operational shape: an upload feature, a hurry, a policy that works because it doesn't reject valid uploads and silently accepts every other key under the prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broad Write Scope
&lt;/h2&gt;

&lt;p&gt;A signed POST policy from a Rails / Express / Django controller looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;upload_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expiration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-01-09T00:00:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conditions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme-uploads&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;starts-with&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;# ← the problem
&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-length-range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10485760&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;["starts-with", "$key", "files/"]&lt;/code&gt; is the line that breaks the model. It says: "the user can upload to any key that starts with &lt;code&gt;files/&lt;/code&gt;." That includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;files/abc-uuid/photo.png&lt;/code&gt; — what the application expects.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;files/admin.html&lt;/code&gt; — fine for the policy, parsed by whatever serves &lt;code&gt;assets.acme.example/admin.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;files/../etc/passwd&lt;/code&gt; — fine for the policy, problematic if any downstream code naïvely joins the key to a filesystem path.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;files/customer-data-dump.csv&lt;/code&gt; — fine for the policy, potentially overwriting an unrelated object.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right policy uses &lt;strong&gt;exact match&lt;/strong&gt;, not prefix match, binding each signed POST to one specific key the server generates server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;upload_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conditions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme-uploads&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                       &lt;span class="c1"&gt;# exact-match condition
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conceptual difference: prefix policies admit a &lt;em&gt;set&lt;/em&gt; of keys; exact policies admit a &lt;em&gt;single&lt;/em&gt; key. The set is hard to bound; the single key is trivially bounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The System Invariant
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every signed S3 upload policy must bind to an exact object key, not a prefix.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Stave's observation schema, the policy itself is modelled as a separate asset &lt;code&gt;s3_upload_policy&lt;/code&gt; because the vulnerability lives in the policy contract, not in the bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme-uploads-signed-policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3_upload_policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3_upload_policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"s3_upload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme-uploads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"allowed_key_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prefix"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"allowed_prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"files/"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allowed_key_mode&lt;/code&gt; carries the engine's verdict (&lt;code&gt;"prefix"&lt;/code&gt; vs &lt;code&gt;"exact"&lt;/code&gt;); &lt;code&gt;allowed_prefix&lt;/code&gt; / &lt;code&gt;allowed_key&lt;/code&gt; carries the evidence which is the actual prefix or exact key the policy admits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stave Control
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CTL.S3.WRITE.SCOPE.001&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;S3 Signed Upload Must Bind To Exact Object Key&lt;/span&gt;
&lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;span class="na"&gt;unsafe_predicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3_upload_policy"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.s3_upload.operation&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;properties.s3_upload.allowed_key_mode&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prefix"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CEL fires whenever the upload policy is in prefix mode for a write operation. That is the &lt;strong&gt;state assertion&lt;/strong&gt; — "this configuration is unsafe."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CEL is Not Enough Here
&lt;/h2&gt;

&lt;p&gt;CEL answers a yes/no question: &lt;em&gt;is the configuration in the unsafe state?&lt;/em&gt; It does not answer the natural follow-up: &lt;em&gt;if it is, what's the impact. What specific keys can an attacker reach that the application doesn't intend?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That second question is &lt;strong&gt;reachability&lt;/strong&gt;. A search over the key space, not a fold over the property bag. That's where Z3 earns its complexity.&lt;/p&gt;

&lt;p&gt;The previous articles in the series stopped at CEL because the patterns were state assertions: "is the bucket public?", "does this reference dangle?" Those are fold-over-properties questions. "Which specific keys does this policy admit that the app doesn't want?" is a different shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Z3 Witness Model
&lt;/h2&gt;

&lt;p&gt;The companion Z3 program at &lt;code&gt;stave/examples/s3-broad-write-scope/z3prove/&lt;/code&gt; reads the same fixture and encodes the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Is there a key K such that the policy admits K, AND&lt;br&gt;
the application's intended-key generator does not produce K?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model uses three named witness keys, encoded as integer constants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 = "files/abc-uuid/photo.png"   (intended — UUID-prefixed)
1 = "files/admin.html"            (admitted by prefix, NOT intended)
2 = "files/../etc/passwd"         (admitted, path traversal)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;admitted = key ∈ admitted_set       (prefix mode → {0,1,2}; exact mode → {0})
intended = (key == 0)
unsafe   = admitted AND NOT intended
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The solver discharges the conjecture against the configuration data the fixture provides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== before (prefix-mode) ===
  policy mode: prefix   exemplar: files/*
  admitted set: [files/abc-uuid/photo.png files/admin.html files/../etc/passwd]
  intended set: ["files/abc-uuid/photo.png"]
  verdict: SAT — witness key "files/admin.html" is admitted but unintended
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SAT, with a concrete witness &lt;code&gt;files/admin.html&lt;/code&gt;. The prefix policy admits this key (it starts with &lt;code&gt;files/&lt;/code&gt;); the application's UUID-prefixed key generator never produces it. The witness is the answer to "what specific filename can a tester write to?"&lt;/p&gt;

&lt;p&gt;After the policy is scoped to exact match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== after  (exact-mode) ===
  policy mode: exact   exemplar: files/abc-uuid/photo.png
  admitted set: [files/abc-uuid/photo.png]
  intended set: ["files/abc-uuid/photo.png"]
  verdict: UNSAT — every admitted key is intended
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UNSAT. There is no key the policy admits that the application does not intend. The remediation provably closes the class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Witness Extraction is The Stronger Claim
&lt;/h2&gt;

&lt;p&gt;Pen-test reports often end with "this configuration is permissive." The customer asks "permissive how?" A list of synthetic example keys is the answer that turns "permissive" into actionable concrete impact. CEL produces the first; Z3 produces the second.&lt;/p&gt;

&lt;p&gt;Z3's contribution for this small enum it is straightforward search. The &lt;em&gt;encoding&lt;/em&gt; is declarative: the program describes the admitted set and the intended set, and lets the solver find the gap. Adding a new witness key (e.g., &lt;code&gt;files/customer-12345/private-data.json&lt;/code&gt;) is a single line. Generalising to a different attack class (write to &lt;code&gt;files/&amp;lt;other-tenant&amp;gt;/...&lt;/code&gt;) is a different predicate, not a different program.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Remediation
&lt;/h2&gt;

&lt;p&gt;Two changes in the application's upload-controller code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; def signed_upload_policy(user_id, filename):
&lt;span class="gd"&gt;-    key = f"files/{filename}"
&lt;/span&gt;&lt;span class="gi"&gt;+    key = f"files/{user_id}/{uuid.uuid4()}/{filename}"
&lt;/span&gt;     return s3.generate_presigned_post(
         Bucket="acme-uploads",
         Key=key,
         Conditions=[
&lt;span class="gd"&gt;-            ["starts-with", "$key", "files/"],
&lt;/span&gt;&lt;span class="gi"&gt;+            {"key": key},                 # exact match
&lt;/span&gt;             ["content-length-range", 0, 10485760],
         ],
     )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first change widens the key namespace per upload (UUID in the path) so two uploads cannot collide. The second change replaces the prefix condition with an exact match. The policy now admits the key the server generated.&lt;/p&gt;

&lt;p&gt;Verify with &lt;code&gt;aws s3api put-bucket-policy --policy file://...&lt;/code&gt; on a stricter resource-policy too, if you want belt-and-braces enforcement that an attacker who somehow gets a broader signed URL still cannot write outside their UUID prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TenantScopedWrites"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:role/UploadProxy"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::acme-uploads/files/${aws:userid}/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"s3:x-amz-server-side-encryption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws:kms"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bucket policy and the signed-URL policy are two independent layers; either alone closes most of the attack surface, both together close it under attacker assumptions that get more aggressive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prevention Lesson
&lt;/h2&gt;

&lt;p&gt;The four reports happened because the upload controller was written in a hurry against a feature deadline, the signed URL worked in QA, and nobody re-read the conditions before shipping. Three layers prevent recurrence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Library default.&lt;/strong&gt; The application's signed-URL helper takes a &lt;code&gt;key&lt;/code&gt; parameter (not a &lt;code&gt;prefix&lt;/code&gt;) and rejects calls that pass a prefix-shaped value. The library's API makes the unsafe form syntactically harder to express. This is the highest-leverage fix. It converts every future upload feature into a safe one by construction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI invariant check.&lt;/strong&gt; &lt;code&gt;stave apply&lt;/code&gt; runs on the pre-merge observation snapshot. A PR that introduces an &lt;code&gt;s3_upload_policy&lt;/code&gt; asset with &lt;code&gt;allowed_key_mode: "prefix"&lt;/code&gt; fails CI. The example shipped with this article is the template with the same predicate and exit code 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Z3 mode in code review.&lt;/strong&gt; For changes that &lt;em&gt;do&lt;/em&gt; deliberately use prefix conditions (rare — public-archive buckets, multi-key bulk uploads), the review template asks the author to enumerate "admitted keys outside the intended set" with the same reasoning the Z3 program automates. The author either produces a clean witness-free justification or reaches for a tighter policy.&lt;/p&gt;

&lt;p&gt;The Z3 mode is not a CI gate. It's a thinking discipline. The witness extraction in the example "think like Z3" looks like in code: write down the set of admitted keys and the set of intended keys, find the gap. The solver removes the manual enumeration; the discipline removes the policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Signed-URL helper rejects prefix conditions on &lt;code&gt;$key&lt;/code&gt;; only exact-key conditions are accepted&lt;/li&gt;
&lt;li&gt;Server-side key generation uses a UUID layer so two uploads cannot collide on key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stave apply&lt;/code&gt; runs in CI and blocks PRs that introduce &lt;code&gt;s3_upload_policy&lt;/code&gt; assets in prefix mode&lt;/li&gt;
&lt;li&gt;Bucket-level policy carries a tenant-scoped Resource condition so a broader signed URL still cannot write outside its tenant prefix&lt;/li&gt;
&lt;li&gt;Code review for new upload features enumerates admitted keys outside the intended set (the "Z3 mode" check) and either produces a witness-free justification or tightens the policy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The four HackerOne reports differ in industry, in feature, in the data their broad write scope eventually reached. The policy line that exposed it was identical: a &lt;code&gt;starts-with&lt;/code&gt; where an exact match belonged.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/s3-broad-write-scope" rel="noopener noreferrer"&gt;&lt;code&gt;s3-broad-write-scope&lt;/code&gt;&lt;/a&gt; is two binaries side by side: a CEL evaluation via &lt;code&gt;pkg/stave.Apply&lt;/code&gt; (asserts the unsafe state) and a Z3 SAT prover (extracts a concrete witness key the policy admits but the application does not intend). The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>You Don't Own Your CloudFront Subdomain Until You Register the Distribution</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:32:04 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/you-dont-own-your-cloudfront-subdomain-until-you-register-the-distribution-3dd4</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/you-dont-own-your-cloudfront-subdomain-until-you-register-the-distribution-3dd4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A researcher checked &lt;code&gt;cdn.grab.com&lt;/code&gt; in a browser. The page did not load, because the CloudFront distribution that the CNAME pointed to was not registered in any AWS account. The researcher registered it in their own account. Then &lt;code&gt;cdn.grab.com&lt;/code&gt; was serving their content.&lt;/p&gt;

&lt;p&gt;This is a gap in how AWS CloudFront distribution ownership works, and it shows up repeatedly across companies that use CloudFront for CDN delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  How CloudFront Subdomain Takeover Works
&lt;/h2&gt;

&lt;p&gt;When you create a CloudFront distribution, AWS assigns it a domain name like &lt;code&gt;d1234abcd5678.cloudfront.net&lt;/code&gt;. You then create a CNAME in your DNS pointing your branded subdomain to that distribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;cdn&lt;/span&gt;.&lt;span class="n"&gt;grab&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;  &lt;span class="n"&gt;CNAME&lt;/span&gt;  &lt;span class="n"&gt;d1234abcd5678&lt;/span&gt;.&lt;span class="n"&gt;cloudfront&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you delete the CloudFront distribution for any reasons such as migrating CDN providers, cleaning up old infrastructure, or decommissioning a product the distribution is gone. The CNAME in your DNS is still present.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cdn.grab.com&lt;/code&gt; still resolves. It still points to &lt;code&gt;*.cloudfront.net&lt;/code&gt;. But the distribution at that endpoint no longer exists in your account.&lt;/p&gt;

&lt;p&gt;Anyone can create a new CloudFront distribution. CloudFront distribution domain names follow a predictable format. If an attacker creates a distribution and configures it to respond to &lt;code&gt;cdn.grab.com&lt;/code&gt; as an alternate domain name which is a standard CloudFront feature, CloudFront will serve their content when &lt;code&gt;cdn.grab.com&lt;/code&gt; is requested.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@todayisnew&lt;/code&gt; did this. They registered the distribution, configured it to respond to &lt;code&gt;cdn.grab.com&lt;/code&gt;, and served a PoC page at &lt;code&gt;http://cdn.grab.com/index.html&lt;/code&gt;. Grab's security team triaged it within hours and confirmed it valid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This is Worse Than Third-Party SaaS Takeover
&lt;/h2&gt;

&lt;p&gt;The previous article in this series covered subdomain takeovers via third-party services such as Vercel, Squarespace, Brandpad, Unbounce. Those services have free tiers. An attacker signs up for free and claims the subdomain.&lt;/p&gt;

&lt;p&gt;CloudFront distributions are different in two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the trust level is higher.&lt;/strong&gt; A subdomain like &lt;code&gt;cdn.grab.com&lt;/code&gt; is serving static assets such as JavaScript files, images, CSS, potentially SDKs. When an attacker controls &lt;code&gt;cdn.grab.com&lt;/code&gt;, they are not just serving a phishing page. They are serving whatever the Grab application loads from that CDN endpoint. If the application loads JavaScript from &lt;code&gt;cdn.grab.com/app.js&lt;/code&gt;, that JavaScript now comes from the attacker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the HTTPS certificate is valid.&lt;/strong&gt; CloudFront provides SSL certificates via ACM. When an attacker registers a distribution with &lt;code&gt;cdn.grab.com&lt;/code&gt; as an alternate domain, they can provision a certificate for &lt;code&gt;cdn.grab.com&lt;/code&gt; through CloudFront's certificate management. The browser sees a valid HTTPS connection to &lt;code&gt;cdn.grab.com&lt;/code&gt;. There is no certificate warning. The lock icon is green.&lt;/p&gt;

&lt;p&gt;The combination of trusted CDN subdomain, valid HTTPS certificate, serving attacker-controlled JavaScript is a supply chain attack vector, not just a phishing risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Attack Path
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Attacker finds cdn.grab.com CNAME pointing to *.cloudfront.net
2. Attacker verifies the distribution is unclaimed
   (curl -I https://cdn.grab.com → connection error or CloudFront default)
3. Attacker creates CloudFront distribution in their AWS account
4. Attacker adds cdn.grab.com as an alternate domain name
5. CloudFront provisions a certificate for cdn.grab.com
6. cdn.grab.com now serves attacker content over valid HTTPS
7. Any application loading assets from cdn.grab.com
   now loads attacker-controlled content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 3 costs money. CloudFront is not free. But the attacker controls a CDN subdomain of a major company with a valid certificate. The economics favor the attacker in any targeted scenario.&lt;/p&gt;

&lt;h2&gt;
  
  
  The System Invariant
&lt;/h2&gt;

&lt;p&gt;The invariant is the same as every subdomain takeover, stated for CloudFront:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every DNS CNAME pointing to a CloudFront endpoint must reference a distribution that exists in the same AWS account.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Observable in an infrastructure snapshot: the DNS record points to &lt;code&gt;*.cloudfront.net&lt;/code&gt;, and the corresponding CloudFront distribution exists in the account's resource inventory. If the distribution does not exist, it is a violation.&lt;/p&gt;

&lt;p&gt;This is detectable without making a live HTTP request to the endpoint. The snapshot contains the DNS records and the CloudFront distribution inventory. Cross-referencing them is a set membership check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stave Detects
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CTL.DNS.DANGLING.002    CRITICAL  initial_access
  "DNS CNAME Must Not Point to Unclaimed CloudFront Distribution"

  Fires when: a DNS CNAME record points to *.cloudfront.net
  and no CloudFront distribution in the account inventory
  has a matching alternate domain name or distribution domain.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The control evaluates two fields from the snapshot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;DNS records, any CNAME ending in &lt;code&gt;.cloudfront.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CloudFront distribution inventory, the list of distributions and their alternate domain names&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a CNAME target has no matching distribution in the account, the control fires. No live HTTP request. No CloudFront API call. Just a cross-reference between two lists in the snapshot.&lt;/p&gt;

&lt;p&gt;Running the Grab scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VERDICT: COVERED

CTL.DNS.DANGLING.002    CRITICAL  initial_access
  cdn.grab.com → *.cloudfront.net
  No CloudFront distribution found with alternate domain cdn.grab.com
  or matching distribution domain in account inventory.

Compound chains:
  shadow_api_exposure     confidence: 1.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Difference from S3 Subdomain Takeover
&lt;/h2&gt;

&lt;p&gt;S3 subdomain takeover works when a DNS record points to &lt;code&gt;bucket-name.s3-website-region.amazonaws.com&lt;/code&gt; and the bucket does not exist. The attacker creates the bucket with the matching name.&lt;/p&gt;

&lt;p&gt;CloudFront subdomain takeover works when a DNS record points to a CloudFront distribution domain and the distribution does not exist. The attacker creates a new distribution and adds the victim's subdomain as an alternate domain name.&lt;/p&gt;

&lt;p&gt;The detection is slightly different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;S3: check if the bucket named in the CNAME endpoint exists in the account&lt;/li&gt;
&lt;li&gt;CloudFront: check if any distribution in the account has the subdomain as an alternate domain name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are cross-reference checks in the snapshot. Neither requires a live request.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CTL.S3.DANGLING.ORIGIN.001&lt;/code&gt; covers S3. &lt;code&gt;CTL.DNS.DANGLING.002&lt;/code&gt; covers CloudFront. The same snapshot assessment catches both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option A: Delete the DNS record.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The CDN subdomain is no longer needed. Remove the CNAME.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Route53 — remove the dangling CNAME&lt;/span&gt;
aws route53 change-resource-record-sets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hosted-zone-id&lt;/span&gt; ZONE_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--change-batch&lt;/span&gt; &lt;span class="s1"&gt;'{
    "Changes": [{
      "Action": "DELETE",
      "ResourceRecordSet": {
        "Name": "cdn.example.com",
        "Type": "CNAME",
        "TTL": 300,
        "ResourceRecords": [
          {"Value": "d1234abcd.cloudfront.net"}
        ]
      }
    }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B: Recreate the distribution.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The CDN subdomain is still needed. Create a new CloudFront distribution, add the subdomain as an alternate domain name, provision the certificate, and restore the origin configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process fix: delete the distribution last.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When decommissioning CDN infrastructure, the order matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Remove the DNS CNAME record
2. Wait for DNS TTL to expire
3. Delete the CloudFront distribution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting the distribution before removing the DNS record leaves a window, potentially days if the TTL is long where the subdomain points to nothing and is claimable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;p&gt;For any AWS account using CloudFront for CDN delivery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every DNS CNAME pointing to &lt;code&gt;*.cloudfront.net&lt;/code&gt; has a corresponding distribution in the account with a matching alternate domain name&lt;/li&gt;
&lt;li&gt;CloudFront distribution decommissioning procedure: DNS record removed before distribution deleted&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CTL.DNS.DANGLING.002&lt;/code&gt; runs against DNS snapshot on each infrastructure change&lt;/li&gt;
&lt;li&gt;CloudFront distribution inventory audited against DNS records periodically&lt;/li&gt;
&lt;li&gt;Alternate domain names on all distributions match active DNS records&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distribution you deleted is still in your DNS. Until you remove that CNAME, the subdomain is available.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Grab &lt;code&gt;cdn.grab.com&lt;/code&gt; CloudFront subdomain takeover was reported by &lt;code&gt;@todayisnew&lt;/code&gt; and triaged by Grab's security team as valid within hours of submission. &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt; detects unclaimed CloudFront distributions via &lt;code&gt;CTL.DNS.DANGLING.002&lt;/code&gt;, evaluated from local infrastructure snapshots without AWS credentials or live HTTP requests.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>Your Deprecated Services Are Your Weakest Doors</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:16:00 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/your-deprecated-services-are-your-weakest-doors-3b8m</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/your-deprecated-services-are-your-weakest-doors-3b8m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;✓ Human-authored analysis; AI used for formatting and proofreading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;IaC tools don't track AWS service deprecation. Terraform will deploy a resource on a frozen service forever. So deprecation tracking must be a&lt;br&gt;
verifiable property in your CI/CD pipeline.&lt;/p&gt;

&lt;p&gt;AWS deprecated 20+ services and features on June 30, 2026. One of them (Simple AD) provides authentication to IAM Identity Center. Another (Bedrock Agents Classic) runs AI agent workloads with 14 distinct security controls. A third (SageMaker Role Manager) is the tool AWS recommends for scoping IAM roles which is the tool we'd tell you to use in a remediation guide.&lt;/p&gt;

&lt;p&gt;How many of these are still running in your accounts right now?&lt;/p&gt;

&lt;p&gt;Terraform, CloudFormation and CDK doesn't know. They'll deploy resources on deprecated services anytime without a warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deprecation nobody noticed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The June 30, 2026 AWS Service Availability update lists ~25 services entering maintenance, sunset, or end-of-support. The announcement is a blog post. There's no API that returns "this service is deprecated." No tag on the resource. No CloudTrail event.&lt;/p&gt;

&lt;p&gt;The services that matter for security:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Simple AD: no MFA support, no trust relationships with your authentication plane on a frozen directory&lt;/li&gt;
&lt;li&gt;Bedrock Agents Classic: 14 security controls, frozen, no new patches&lt;/li&gt;
&lt;li&gt;SageMaker Role Manager: the scoping tool itself is deprecated. The roles it created stay overprivileged longer&lt;/li&gt;
&lt;li&gt;IoT Device Defender Detect: your IoT monitoring just stopped evolving&lt;/li&gt;
&lt;li&gt;SageMaker Model Monitor: your model drift detection is frozen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why IaC doesn't help&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Blog post published → someone reads it (maybe)&lt;/li&gt;
&lt;li&gt;Ticket created → someone files a migration task (maybe)&lt;/li&gt;
&lt;li&gt;Ticket prioritized → it sits behind feature work (always)&lt;/li&gt;
&lt;li&gt;Meanwhile, &lt;code&gt;terraform apply&lt;/code&gt; keeps deploying resources on the deprecated service without a warning&lt;/li&gt;
&lt;li&gt;New team member joins, doesn't know about the deprecation, creates more resources on the deprecated service&lt;/li&gt;
&lt;li&gt;18 months later: the service is still running, accumulating permissions, receiving no security patches&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The IaC tool's job is to make the current configuration converge to the declared state. It has no concept of "this declared state is going away."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deprecation as shadow IT&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A deprecated service in maintenance mode is the worst kind of shadow IT:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sanctioned (AWS still runs it)&lt;/li&gt;
&lt;li&gt;Visible (it's in the console)&lt;/li&gt;
&lt;li&gt;Costs money (you're paying for it)&lt;/li&gt;
&lt;li&gt;Silently stops receiving security improvements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody thinks of it as shadow IT because it's official. But from the attacker's perspective, it's a service that won't get security patches, won't benefit from AWS's evolving defaults, and sits in the account with permissions nobody reviews because "it's going away."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making deprecation a verifiable property&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Stave approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A control fires on every scan when a resource is deployed on a deprecated service&lt;/li&gt;
&lt;li&gt;The finding doesn't get lost in a backlog. It's a property that fails verification until the state changes&lt;/li&gt;
&lt;li&gt;It runs in CI/CD: &lt;code&gt;stave ci gate&lt;/code&gt; blocks deployment of new resources on deprecated services&lt;/li&gt;
&lt;li&gt;It runs continuously: &lt;code&gt;stave apply&lt;/code&gt; catches existing resources that haven't been migrated&lt;/li&gt;
&lt;li&gt;The remediation text links directly to the AWS migration guide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practitioner sees the finding every pipeline run. They can choose to tackle it when they can. But it never disappears from the output until the migration is done. It's a built-in reminder, not a ticket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Section 5 — Temporal risk: a new dimension&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most security tools measure spatial risk: "this configuration is bad right now." Deprecation introduces temporal risk: "this configuration is getting worse over time."&lt;/p&gt;

&lt;p&gt;A Simple AD directory providing authentication today has the same security properties it had yesterday. But it's one day closer to the point where&lt;br&gt;
AWS stops patching it. The risk accumulates.&lt;/p&gt;

&lt;p&gt;Stave is the only tool that knows the difference between "this configuration is valid" and "this configuration is valid for now." The deprecation timeline isn't metadata. It's a security property with a deadline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compound: spatial + temporal&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The strongest finding: a resource that's simultaneously near a compound breach path AND running on a deprecated service.&lt;/p&gt;

&lt;p&gt;Example: A Simple AD directory (deprecated, no MFA support) providing authentication to IAM Identity Center, where session duration is 12 hours. Spatial risk: the authentication chain is near a compound finding. Temporal risk: the directory service stops getting security improvements. Each dimension alone is a finding. Together they describe a resource that's getting weaker over time while already close to a breach path.&lt;/p&gt;

&lt;p&gt;Your deprecated services are your weakest doors. Because they're the doors that stop getting locks.&lt;/p&gt;

&lt;p&gt;Quick Facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;25+ services in the June 2026 deprecation wave&lt;/li&gt;
&lt;li&gt;14 Bedrock Agents Classic controls affected&lt;/li&gt;
&lt;li&gt;5 Simple AD controls affected&lt;/li&gt;
&lt;li&gt;4 IoT Device Defender Detect controls affected&lt;/li&gt;
&lt;li&gt;2 + 1 chain for SageMaker Role Manager&lt;/li&gt;
&lt;li&gt;2 for SageMaker Model Monitor&lt;/li&gt;
&lt;li&gt;Zero IaC tools provide deprecation warnings&lt;/li&gt;
&lt;li&gt;Zero AWS APIs return "this service is deprecated"&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>devops</category>
      <category>cloudnative</category>
    </item>
  </channel>
</rss>
