AI Terraform Security Auditor

Build a web app that scans Terraform code for security issues using Claude AI.

Introduction

30 Second Summary

Infrastructure as Code means your entire cloud security posture lives in text files. A single misconfiguration can expose databases to the internet or grant admin access to every AWS service.

In this project, you will build a web application that scans Terraform configurations for security vulnerabilities. A deterministic rule engine catches real misconfigurations, and Claude AI enriches each finding with risk explanations and secure code fixes.

What You'll Build

Paste a Terraform file into your scanner, click Scan, and watch prioritized security findings appear with exact source lines highlighted, plain-English risk explanations, and drop-in secure replacements you can copy straight into your codebase.

By the end of this project, you'll have:

  • A deterministic rule engine that detects 7 categories of Terraform misconfigurations (public S3 buckets, IAM wildcards, open security groups, missing encryption, public databases, unencrypted volumes, and hardcoded secrets) with exact line numbers and evidence strings.
  • Claude AI enrichment that scores overall risk on a 1-10 scale, ranks findings by exploitability, explains each one in plain English, and generates minimal secure Terraform fixes using structured outputs.
  • A full-stack Next.js scanner UI where you paste Terraform, see findings rendered as severity-badged cards with source evidence, and expand each one to view the secure fix code.
  • Secret Mission: Build a "Fix All" feature that generates a complete hardened Terraform file and shows a before/after diff with per-change security explanations.

Are there any prerequisites?

You need Node.js 22+ installed, an Anthropic API key, and some familiarity with Terraform HCL syntax and AWS security concepts (S3 buckets, IAM policies, security groups). No prior experience with AI SDKs is required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Scaffold the Next.js Project

Your security auditor needs a foundation: a running TypeScript web app with the Anthropic SDK wired up for Claude calls and Zod for validating structured responses.

In this step, you will scaffold a Next.js project with TypeScript and the App Router, install your dependencies, and lay out the file structure the scanner will use in later steps.

In this step, get ready to:
  • Create a Next.js TypeScript project and install dependencies.
  • Configure the Anthropic API key as an environment variable.
  • Set up the project file structure with shared types.
Create the Next.js project and install dependencies
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Next.js project by running this command:
npx create-next-app@latest terraform-auditor --typescript --tailwind --eslint --app --use-npm

What does this command do?

The flags answer the interactive setup prompts non-interactively so the scaffold is fully reproducible.

  • --typescript enables TypeScript with a pre-configured tsconfig.json.
  • --tailwind installs Tailwind CSS for styling.
  • --eslint adds linting with Next.js-specific rules.
  • --app uses the App Router (the recommended routing system).
  • --use-npm ensures npm is the package manager.

What about the remaining prompts?

