<?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: Victor Okoroafor</title>
    <description>The latest articles on DEV Community by Victor Okoroafor (@okoroaforvic).</description>
    <link>https://dev.to/okoroaforvic</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%2F4173305%2F8fa9e583-4568-4280-aadd-cafec2723076.jpg</url>
      <title>DEV Community: Victor Okoroafor</title>
      <link>https://dev.to/okoroaforvic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/okoroaforvic"/>
    <language>en</language>
    <item>
      <title>My Policy-as-Code Check Passed. It Never Read the Policy.</title>
      <dc:creator>Victor Okoroafor</dc:creator>
      <pubDate>Mon, 19 Oct 2026 15:27:22 +0000</pubDate>
      <link>https://dev.to/okoroaforvic/my-policy-as-code-check-passed-it-never-read-the-policy-42g4</link>
      <guid>https://dev.to/okoroaforvic/my-policy-as-code-check-passed-it-never-read-the-policy-42g4</guid>
      <description>&lt;p&gt;In my &lt;a href="https://victorokoroafor.hashnode.dev/how-to-block-over-privileged-iam-roles-in-aws-using-policy-as-code" rel="noopener noreferrer"&gt;last post&lt;/a&gt;, I showed how OPA and Conftest block over-privileged IAM roles before anything gets deployed. I ended with a warning: there's a case where the check passes when it shouldn't.&lt;/p&gt;

&lt;p&gt;This is that case. It's worse than a missed wildcard, because nothing tells you it happened.&lt;/p&gt;

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

&lt;p&gt;My deny rule starts like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Statement&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the policy from the Terraform plan, parses it, and looks for a wildcard. It works perfectly when Terraform already knows the full policy at plan time.&lt;/p&gt;

&lt;p&gt;Real policies are rarely that simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy Terraform can't see yet
&lt;/h2&gt;

&lt;p&gt;Most IAM policies point at resources you're creating in the same apply. To test this, I wrote a small Terraform config with a DynamoDB table, a Lambda role, and this policy:&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_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"lambda_dynamodb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
      &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_dynamodb_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a fresh deploy, the table doesn't exist, so its ARN doesn't exist either. Terraform can't build the final JSON string, so it marks the whole policy as unknown:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9myhbobw9j2vu4ecnwa2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9myhbobw9j2vu4ecnwa2.png" alt="Terraform plan output for aws_iam_role_policy.lambda_dynamodb, with the policy, id, role, and name_prefix fields all shown as " width="711" height="194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the plan JSON, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"after"&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;"lambda_dynamodb"&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="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"after_unknown"&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;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="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;h2&gt;
  
  
  What OPA does with nothing
&lt;/h2&gt;

&lt;p&gt;My rule reads &lt;code&gt;resource.change.after.policy&lt;/code&gt;, and that field isn't there. In Rego, a missing field isn't an error. It's undefined, and an undefined line means the rule body doesn't match.&lt;/p&gt;

&lt;p&gt;So the deny rule doesn't fire. No deny means no violation. No violation means Conftest exits 0.&lt;/p&gt;

&lt;p&gt;Here's the real run. The wildcard is right there on line 55 of main.tf, Terraform confirms the policy is unknown, and my rules pass it anyway:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9c0odz1uwszzl72gfnt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9c0odz1uwszzl72gfnt.png" alt="Terminal showing three commands. grep finds Action = " width="798" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Honestly, my first reaction was confusion. The wildcard was right there in the code, and the check was green. It took me a while to realise the rule hadn't approved the policy. It had never seen it.&lt;/p&gt;

&lt;p&gt;The check didn't say yes. It said nothing, and my pipeline would have treated silence as a yes. In my pipeline, the apply job only runs after a green Conftest step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the dangerous one
&lt;/h2&gt;

&lt;p&gt;A missed edge case like &lt;code&gt;"s3:*"&lt;/code&gt; is a gap you can see in the rule. My own rule has that gap too. But this one is worse, because it hides in the input. The rule is correct. The data it needs just isn't there, and a deny-only policy fails open by design: it only blocks what it can prove is bad.&lt;/p&gt;

&lt;p&gt;In security terms, that's backwards. If a gate can't evaluate something, it should stop it, not wave it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: fail closed
&lt;/h2&gt;

&lt;p&gt;I added a rule that denies any IAM policy Terraform can't resolve at plan time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;iam_policy_resources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_excluded_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;after_unknown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"IAM policy '%s' can't be evaluated at plan time (known after apply): failing closed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;iam_policy_resources&lt;/code&gt; and &lt;code&gt;is_excluded_policy&lt;/code&gt; are helpers already in my policy file. The full file is in the repo.&lt;/p&gt;

&lt;p&gt;Same plan, new rule:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fje1byv7rz2cinihvncni.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fje1byv7rz2cinihvncni.png" alt="Conftest with the new policies fails: " width="800" height="83"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now an unreadable policy is a failed check, not a silent pass. In CI, that failure stops the pipeline before apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I turned it on in my own project
&lt;/h2&gt;