The CLI may ask about a few additional options. When prompted:

  • src/ directory: select No (the project uses app/ and lib/ at the root).
  • Import alias: keep the default @/*.
  • For any other prompts (React Compiler, AGENTS.md), select No.
  • Move into the project folder by running:
cd terraform-auditor
  • Install the Anthropic SDK and Zod by running:
npm install @anthropic-ai/sdk zod

What are these packages?

  • @anthropic-ai/sdk is the official TypeScript client for calling Claude models.
  • zod is a schema validation library that the SDK uses to enforce structured output shapes from Claude.
  • Confirm both packages installed by running:
cat package.json | grep -E "anthropic|zod"

You should see both @anthropic-ai/sdk and zod listed under dependencies in the output.

Don't see one of the packages?

  • Make sure you are inside the terraform-auditor folder before running npm install.
  • Check your internet connection. Both packages download from the npm registry.
  • help me troubleshoot my npm install
Configure your Anthropic API key

The Anthropic SDK automatically reads your API key from an environment variable called ANTHROPIC_API_KEY. Next.js loads variables from a .env.local file at the project root.

  • Create the environment file by running:
echo 'ANTHROPIC_API_KEY=your-api-key-here' > .env.local
  • Open .env.local in your editor and replace your-api-key-here with your actual Anthropic API key.
  • Save the file.

Keep your key safe

The .env.local file is already included in .gitignore by default in Next.js projects. Your key will never be committed to version control.

  • Confirm the file exists by running:
ls -a | grep .env

You should see .env.local in the output.

Create the project file structure and define types

The scanner is split into four modules: a parser, a rule engine, a Claude enrichment layer, and an API route. Each lives in its own file so you can build and test them independently.

  • Create the lib folder and stub files by running:
mkdir -p lib
touch lib/types.ts lib/parser.ts lib/rules.ts lib/claude.ts
  • Create the API route file by running:
mkdir -p app/api/scan
touch app/api/scan/route.ts
  • Confirm the structure exists by running:
ls lib app/api/scan

You should see types.ts, parser.ts, rules.ts, and claude.ts listed under lib, and route.ts under app/api/scan.

  • Add stub comments to the files that will be implemented later. Run:
echo '// stub' > lib/parser.ts
echo '// stub' > lib/rules.ts
echo '// stub' > lib/claude.ts
echo '// stub' > app/api/scan/route.ts

Now open lib/types.ts in your editor. This file defines the data shapes that every other module imports.

  • Paste the following type definitions into lib/types.ts:
export type Severity = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW";

export interface TerraformBlock {
  type: string;
  resourceType: string;
  name: string;
  attributes: string;
  startLine: number;
  endLine: number;
  rawSource: string;
}

What does this code do?

  • Severity limits findings to four priority levels the UI will color-code later.
  • TerraformBlock represents one parsed resource block from the HCL input. It tracks the resource type, name, raw source text, and exact start/end line numbers so findings can point at the offending line.
  • Below the TerraformBlock interface, add the Finding and EnrichedFinding types:
export interface Finding {
  ruleId: string;
  severity: Severity;
  resource: string;
  resourceType: string;
  message: string;
  line: number;
  evidence: string;
}

export interface EnrichedFinding extends Finding {
  priority: number;
  explanation: string;
  fix: string;
  isAiOnly: boolean;
}

What does this code do?

  • Finding is what the deterministic rule engine produces. Each finding has a unique ruleId, severity, the affected resource name, the exact line number, and the evidence string that triggered the match.
  • EnrichedFinding extends a finding with Claude's additions: a priority rank, a plain-English explanation, a secure Terraform fix, and a flag marking whether the suggestion came from AI alone.
  • Finally, add the AuditResult interface at the bottom of the file:
export interface AuditResult {
  findings: Finding[];
  enriched: EnrichedFinding[] | null;
  overallScore: number | null;
  summary: string | null;
  error: string | null;
}

What does this code do?

AuditResult is the shape the API returns to the frontend. It always contains the raw deterministic findings. The enriched fields (Claude's score, explanations, and fixes) are nullable so the scanner degrades gracefully if the AI call fails.

  • Save lib/types.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/types.ts and that the stub files exist.

ⓧ I'd like to double check the full code

Here is the complete lib/types.ts file:

export type Severity = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW";

export interface TerraformBlock {
  type: string;
  resourceType: string;
  name: string;
  attributes: string;
  startLine: number;
  endLine: number;
  rawSource: string;
}

export interface Finding {
  ruleId: string;
  severity: Severity;
  resource: string;
  resourceType: string;
  message: string;
  line: number;
  evidence: string;
}

export interface EnrichedFinding extends Finding {
  priority: number;
  explanation: string;
  fix: string;
  isAiOnly: boolean;
}

export interface AuditResult {
  findings: Finding[];
  enriched: EnrichedFinding[] | null;
  overallScore: number | null;
  summary: string | null;
  error: string | null;
}
Verify the dev server starts

Everything is wired up. Before you run the dev server, what do you expect to see at localhost:3000?

  • Start the development server by running:
npm run dev

Open your browser to http://localhost:3000. You should see the default Next.js starter page with the Vercel logo and links to documentation.

Don't see the starter page?

  • Check your terminal for error messages. A common issue is port 3000 already being in use. Stop any other process using that port, then run npm run dev again.
  • Make sure you are inside the terraform-auditor folder when running the command.
  • help me get my Next.js dev server running

Your scaffolding is done. You have a running Next.js app, the Anthropic SDK ready to call Claude, and type definitions that every module will import. Next up, you will build the HCL parser and your first security rule to produce a real finding.

Build the HCL Parser and First Security Rule

Your project scaffold is in place and the dev server is running. Now you need the core analysis engine: code that reads raw Terraform text and identifies security misconfigurations.

In this step, you will build a parser that extracts individual resource blocks from Terraform files, tracking exact line numbers for every match. Then you will pair it with your first detection rule to flag public S3 buckets. By the end, you will POST Terraform code to your API and see a real finding come back.

In this step, get ready to:
  • Build an HCL block parser that extracts resources with line numbers.
  • Write a security rule that detects public S3 bucket ACLs.
  • Wire the parser and rule into a POST API endpoint and test it.
Implement the HCL block parser

Terraform files use a block syntax with curly braces. Your parser needs to identify where each resource block starts and ends so that findings can point to the exact offending line.

  • Open lib/parser.ts in your editor and replace the stub comment with the following code:
import { TerraformBlock } from "./types";

// Parses raw Terraform text into structured blocks with line numbers
export function parseTerraformBlocks(source: string): TerraformBlock[] {
  const lines = source.split("\n");
  const blocks: TerraformBlock[] = [];
  let i = 0;

  while (i < lines.length) {
    const line = lines[i];
    // Match resource or data block opening lines
    const resourceMatch = line.match(
      /^\s*(resource|data)\s+"([^"]+)"\s+"([^"]+)"\s*\{/
    );

    if (resourceMatch) {
      const type = resourceMatch[1];
      const resourceType = resourceMatch[2];
      const name = resourceMatch[3];
      const startLine = i + 1;
      let braceDepth = 1;
      let j = i + 1;
  • Continue adding directly below the code you just pasted, still inside the same if (resourceMatch) block:
      // Track brace depth to find the block's closing brace
      while (j < lines.length && braceDepth > 0) {
        const current = lines[j];
        for (const char of current) {
          if (char === "{") braceDepth++;
          if (char === "}") braceDepth--;
        }
        j++;
      }

      const endLine = j;
      const blockLines = lines.slice(i, j);
      const rawSource = blockLines.join("\n");
      const attributes = blockLines.slice(1, -1).join("\n");

      blocks.push({
        type,
        resourceType,
        name,
        attributes,
        startLine,
        endLine,
        rawSource,
      });

      i = j;
    } else {
      i++;
    }
  }

  return blocks;
}

What does this code do?

The parser walks through every line of the input looking for lines that match Terraform's resource "type" "name" { pattern.

When it finds one, it counts opening and closing braces to locate the matching }. It records the raw source text and start/end line numbers so findings can point to exactly where the problem lives.

  • Save lib/parser.ts.
  • Check the terminal running npm run dev. You should see no TypeScript compilation errors.

Seeing a TypeScript error?

Make sure the import line at the top references "./types" with the dot-slash relative path. If you see a missing module error, confirm lib/types.ts exists and exports the TerraformBlock interface from Step 1.

Help me debug this TypeScript error

Now add the helper function that individual rules will use to locate suspicious lines within a parsed block.

  • Add the following function at the bottom of lib/parser.ts, below the closing brace of parseTerraformBlocks:
// Searches within a block for a line matching the given pattern
export function findLineInBlock(
  source: string,
  block: TerraformBlock,
  pattern: RegExp
): { line: number; evidence: string } | null {
  const lines = source.split("\n");
  for (let i = block.startLine - 1; i < block.endLine; i++) {
    const match = lines[i].match(pattern);
    if (match) {
      return { line: i + 1, evidence: lines[i].trim() };
    }
  }
  return null;
}

What does findLineInBlock do?

It narrows the search to just the lines within one resource block, tests each line against a regex, and returns the exact line number plus the trimmed evidence string. Rules call this helper instead of scanning the entire file.

  • Save lib/parser.ts.
  • Confirm the terminal still shows no errors.

✔️ Awesome, I've got everything!

Great. Your parser file is complete and ready to use.

ⓧ I'd like to double check the full code

Scroll up and confirm your lib/parser.ts contains these chunks in order:

  • The import { TerraformBlock } line at the top.
  • The parseTerraformBlocks function with the regex match, brace-depth while loop, and blocks.push(...) call.
  • The findLineInBlock function at the bottom.
Build the first security rule

The parser extracts blocks. Now you need a rule that inspects those blocks for insecure patterns. The first rule detects S3 buckets with public ACL settings.

  • Open lib/rules.ts and replace the stub with the following imports, type definition, and helper function:
import { TerraformBlock, Finding, Severity } from "./types";
import { findLineInBlock } from "./parser";

// Each rule takes a block and returns any findings
type Rule = (
  block: TerraformBlock,
  source: string,
  allBlocks: TerraformBlock[]
) => Finding[];

// Helper to construct a Finding with consistent structure
function createFinding(
  ruleId: string,
  severity: Severity,
  block: TerraformBlock,
  message: string,
  line: number,
  evidence: string
): Finding {
  return {
    ruleId,
    severity,
    resource: `${block.resourceType}.${block.name}`,
    resourceType: block.resourceType,
    message,
    line,
    evidence,
  };
}

What does createFinding do?

Every rule needs to produce findings with the same shape. The createFinding helper ensures consistency by building the resource identifier in type.name format and stamping the severity, line number, and evidence on every finding.

  • Continue adding below createFinding in lib/rules.ts. This is the detection rule for public S3 ACLs:
// Detects S3 buckets with public ACL settings
const s3PublicAccess: Rule = (block, source) => {
  const findings: Finding[] = [];
  if (block.resourceType !== "aws_s3_bucket") return findings;

  const aclMatch = findLineInBlock(
    source,
    block,
    /acl\s*=\s*"(public-read|public-read-write)"/
  );
  if (aclMatch) {
    findings.push(
      createFinding(
        "S3_PUBLIC_ACL",
        "CRITICAL",
        block,
        "S3 bucket has a public ACL allowing unrestricted read access",
        aclMatch.line,
        aclMatch.evidence
      )
    );
  }
  return findings;
};

How does the rule work?

The rule first short-circuits if the block is not an aws_s3_bucket resource. Then it uses findLineInBlock to search for acl = "public-read" or acl = "public-read-write". If found, it creates a CRITICAL finding with the exact line and evidence.

  • Save lib/rules.ts and confirm no TypeScript errors appear in your terminal.
  • Finally, add the rule registry and the runRules function below s3PublicAccess in lib/rules.ts:
// Registry of all active rules
const allRules: Rule[] = [
  s3PublicAccess,
];

// Runs every rule against every parsed block
export function runRules(
  blocks: TerraformBlock[],
  source: string
): Finding[] {
  const findings: Finding[] = [];
  for (const block of blocks) {
    for (const rule of allRules) {
      findings.push(...rule(block, source, blocks));
    }
  }
  return findings;
}

What does runRules do?

It loops through every parsed block and applies every registered rule. The allRules array makes it trivial to add more rules later. You will expand this array in the next step.

  • Save lib/rules.ts.
  • Confirm the terminal shows no errors.

✔️ Awesome, I've got everything!

Great. Your rule engine is ready to detect public S3 buckets.

ⓧ I'd like to double check the full code

Scroll up and confirm your lib/rules.ts contains these pieces in order:

  • Imports from "./types" and "./parser".
  • The Rule type definition.
  • The createFinding helper function.
  • The s3PublicAccess rule constant.
  • The allRules array and runRules export.
Wire the API route and test the scanner

The parser and rule are ready. Now connect them to a POST endpoint so you can send Terraform text and get findings back as JSON.

  • Open app/api/scan/route.ts and replace the stub with this route handler:
import { NextRequest, NextResponse } from "next/server";
import { parseTerraformBlocks } from "@/lib/parser";
import { runRules } from "@/lib/rules";
import { AuditResult } from "@/lib/types";

export async function POST(request: NextRequest) {
  const { terraform } = await request.json();

  // Validate that the request includes Terraform text
  if (!terraform || typeof terraform !== "string") {
    return NextResponse.json(
      { error: "Missing terraform field in request body" },
      { status: 400 }
    );
  }

  // Parse blocks and run deterministic rules
  const blocks = parseTerraformBlocks(terraform);
  const findings = runRules(blocks, terraform);

  const result: AuditResult = {
    findings,
    enriched: null,
    overallScore: null,
    summary: null,
    error: null,
  };

  return NextResponse.json(result);
}

What does this route do?

It accepts a POST request with a terraform field in the JSON body. It validates the input, parses the Terraform into blocks, runs all registered rules, and returns the findings as a JSON AuditResult.

The enriched and overallScore fields are null for now. You will fill those in when you add Claude in a later step.

  • Save app/api/scan/route.ts.

Before you run the test, what do you expect to see when you POST a Terraform snippet containing acl = "public-read" on line 3? Think about what ruleId and severity should appear in the response.

  • Test the endpoint by running this command in a new terminal:
curl -X POST http://localhost:3000/api/scan \
  -H "Content-Type: application/json" \
  -d '{"terraform": "resource \"aws_s3_bucket\" \"data\" {\n  bucket = \"my-bucket\"\n  acl    = \"public-read\"\n}"}'

You should see a JSON response containing a finding with ruleId set to S3_PUBLIC_ACL, severity CRITICAL, line set to 3, and the evidence string showing the matched ACL line.

Getting a 404 or empty findings array?

If you see a 404, make sure your file is at app/api/scan/route.ts (not pages/api). The App Router requires the app/ directory structure.

If you see {"findings":[]} with an empty array, double-check that the curl payload includes the \n newline characters so the parser sees multiple lines.

Help me debug my /api/scan endpoint

✔️ Awesome, I've got everything!

Great. Double check you have saved app/api/scan/route.ts.

ⓧ I'd like to double check the full code

Here is the complete app/api/scan/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { parseTerraformBlocks } from "@/lib/parser";
import { runRules } from "@/lib/rules";
import { AuditResult } from "@/lib/types";

export async function POST(request: NextRequest) {
  const { terraform } = await request.json();

  if (!terraform || typeof terraform !== "string") {
    return NextResponse.json(
      { error: "Missing terraform field in request body" },
      { status: 400 }
    );
  }

  const blocks = parseTerraformBlocks(terraform);
  const findings = runRules(blocks, terraform);

  const result: AuditResult = {
    findings,
    enriched: null,
    overallScore: null,
    summary: null,
    error: null,
  };

  return NextResponse.json(result);
}

Your scanner now has a working parse-and-detect pipeline. It takes raw Terraform text, finds resource blocks, and flags public S3 buckets with exact line numbers and evidence. Next up, you will expand the rule engine to cover six more vulnerability categories.

Complete the Deterministic Rule Engine

Your scanner can detect one vulnerability: public S3 buckets. A single rule proves the architecture works, but real-world IaC scanners like Snyk and Checkov cover dozens of attack surfaces.

In this step, you will add six more rule categories covering IAM wildcards, open security groups, missing encryption, public databases, unencrypted volumes, and hardcoded secrets. The output is still a flat, unprioritized list with no remediation guidance. That limitation is exactly what Claude will solve in the next step.

In this step, get ready to:
  • Add IAM wildcard and S3 public access block detection rules.
  • Add security group ingress, missing encryption, and public database rules.
  • Add hardcoded secrets and unencrypted EBS volume detection.
Add IAM wildcard and S3 public access block detection

IAM misconfigurations are the highest-risk finding category in AWS. A single wildcard action grants an attacker the ability to do anything in the account.

  • In lib/rules.ts, find the closing of the s3PublicAccess rule (the line return findings; followed by };).
  • Add the S3 public access block rule below it by pasting this code:
const s3PublicAccessBlock: Rule = (block, source) => {
  const findings: Finding[] = [];
  if (block.resourceType !== "aws_s3_bucket_public_access_block") return findings;
  // Check for disabled public access protections
  const patterns = [
    { pattern: /block_public_acls\s*=\s*false/, id: "S3_PUBLIC_ACCESS_BLOCK" },
    { pattern: /block_public_policy\s*=\s*false/, id: "S3_PUBLIC_ACCESS_BLOCK" },
    { pattern: /restrict_public_buckets\s*=\s*false/, id: "S3_PUBLIC_ACCESS_BLOCK" },
  ];
  for (const { pattern, id } of patterns) {
    const match = findLineInBlock(source, block, pattern);
    if (match) {
      findings.push(createFinding(id, "HIGH", block, "S3 public access block is disabled, allowing public access", match.line, match.evidence));
    }
  }
  return findings;
};

What does this code do?

This rule targets aws_s3_bucket_public_access_block resources. AWS provides this resource type as a safety net that prevents public access even if bucket ACLs or policies allow it.

When any of the protection flags are explicitly set to false, the safety net is disabled. The rule flags each disabled protection as a HIGH severity finding.

  • Below the s3PublicAccessBlock rule you just added, paste the IAM wildcard detection rule:
const iamWildcard: Rule = (block, source) => {
  const findings: Finding[] = [];
  // Only check IAM policy resources
  if (block.resourceType !== "aws_iam_policy" && block.resourceType !== "aws_iam_role_policy" && block.resourceType !== "aws_iam_policy_document") return findings;
  // Detect wildcard actions ("Action": "*")
  const actionWildcard = findLineInBlock(source, block, /"Action"\s*:\s*("\*"|\["\*"\])/);
  if (actionWildcard) {
    findings.push(createFinding("IAM_WILDCARD_ACTION", "CRITICAL", block, "IAM policy grants wildcard actions (all AWS actions allowed)", actionWildcard.line, actionWildcard.evidence));
  }
  // Detect wildcard resources ("Resource": "*")
  const resourceWildcard = findLineInBlock(source, block, /"Resource"\s*:\s*("\*"|\["\*"\])/);
  if (resourceWildcard) {
    findings.push(createFinding("IAM_WILDCARD_RESOURCE", "HIGH", block, "IAM policy applies to all resources (no scope restriction)", resourceWildcard.line, resourceWildcard.evidence));
  }
  // Detect broad service-level wildcards like "s3:*" or "ec2:*"
  const broadService = findLineInBlock(source, block, /"Action"\s*:\s*.*"(s3|ec2|iam|lambda|rds):\*"/);
  if (broadService) {
    findings.push(createFinding("IAM_BROAD_SERVICE", "HIGH", block, "IAM policy grants all actions for an entire AWS service", broadService.line, broadService.evidence));
  }
  return findings;
};

What does this code do?

The IAM rule checks three increasingly specific patterns. First, it catches full wildcard actions ("Action": "*") which grant every possible AWS permission.

Second, it flags wildcard resources ("Resource": "*") meaning the policy applies to every resource in the account. Third, it detects service-level wildcards like "s3:*" that grant all actions within a single service.

  • Now scroll down to the allRules array. Find this line:
const allRules: Rule[] = [
  s3PublicAccess,
];
  • Replace it with the following to register the two new rules:
const allRules: Rule[] = [
  s3PublicAccess,
  s3PublicAccessBlock,
  iamWildcard,
];
  • Save lib/rules.ts.
  • Test the new rules by running this curl command in a new terminal:
curl -s -X POST http://localhost:3000/api/scan -H "Content-Type: application/json" -d '{"terraform": "resource \"aws_iam_policy\" \"admin\" {\n  policy = jsonencode({\n    Statement = [{\n      Effect = \"Allow\"\n      \"Action\": \"*\"\n      \"Resource\": \"*\"\n    }]\n  })\n}"}' | head -c 500

You should see findings with ruleId IAM_WILDCARD_ACTION and IAM_WILDCARD_RESOURCE in the JSON response.

Not seeing IAM findings?

  • Make sure you added iamWildcard to the allRules array. Defining the function without registering it means it never runs.
  • Check that you saved the file. Unsaved changes in your editor are not picked up by the dev server.
  • Verify the regex patterns use double quotes around Action and Resource. The test payload uses the JSON-in-HCL style that Terraform supports.

Help me debug my IAM wildcard rule

Add security group, encryption, and database rules

Open security groups are one of the most common entry points for attackers. A 0.0.0.0/0 CIDR on port 22 means anyone on the internet can attempt SSH connections to your instance.

  • In lib/rules.ts, add the security group ingress rule below the iamWildcard function:
const securityGroupIngress: Rule = (block, source) => {
  const findings: Finding[] = [];
  if (block.resourceType !== "aws_security_group" && block.resourceType !== "aws_security_group_rule") return findings;
  // First check if the block has an open CIDR
  const openCidr = findLineInBlock(source, block, /cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/);
  if (!openCidr) return findings;
  // Check for sensitive ports exposed to the internet
  const sensitivePorts = [
    { pattern: /from_port\s*=\s*22/, port: "22 (SSH)" },
    { pattern: /from_port\s*=\s*3389/, port: "3389 (RDP)" },
    { pattern: /from_port\s*=\s*3306/, port: "3306 (MySQL)" },
    { pattern: /from_port\s*=\s*5432/, port: "5432 (PostgreSQL)" },
    { pattern: /from_port\s*=\s*27017/, port: "27017 (MongoDB)" },
  ];
  for (const { pattern, port } of sensitivePorts) {
    const portMatch = findLineInBlock(source, block, pattern);
    if (portMatch) {
      findings.push(createFinding("SG_OPEN_INGRESS", "CRITICAL", block, `Security group allows unrestricted ingress on port ${port} from 0.0.0.0/0`, openCidr.line, openCidr.evidence));
    }
  }
  // Fallback: check for all-ports-open pattern
  if (findings.length === 0) {
    const allPorts = findLineInBlock(source, block, /from_port\s*=\s*0/);
    const toAllPorts = findLineInBlock(source, block, /to_port\s*=\s*65535/);
    if (allPorts && toAllPorts) {
      findings.push(createFinding("SG_OPEN_ALL_PORTS", "CRITICAL", block, "Security group allows unrestricted ingress on ALL ports from 0.0.0.0/0", openCidr.line, openCidr.evidence));
    }
  }
  return findings;
};

What does this code do?

The rule first checks whether the security group has cidr_blocks = ["0.0.0.0/0"] (open to the entire internet). If not, it returns early since restricted CIDRs are not flagged.

When the CIDR is open, it checks for sensitive ports (SSH, RDP, MySQL, PostgreSQL, MongoDB) that should never face the public internet. The fallback catches the all-ports-open pattern (from_port = 0 to to_port = 65535).

  • Below the security group rule, add the missing encryption and public database detection rules:
const missingEncryption: Rule = (block, source) => {
  const findings: Finding[] = [];
  // Check S3 buckets for server-side encryption
  if (block.resourceType === "aws_s3_bucket") {
    const hasEncryption = block.attributes.includes("server_side_encryption_configuration");
    if (!hasEncryption) {
      findings.push(createFinding("S3_NO_ENCRYPTION", "HIGH", block, "S3 bucket does not have server-side encryption configured", block.startLine, `resource "aws_s3_bucket" "${block.name}" { ... } (no encryption block)`));
    }
  }
  // Check RDS instances for storage encryption
  if (block.resourceType === "aws_db_instance") {
    const noEncryption = !block.attributes.includes("storage_encrypted");
    const encryptedFalse = findLineInBlock(source, block, /storage_encrypted\s*=\s*false/);
    if (noEncryption || encryptedFalse) {
      const line = encryptedFalse?.line || block.startLine;
      const evidence = encryptedFalse?.evidence || `resource "aws_db_instance" "${block.name}" { ... } (storage_encrypted not set)`;
      findings.push(createFinding("RDS_NO_ENCRYPTION", "HIGH", block, "RDS instance does not have storage encryption enabled", line, evidence));
    }
  }
  return findings;
};

const publicDatabase: Rule = (block, source) => {
  const findings: Finding[] = [];
  if (block.resourceType !== "aws_db_instance" && block.resourceType !== "aws_rds_cluster") return findings;
  // Flag databases exposed to the public internet
  const publicAccess = findLineInBlock(source, block, /publicly_accessible\s*=\s*true/);
  if (publicAccess) {
    findings.push(createFinding("DB_PUBLIC_ACCESS", "CRITICAL", block, "Database instance is publicly accessible from the internet", publicAccess.line, publicAccess.evidence));
  }
  return findings;
};

What does this code do?

  • The missingEncryption rule uses two detection strategies: for S3, it checks whether the attributes contain the server_side_encryption_configuration block at all. For RDS, it checks if storage_encrypted is missing or explicitly set to false.
  • The publicDatabase rule flags any aws_db_instance or aws_rds_cluster with publicly_accessible = true. A public database is almost always a critical misconfiguration.
  • Update the allRules array to register these three new rules. Find:
const allRules: Rule[] = [
  s3PublicAccess,
  s3PublicAccessBlock,
  iamWildcard,
];
  • Replace it with:
const allRules: Rule[] = [
  s3PublicAccess,
  s3PublicAccessBlock,
  iamWildcard,
  securityGroupIngress,
  missingEncryption,
  publicDatabase,
];
  • Save lib/rules.ts.
  • Test the security group rule by running:
curl -s -X POST http://localhost:3000/api/scan -H "Content-Type: application/json" -d '{"terraform": "resource \"aws_security_group\" \"web\" {\n  ingress {\n    from_port = 22\n    to_port = 22\n    protocol = \"tcp\"\n    cidr_blocks = [\"0.0.0.0/0\"]\n  }\n}\nresource \"aws_db_instance\" \"prod\" {\n  publicly_accessible = true\n}"}' | head -c 500

You should see findings with ruleId SG_OPEN_INGRESS, RDS_NO_ENCRYPTION, and DB_PUBLIC_ACCESS in the response.

Only seeing some findings?

  • If SG_OPEN_INGRESS is missing, check that the regex for cidr_blocks escapes the dots and slash correctly: /cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/.
  • If database findings are missing, make sure both missingEncryption and publicDatabase are in the allRules array.

Help me debug my security group or database rules

Add hardcoded secrets and unencrypted volume detection

Hardcoded credentials in Terraform files are a common accident. Once committed to version control, secrets are exposed to anyone with repository access.

  • In lib/rules.ts, add the final two rules below the publicDatabase function:
const unencryptedVolume: Rule = (block, source) => {
  const findings: Finding[] = [];
  if (block.resourceType !== "aws_ebs_volume") return findings;
  // Flag if encrypted = true is not present
  const encrypted = findLineInBlock(source, block, /encrypted\s*=\s*true/);
  if (!encrypted) {
    const explicitFalse = findLineInBlock(source, block, /encrypted\s*=\s*false/);
    findings.push(createFinding("EBS_NO_ENCRYPTION", "HIGH", block, "EBS volume is not encrypted at rest", explicitFalse?.line || block.startLine, explicitFalse?.evidence || `resource "aws_ebs_volume" "${block.name}" { ... } (encrypted not set)`));
  }
  return findings;
};

const hardcodedSecrets: Rule = (block, source) => {
  const findings: Finding[] = [];
  // Regex patterns for common secret formats
  const secretPatterns = [
    { pattern: /AKIA[0-9A-Z]{16}/, id: "HARDCODED_AWS_KEY", message: "Hardcoded AWS access key ID detected" },
    { pattern: /(password|secret_key|api_key|token)\s*=\s*"[^"]{8,}"/i, id: "HARDCODED_SECRET", message: "Hardcoded secret or credential detected in attribute value" },
    { pattern: /-----BEGIN\s*(RSA|EC|DSA|OPENSSH)?\s*PRIVATE KEY-----/, id: "HARDCODED_PRIVATE_KEY", message: "Hardcoded private key detected" },
  ];
  for (const { pattern, id, message } of secretPatterns) {
    const match = findLineInBlock(source, block, pattern);
    if (match) {
      findings.push(createFinding("SECRETS_" + id, "CRITICAL", block, message, match.line, match.evidence));
    }
  }
  return findings;
};

What does this code do?

  • The unencryptedVolume rule checks EBS volumes for the absence of encrypted = true. It reports the explicit false line if present, or falls back to the resource start line.
  • The hardcodedSecrets rule uses three regex patterns: AWS access key IDs (AKIA followed by 16 alphanumeric characters), password-like attribute values (8+ characters in quotes), and PEM-formatted private keys.
  • Update the allRules array to its final form with all eight rules. Find the current array and replace it with:
const allRules: Rule[] = [
  s3PublicAccess,
  s3PublicAccessBlock,
  iamWildcard,
  securityGroupIngress,
  missingEncryption,
  publicDatabase,
  unencryptedVolume,
  hardcodedSecrets,
];
  • Save lib/rules.ts.

Before you run the final test, predict: how many findings do you expect from a snippet that has one vulnerability from each of the seven categories?

  • Run the full multi-vulnerability test:
curl -s -X POST http://localhost:3000/api/scan -H "Content-Type: application/json" -d '{"terraform": "resource \"aws_s3_bucket\" \"data\" {\n  bucket = \"my-bucket\"\n  acl = \"public-read\"\n}\nresource \"aws_iam_policy\" \"admin\" {\n  policy = jsonencode({\n    Statement = [{\n      \"Action\": \"*\"\n      \"Resource\": \"*\"\n    }]\n  })\n}\nresource \"aws_security_group\" \"web\" {\n  ingress {\n    from_port = 22\n    to_port = 22\n    protocol = \"tcp\"\n    cidr_blocks = [\"0.0.0.0/0\"]\n  }\n}\nresource \"aws_db_instance\" \"prod\" {\n  publicly_accessible = true\n  password = \"SuperSecret123!\"\n}\nresource \"aws_ebs_volume\" \"disk\" {\n  size = 100\n  encrypted = false\n}"}' | python3 -m json.tool | grep ruleId

You should see 7 or more ruleId lines covering all categories: S3_PUBLIC_ACL, S3_NO_ENCRYPTION, IAM_WILDCARD_ACTION, IAM_WILDCARD_RESOURCE, SG_OPEN_INGRESS, DB_PUBLIC_ACCESS, RDS_NO_ENCRYPTION, SECRETS_HARDCODED_SECRET, and EBS_NO_ENCRYPTION.

Why more than 7 findings from 5 resources?

A single resource can trigger multiple rules. The S3 bucket triggers both S3_PUBLIC_ACL (public ACL) and S3_NO_ENCRYPTION (no encryption configured). The IAM policy triggers both IAM_WILDCARD_ACTION and IAM_WILDCARD_RESOURCE. This is intentional. Each finding represents a distinct security risk that needs separate remediation.

Seeing fewer findings than expected?

  • Count the entries in your allRules array. There should be exactly 8 rule functions listed.
  • If SECRETS_HARDCODED_SECRET is missing, check that the password regex requires 8+ characters: "[^"]{8,}" not "[^"]+".
  • If EBS_NO_ENCRYPTION is missing, the rule checks for the absence of encrypted = true. Make sure the logic uses if (!encrypted) (not if encrypted).

Help me debug my rule engine findings count

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/rules.ts with all eight rule functions and the complete allRules array.

ⓧ I'd like to double check the full code

Your lib/rules.ts file is long, so scroll through and confirm the following are present in this order:

  • Imports (TerraformBlock, Finding, Severity from types, findLineInBlock from parser).
  • The Rule type alias and createFinding helper function.
  • Eight rule functions: s3PublicAccess, s3PublicAccessBlock, iamWildcard, securityGroupIngress, missingEncryption, publicDatabase, unencryptedVolume, hardcodedSecrets.
  • The allRules array listing all eight functions.
  • The runRules export function that iterates blocks and rules.

Your rule engine now catches real misconfigurations across seven attack categories. The findings are accurate and fast, but they are a flat unsorted list with no explanation of the actual risk or how to fix it. In the next step, you will wire up Claude to score, prioritize, explain, and generate secure Terraform fixes for every finding.

Add Claude AI Enrichment with Structured Outputs

Your deterministic rule engine reliably finds security misconfigurations with exact line numbers and evidence strings. But the output is a flat, unsorted list with no context about real-world risk, no prioritization, and no remediation code.

In this step, you will wire up Claude Haiku 4.5 to layer intelligence on top of confirmed findings. Using structured outputs with Zod schemas and client.messages.parse(), you will get guaranteed-schema JSON back from Claude containing risk scores, plain-English explanations, and drop-in secure Terraform fixes.

In this step, get ready to:
  • Define Zod schemas that describe the exact shape of Claude's structured response.
  • Build an enrichment function that prompts Claude with your Terraform source and deterministic findings.
  • Update the API route to call Claude and gracefully degrade if the call fails.
Define the Zod schemas for Claude's structured response

Structured outputs guarantee that Claude's response matches a schema you define. Instead of parsing free-form text, you get a typed object back every time.

  • Open lib/claude.ts and replace the stub comment with the following imports and schema definitions:
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { Finding, EnrichedFinding } from "./types";

// Initialize the Anthropic client (reads ANTHROPIC_API_KEY from env)
const client = new Anthropic();

// Schema for each enriched finding Claude returns
const EnrichedFindingSchema = z.object({
  ruleId: z.string(),
  priority: z.number(),
  explanation: z.string(),
  fix: z.string(),
  isAiOnly: z.boolean(),
});

// Schema for Claude's full audit response
const AuditResponseSchema = z.object({
  overallScore: z.number(),
  summary: z.string(),
  enrichedFindings: z.array(EnrichedFindingSchema),
  aiOnlySuggestions: z.array(
    z.object({
      suggestion: z.string(),
      explanation: z.string(),
      severity: z.string(),
    })
  ),
});

What does this code do?

The EnrichedFindingSchema describes what Claude should return for each finding: a priority rank, a plain-English explanation, and a secure Terraform fix.

The AuditResponseSchema wraps everything together: an overall risk score (1-10), a summary paragraph, the enriched findings array, and a separate aiOnlySuggestions array for issues Claude notices that the deterministic rules missed.

The zodOutputFormat() helper converts this Zod schema into the format the Anthropic API needs for guaranteed structured responses.

  • Save lib/claude.ts.
Build the Claude enrichment function

Now you need a function that sends your Terraform source and deterministic findings to Claude, then maps the structured response back onto your existing finding objects.

  • Below the AuditResponseSchema definition in lib/claude.ts, add the enrichFindings function with its API call:
export async function enrichFindings(
  terraformSource: string,
  findings: Finding[]
): Promise<{
  overallScore: number;
  summary: string;
  enrichedFindings: EnrichedFinding[];
  aiOnlySuggestions: Array<{ suggestion: string; explanation: string; severity: string; }>;
}> {
  // Call Claude with structured output format
  const response = await client.messages.parse({
    model: "claude-haiku-4-5",
    max_tokens: 4096,
    messages: [
      {
        role: "user",
        content: `You are a Terraform security auditor. Analyze the following Terraform code and its deterministic security findings.\n\nFor each finding, provide:\n1. A priority rank (1 = most urgent to fix first)\n2. A plain-English explanation of the real-world risk (what an attacker could do)\n3. A minimal secure Terraform fix (just the corrected lines, not the entire block)\n4. Set isAiOnly to false for all findings from the deterministic engine\n\nAlso provide an overall risk score (1-10, where 10 is critically insecure) and a one-paragraph summary.\n\nIf you notice additional security concerns NOT caught by the deterministic rules, add them to aiOnlySuggestions with isAiOnly context. Keep these separate from confirmed findings.\n\nTERRAFORM CODE:\n\`\`\`hcl\n${terraformSource}\n\`\`\`\n\nDETERMINISTIC FINDINGS:\n${JSON.stringify(findings, null, 2)}`,
      },
    ],
    output_config: { format: zodOutputFormat(AuditResponseSchema) },
  });

What does this code do?

The client.messages.parse() method sends a request to Claude and automatically validates the response against your Zod schema. If Claude's output does not match the schema, the SDK throws an error.

The output_config field tells the API to constrain Claude's output to the exact JSON structure defined by AuditResponseSchema. You get typed data back, not free-form text.

The prompt passes both the raw Terraform source and the deterministic findings array so Claude has full context for prioritization and fix generation.

  • Immediately below the }); that closes the client.messages.parse() call, add the response processing logic to complete the function:
  const parsed = response.parsed_output!;

  // Map Claude's enrichment back onto the original findings
  const enrichedFindings: EnrichedFinding[] = findings.map((finding) => {
    const enrichment = parsed.enrichedFindings.find((e) => e.ruleId === finding.ruleId);
    return {
      ...finding,
      priority: enrichment?.priority ?? 99,
      explanation: enrichment?.explanation ?? "No additional context available.",
      fix: enrichment?.fix ?? "",
      isAiOnly: false,
    };
  });

  return {
    overallScore: parsed.overallScore,
    summary: parsed.summary,
    enrichedFindings: enrichedFindings.sort((a, b) => a.priority - b.priority),
    aiOnlySuggestions: parsed.aiOnlySuggestions,
  };
}

What does this code do?

The parsed_output property contains Claude's response already validated and typed according to AuditResponseSchema.

The .map() merges Claude's enrichment (priority, explanation, fix) onto each original deterministic finding. If Claude does not return enrichment for a specific finding, fallback values keep the object complete.

The returned array is sorted by priority so the most urgent finding appears first.

  • Save lib/claude.ts.

Seeing a TypeScript error on parsed_output?

Make sure your EnrichedFinding import comes from ./types. If the import path is wrong, the return type will not match.

Confirm your zod and @anthropic-ai/sdk packages are installed from Step 1.

help me fix a TypeScript error in my Claude enrichment function

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/claude.ts with all three sections in place.

ⓧ I'd like to double check the full code

Your lib/claude.ts file has three sections you built above, in this order:

  • Imports, client initialization, and the two Zod schemas (EnrichedFindingSchema and AuditResponseSchema).
  • The enrichFindings function signature and the client.messages.parse() call with the prompt.
  • The response processing: parsed_output extraction, .map() merge, sort by priority, and the return statement.

Scroll up and confirm each chunk is present and in order.

Update the API route to call Claude with graceful degradation

The scanner should always return deterministic findings, even if Claude is unavailable. You will wrap the Claude call in a try/catch so the API degrades gracefully.

  • Open app/api/scan/route.ts and find the import lines at the top. Add the enrichFindings import so your imports look like this:
import { NextRequest, NextResponse } from "next/server";
import { parseTerraformBlocks } from "@/lib/parser";
import { runRules } from "@/lib/rules";
import { enrichFindings } from "@/lib/claude";
import { AuditResult } from "@/lib/types";
  • Find this line near the bottom of the file:
  return NextResponse.json(result);
  • Replace it with the following try/catch block that calls Claude and then returns the result:
  try {
    const enriched = await enrichFindings(terraform, findings);
    result.enriched = enriched.enrichedFindings;
    result.overallScore = enriched.overallScore;
    result.summary = enriched.summary;
  } catch (err) {
    result.error =
      "AI enrichment failed. Showing deterministic results only.";
  }

  return NextResponse.json(result);

What does this code do?

The try/catch ensures the endpoint never crashes. If Claude's API is down or the key is invalid, the response still includes all deterministic findings in the findings field. The error field signals to the frontend that enrichment was unavailable.

When the call succeeds, the enriched field contains priority-ranked findings with explanations and fixes, and overallScore holds the 1-10 risk rating.

  • Save app/api/scan/route.ts.

Seeing a module not found error?

Check that the import path is @/lib/claude (not ./lib/claude). The @/ alias resolves from the project root.

help me fix a module import error in my scan route

✔️ Awesome, I've got everything!

Great. Double check you have saved app/api/scan/route.ts with the new import and the try/catch block in place.

ⓧ I'd like to double check the full code

Your app/api/scan/route.ts should now have these parts in order:

  • Five import lines including enrichFindings from @/lib/claude.
  • The POST handler that parses the request, validates the terraform field, runs parseTerraformBlocks and runRules.
  • The result object initialization with null fields.
  • A try/catch calling enrichFindings() and assigning result.enriched, result.overallScore, and result.summary.
  • The final return NextResponse.json(result) after the catch block.

Scroll up and confirm each section matches your file.

Before you test, what do you expect the response to include that it did not have before?

  • Test the enriched scanner by running this command in a new terminal:
curl -s -X POST http://localhost:3000/api/scan \
  -H "Content-Type: application/json" \
  -d '{"terraform": "resource \"aws_s3_bucket\" \"data\" {\n  bucket = \"my-bucket\"\n  acl = \"public-read\"\n}\n\nresource \"aws_iam_policy\" \"admin\" {\n  name = \"admin\"\n  policy = <<EOF\n{\"Version\": \"2012-10-17\", \"Statement\": [{\"Effect\": \"Allow\", \"Action\": \"*\", \"Resource\": \"*\"}]}\nEOF\n}"}' | head -c 2000

You should see JSON containing an overallScore number (likely 8-10 for this insecure snippet), an enriched array where each finding now has priority, explanation, and fix fields, and a summary paragraph from Claude.

Getting an empty enriched field or seeing the error message?

Check that your ANTHROPIC_API_KEY is set correctly in .env.local. The Anthropic SDK reads this variable automatically.

Make sure your dev server is still running. If you stopped it earlier, restart with npm run dev.

help me debug why my Claude enrichment call is failing

Your scanner now combines deterministic precision with AI intelligence. Next up, you will build a frontend that turns this JSON into an interactive security dashboard.

Build the Scanner Frontend

You have a powerful API that accepts Terraform code and returns enriched security findings. But right now the only way to use it is through curl commands in the terminal.

A web UI turns your scanner into something you can demo and use interactively. In this step, you will build a React page where you paste Terraform, click Scan, and see prioritized findings with severity badges, risk explanations, and expandable fix code.

In this step, get ready to:
  • Build the main page component with a textarea, Scan button, and loading state.
  • Render findings as cards with severity badges, evidence, and expandable secure fixes.
  • Display the overall risk score and Claude's summary at the top of the results panel.
Set up the page with imports and sample data

The scanner frontend is a client component because it manages local state (the Terraform input, loading status, and scan results). You will build it in app/page.tsx.

  • Create app/page.tsx (replace any existing content) and add the following imports and severity color map:
"use client";

import { useState } from "react";
import { AuditResult, EnrichedFinding, Severity } from "@/lib/types";

const severityColors: Record<Severity, string> = {
  CRITICAL: "bg-red-100 text-red-800 border-red-300",
  HIGH: "bg-orange-100 text-orange-800 border-orange-300",
  MEDIUM: "bg-yellow-100 text-yellow-800 border-yellow-300",
  LOW: "bg-blue-100 text-blue-800 border-blue-300",
};

What does this code do?

The "use client" directive marks this as a React client component so it can use useState for interactive state.

The severityColors map translates each severity level into Tailwind CSS classes for color-coded badges.

  • Below the severity colors, add a sample Terraform constant that pre-fills the textarea so first-time users can click Scan immediately:
const SAMPLE_TERRAFORM = `resource "aws_s3_bucket" "data_lake" {
  bucket = "company-data-lake"
  acl    = "public-read"
}

resource "aws_iam_policy" "admin_policy" {
  name = "admin-access"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      "Action": "*"
      "Resource": "*"
    }]
  })
}

resource "aws_security_group" "web_sg" {
  name = "web-access"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_db_instance" "production" {
  engine               = "mysql"
  instance_class       = "db.t3.micro"
  publicly_accessible  = true
  password             = "SuperSecret123!"
}

resource "aws_ebs_volume" "storage" {
  availability_zone = "us-east-1a"
  size              = 100
  encrypted         = false
}`;

Why include sample Terraform?

This constant contains five intentionally insecure resources covering public S3 access, IAM wildcards, open SSH, public databases, and unencrypted volumes. It lets anyone test the scanner without writing Terraform from scratch.

  • Next, add the Home component with its state variables and the handleScan function:
export default function Home() {
  const [terraform, setTerraform] = useState(SAMPLE_TERRAFORM);
  const [result, setResult] = useState<AuditResult | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleScan() {
    setLoading(true);
    setResult(null);
    try {
      const response = await fetch("/api/scan", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ terraform }),
      });
      const data: AuditResult = await response.json();
      setResult(data);
    } catch {
      setResult({
        findings: [],
        enriched: null,
        overallScore: null,
        summary: null,
        error: "Failed to connect to scanner API.",
      });
    }
    setLoading(false);
  }

  const findings = result?.enriched ?? result?.findings ?? [];

What does this code do?

  • Three state variables track the Terraform input, the scan result, and whether a scan is in progress.
  • handleScan POSTs the Terraform text to your /api/scan route, stores the response, and catches network errors gracefully.
  • The findings derivation prefers enriched results from Claude but falls back to raw deterministic findings if enrichment failed.
Add the page layout with textarea and results panel

The UI uses a two-column grid. The left column holds the Terraform textarea and Scan button. The right column shows the risk score, error messages, and findings list.

  • Still inside the Home component, add the return statement starting with the left column (textarea and button):
  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">
          Terraform Security Auditor
        </h1>
        <p className="text-gray-600 mb-6">
          Paste your Terraform configuration below for an instant security audit.
        </p>

        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-2">
              Terraform Configuration
            </label>
            <textarea
              value={terraform}
              onChange={(e) => setTerraform(e.target.value)}
              className="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
              placeholder="Paste your Terraform code here..."
            />
            <button
              onClick={handleScan}
              disabled={loading || !terraform.trim()}
              className="mt-4 w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
            >
              {loading ? "Scanning..." : "Scan for Security Issues"}
            </button>
          </div>

What does this code do?

  • The textarea is bound to the terraform state, pre-filled with the sample insecure code.
  • The button calls handleScan and disables itself while loading or when the textarea is empty.
  • The button text swaps to "Scanning..." during the API call so the user knows it is working.
  • Directly below the left column div, add the right column with the risk score header, error display, and findings list:
          <div>
            <h2 className="text-sm font-medium text-gray-700 mb-2">
              Findings
              {result && ` (${findings.length} issues found)`}
            </h2>

            {result?.overallScore && (
              <div className="mb-4 p-4 bg-white rounded-lg border border-gray-200">
                <div className="flex items-center gap-3">
                  <span className="text-2xl font-bold text-red-600">
                    {result.overallScore}/10
                  </span>
                  <span className="text-sm text-gray-600">Risk Score</span>
                </div>
                {result.summary && (
                  <p className="mt-2 text-sm text-gray-700">{result.summary}</p>
                )}
              </div>
            )}

            {result?.error && (
              <div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
                {result.error}
              </div>
            )}

What does this code do?

  • The risk score renders Claude's 1-10 rating in large red text alongside the one-paragraph summary.
  • The error banner appears in yellow when Claude enrichment fails. The scanner still shows deterministic findings underneath.
  • Below the error display, add the scrollable findings list and close all remaining tags for the Home component:
            <div className="space-y-3 max-h-[600px] overflow-y-auto">
              {findings.map((finding, i) => (
                <FindingCard key={i} finding={finding} />
              ))}
              {result && findings.length === 0 && (
                <div className="p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
                  No security issues found.
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
    </main>
  );
}

What does this code do?

  • Each finding renders via a FindingCard component (which you will build next).
  • A green "No security issues found" message shows when the scan completes with zero findings.
Build the FindingCard component

Each finding needs its own card showing the severity level, rule ID, affected resource, exact source evidence, and an expandable section for Claude's explanation and fix code.

  • Below the Home component (still in the same app/page.tsx file), add the top portion of the FindingCard component:
function FindingCard({ finding }: { finding: EnrichedFinding | any }) {
  const [expanded, setExpanded] = useState(false);
  const severity: Severity = finding.severity;

  return (
    <div className="p-4 bg-white rounded-lg border border-gray-200">
      <div className="flex items-start justify-between gap-2">
        <div className="flex items-center gap-2">
          <span
            className={`px-2 py-0.5 text-xs font-medium rounded border ${severityColors[severity]}`}
          >
            {severity}
          </span>
          <span className="text-xs font-mono text-gray-500">
            {finding.ruleId}
          </span>
        </div>
        <span className="text-xs text-gray-400">Line {finding.line}</span>
      </div>

      <p className="mt-2 text-sm font-medium text-gray-900">
        {finding.resource}
      </p>
      <p className="mt-1 text-sm text-gray-600">{finding.message}</p>

      <div className="mt-2 p-2 bg-gray-50 rounded font-mono text-xs text-gray-700 overflow-x-auto">
        {finding.evidence}
      </div>

What does this code do?

  • The header row shows the severity badge (color-coded using severityColors), the rule ID in monospace, and the line number on the right.
  • Below the header, the resource name and message describe what was found.
  • The evidence block shows the exact HCL line that triggered the rule, in a monospace grey box.
  • Complete the FindingCard with the expandable explanation and secure fix section:
      {"explanation" in finding && finding.explanation && (
        <div className="mt-3">
          <p className="text-sm text-gray-700">{finding.explanation}</p>
          {finding.fix && (
            <button
              onClick={() => setExpanded(!expanded)}
              className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium"
            >
              {expanded ? "Hide Fix" : "Show Secure Fix"}
            </button>
          )}
          {expanded && finding.fix && (
            <pre className="mt-2 p-3 bg-green-50 border border-green-200 rounded text-xs font-mono overflow-x-auto">
              {finding.fix}
            </pre>
          )}
        </div>
      )}
    </div>
  );
}

What does this code do?

  • The "explanation" in finding check detects whether Claude's enrichment is present. If so, it renders the plain-English risk explanation.
  • The "Show Secure Fix" button toggles the expanded state, revealing or hiding Claude's generated secure Terraform replacement in a green code block.
  • Save app/page.tsx.

Before you check the browser, what do you expect to see when the page loads? Think about what SAMPLE_TERRAFORM does to the textarea.

  • Switch to your browser and open localhost:3000.

You should see a page titled "Terraform Security Auditor" with the left column showing a textarea pre-filled with the sample insecure Terraform code, and a blue "Scan for Security Issues" button below it.

  • Click the Scan for Security Issues button.

The button text changes to "Scanning..." while Claude processes. After 1-2 seconds, findings appear in the right column as cards with red/orange severity badges, rule IDs, resource names, evidence lines, and Claude's risk explanations. Click "Show Secure Fix" on any card to reveal the hardened Terraform code.

Page not loading or showing errors?

  • If you see a compile error mentioning FindingCard, make sure the FindingCard function is defined below the closing } of the Home component in the same file.
  • If clicking Scan returns no results or shows "Failed to connect to scanner API", confirm your dev server is running with npm run dev in the terminal.
  • If you see the error banner "AI enrichment failed", your ANTHROPIC_API_KEY in .env.local may be invalid. The deterministic findings should still appear below the banner.

If you are stuck, help me debug my page.tsx file.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/page.tsx and can see findings appearing after clicking Scan.

ⓧ I'd like to double check the full code

Your app/page.tsx should contain these sections in order. Scroll up and confirm each is present:

  • The "use client" directive, imports, and severityColors map at the top.
  • The SAMPLE_TERRAFORM template literal with five insecure resources.
  • The Home component with terraform, result, and loading state.
  • The handleScan async function that POSTs to /api/scan.
  • The return JSX with a two-column grid, textarea, button, score header, error banner, and findings map.
  • The FindingCard function below Home with severity badge, evidence, explanation, and expandable fix.

Your scanner is fully functional. You can paste any Terraform configuration, click Scan, and get prioritized security findings with explanations and ready-to-use fixes. Next up, a Secret Mission that takes this even further with a "Fix All" feature.

Secret mission

Build a "Fix All" Feature

Your scanner finds problems and explains them one at a time. But security teams need to remediate entire files at once. In this mission, you'll build a "Fix All" button that generates a complete hardened Terraform file and shows a before/after diff with per-change explanations.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no cloud resources or ongoing costs to worry about. The only cost was Claude Haiku 4.5 API usage (approximately $0.01-0.05 per scan), which stops the moment you stop making requests.

Decide whether to keep your project files to continue experimenting, or delete them entirely.

Resources you used:

  • Next.js project folder on your Desktop (contains all source code, node_modules, and .env.local with your API key).
  • The Next.js dev server process (if still running in your terminal).

Keep everything running

No action needed. Choose this if you want to keep experimenting with different Terraform configurations or extending the scanner with new rules.

  • Your project files stay on your Desktop, ready to use anytime.
  • To restart the dev server later, open your terminal, navigate to your project folder, and run npm run dev.
  • Your Anthropic API key remains in .env.local and is never committed to version control.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your files stay on disk for when you return.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • No API charges accrue while the server is stopped. You only pay when you actively run scans.

Delete - I don't want to use this again

Remove all project files and free up disk space. This is irreversible.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • Delete the project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf terraform-security-auditor

On Windows, run:

cd ~\Desktop
Remove-Item -Recurse -Force terraform-security-auditor

Replace the folder name if yours differs

If you named your project something different when running npx create-next-app@latest, substitute that name in the command above.

  • Optionally, revoke your Anthropic API key if you no longer need it. Visit the API Keys page in the Anthropic Console and delete the key you used for this project.

Nice Work!

Nice Work!

You just built a full-stack security auditor that combines deterministic rule analysis with AI-powered enrichment. Paste any Terraform configuration and get instant, prioritized findings with real remediation code.

Here's what you learned:

  • Built a custom HCL block parser that extracts Terraform resource blocks with exact line numbers and raw source evidence, then implemented a 7-category deterministic rule engine covering public S3 access, IAM wildcards, open security groups, missing encryption, public databases, unencrypted volumes, and hardcoded secrets.
  • Used Claude Haiku 4.5 structured outputs with Zod schemas and client.messages.parse() to add risk scoring, prioritized explanations, and secure Terraform fixes to confirmed findings.
  • Built a full-stack Next.js scanner UI that renders findings as cards with severity badges, source evidence, Claude's risk explanations, and expandable fix code blocks.
  • Secret Mission: Added a "Fix All" feature that generates complete hardened Terraform with a before/after diff and per-change security explanations.

Ready to quiz yourself?