&lt;p&gt;This is where it got real. I ran the new rule against the Terraform for my final-year project, and it failed on two of my own IAM policies. Both pointed at resources created in the same apply, a DynamoDB table and a Cognito user pool. My old rules had been passing them without reading them.&lt;/p&gt;

&lt;p&gt;So the fail-closed rule did its job. It also meant my deploys would fail until I fixed the policies. There were two ways to deal with that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Split the apply.&lt;/strong&gt; Create the table first, then the policy on the next run, when the ARN is known.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the ARN from values Terraform already knows&lt;/strong&gt;, like the account ID, region, and a fixed table name, so the policy resolves at plan time.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with the second. It keeps a single apply, and because the policy is readable at plan time, my wildcard rule can check it again. The fail-closed rule stays as a safety net for anything that still can't be resolved.&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;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_caller_identity"&lt;/span&gt; &lt;span class="s2"&gt;"current"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_region"&lt;/span&gt; &lt;span class="s2"&gt;"current"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"lambda_dynamodb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambda_execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:GetItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"dynamodb:PutItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"dynamodb:UpdateItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"dynamodb:Query"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:dynamodb:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:table/${var.transactions_table_name}"&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table and the policy both get their name from one shared local, so they can't drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this trick stops working
&lt;/h2&gt;

&lt;p&gt;Building ARNs only works for names you control. Cognito was the exception: AWS generates the user pool ID when it creates the pool, so there's no way to write the exact ARN at plan time.&lt;/p&gt;

&lt;p&gt;For that one, I scoped the policy to user pools in my own account and region, with read-only actions:&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="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:cognito-idp:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:userpool/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's broader than one pool, but much narrower than &lt;code&gt;"*"&lt;/code&gt;. I chose that over adding an exception to the fail-closed rule, because exceptions are how silent passes come back.&lt;/p&gt;

&lt;p&gt;After the fix, no IAM policy in the plan is unknown, and the pipeline goes green for the right reason this time:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0c8b81ndxr58dg55wfwf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0c8b81ndxr58dg55wfwf.png" alt="GitHub Actions run where the Terraform Plan + OPA Validation job passed, the Conftest step shows 11 tests, 11 passed, 0 failures, and Terraform Apply is skipped." width="799" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;Every policy-as-code rule makes an assumption about its input. Mine assumed the policy would be there.&lt;/p&gt;

&lt;p&gt;When you write a deny rule, ask one more question: what happens when the field I'm reading is missing? If the answer is "the rule doesn't fire," you've built a gate that fails open.&lt;/p&gt;

&lt;p&gt;Test your gates with bad data. Then test them with no data.&lt;/p&gt;

&lt;p&gt;Full code, including the fail-closed rule: &lt;a href="https://github.com/vicGrey/aws-serverless-zero-trust-governance" rel="noopener noreferrer"&gt;github.com/vicGrey/aws-serverless-zero-trust-governance&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>security</category>
      <category>terraform</category>
    </item>
    <item>
      <title>I Built an Offline AWS Auditor. It Caught My Mistakes, Then Made Its Own.</title>
      <dc:creator>Victor Okoroafor</dc:creator>
      <pubDate>Sun, 18 Oct 2026 22:11:09 +0000</pubDate>
      <link>https://dev.to/okoroaforvic/i-built-an-offline-aws-auditor-it-caught-my-mistakes-then-made-its-own-2j25</link>
      <guid>https://dev.to/okoroaforvic/i-built-an-offline-aws-auditor-it-caught-my-mistakes-then-made-its-own-2j25</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My first user (it was me)
&lt;/h2&gt;

&lt;p&gt;My final-year project was a serverless fintech backend on AWS: 48 Terraform resources and 10 IAM policies, all written by me, a cybersecurity student who thought he knew what he was doing.&lt;/p&gt;

&lt;p&gt;I was the target user. A small team, no security engineer, permissions written under deadline. So before offering this to anyone else, I ran ZeroLeak on my own plan with the Wi-Fi off. It found things I'd missed, and it handed me two "fixes" that would have broken my own app. More on that below.&lt;/p&gt;

&lt;p&gt;Would I pay for it? Not for the tool, which is free and open source. I'd pay for the reviewed report, because I know exactly how confidently wrong my own tool can be. I only know that because I was the customer first.&lt;/p&gt;

&lt;p&gt;The people I actually want to help look a lot like the person who wrote that project. Meet Ada. She's the CTO of a six-person fintech startup. (She's made up, but if you've worked at a startup, you've met her.) Her team ships on AWS fast, and nobody's job is security. She knows their permissions are messy, and she can't say which role is the dangerous one.&lt;/p&gt;

&lt;p&gt;She has three options: hire a security firm she can't afford, paste her AWS setup into a cloud chatbot and hope nobody minds, or ignore it until something breaks.&lt;/p&gt;

&lt;p&gt;I just graduated in cybersecurity, and I want to be her fourth option. This post is about the tool I built to make that offer, and about the three times it told me everything was fine when it wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Picture an AWS account as an office building. IAM policies are the keycards: they decide who can open which doors. Most cloud breaches don't involve anyone breaking a door down. Someone simply holds a keycard that opens far more than it should. In a policy, a &lt;code&gt;*&lt;/code&gt; is a master key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZeroLeak AI&lt;/strong&gt; reviews those keycards entirely on a laptop. No cloud AI, no API keys, and no internet once it's set up. You give it one policy, or a whole Terraform plan (the blueprint a team uses to build their AWS setup), and two very different reviewers look at it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A security guard with a checklist.&lt;/strong&gt; Conftest runs Rego rules (OPA) and checks for known-bad patterns in about 20 milliseconds. It's strict, fast, and impossible to sweet-talk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A detective.&lt;/strong&gt; Gemma 2 2B, an open AI model running locally through Ollama, explains the risks in plain English and suggests a tighter policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The guard then re-checks the detective's suggestion. The report lists exactly which permissions the fix removed, and everything exports to a PDF Ada can hand to her team or her investors.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tvyzphpwzvo7lm8qtk7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tvyzphpwzvo7lm8qtk7.png" alt="ZeroLeak app showing Test 04 marked FAILED (1) by the rule gate, with Gemma's explanation of the PassRole and Lambda escalation risk" width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Test 04 after codifying Rule 4: the guard now fails it, and Gemma explains why.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The side hustle
&lt;/h3&gt;

&lt;p&gt;Here's what I'd offer Ada:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;She sends me her IAM policy documents under an NDA. Not her whole Terraform plan, since plans can contain secrets.&lt;/li&gt;
&lt;li&gt;I run them through ZeroLeak on my own laptop, check every finding by hand, and send back a PDF report with proposed fixes.&lt;/li&gt;
&lt;li&gt;No AI company ever sees her infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My pilot price is &lt;strong&gt;$100 for up to 10 policies&lt;/strong&gt;, with the first few reviews discounted in exchange for honest feedback. I don't have a paying client yet. This post is part of finding the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/kElt8JVX_7k" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The first &lt;code&gt;docker compose up&lt;/code&gt; needs internet once to download the app and the model. After that, the whole audit, PDF included, runs with the network off. Telemetry is disabled (&lt;code&gt;gatherUsageStats = false&lt;/code&gt;). You can test the privacy claim yourself by switching off your Wi-Fi.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frlz1b8ebwz7ev0ivdj1e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frlz1b8ebwz7ev0ivdj1e.png" alt="First page of the ZeroLeak PDF report for Test 04, showing RULE GATE: FAILED (1 remaining)" width="653" height="846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Page one of a Test 04 report. On this run the gate refused Gemma's fix, and the report says so up front (more on why below).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vicGrey" rel="noopener noreferrer"&gt;
        vicGrey
      &lt;/a&gt; / &lt;a href="https://github.com/vicGrey/zeroleak" rel="noopener noreferrer"&gt;
        zeroleak
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;a rel="noopener noreferrer" href="https://github.com/vicGrey/zeroleak/assets/banner.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FvicGrey%2Fzeroleak%2FHEAD%2Fassets%2Fbanner.svg" alt="ZeroLeak AI: Offline AWS IAM Policy Auditor" width="100%"&gt;&lt;/a&gt;
&lt;br&gt;&lt;br&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🛡️ ZeroLeak AI&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Offline AWS IAM Policy Auditor&lt;/h3&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A deterministic rule gate (OPA / Conftest Rego) plus a local open-weight model (Google Gemma 2 2B)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/vicGrey/zeroleak/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/08cef40a9105b6526ca22088bc514fbfdbc9aac1ddbf8d4e6c750e3a88a44dca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d626c75652e737667" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://ai.google.dev/gemma" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/8ffd14c3f8db53c560a1c63fe7217e48ef04c9a4b05ac30d9158d4347a64f163/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4d6f64656c2d476f6f676c6525323047656d6d612532303225323032422d384534344144" alt="Model"&gt;&lt;/a&gt;
&lt;a href="https://www.conftest.dev/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/df9054678d21d468cf9b5c5d8fad1d91ee90de62a66b399953e98b41507a16b6/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f506f6c696379253230456e67696e652d436f6e66746573742532302532462532304f50412532305265676f2d303041444438" alt="Policy Gate"&gt;&lt;/a&gt;
&lt;a href="https://github.com/vicGrey/zeroleak/docs/ARCHITECTURE_PLAN.md" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fba34158fc9f4859266e7740f712d25839385411f26de904449f0e3298005fe3/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507269766163792d4f66666c696e652532302532362532305a65726f25323054656c656d657472792d73756363657373" alt="Privacy"&gt;&lt;/a&gt;
&lt;a href="https://github.com/vicGrey/zeroleak/docker-compose.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/d8e463b68a3e6e0ca3324e7315c2c97fcd8dade8f437ed58f3957e9d962ac1e1/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4465706c6f796d656e742d446f636b6572253230253236253230436f6d706f73652d323439364544" alt="Docker"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://python.org/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b43cbee196e104f1912e1e1f08745aac72ee904fe95aa463d7b246cc2ccfe691/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507974686f6e2d332e31302b2d3337373641423f7374796c653d666c61742d737175617265266c6f676f3d707974686f6e266c6f676f436f6c6f723d7768697465" alt="Python"&gt;&lt;/a&gt;
&lt;a href="https://ollama.com/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/488244c9dcbccf18bc5d9029d471ecbc688a0ea68e2648af2815cdcc8ce4bb37/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4f6c6c616d612d6c6f63616c25323072756e74696d652d3030303030303f7374796c653d666c61742d737175617265266c6f676f3d6f6c6c616d61266c6f676f436f6c6f723d7768697465" alt="Ollama"&gt;&lt;/a&gt;
&lt;a href="https://streamlit.io/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/f43785391810ec9f1a7ffa1f85671260dc3b8427cd0cbd8fbaf59da0b50d8b20/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53747265616d6c69742d55492d4646344234423f7374796c653d666c61742d737175617265266c6f676f3d73747265616d6c6974266c6f676f436f6c6f723d7768697465" alt="Streamlit"&gt;&lt;/a&gt;
&lt;a href="https://terraform.io/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3a9c8267588e7802ea8929927bed02dccdd1a56d32e95c5ebb6a290c5f9f2ac1/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5465727261666f726d2d706c616e25323061756469742d3834344642413f7374796c653d666c61742d737175617265266c6f676f3d7465727261666f726d266c6f676f436f6c6f723d7768697465" alt="Terraform"&gt;&lt;/a&gt;
&lt;a href="https://aws.amazon.com/iam/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/1535b0131a3fe8baabaa636a04f1fad75012ee244f652100d6ad05d0bb1ff180/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4157532d49414d2d4646393930303f7374796c653d666c61742d737175617265266c6f676f3d616d617a6f6e617773266c6f676f436f6c6f723d7768697465" alt="AWS IAM"&gt;&lt;/a&gt;
&lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12" rel="nofollow"&gt;&lt;img src="https://camo.githubusercontent.com/5d35ad2f6794506bc8ff26b92dfe4a5febf549c9c722945522a64fafd97c53fa/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4445562532304861636b746f626572666573742d323032362d4646384630303f7374796c653d666c61742d737175617265266c6f676f3d646576646f74746f266c6f676f436f6c6f723d7768697465" alt="Hacktoberfest"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;br&gt;
&lt;p&gt;&lt;strong&gt;🔒 No cloud endpoints. No API tokens. No vendor DPA. $0 cloud spend.&lt;/strong&gt;&lt;/p&gt;
&lt;br&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;tbody&gt;
&lt;tr&gt;
    &lt;td&gt;
&lt;b&gt;~20 ms&lt;/b&gt;&lt;br&gt;rule gate per policy
&lt;/td&gt;
    &lt;td&gt;
&lt;b&gt;52.2 s&lt;/b&gt;&lt;br&gt;mean Gemma 2 2B on CPU
&lt;/td&gt;
    &lt;td&gt;
&lt;b&gt;0 bytes&lt;/b&gt;&lt;br&gt;leave the machine
&lt;/td&gt;
    &lt;td&gt;
&lt;b&gt;10&lt;/b&gt;&lt;br&gt;hand-crafted benchmark policies
&lt;/td&gt;
    &lt;td&gt;
&lt;b&gt;$0&lt;/b&gt;&lt;br&gt;cloud spend
&lt;/td&gt;
  &lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;br&gt;
&lt;p&gt;&lt;a href="https://github.com/vicGrey/zeroleak#-5-stage-pipeline-architecture" rel="noopener noreferrer"&gt;Architecture&lt;/a&gt; • &lt;a href="https://github.com/vicGrey/zeroleak#-the-discovery-loop-breaking-the-circular-verification-trap" rel="noopener noreferrer"&gt;Discovery Loop&lt;/a&gt; • &lt;a href="https://github.com/vicGrey/zeroleak#-benchmark-10-hand-crafted-policies" rel="noopener noreferrer"&gt;Benchmark&lt;/a&gt; • &lt;a href="https://github.com/vicGrey/zeroleak#-case-study-a-real-terraform-plan" rel="noopener noreferrer"&gt;Real Plan&lt;/a&gt; • &lt;a href="https://github.com/vicGrey/zeroleak#-quickstart" rel="noopener noreferrer"&gt;Quickstart&lt;/a&gt; • &lt;a href="https://github.com/vicGrey/zeroleak#%EF%B8%8F-the-wi-fi-off-test" rel="noopener noreferrer"&gt;Wi-Fi Off Test&lt;/a&gt; • &lt;a href="https://github.com/vicGrey/zeroleak#%EF%B8%8F-engineering-integrity-and-boundaries" rel="noopener noreferrer"&gt;Limits&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;💡 The problem: cloud LLMs send your infrastructure secrets off-machine&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;IAM policies are a map of an organization's infrastructure: account IDs, role naming conventions, ARN hierarchies, bucket names. Auditing them with a cloud LLM API means sending that map to a third party.&lt;/p&gt;
&lt;p&gt;For freelance cloud security consultants and DevSecOps contractors working under NDAs, and for regulated teams (HIPAA, PCI-DSS, SOC 2), that's often not allowed without a signed…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vicGrey/zeroleak" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/vicGrey/zeroleak.git
&lt;span class="nb"&gt;cd &lt;/span&gt;zeroleak
docker compose up   &lt;span class="c"&gt;# then open http://localhost:8501&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT licensed. The baseline wildcard rules are adapted from my final-year project, &lt;a href="https://github.com/vicGrey/aws-serverless-zero-trust-governance" rel="noopener noreferrer"&gt;aws-serverless-zero-trust-governance&lt;/a&gt;. Everything else (the Gemma pipeline, the combination rule, the Terraform plan parser, and the reports) was built during the challenge week.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;p&gt;Gemma 2 2B runs locally in Ollama at temperature 0.1 (low randomness, so it gives near-identical answers on every run). Each policy gets one structured prompt that returns both the explanation and the fixed JSON, and a sanitizer repairs the JSON when the model cuts off on a long policy. Conftest/OPA runs the Rego v1 rules, Streamlit is the interface, WeasyPrint makes the PDFs, and Docker Compose runs the app next to an Ollama container that downloads &lt;code&gt;gemma2:2b&lt;/code&gt; on first start. A small parser pulls every IAM policy out of &lt;code&gt;terraform show -json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I picked the 2B model on purpose. It's a ~1.6 GB download that runs in 3 to 4 GB of RAM on an ordinary laptop, and that's the kind of machine a freelancer actually owns. The cost is speed: &lt;strong&gt;52.2 seconds per policy on average (39.0s to 67.3s)&lt;/strong&gt; on a CPU. Newer and bigger Gemma releases exist. The model is one tag in Ollama, so you can swap it, but I haven't tested a larger one here.&lt;/p&gt;

&lt;p&gt;I tested it on 10 policies I wrote, each targeting a known IAM mistake:&lt;/p&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;What the policy does wrong&lt;/th&gt;
&lt;th&gt;Guard (rules)&lt;/th&gt;
&lt;th&gt;Detective (Gemma)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;01&lt;/td&gt;
&lt;td&gt;Nothing (clean control)&lt;/td&gt;
&lt;td&gt;0, correct&lt;/td&gt;
&lt;td&gt;2, false positive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02&lt;/td&gt;
&lt;td&gt;Wildcard action&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03&lt;/td&gt;
&lt;td&gt;Wildcard resource&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;04&lt;/td&gt;
&lt;td&gt;PassRole + Lambda escalation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0, missed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1, caught&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;05&lt;/td&gt;
&lt;td&gt;Over-broad DynamoDB access&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;06&lt;/td&gt;
&lt;td&gt;Same risk, different JSON format&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;07&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NotAction&lt;/code&gt; loophole&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08&lt;/td&gt;
&lt;td&gt;Wildcard &lt;code&gt;sts:AssumeRole&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09&lt;/td&gt;
&lt;td&gt;EC2 instance-profile escalation&lt;/td&gt;
&lt;td&gt;1 (wildcard only)&lt;/td&gt;
&lt;td&gt;2 (incl. escalation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Prompt injection hidden in a label&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;resisted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The guard caught almost everything, in milliseconds. The detective earned its 52 seconds on the two rows where the danger was a &lt;em&gt;combination&lt;/em&gt; of permissions the guard had no rule for (and gave me my first false positive on row 01, flagging standard logging as a hazard).&lt;/p&gt;

&lt;p&gt;Getting to a table I could trust was the hard part.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three green checkmarks I couldn't trust
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. The guard wasn't looking.&lt;/strong&gt; Test 04 lets someone create a Lambda function and hand it a more powerful role than their own. My first pipeline said: original policy, 0 violations; Gemma flags the danger and writes a fix; fixed policy, 0 violations. Two green checkmarks, "Remediated and Verified."&lt;/p&gt;

&lt;p&gt;But the guard's checklist only covered master keys. It had no line for this combination, so it waved through the dangerous original &lt;em&gt;and&lt;/em&gt; the fix. A broken fix would have passed just the same. Emma, a friend who freelances on Upwork, pointed out that the verification was circular.&lt;/p&gt;

&lt;p&gt;The fix was a loop: when the detective finds something new, a human writes it into the guard's checklist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="ow"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rego&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;

&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ow"&gt;some&lt;/span&gt; &lt;span class="n"&gt;s1&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;get_statements&lt;/span&gt;
    &lt;span class="n"&gt;s1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Effect&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="n"&gt;has_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s1&lt;/span&gt;&lt;span class="p"&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="ow"&gt;some&lt;/span&gt; &lt;span class="n"&gt;s2&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;get_statements&lt;/span&gt;
    &lt;span class="n"&gt;s2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Effect&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="n"&gt;has_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"lambda:CreateFunction"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"[passrole_lambda_escalation] PassRole with lambda:CreateFunction allows privilege escalation."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# helpers in policies/iam_policy.rego&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the original fails (1 violation), and in my logged run the fix passed (0). The green check finally means something.&lt;/p&gt;

&lt;p&gt;It also works against me. On a later run, Gemma kept both actions but scoped them to a single function and a single role, &lt;code&gt;role/role-name&lt;/code&gt;, a placeholder ZeroLeak flags before deployment. The gate still refused it (RULE GATE: FAILED, 1 remaining), because my rule checks which actions are present, not which resources they touch. Whether that fix is safe depends on what &lt;code&gt;role-name&lt;/code&gt; can do. That's a call for a human, not for the rule or the model.&lt;/p&gt;

&lt;p&gt;Test 09 has the same gap until someone writes its rule too: nothing in my ruleset tests for PassRole + RunInstances, so any "verified" there only means "passed the wildcard rules."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. My own real project.&lt;/strong&gt; I ran ZeroLeak on that same plan, with the Wi-Fi off. It took &lt;strong&gt;102.29 seconds&lt;/strong&gt;. Of the 10 policies, 5 passed, 2 failed, and 3 couldn't be read at all.&lt;/p&gt;

&lt;p&gt;Those 3 pointed at things that don't exist until deployment (a bucket, a database table, a login pool), so they show up as "unknown" in a plan. A scanner that treats unknown as "nothing to flag" calls them clean. ZeroLeak now lists them as &lt;strong&gt;NOT EVALUATED (KNOWN AFTER APPLY)&lt;/strong&gt;, so the client gets a to-do list instead of false comfort.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0nkb46ok7ws89xkqtlqu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0nkb46ok7ws89xkqtlqu.png" alt="ZeroLeak plan audit summary: 48 resources, 10 IAM policies, 2 failed, 5 passed, 3 known after apply" width="799" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3r3k3ian17meg24tivhq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3r3k3ian17meg24tivhq.png" alt="Three orange rows marked NOT EVALUATED (KNOWN AFTER APPLY): an S3 bucket policy, a DynamoDB table, and a Cognito user pool" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;My final-year project's plan: 48 resources, 10 policies, 3 not evaluated at plan time. Runtime varies by machine and model state; this rerun took 88s, while the logged run took 102.29s.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The 2 failures were worse. Gemma rewrote both, the guard passed both rewrites, and both were wrong. One removed &lt;code&gt;logs:PutLogEvents&lt;/code&gt; from a logging role, the one permission it existed for. That's like fixing a cashier's keycard by taking away access to the till. The other scoped &lt;code&gt;config:PutEvaluations&lt;/code&gt; to a fake example ARN, but that action &lt;a href="https://docs.aws.amazon.com/service-authorization/latest/reference/list_config.html" rel="noopener noreferrer"&gt;doesn't support resource-level permissions&lt;/a&gt;, so &lt;code&gt;*&lt;/code&gt; was actually correct. The guard passed both because it only looked for a literal &lt;code&gt;*&lt;/code&gt;, and any other text satisfied it.&lt;/p&gt;

&lt;p&gt;The report badge used to say "RE-VERIFICATION: PASSED". It now says &lt;strong&gt;"RULE GATE: PASSED (Workload &amp;amp; ARN validity not checked)"&lt;/strong&gt;, which is the honest version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The final pressure test.&lt;/strong&gt; Three fresh policies, none used in development.&lt;/p&gt;

&lt;p&gt;First, Gemma flagged a policy combining &lt;code&gt;iam:PassRole&lt;/code&gt;, &lt;code&gt;iam:CreateAccessKey&lt;/code&gt; and &lt;code&gt;iam:PutUserPolicy&lt;/code&gt;. That one is more of a sanity check than a discovery: either of the last two is enough to escalate privileges on its own, and standard scanners like Cloudsplaining already flag them.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;arn:aws:s3:::*/*&lt;/code&gt; (every file in every bucket) slipped past the baseline guard because it isn't a bare &lt;code&gt;*&lt;/code&gt;, so the rules now catch wildcard ARNs too.&lt;/p&gt;

&lt;p&gt;Third, on a perfectly fine read-only policy, Gemma confidently added a Condition: &lt;code&gt;"s3:BucketOwner": "arn:aws:iam::123456789012:user/my-user"&lt;/code&gt;. In AWS S3, a valid key is &lt;code&gt;s3:ResourceAccount&lt;/code&gt; (which takes a 12-digit account ID, not an IAM user ARN), not &lt;code&gt;s3:BucketOwner&lt;/code&gt;. An IAM condition on a key AWS doesn't define never matches, so the Allow would silently fail and reads would be denied. The guard passed it because it only looked for wildcards.&lt;/p&gt;

&lt;p&gt;So ZeroLeak now shows "preserved intact" only when the guard and Gemma agree. When they don't, the report says &lt;strong&gt;Manual Review Recommended&lt;/strong&gt; and labels any fix as advisory (no copy-paste fix without manual verification).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;So, why would Ada pay a person? Because the tool can be confidently wrong in ways that look right. ZeroLeak does the private, tireless first pass, and a human who knows what each permission is for does the part that matters.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does open innovation matter?
&lt;/h2&gt;

&lt;p&gt;The prompt for this challenge asks why open innovation matters for what I built. Here's the specific answer, not the general one.&lt;/p&gt;

&lt;p&gt;For Ada, three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy she can verify.&lt;/strong&gt; "Your setup never leaves my laptop" is a promise a hosted AI can't keep, however good its privacy policy. With Gemma running locally, you can check it by unplugging the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A price that works.&lt;/strong&gt; Every audit costs nothing to run, with no per-request bill, so my only real cost is my own time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results anyone can check.&lt;/strong&gt; Anyone can download the same open model, run my ten benchmark policies and three pressure tests from the repo, and see if they get my results, with no account and no API key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A big hosted model would probably be faster and smarter than a 2B model on a laptop. I'm trading that for privacy and cost, and for a rule gate that doesn't depend on the model being right.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell Ada before she pays me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A pass means "passed my rules," not "safe."&lt;/strong&gt; Zero violations is zero violations of the rules I wrote. My PassRole rule is deliberately broad and would also flag a properly scoped deployer role. A production rule should check &lt;code&gt;Resource&lt;/code&gt; and require an &lt;code&gt;iam:PassedToService&lt;/code&gt; condition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The detective cries wolf.&lt;/strong&gt; Small open-weight models have variance. In my benchmark run, Gemma flagged standard CloudWatch logging combined with S3 reads as two phantom combination hazards on my clean control policy (row 01). That is my first measured false positive, and it's why every flag gets human review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It reads one policy at a time.&lt;/strong&gt; Real attacks often chain several policies or roles together. A graph tool like PMapper maps those paths, and ZeroLeak complements it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast mode skips passing policies.&lt;/strong&gt; To keep big plans quick, Gemma only reviews policies that fail the guard, so a hidden combination like Test 04 needs single-policy mode or a rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The evidence is small.&lt;/strong&gt; Ten test policies, one real plan, and three pressure tests show promise, not a proven detection rate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I started this project thinking the AI was the product. I'm finishing it knowing the product is the judgment: knowing when a green checkmark was actually looking for the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best use of Gemma:&lt;/strong&gt; Gemma 2 2B runs fully locally through Ollama as the detective in the pipeline. It flagged two risky permission combinations (tests 04 and 09) that the rule gate had no rule for. Every audit runs behind a deterministic rule gate, and because the model runs locally, no client data leaves the machine.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Block Over-Privileged IAM Roles in AWS Using Policy-as-Code</title>
      <dc:creator>Victor Okoroafor</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:29:55 +0000</pubDate>
      <link>https://dev.to/okoroaforvic/how-to-block-over-privileged-iam-roles-in-aws-using-policy-as-code-1k8</link>
      <guid>https://dev.to/okoroaforvic/how-to-block-over-privileged-iam-roles-in-aws-using-policy-as-code-1k8</guid>
      <description>&lt;p&gt;Chances are, your AWS account has an over-privileged IAM role right now, and you may not know which one.&lt;/p&gt;

&lt;p&gt;When you build infrastructure, you write Terraform that declares your resources and assigns IAM roles to them. IAM controls who can do what in your account, so the principle of least privilege has to apply: an entity should get only the permissions it needs to do its job.&lt;/p&gt;

&lt;p&gt;That rule gets broken constantly, and the usual shortcut is a wildcard:&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;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"Action": "*"&lt;/code&gt; grants a function every permission it needs, plus every permission it doesn't. If that function is ever compromised, the attacker inherits everything that role can reach. One line in a policy file becomes your blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the check goes
&lt;/h2&gt;

&lt;p&gt;You can run &lt;code&gt;terraform apply&lt;/code&gt; from your laptop, but most teams run it through a CI/CD pipeline. A pipeline is a list of commands, and each one finishes with an exit code. Exit code 0 means the step passed and the pipeline continues. Any other exit code means the step failed and the pipeline stops.&lt;/p&gt;

&lt;p&gt;If you can make a security check fail with a non-zero exit code, you can halt the deployment. That's the whole idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy
&lt;/h2&gt;

&lt;p&gt;To block over-privileged roles, you write a policy in Rego. OPA, the policy engine, evaluates your Rego against an input document, which here is your Terraform plan converted to JSON. If your infrastructure code contains a wildcard where it shouldn't, OPA flags it.&lt;/p&gt;

&lt;p&gt;Here's the rule that does the work, written in OPA v1 syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="ow"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;
&lt;span class="ow"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rego&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;

&lt;span class="n"&gt;iam_policy_resources&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Does this field contain a wildcard? Handles a single string or a list.&lt;/span&gt;
&lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;iam_policy_resources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Statement&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"IAM policy '%s' contains wildcard (*) action: violates least privilege"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&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;A second rule does the same check for &lt;code&gt;Resource&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that bit me
&lt;/h2&gt;

&lt;p&gt;My first version of &lt;code&gt;has_wildcard&lt;/code&gt; only had the string check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="c1"&gt;# my first version: only catches Action = "*"&lt;/span&gt;
&lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It caught &lt;code&gt;"Action": "*"&lt;/code&gt;. It missed &lt;code&gt;"Action": ["s3:GetObject", "*"]&lt;/code&gt;, which grants the same thing written differently. My policy let it through.&lt;/p&gt;

&lt;p&gt;The fix is the second &lt;code&gt;has_wildcard&lt;/code&gt; definition above. That &lt;code&gt;field[_]&lt;/code&gt; walks every element in the array instead of treating the array as a single value, so a wildcard sitting anywhere inside the list gets caught.&lt;/p&gt;

&lt;p&gt;That second definition is the difference between a policy that works and a policy that looks like it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a decision into a failed pipeline
&lt;/h2&gt;

&lt;p&gt;OPA only produces a decision. It doesn't decide whether your pipeline lives or dies.&lt;/p&gt;

&lt;p&gt;That's where Conftest comes in. Conftest runs on top of OPA and prints pass or fail the way a normal software test does.&lt;/p&gt;

&lt;p&gt;When Conftest finds a deny violation, it exits with code 1, and the GitHub runner stops. Here are the steps that matter in my workflow:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terraform Plan&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./terraform&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform plan -out=tfplan&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;Convert Plan to JSON&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./terraform&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform show -json tfplan &amp;gt; tfplan.json&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OPA Policy Validation (Conftest)&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./terraform&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;conftest test tfplan.json --policy ../policies/ --all-namespaces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deploy job is a separate job that waits for this one:&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;terraform-apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-plan-and-validate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the validation job fails, the apply job never runs. &lt;code&gt;terraform plan&lt;/code&gt; reads from AWS to work out what would change, but nothing gets created or modified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7zm2srtjxf88gsse457.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7zm2srtjxf88gsse457.png" alt="GitHub Actions run showing the OPA Validation job failing. Conftest reports that IAM policy 'lambda_dynamodb' contains a wildcard action, violating least privilege. The step exits with code 1, and the Terraform Apply job is greyed out because it never ran." width="799" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Conftest failed the build. Exit code 1. Nothing was deployed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;GitHub Actions emails you when the job fails by default, and you can wire it to Slack with a webhook if that's where your team lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;I tested the policy against a deliberately over-privileged IAM role, one with &lt;code&gt;"Action": "*"&lt;/code&gt; in its policy document. The build failed, and the deployment never ran.&lt;/p&gt;

&lt;p&gt;The validation step added about 7.3 seconds to the pipeline. That's the cost of catching a wildcard before it reaches your account, and against a pipeline that already ran for over a minute, it was noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't catch
&lt;/h2&gt;

&lt;p&gt;The rule matches a bare &lt;code&gt;"*"&lt;/code&gt; only. A service-level wildcard like &lt;code&gt;"s3:*"&lt;/code&gt; still gets through, and catching it would need a pattern match rather than an exact string check.&lt;/p&gt;

&lt;p&gt;It also only guards what goes through the pipeline. A change made directly in the console bypasses it, which is why my project also uses AWS Config to catch configuration drift after deployment.&lt;/p&gt;

&lt;p&gt;My repo also excludes two platform policies, because AWS requires a wildcard resource for them. Real pipelines usually need a small exception list like that.&lt;/p&gt;

&lt;p&gt;There's a bigger failure mode I found later, one where the check passes when it shouldn't because it can't read the policy at all. I wrote about it here: &lt;a href="https://victorokoroafor.hashnode.dev/my-policy-as-code-check-passed-it-never-read-the-policy" rel="noopener noreferrer"&gt;My Policy-as-Code Check Passed. It Never Read the Policy.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd rather tell you where the gaps are than pretend the policy is complete. A rule you trust too much is worse than no rule at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;A bare wildcard in a policy that Terraform can fully resolve at plan time never reaches AWS through this pipeline. The pipeline decides, and the pipeline says no.&lt;/p&gt;

&lt;p&gt;That's the common case. It isn't every case, and &lt;a href="https://victorokoroafor.hashnode.dev/my-policy-as-code-check-passed-it-never-read-the-policy" rel="noopener noreferrer"&gt;the exception gets its own post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full implementation, with the Terraform, the Rego policies, and the GitHub Actions workflow, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/vicGrey/aws-serverless-zero-trust-governance" rel="noopener noreferrer"&gt;github.com/vicGrey/aws-serverless-zero-trust-governance&lt;/a&gt;&lt;/p&gt;

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