Build an AI Docker Security Reviewer
Analyze Dockerfiles for vulnerabilities using deterministic rules and Claude AI.
Introduction
30 Second Summary
Every container your team ships starts with a Dockerfile. A single misconfiguration there (running as root, leaking a secret, pulling an unverified image) can expose your entire production environment.
In this project, you will build a web app that analyzes Dockerfiles for security vulnerabilities using deterministic rules, then uses Claude AI to transform raw findings into a scored, prioritized security report with plain-English explanations and copy-paste fix snippets.
What You'll Build
Paste any Dockerfile into your app, click Analyze, and watch it return a color-coded security score alongside prioritized findings that tell you exactly what to fix, why it matters, and how to fix it.
By the end of this project, you'll have:
- A paste-and-analyze form where you submit any Dockerfile and instantly see a 0-100 security score with a letter grade (A-F) and risk level indicator.
- A deterministic security analyzer that catches real vulnerabilities (root users, unpinned images, leaked secrets, missing cache cleanup) and points to the exact line number as evidence.
- A Claude-powered scoring engine that transforms flat findings into a prioritized report with severity ratings, real-world impact explanations, and recommended fix snippets you can copy directly into your Dockerfile.
- Secret Mission: A one-click "Generate Hardened Dockerfile" feature that produces a corrected version addressing all findings.
Are there any prerequisites?
You will need Node.js 22.x installed, an Anthropic API key, and familiarity with Docker and Dockerfiles.
Experience with Next.js App Router and TypeScript is also expected.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Container security is one of those things that's easy to ignore until a compromised image makes it to production. You're about to build a tool that makes catching Dockerfile security issues as simple as pasting text into a box.
Scaffold the Project and Install Dependencies
Every feature of the Docker Security Reviewer needs a foundation: a web framework to serve the UI, an SDK to talk to Claude, and a validation library to enforce structured outputs.
In this step, you will scaffold a Next.js project with TypeScript and Tailwind CSS, then install the Anthropic SDK and Zod for AI integration and schema validation.
In this step, get ready to:
- Scaffold a new Next.js 16 project with TypeScript, Tailwind CSS, and the App Router.
- Install the Anthropic SDK and Zod.
- Configure your API key and start the development server.
Scaffold a new Next.js project
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the Next.js project by running this command:
npx create-next-app@latest docker-security-reviewer --yes
What does this command do?
The --yes flag accepts all recommended defaults: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack, the @/* import alias, and an AGENTS.md file.
This creates a folder called docker-security-reviewer on your Desktop with the full project scaffold and all base dependencies already installed.
- Move into the new project folder by running:
cd docker-security-reviewer
- Confirm the scaffold worked by running:
ls
You should see files like package.json, tsconfig.json, next.config.ts, and an app folder in the output.
Command not found or permission error?
- Make sure Node.js 22.x is installed by running node --version in your terminal.
- If npx is not found, reinstall Node.js from the official site.
- help me troubleshoot the create-next-app scaffold
Install the Anthropic SDK and Zod
The project needs two additional packages: the Anthropic SDK to call Claude's API, and Zod to define schemas that enforce structured output from Claude's responses.
- Install both packages by running:
npm install @anthropic-ai/sdk zod
You should see both packages added in the terminal output with no errors.
Why these two packages?
The @anthropic-ai/sdk package provides client.messages.parse() and the zodOutputFormat helper. Together they let you send a Zod schema to Claude and get back a validated, typed response.
Zod is also a peer dependency of the Anthropic SDK (^3.25.0 || ^4.0.0), so it must be installed explicitly.
- Create the .env.local file for your API key by running:
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env.local
- Open .env.local in your preferred text editor and replace your-api-key-here with your actual Anthropic API key.
- Confirm the file exists and contains your key by running:
cat .env.local
You should see ANTHROPIC_API_KEY= followed by your key value.
Keep your key safe
Next.js automatically ignores .env.local in the default .gitignore, so your key will not be committed to version control.
npm install failed?
- Check you are inside the docker-security-reviewer folder (run pwd to confirm).
- If you see a network error, check your internet connection and retry.
- help me fix my npm install error
Start the development server
With the scaffold and dependencies in place, you can now start the development server to verify everything is wired up correctly.
- Before you run the next command, what do you expect to see in the browser at http://localhost:3000?
- Start the dev server by running:
npm run dev
- Open your browser and visit http://localhost:3000.
You should see the default Next.js starter page with the Next.js logo and links to documentation.
What is running?
The npm run dev command starts Next.js in development mode with Turbopack (the default bundler). Your terminal shows a local URL at http://localhost:3000 and hot-reloads whenever you save a file.
Leave this terminal running for the rest of the project. Open a new terminal tab or window whenever you need to run additional commands.
Page not loading?
- Make sure the terminal shows no errors after running npm run dev. Look for a line containing localhost:3000 in the output.
- If port 3000 is already in use, Next.js automatically picks the next available port. Check your terminal output for the actual URL.
- help me get my Next.js dev server running
Your project foundation is ready. Next up, you will build the deterministic Dockerfile analyzer that catches security issues with line-number precision.
Build the Deterministic Dockerfile Analyzer
Your Next.js project has the dependencies it needs. Now you need the core analysis engine.
A security scanner that relies entirely on AI would have a critical flaw: the AI could hallucinate vulnerabilities that don't exist or miss real ones. The deterministic analyzer solves this by parsing each Dockerfile line against known security rules and producing findings with exact line-number evidence. AI will augment this later, but it will never replace it as the source of truth.
In this step, get ready to:
- Create the analyzer module with typed data structures for findings and validation results.
- Implement 11+ security rules covering supply chain attacks, privilege escalation, secret leakage, and image bloat.
- Add input validation that rejects empty, oversized, or malformed Dockerfile content.
Create the analyzer file with types and parser scaffold
Every finding your analyzer produces needs a consistent shape so the UI and AI layers can consume it reliably. You will define the types first, then build the function scaffold that iterates through a Dockerfile line by line.
- Create a new folder and file at app/lib/analyzer.ts by right-clicking the app folder in VS Code's Explorer sidebar, selecting New Folder, naming it lib, then right-clicking lib and creating a new file called analyzer.ts.
- Add the type definitions that describe a finding's shape:
export type Severity = "critical" | "high" | "medium" | "low";
export interface Finding {
rule: string;
severity: Severity;
line: number;
instruction: string;
message: string;
}
export interface ValidationResult {
valid: boolean;
error?: string;
}
What does this code do?
These types define the contract for all security analysis output.
- Severity limits severity values to exactly four levels so the UI can color-code them deterministically.
- Finding guarantees every detected issue carries its rule name, severity, the exact line number, the matched instruction text, and a human-readable message.
- ValidationResult standardizes how input validation communicates pass or fail with an optional error string.
- Below the types, add the analyzeDockerfile function scaffold that sets up the line-by-line parser:
export function analyzeDockerfile(content: string): Finding[] {
const findings: Finding[] = [];
const lines = content.split("\n");
let hasUserInstruction = false;
let hasHealthcheck = false;
let hasMultipleFroms = false;
let fromCount = 0;
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = lines[i].trim();
// Skip empty lines and comments
if (line === "" || line.startsWith("#")) continue;
// Security rules will be added here
}
return findings;
}
What does this code do?
This scaffold splits the Dockerfile into lines and iterates through each one, tracking the current line number (1-indexed for human-readable output). It skips blank lines and comments, then will run each meaningful line through your security rules.
The tracking booleans (hasUserInstruction, hasHealthcheck, hasMultipleFroms) let you detect the absence of required instructions after the loop finishes.
- Save analyzer.ts.
- Confirm your types compile by running this command in a new terminal:
npx tsc --noEmit
You should see no errors printed. If you see errors about next-env.d.ts, run npx next build once to generate it, then re-run the type check.
Seeing type errors?
- Check that the Finding interface uses Severity (capital S) matching the type you defined above it.
- Make sure the file is saved as app/lib/analyzer.ts with the .ts extension, not .tsx.
Still stuck? Help me fix TypeScript errors in my analyzer.ts file
Implement the security rules
Each rule follows the same pattern: test the current line against a regex, and if it matches a known anti-pattern, push a Finding with the evidence. You will add these rules inside the for loop, replacing the // Security rules will be added here comment.
- In analyzer.ts, find the comment // Security rules will be added here inside the for loop and replace it with the supply chain rules that detect unpinned images and track USER/HEALTHCHECK presence:
// Rule: FROM with :latest or no tag
if (/^FROM\s+/i.test(line)) {
fromCount++;
if (fromCount > 1) hasMultipleFroms = true;
const image = line.replace(/^FROM\s+/i, "").split(/\s+/)[0];
if (image.includes(":latest")) {
findings.push({
rule: "unpinned-tag-latest",
severity: "high",
line: lineNumber,
instruction: line,
message:
"Image uses :latest tag which is mutable and unpredictable. Pin to a specific version.",
});
} else if (!image.includes(":") && !image.includes("@sha256")) {
findings.push({
rule: "unpinned-tag-missing",
severity: "high",
line: lineNumber,
instruction: line,
message:
"Image has no version tag, defaulting to :latest. Pin to a specific version.",
});
}
}
// Rule: USER instruction present
if (/^USER\s+/i.test(line)) {
hasUserInstruction = true;
}
// Rule: HEALTHCHECK instruction present
if (/^HEALTHCHECK\s+/i.test(line)) {
hasHealthcheck = true;
}
What does this code do?
- The FROM rule extracts the image name and checks for :latest or a completely missing tag. Both are supply chain risks because the image content can change without warning.
- USER and HEALTHCHECK lines are tracked as booleans. Their absence is the vulnerability, so you will check these flags after the loop ends.
- fromCount tracks multi-stage builds. A single FROM means the final image includes all build-time tools.
- Below the HEALTHCHECK tracking, add rules that detect secrets baked into image layers and misuse of ADD for local files:
// Rule: Secrets in ENV or ARG
if (/^(ENV|ARG)\s+/i.test(line)) {
const secretPattern =
/(_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIAL|API_KEY|SECRET_KEY|ACCESS_KEY|PRIVATE_KEY)[\s=]/i;
if (secretPattern.test(line)) {
findings.push({
rule: "secrets-in-env",
severity: "critical",
line: lineNumber,
instruction: line,
message:
"Potential secret in ENV/ARG instruction. Secrets baked into image layers are permanently recoverable via docker history.",
});
}
}
// Rule: ADD instead of COPY for local files
if (/^ADD\s+/i.test(line)) {
const source = line.replace(/^ADD\s+/i, "").split(/\s+/)[0];
if (!source.startsWith("http") && !source.startsWith("--")) {
findings.push({
rule: "add-instead-of-copy",
severity: "medium",
line: lineNumber,
instruction: line,
message:
"ADD used for local file copy. Use COPY instead; ADD has implicit tar extraction and URL fetch behavior that can be exploited.",
});
}
}
What does this code do?
- The secrets rule checks ENV/ARG lines for common credential suffixes like _KEY, _TOKEN, _SECRET. These are permanently recoverable from any image layer via docker history.
- The ADD rule flags local file copies that should use COPY. ADD has hidden tar extraction and URL fetch behavior that attackers can exploit.
- Save analyzer.ts and run the type checker again to catch any typos in the rules you just added:
npx tsc --noEmit
No errors means your regex patterns and finding objects all type-check correctly.
- Below the ADD rule, add the package manager hygiene rules that detect missing cache cleanup and a critical remote code execution pattern:
// Rule: apt-get install without cleanup
if (/apt-get\s+install/i.test(line) && !/rm\s+-rf\s+\/var\/lib\/apt\/lists/i.test(line)) {
// Check if cleanup is in the same RUN command (multi-line)
let fullCommand = line;
let j = i;
while (j > 0 && lines[j - 1].trim().endsWith("\\")) {
fullCommand = lines[j - 1].trim() + " " + fullCommand;
j--;
}
let k = i;
while (k < lines.length - 1 && lines[k].trim().endsWith("\\")) {
fullCommand = fullCommand + " " + lines[k + 1].trim();
k++;
}
if (!/rm\s+-rf\s+\/var\/lib\/apt\/lists/i.test(fullCommand)) {
findings.push({
rule: "no-cache-cleanup",
severity: "medium",
line: lineNumber,
instruction: line,
message:
"apt-get install without cache cleanup (rm -rf /var/lib/apt/lists/*) in the same RUN layer increases image size.",
});
}
}
// Rule: curl/wget piped to shell
if (/curl\s+.*\|\s*(sh|bash)/i.test(line) || /wget\s+.*\|\s*(sh|bash)/i.test(line)) {
findings.push({
rule: "curl-pipe-shell",
severity: "critical",
line: lineNumber,
instruction: line,
message:
"Piping curl/wget to shell executes unverified remote code during build with no integrity check.",
});
}
What does this code do?
- The cache cleanup rule handles multi-line RUN commands (joined with \) by assembling the full command before checking for rm -rf /var/lib/apt/lists. Without cleanup, each install layer bloats the image with cached package lists.
- The curl-pipe-shell rule catches curl ... | sh and wget ... | bash patterns. These execute arbitrary remote code during the build with zero integrity verification.
- Continue below the curl-pipe-shell rule with the --no-install-recommends check and the sensitive file copy detection:
// Rule: apt-get install without --no-install-recommends
if (
/apt-get\s+install/i.test(line) &&
!/--no-install-recommends/i.test(line)
) {
let fullCommand = line;
let j = i;
while (j > 0 && lines[j - 1].trim().endsWith("\\")) {
fullCommand = lines[j - 1].trim() + " " + fullCommand;
j--;
}
let k = i;
while (k < lines.length - 1 && lines[k].trim().endsWith("\\")) {
fullCommand = fullCommand + " " + lines[k + 1].trim();
k++;
}
if (!/--no-install-recommends/i.test(fullCommand)) {
findings.push({
rule: "no-install-recommends",
severity: "low",
line: lineNumber,
instruction: line,
message:
"apt-get install without --no-install-recommends installs unnecessary packages that increase attack surface.",
});
}
}
// Rule: COPY of sensitive files
if (/^COPY\s+/i.test(line)) {
const sensitiveFiles = /\.(env|pem|key|p12|pfx|jks)\b/i;
if (sensitiveFiles.test(line)) {
findings.push({
rule: "copy-sensitive-files",
severity: "high",
line: lineNumber,
instruction: line,
message:
"Copying sensitive files (credentials, keys) into the image. These persist in layers even if deleted later.",
});
}
}
What does this code do?
- The --no-install-recommends rule uses the same multi-line assembly pattern. Without this flag, apt installs suggested packages that expand the attack surface.
- The COPY rule matches file extensions commonly associated with credentials (.env, .pem, .key). These persist in image layers permanently, even if a later RUN command deletes them.
- Save analyzer.ts and run the type checker to confirm everything still compiles:
npx tsc --noEmit
No output means all your regex patterns and finding pushes are correctly typed.
- Add the EXPOSE port check below the COPY sensitive files rule:
// Rule: EXPOSE privileged ports
if (/^EXPOSE\s+/i.test(line)) {
const ports = line
.replace(/^EXPOSE\s+/i, "")
.split(/\s+/)
.map((p) => parseInt(p.split("/")[0], 10))
.filter((p) => !isNaN(p));
for (const port of ports) {
if (port < 1024 && port !== 80 && port !== 443) {
findings.push({
rule: "privileged-port",
severity: "low",
line: lineNumber,
instruction: line,
message: `Exposing privileged port ${port} (below 1024) may require root privileges at runtime.`,
});
}
}
}
What does this code do?
This rule parses port numbers from EXPOSE instructions (handling the port/protocol format) and flags any below 1024 that aren't the standard web ports 80 and 443. Privileged ports require root, which contradicts running as a non-root user.
- Now add the post-loop checks that detect the absence of important instructions. Place this code after the closing } of the for loop but before the return findings; statement:
// Rule: No USER instruction (runs as root)
if (!hasUserInstruction) {
findings.push({
rule: "no-user-instruction",
severity: "high",
line: 0,
instruction: "(entire Dockerfile)",
message:
"No USER instruction found. Container will run as root, enabling privilege escalation if a container escape occurs.",
});
}
// Rule: No HEALTHCHECK
if (!hasHealthcheck) {
findings.push({
rule: "no-healthcheck",
severity: "low",
line: 0,
instruction: "(entire Dockerfile)",
message:
"No HEALTHCHECK instruction. Container orchestrators cannot detect if the application is healthy.",
});
}
// Rule: No multi-stage build
if (!hasMultipleFroms) {
findings.push({
rule: "no-multistage",
severity: "medium",
line: 0,
instruction: "(entire Dockerfile)",
message:
"Single-stage build detected. Multi-stage builds reduce final image size and exclude build-time tools from production.",
});
}
What does this code do?
These three checks fire after the loop because they detect missing instructions rather than problematic ones. They use line: 0 to indicate the finding applies to the entire file, not a specific line.
- No USER means the container runs as root, enabling privilege escalation on container escape.
- No HEALTHCHECK means orchestrators like Kubernetes cannot detect if the app inside has crashed.
- A single FROM means build tools (compilers, package managers) ship in the production image unnecessarily.
- Save analyzer.ts and run the type checker one more time:
npx tsc --noEmit
Clean output confirms all 11+ rules compile correctly. Your analyzer function is complete.
Seeing a type error in the post-loop checks?
- Make sure the post-loop checks are outside the for loop's closing brace but inside the function body (before return findings;).
- Check that the boolean variable names (hasUserInstruction, hasHealthcheck, hasMultipleFroms) match exactly between the declarations at the top of the function and the checks here.
Still stuck? Help me fix type errors in my analyzeDockerfile post-loop checks
Add input validation
Before the analyzer processes content, it needs to reject obviously invalid input. Empty strings, extremely large payloads, and content that contains no FROM instruction should be caught early with clear error messages.
- In analyzer.ts, add the validateInput function between the ValidationResult interface and the analyzeDockerfile function:
export function validateInput(content: string): ValidationResult {
if (!content || content.trim().length === 0) {
return { valid: false, error: "Dockerfile content is empty." };
}
if (content.length > 50000) {
return {
valid: false,
error: "Dockerfile content exceeds 50,000 character limit.",
};
}
const hasFrom = /^FROM\s+/im.test(content);
if (!hasFrom) {
return {
valid: false,
error:
"No FROM instruction found. This does not appear to be a valid Dockerfile.",
};
}
return { valid: true };
}
What does this code do?
- The empty check catches both null/undefined and whitespace-only strings.
- The 50,000 character limit prevents denial-of-service from massive payloads hitting the regex engine.
- The FROM check uses the multiline flag (m) to match FROM at the start of any line. Every valid Dockerfile must have at least one FROM instruction.
- Save analyzer.ts.
Before you run the final test, what do you think the analyzer will return for a Dockerfile with FROM node:latest and an apt-get install without cleanup? How many findings would you expect?
- Create a temporary test file at the project root to verify the analyzer works end-to-end:
import { analyzeDockerfile, validateInput } from "./app/lib/analyzer";
// A deliberately insecure Dockerfile
const testDockerfile = `FROM node:latest
RUN apt-get update && apt-get install -y curl
ENV API_KEY=secret123
COPY .env /app/.env
RUN curl https://example.com/install.sh | bash
CMD ["node", "index.js"]`;
// Test validation
console.log("Validation:", validateInput(testDockerfile));
// Test analysis
const findings = analyzeDockerfile(testDockerfile);
console.log(`\nFound ${findings.length} issues:\n`);
findings.forEach((f) => {
console.log(`[${f.severity.toUpperCase()}] Line ${f.line}: ${f.rule}`);
console.log(` ${f.message}\n`);
});
- Run the test file:
npx tsx test-analyzer.ts
You should see Validation: { valid: true } followed by a list of findings. The test Dockerfile triggers at least 7 findings: unpinned :latest tag, no USER instruction, secrets in ENV, no cache cleanup, no --no-install-recommends, copying a .env file, curl piped to shell, no HEALTHCHECK, and no multi-stage build.
Getting MODULE_NOT_FOUND or import errors?
- Make sure you created test-analyzer.ts in the project root (same level as package.json), not inside app/.
- Verify the import path is ./app/lib/analyzer (relative from the project root).
- If npx tsx prompts you to install a package, type y to confirm.
Still stuck? Help me run my test-analyzer.ts file with npx tsx
- Delete the test file now that you have confirmed the analyzer works:
rm test-analyzer.ts
✔️ Awesome, I've got everything!
Great. Double check you have saved app/lib/analyzer.ts and that npx tsc --noEmit still passes cleanly.
ⓧ I'd like to double check the full code
Your app/lib/analyzer.ts file is longer than 30 lines, so confirm each section is present in this order:
- Severity type, Finding interface, ValidationResult interface (at the top).
- validateInput function (three checks: empty, over 50000, no FROM).
- analyzeDockerfile function opening with tracking variables and the for loop.
- Inside the loop: FROM tag check, USER tracking, HEALTHCHECK tracking, secrets in ENV/ARG, apt-get no-cache-cleanup, apt-get no-install-recommends, ADD vs COPY, EXPOSE privileged ports, curl-pipe-shell, COPY sensitive files.
- After the loop (before return): no-user-instruction check, no-healthcheck check, no-multistage check.
Your deterministic analyzer is locked in. It catches 11+ security anti-patterns with line-number precision and evidence. In the next step, you will wire this up to a UI so you can paste real Dockerfiles and see the raw findings rendered in the browser.
Wire Up the UI and Show Raw Findings
Your analyzer can parse Dockerfiles and catch security issues, but right now it only exists as a TypeScript module with no way to interact with it.
In this step, you'll wire the analyzer to a paste form and an API endpoint so you can actually use it. You'll see findings rendered as a flat, unsorted checklist. This list is informative but limited: no score, no prioritization, no guidance on what matters most.
In this step, get ready to:
- Define Zod validation schemas for input and output data shapes.
- Create a Route Handler that runs the deterministic analyzer on POST requests.
- Build a client component with a Dockerfile paste form that displays raw findings.
Create the Zod validation schemas
Before the API can accept requests, you need schemas that define valid input and the shape of the report. Zod lets you declare these shapes once and use them for both validation and TypeScript type inference.
- Create a new file at app/lib/schemas.ts and add the input validation schema plus the finding schema:
import { z } from "zod";
export const DockerfileInputSchema = z.object({
dockerfile: z
.string()
.min(1, "Dockerfile content is required.")
.max(50000, "Dockerfile content exceeds 50,000 character limit."),
});
export const FindingSchema = z.object({
rule: z.string(),
severity: z.enum(["critical", "high", "medium", "low"]),
line: z.number(),
instruction: z.string(),
message: z.string(),
explanation: z.string(),
fix: z.string(),
});
What does this code do?
DockerfileInputSchema validates incoming requests: the dockerfile field must be a non-empty string under 50,000 characters.
FindingSchema describes what an enhanced finding looks like after AI processing. It includes explanation and fix fields that Claude will fill in later.
- Below the FindingSchema, add the full security report schema and type exports:
export const SecurityReportSchema = z.object({
score: z.number().min(0).max(100),
grade: z.enum(["A", "B", "C", "D", "F"]),
riskLevel: z.enum(["critical", "high", "medium", "low"]),
findings: z.array(FindingSchema),
recommendations: z.array(
z.object({
title: z.string(),
description: z.string(),
})
),
hardenedExample: z.string(),
});
export type SecurityReport = z.infer<typeof SecurityReportSchema>;
export type EnhancedFinding = z.infer<typeof FindingSchema>;
Why define the full report schema now?
SecurityReportSchema describes the structured output Claude will return in the next step: a score, grade, risk level, enhanced findings, recommendations, and a hardened Dockerfile example.
Defining it now means the type system is ready before you add AI integration.
- Save app/lib/schemas.ts.
- Check your terminal where npm run dev is running. You should see no compilation errors.
Seeing a module not found error?
Make sure you installed zod in Step 1. If it's missing, run npm install zod in your project folder and restart the dev server.
help me fix a module not found error for zod in my Next.js project
Create the API Route Handler
Next.js Route Handlers let you define server-side API endpoints using standard Web Request and Response APIs. You'll create a POST endpoint that validates the input, runs the analyzer, and returns the raw findings.
- Create the folder app/api/analyze/ and add a route.ts file inside it with the following code:
import { NextResponse } from "next/server";
import { DockerfileInputSchema } from "@/app/lib/schemas";
import { analyzeDockerfile, validateInput } from "@/app/lib/analyzer";
export async function POST(request: Request) {
try {
const body = await request.json();
const parseResult = DockerfileInputSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.errors[0].message },
{ status: 400 }
);
}
const { dockerfile } = parseResult.data;
const validation = validateInput(dockerfile);
if (!validation.valid) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const findings = analyzeDockerfile(dockerfile);
return NextResponse.json({ rawFindings: findings });
} catch (error) {
console.error("Analysis error:", error);
return NextResponse.json(
{ error: "An unexpected error occurred during analysis." },
{ status: 500 }
);
}
}
What does this Route Handler do?
- DockerfileInputSchema.safeParse(body) validates the request body matches the expected shape.
- validateInput() runs additional checks (empty content, character limit, missing FROM instruction).
- analyzeDockerfile() runs all 11+ security rules and returns the findings array.
- If the Claude API fails later, the outer try/catch returns a generic 500 error so the app never crashes.
- Save app/api/analyze/route.ts.
- Test the endpoint by running this command in a new terminal tab:
curl -X POST http://localhost:3000/api/analyze \
-H "Content-Type: application/json" \
-d '{"dockerfile": "FROM node:latest\nRUN apt-get install -y curl"}'
You should see a JSON response containing a rawFindings array with multiple findings (unpinned tag, no cache cleanup, no USER instruction, etc.).
Getting a 404 or connection refused?
Make sure your dev server is still running in another terminal tab (npm run dev). Verify the file is at the exact path app/api/analyze/route.ts (not src/app/ or pages/api/).
help me debug a 404 error from my Next.js Route Handler at /api/analyze
Build the Dockerfile analysis form
The API works, but you need a form to paste Dockerfiles into and see findings without using curl. You'll replace the default Next.js starter page with a client component that posts to your new endpoint and renders the results.
- Open app/page.tsx and replace its entire contents. Start with the component setup and state declarations:
"use client";
import { useState } from "react";
import type { Finding } from "@/app/lib/analyzer";
export default function Home() {
const [dockerfile, setDockerfile] = useState("");
const [rawFindings, setRawFindings] = useState<Finding[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
- Directly below the state declarations, add the analysis handler that posts to your API:
async function handleAnalyze() {
if (!dockerfile.trim()) {
setError("Please paste or upload a Dockerfile.");
return;
}
setLoading(true);
setError(null);
setRawFindings(null);
try {
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dockerfile }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Analysis failed.");
return;
}
setRawFindings(data.rawFindings);
} catch (err) {
setError("Failed to connect to the analysis server.");
} finally {
setLoading(false);
}
}
- Next, add the file upload handler and the opening of the return JSX with the textarea form:
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setDockerfile(content);
};
reader.readAsText(file);
}
return (
<main className="mx-auto max-w-4xl p-6">
<h1 className="text-3xl font-bold text-gray-900">Docker Security Reviewer</h1>
<p className="mt-2 text-gray-600">Paste a Dockerfile for an instant security audit.</p>
<div className="mt-6 space-y-4">
<textarea
value={dockerfile}
onChange={(e) => setDockerfile(e.target.value)}
placeholder={`FROM node:latest\nRUN apt-get update && apt-get install -y curl\nCOPY . /app\nCMD ["node", "index.js"]`}
className="h-48 w-full rounded-lg border border-gray-300 p-4 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
- Continue with the Analyze button, file upload label, and the error/loading states:
<div className="flex items-center gap-4">
<button
onClick={handleAnalyze}
disabled={loading}
className="rounded-lg bg-blue-600 px-6 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Analyzing..." : "Analyze Dockerfile"}
</button>
<label className="cursor-pointer text-sm text-blue-600 hover:underline">
Or upload a file
<input type="file" accept=".dockerfile,Dockerfile,*" onChange={handleFileUpload} className="hidden" />
</label>
</div>
</div>
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
)}
{loading && (
<div className="mt-6 flex items-center gap-3 text-gray-600">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
Running security analysis...
</div>
)}
- Finally, add the findings list, the empty-state message, and close the component:
{rawFindings && rawFindings.length > 0 && (
<div className="mt-6 space-y-3">
<h2 className="text-lg font-semibold text-gray-900">Raw Findings ({rawFindings.length})</h2>
{rawFindings.map((finding, i) => (
<div key={i} className="rounded border border-gray-200 p-3 text-sm">
<span className="font-medium uppercase text-gray-700">[{finding.severity}]</span>{" "}
{finding.line > 0 && <span className="text-gray-500">Line {finding.line}: </span>}
{finding.message}
{finding.line > 0 && (
<code className="mt-1 block text-xs text-gray-600">{finding.instruction}</code>
)}
</div>
))}
</div>
)}
{rawFindings && rawFindings.length === 0 && (
<div className="mt-6 rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
No security issues found. This Dockerfile follows best practices.
</div>
)}
</main>
);
}
What does this component do?
- handleAnalyze() posts the textarea content to /api/analyze and stores the response findings in state.
- handleFileUpload() reads a dropped or selected file as text and populates the textarea.
- The JSX renders a textarea, an Analyze button, a file upload trigger, and conditional sections for errors, loading spinners, findings, and an empty state.
- Each finding displays its severity as a badge, the line number, the message, and the matched instruction.
- Save app/page.tsx.
- Switch to your browser and refresh http://localhost:3000.
You should see the Docker Security Reviewer heading, a large textarea with placeholder Dockerfile text, and an Analyze button.
Before you click Analyze, think: what do you expect the findings list to look like for a Dockerfile that uses FROM node:latest and runs apt-get install with no cleanup?
- Paste this sample insecure Dockerfile into the textarea:
FROM node:latest
RUN apt-get update && apt-get install -y curl
COPY .env /app/.env
COPY . /app
CMD ["node", "index.js"]
- Click Analyze Dockerfile.
You should see a list of raw findings appear below the form. Each finding shows a severity label (like [critical] or [high]), the line number, and the message explaining what's wrong.
Notice what's missing: the list is completely flat and unsorted. There's no overall score, no letter grade, no prioritization by severity, and no guidance on how to fix each issue. That's the gap Claude will fill in the next step.
Not seeing any findings?
Open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for network errors. If you see a 404 for /api/analyze, confirm the route file is saved at app/api/analyze/route.ts.
help me debug why my Dockerfile analyzer returns no findings when I click Analyze
✔️ Awesome, I've got everything!
Great. Make sure you've saved both app/page.tsx and app/api/analyze/route.ts.
ⓧ I'd like to double check the full code
Since the files exceed 30 lines, scroll up and confirm each section is present in order:
- app/lib/schemas.ts: DockerfileInputSchema, FindingSchema, SecurityReportSchema, and the two type exports.
- app/api/analyze/route.ts: imports, POST function with Zod parse, validateInput, analyzeDockerfile, and error handling.
- app/page.tsx: "use client" directive, imports, 4 state hooks, handleAnalyze, handleFileUpload, textarea form, button, file input, error/loading/findings/empty-state conditionals, and the closing braces.
Your analyzer is live and catching real issues. Next up, you'll bring in Claude to transform this flat checklist into a scored, prioritized security report with explanations and fix snippets.
Add Claude AI to Score and Prioritize Findings
Your deterministic analyzer catches real security issues with line-number precision. But the flat list it produces treats a critical secret leak with the same visual weight as a missing HEALTHCHECK.
In this step, you connect Claude to transform those raw findings into a scored, prioritized security report. Claude adds plain-English explanations and copy-paste fix snippets for every issue, but it never invents new vulnerabilities. It only enriches what the deterministic checks already confirmed.
In this step, get ready to:
- Create the AI analyzer module with Claude structured outputs.
- Update the Route Handler to call Claude after deterministic analysis.
- See a scored, graded security report in the browser.
Create the AI analyzer module
The Anthropic SDK provides a client.messages.parse() method that sends a prompt to Claude and validates the response against a Zod schema automatically. This gives you typed, structured JSON back instead of freeform text.
- Create a new file at app/lib/ai-analyzer.ts and add the imports, function signature, and client setup:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { SecurityReportSchema, type SecurityReport } from "./schemas";
import type { Finding } from "./analyzer";
export async function generateSecurityReport(
dockerfile: string,
findings: Finding[]
): Promise<SecurityReport> {
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
What does this code do?
The zodOutputFormat helper converts your Zod schema into the JSON schema format that Claude's output_config parameter expects. Claude is then contractually bound to return JSON matching your schema.
The client reads ANTHROPIC_API_KEY from your .env.local file via Next.js environment variable loading. This only works in server-side code like Route Handlers.
- Add the client.messages.parse() call directly below the client creation. This is the core AI integration:
const response = await client.messages.parse({
model: "claude-sonnet-4-6",
max_tokens: 4096,
output_config: { format: zodOutputFormat(SecurityReportSchema) },
system: `You are a Docker security expert. You receive a Dockerfile and a list of deterministic security findings (the source of truth). Your job is to:
1. Score the Dockerfile from 0 (completely insecure) to 100 (production-hardened) based ONLY on the deterministic findings provided.
2. Assign a letter grade: A (90-100), B (80-89), C (70-79), D (60-69), F (below 60).
3. Assign an overall risk level based on the highest severity finding present.
4. For each deterministic finding, provide a plain-English explanation of the real-world security impact and a concrete fix snippet showing the corrected Dockerfile instruction.
5. Optionally provide recommendations (clearly labeled as AI suggestions, NOT confirmed vulnerabilities) for additional hardening beyond what the deterministic checks found.
6. Provide a hardenedExample: a complete, corrected version of the input Dockerfile addressing all findings.
CRITICAL RULES:
- Never invent additional confirmed vulnerabilities. The findings array is the source of truth.
- Any AI-only observations MUST go in the recommendations array, not the findings array.
- Keep the same rule, severity, line, instruction, and message from each deterministic finding. Only add explanation and fix fields.
- Fix snippets should be minimal, showing only the corrected line(s).`,
messages: [
{
role: "user",
content: `Analyze this Dockerfile and enhance the deterministic findings with explanations and fixes.\n\nDOCKERFILE:\n\`\`\`dockerfile\n${dockerfile}\n\`\`\`\n\nDETERMINISTIC FINDINGS:\n${JSON.stringify(findings, null, 2)}`,
},
],
});
if (!response.parsed_output) {
throw new Error("Failed to parse Claude response into expected schema.");
}
return response.parsed_output;
}
What does this code do?
The output_config parameter tells Claude to return structured JSON matching SecurityReportSchema. The SDK validates the response and gives you a typed response.parsed_output object.
The system prompt establishes critical boundaries. Claude scores based ONLY on the deterministic findings you pass in. It never invents new confirmed vulnerabilities. Any AI-only observations go into a separate recommendations array clearly labeled as suggestions.
The user message sends both the raw Dockerfile text and the JSON-serialized findings array, giving Claude the full context to explain each issue and write fix snippets.
Why keep AI and deterministic findings separate?
LLMs can hallucinate. If Claude could add its own confirmed vulnerabilities, you could never trust the findings list. By making the deterministic analyzer the source of truth and Claude the explainer, you get reliable detection with accessible explanations.
- Save app/lib/ai-analyzer.ts.
Confirm your editor shows no red squiggly underlines on the import statements.
Seeing a red squiggly under the import?
If your editor shows an error on the @anthropic-ai/sdk import, the package may not be installed. Re-run the install command from Step 1.
help me debug this import error in ai-analyzer.ts
Update the Route Handler to call Claude
The route currently returns raw findings directly. You need to pass those findings through Claude first, then return the full structured report.
- In app/api/analyze/route.ts, find this import block at the top of the file:
import { NextResponse } from "next/server";
import { DockerfileInputSchema } from "@/app/lib/schemas";
import { analyzeDockerfile, validateInput } from "@/app/lib/analyzer";
- Add the generateSecurityReport import so the block looks like this:
import { NextResponse } from "next/server";
import { DockerfileInputSchema } from "@/app/lib/schemas";
import { analyzeDockerfile, validateInput } from "@/app/lib/analyzer";
import { generateSecurityReport } from "@/app/lib/ai-analyzer";
- Next, find this line near the bottom of the POST function:
const findings = analyzeDockerfile(dockerfile);
return NextResponse.json({ rawFindings: findings });
- Replace just the return line with a try/catch that calls Claude and degrades gracefully:
const findings = analyzeDockerfile(dockerfile);
try {
const report = await generateSecurityReport(dockerfile, findings);
return NextResponse.json({ report, rawFindings: findings });
} catch (aiError) {
console.error("Claude API error:", aiError);
return NextResponse.json({
report: null,
rawFindings: findings,
aiError: "AI analysis unavailable. Showing deterministic findings only.",
});
}
Why the nested try/catch?
If Claude's API is down, rate-limited, or your key is invalid, the outer route still returns the deterministic findings. The tool degrades gracefully rather than breaking entirely. The aiError field tells the frontend to show a warning while still displaying raw results.
- Save app/api/analyze/route.ts.
TypeScript error on generateSecurityReport?
Make sure the import path matches your file exactly: "@/app/lib/ai-analyzer". The @/ prefix maps to your project root thanks to the tsconfig.json path alias that create-next-app configured.
help me fix the import error for generateSecurityReport
Update the page to display the scored report
The page currently only handles rawFindings in the response. You need to add state for the full report and render the score when Claude succeeds.
- In app/page.tsx, find the import statement at the top:
import type { Finding } from "@/app/lib/analyzer";
- Add the SecurityReport type import below it:
import type { Finding } from "@/app/lib/analyzer";
import type { SecurityReport } from "@/app/lib/schemas";
- Find the existing state declarations inside the Home component:
const [dockerfile, setDockerfile] = useState("");
const [rawFindings, setRawFindings] = useState<Finding[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
- Replace that block with these expanded state declarations:
const [dockerfile, setDockerfile] = useState("");
const [report, setReport] = useState<SecurityReport | null>(null);
const [rawFindings, setRawFindings] = useState<Finding[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [aiError, setAiError] = useState<string | null>(null);
- Inside handleAnalyze, find these reset lines:
setLoading(true);
setError(null);
setRawFindings(null);
- Replace them with:
setLoading(true);
setError(null);
setAiError(null);
setReport(null);
setRawFindings(null);
- Find the line that sets the raw findings from the response:
setRawFindings(data.rawFindings);
- Replace it with:
if (data.aiError) {
setAiError(data.aiError);
}
setReport(data.report);
setRawFindings(data.rawFindings);
- Finally, add a report display section. Find the closing {loading && ( block and add this directly after it, before the existing rawFindings display:
{aiError && (
<div className="mt-4 rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
{aiError}
</div>
)}
{report && (
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-6">
<div className="text-center">
<div className="text-4xl font-bold text-gray-900">{report.score}</div>
<div className="text-sm text-gray-500">/ 100</div>
</div>
<div className="text-2xl font-bold">{report.grade}</div>
<div className="text-sm uppercase">{report.riskLevel} risk</div>
</div>
</div>
<h2 className="text-lg font-semibold">Findings ({report.findings.length})</h2>
{report.findings.map((f, i) => (
<div key={i} className="rounded border p-3 text-sm">
<span className="font-bold uppercase">[{f.severity}]</span> {f.message}
<p className="mt-1 text-gray-600">{f.explanation}</p>
<pre className="mt-1 rounded bg-gray-800 p-2 text-xs text-green-400">{f.fix}</pre>
</div>
))}
</div>
)}
What does this code do?
The aiError state shows a yellow warning banner when Claude is unavailable. The report state renders the score, grade, risk level, and each enhanced finding with its explanation and fix snippet.
The existing raw findings display still appears as a fallback when report is null. This means the app remains useful even if the Claude API is down.
- Save app/page.tsx.
Verify the AI-powered security report
Before you run the analysis, what do you think the response will look like now compared to the flat list you saw before?
- Make sure your dev server is still running (npm run dev).
- Open http://localhost:3000 in your browser.
- Paste this insecure Dockerfile into the textarea:
FROM node:latest
ENV API_KEY=sk-secret-key-12345
RUN apt-get update && apt-get install -y curl
ADD . /app
COPY .env /app/.env
EXPOSE 22
CMD ["node", "index.js"]
- Click Analyze Dockerfile and wait 3-8 seconds for Claude to respond.
You should see a numeric score (likely between 10-30 for this insecure Dockerfile), a letter grade (F or D), a risk level, and each finding now includes an explanation paragraph and a fix code snippet.
Seeing "AI analysis unavailable" in a yellow banner?
This means the Claude API call failed. Check these common causes:
- Open .env.local and confirm ANTHROPIC_API_KEY is set to a valid key (not the placeholder text).
- Restart the dev server after editing .env.local. Next.js only reads environment files at startup.
- Check the terminal running npm run dev for the specific error message printed by console.error.
help me debug why my Claude API call is failing in the analyze route
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/lib/ai-analyzer.ts and app/api/analyze/route.ts.
ⓧ I'd like to double check the full code
Here is the complete app/lib/ai-analyzer.ts. Confirm your file has both chunks from this substep (the imports and client setup, then the messages.parse call and return):
Scroll up to the two code blocks under "Create the AI analyzer module" and confirm your file contains both in order. The file starts with the four imports, then the generateSecurityReport function containing the client creation, the client.messages.parse() call with the full system prompt, and the parsed_output check.
Here is the complete app/api/analyze/route.ts:
import { NextResponse } from "next/server";
import { DockerfileInputSchema } from "@/app/lib/schemas";
import { analyzeDockerfile, validateInput } from "@/app/lib/analyzer";
import { generateSecurityReport } from "@/app/lib/ai-analyzer";
export async function POST(request: Request) {
try {
const body = await request.json();
const parseResult = DockerfileInputSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.errors[0].message },
{ status: 400 }
);
}
const { dockerfile } = parseResult.data;
const validation = validateInput(dockerfile);
if (!validation.valid) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const findings = analyzeDockerfile(dockerfile);
try {
const report = await generateSecurityReport(dockerfile, findings);
return NextResponse.json({ report, rawFindings: findings });
} catch (aiError) {
console.error("Claude API error:", aiError);
return NextResponse.json({
report: null,
rawFindings: findings,
aiError: "AI analysis unavailable. Showing deterministic findings only.",
});
}
} catch (error) {
console.error("Analysis error:", error);
return NextResponse.json(
{ error: "An unexpected error occurred during analysis." },
{ status: 500 }
);
}
}
Your analyzer now produces a full security report with scores, explanations, and fix snippets. Next up, you will build polished UI components to make this report look as professional as the analysis behind it.
Build the Security Report UI
Your API route returns a full structured security report from Claude, complete with scores, grades, enhanced findings, and fix snippets. But right now your terminal is showing import errors because page.tsx references component files that do not exist yet.
In this step, you will create three React components that transform the structured report data into a polished security dashboard with color-coded severity indicators, expandable detail cards, and copy-paste fix snippets.
In this step, get ready to:
- Create a SecurityReportHeader component with score badge, grade, and risk level.
- Create FindingCard and Recommendations components for detailed finding display.
- Verify the full report UI renders correctly with both insecure and secure Dockerfiles.
Create the SecurityReportHeader component
The report header is the first thing a user sees after analysis. It communicates the overall health of the Dockerfile at a glance with a score, letter grade, and risk level.
- Create a new file at app/components/SecurityReport.tsx and add the imports, color maps, and function declaration with the severity counter:
"use client";
import type { SecurityReport } from "@/app/lib/schemas";
// Map letter grades to Tailwind background colors
const gradeColors: Record<string, string> = {
A: "bg-green-500",
B: "bg-green-400",
C: "bg-yellow-500",
D: "bg-orange-500",
F: "bg-red-500",
};
// Map risk levels to badge colors
const riskColors: Record<string, string> = {
critical: "bg-red-600 text-white",
high: "bg-orange-500 text-white",
medium: "bg-yellow-500 text-black",
low: "bg-blue-500 text-white",
};
export function SecurityReportHeader({ report }: { report: SecurityReport }) {
// Count how many findings exist at each severity
const severityCounts = report.findings.reduce(
(acc, f) => {
acc[f.severity] = (acc[f.severity] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
What does this code do?
The gradeColors and riskColors maps translate letter grades and risk levels into Tailwind CSS classes for colored badges.
The severityCounts reducer tallies findings by severity so the header can show a quick summary like "2 critical, 3 high, 1 medium".
- Still in SecurityReport.tsx, add the return statement directly below the severityCounts reducer to render the score, grade badge, and risk pill:
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-6">
<div className="text-center">
<div className="text-4xl font-bold text-gray-900">{report.score}</div>
<div className="text-sm text-gray-500">/ 100</div>
</div>
<div
className={`flex h-16 w-16 items-center justify-center rounded-full text-2xl font-bold text-white ${gradeColors[report.grade]}`}
>
{report.grade}
</div>
<div>
<span
className={`inline-block rounded-full px-3 py-1 text-sm font-medium ${riskColors[report.riskLevel]}`}
>
{report.riskLevel.toUpperCase()} RISK
</span>
<div className="mt-2 text-sm text-gray-600">
{Object.entries(severityCounts)
.sort(
(a, b) =>
["critical", "high", "medium", "low"].indexOf(a[0]) -
["critical", "high", "medium", "low"].indexOf(b[0])
)
.map(([sev, count]) => `${count} ${sev}`)
.join(", ")}
</div>
</div>
</div>
</div>
);
}
What does the header render?
The header displays three elements side by side: a large score number out of 100, a colored circle with the letter grade (A through F), and a pill-shaped risk level indicator.
Below the risk pill, a summary string counts findings by severity so the user immediately knows what they are dealing with.
- Save app/components/SecurityReport.tsx.
- Check the terminal running npm run dev. You should see the import error for SecurityReport disappear. Errors for FindingCard and Recommendations will remain until the next substep.
Seeing a type error?
Make sure the import path matches exactly: @/app/lib/schemas. The SecurityReport type is exported from that file.
help me fix a type error in SecurityReport.tsx
Create the FindingCard and Recommendations components
Each security finding needs its own card showing the severity, affected line, and what went wrong. The card expands to reveal a plain-English explanation and a concrete fix snippet.
- Create a new file at app/components/FindingCard.tsx and add the directive, imports, and severity style maps:
"use client";
import { useState } from "react";
import type { EnhancedFinding } from "@/app/lib/schemas";
// Card background and border color by severity
const severityStyles: Record<string, string> = {
critical: "border-l-red-600 bg-red-50",
high: "border-l-orange-500 bg-orange-50",
medium: "border-l-yellow-500 bg-yellow-50",
low: "border-l-blue-500 bg-blue-50",
};
// Small badge pill color by severity
const severityBadge: Record<string, string> = {
critical: "bg-red-600 text-white",
high: "bg-orange-500 text-white",
medium: "bg-yellow-500 text-black",
low: "bg-blue-500 text-white",
};
What are the two style maps for?
The severityStyles map controls the card's left border color and background tint. Critical findings get a red border, high gets orange, and so on.
The severityBadge map styles the small label pill that sits at the top of each card.
- Below the style maps in FindingCard.tsx, add the FindingCard function. This first section renders the always-visible collapsed view with the severity badge, line number, rule name, message, instruction snippet, and the expand button:
export function FindingCard({ finding }: { finding: EnhancedFinding }) {
const [expanded, setExpanded] = useState(false);
return (
<div
className={`rounded-lg border-l-4 p-4 ${severityStyles[finding.severity]}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className={`rounded px-2 py-0.5 text-xs font-bold uppercase ${severityBadge[finding.severity]}`}
>
{finding.severity}
</span>
{finding.line > 0 && (
<span className="text-xs text-gray-500">Line {finding.line}</span>
)}
<span className="text-sm font-medium text-gray-700">
{finding.rule}
</span>
</div>
<p className="mt-1 text-sm text-gray-800">{finding.message}</p>
{finding.line > 0 && (
<code className="mt-2 block rounded bg-gray-800 p-2 text-xs text-green-400">
{finding.instruction}
</code>
)}
</div>
<button
onClick={() => setExpanded(!expanded)}
className="ml-4 text-sm text-blue-600 hover:underline"
>
{expanded ? "Less" : "More"}
</button>
</div>
- Directly below the closing </div> you just pasted, add the expandable detail section that shows the AI explanation and fix snippet, then close the component:
{expanded && (
<div className="mt-3 border-t border-gray-200 pt-3">
<p className="text-sm text-gray-700">{finding.explanation}</p>
<div className="mt-2">
<span className="text-xs font-medium text-gray-500">
Recommended fix:
</span>
<pre className="mt-1 rounded bg-gray-800 p-3 text-xs text-green-400 overflow-x-auto">
{finding.fix}
</pre>
</div>
</div>
)}
</div>
);
}
How does the expand/collapse work?
The expanded state boolean toggles when the user clicks "More" or "Less". When expanded is true, the component renders a bordered section showing the AI-generated explanation and a <pre> block with the fix snippet.
This keeps the findings list scannable. Users only drill into detail for findings they care about.
- Save app/components/FindingCard.tsx.
- Check your terminal. The FindingCard import error should disappear.
Now create the Recommendations component that displays AI-only observations separately from confirmed findings.
- Create a new file at app/components/Recommendations.tsx with the following content:
"use client";
export function Recommendations({
recommendations,
}: {
recommendations: { title: string; description: string }[];
}) {
// Don't render anything if there are no recommendations
if (recommendations.length === 0) return null;
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-blue-800">
<span className="rounded bg-blue-200 px-2 py-0.5 text-xs">
AI Suggestion
</span>
Additional Recommendations
</h3>
<ul className="mt-3 space-y-2">
{recommendations.map((rec, i) => (
<li key={i} className="text-sm text-blue-900">
<span className="font-medium">{rec.title}:</span> {rec.description}
</li>
))}
</ul>
</div>
);
}
Why separate recommendations from findings?
The deterministic findings are confirmed issues with line-number evidence. Recommendations are AI-generated suggestions that go beyond what the static rules caught.
Giving them a distinct blue style with an "AI Suggestion" badge makes the boundary between verified facts and AI opinions visually clear. This is critical for trust in a security tool.
- Save app/components/Recommendations.tsx.
- Check your terminal. All import errors should be gone and the dev server should compile successfully.
Still seeing import errors?
Confirm your files are saved at exactly these paths: app/components/SecurityReport.tsx, app/components/FindingCard.tsx, and app/components/Recommendations.tsx. The import paths in page.tsx use @/app/components/ which maps to this exact directory.
help me fix component import errors in my Next.js app
✔️ Awesome, I've got everything!
Great. Make sure all three component files are saved before continuing.
ⓧ I'd like to double check the full code
For SecurityReport.tsx: scroll up and confirm you have the imports and color maps chunk first, then the return statement chunk closing the function.
For FindingCard.tsx: confirm you have the style maps chunk first, then the function start chunk (through the closing </div> of the flex-between wrapper), then the expanded section chunk closing the function.
For Recommendations.tsx, here is the complete file:
"use client";
export function Recommendations({
recommendations,
}: {
recommendations: { title: string; description: string }[];
}) {
if (recommendations.length === 0) return null;
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-blue-800">
<span className="rounded bg-blue-200 px-2 py-0.5 text-xs">
AI Suggestion
</span>
Additional Recommendations
</h3>
<ul className="mt-3 space-y-2">
{recommendations.map((rec, i) => (
<li key={i} className="text-sm text-blue-900">
<span className="font-medium">{rec.title}:</span> {rec.description}
</li>
))}
</ul>
</div>
);
}
Test the full report UI with insecure and secure Dockerfiles
All three components are in place. Time to test the full end-to-end experience.
- Return to your browser at http://localhost:3000.
- Paste this insecure Dockerfile into the textarea:
FROM node:latest
ENV API_KEY=sk-secret-12345
RUN apt-get update && apt-get install -y curl
ADD . /app
EXPOSE 22
CMD ["node", "index.js"]
Before you click Analyze Dockerfile: what score range do you expect for this Dockerfile? Think about how many rules it violates.
- Click Analyze Dockerfile.
After 3-8 seconds, you should see the full security report. The score will be low (likely an F grade) with a red or orange risk level pill. The findings list shows severity-sorted cards with colored left borders.
- Click More on any finding to expand the explanation and fix snippet.
- Scroll down to confirm the blue Recommendations section appears with the "AI Suggestion" label.
- Click Show Hardened Dockerfile to see the corrected version in a code block.
Now test the positive case to confirm high scores render correctly.
- Clear the textarea and paste this minimal secure Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
HEALTHCHECK CMD wget --spider http://localhost:3000 || exit 1
CMD ["node", "index.js"]
- Click Analyze Dockerfile.
- You should see a high score (80+) with a green or yellow grade badge. The findings list will be short or empty. Recommendations may appear with the blue "AI Suggestion" label.
Report not rendering?
If you see a yellow warning bar saying AI analysis is unavailable, check that your ANTHROPIC_API_KEY in .env.local is correct and that your account has available credits. The app should still show raw findings as a fallback.
If nothing renders at all, open your browser's developer console and check for JavaScript errors. A common issue is a missing file or typo in a component export name.
help me debug why my security report UI is not rendering
Your security reviewer now delivers a polished, actionable report from a single paste. Next up, you will take on the Secret Mission and add a one-click hardened Dockerfile generator.
Secret mission
Generate a One-Click Hardened Dockerfile
What if your reviewer could not only find security issues but fix them too? Add a one-click hardened Dockerfile generator that produces a corrected version addressing all findings, complete with a changelog showing what changed and why.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with no cloud resources to clean up. The only cost is Anthropic API usage (approximately $0.01-0.03 per analysis), which only occurs when you actively submit a Dockerfile for review.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- docker-security-reviewer project folder on your Desktop (contains all source code and node_modules).
- Next.js dev server (running process in your terminal).
- Anthropic API key stored in .env.local inside the project folder.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the Docker Security Reviewer.
- Your dev server continues running at http://localhost:3000.
- Each analysis you run costs approximately $0.01-0.03 in Anthropic API tokens.
- No charges occur when the app is idle.
Pause - I'll come back to this later
Stop the dev server to free up your terminal. Your project files stay on disk and you can restart any time.
- In the terminal where your dev server is running, press Ctrl+C to stop the process.
- No ongoing costs while paused. Resume later by running npm run dev from the docker-security-reviewer folder.
Delete - I don't want to use this again
Remove all project files and start fresh. This is irreversible.
- In the terminal where your dev server is running, press Ctrl+C to stop the process.
- Delete the project folder by running:
cd ~/Desktop
rm -rf docker-security-reviewer
- On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force docker-security-reviewer
- Confirm deletion by running ls (macOS/Linux) or dir (Windows). You should no longer see docker-security-reviewer in the output.
- Your .env.local file (containing your Anthropic API key) was inside the project folder and is now deleted. If you want to revoke the key entirely so it can never be used again, visit your Anthropic API keys dashboard and delete the key there.
Nice Work!
Nice Work!
You just built a Docker Security Reviewer that combines deterministic static analysis with AI-powered scoring to produce actionable security reports from any Dockerfile.
You've learned how to:
- Build a deterministic Dockerfile analyzer that catches 11+ common vulnerabilities with line-number precision, producing findings with rule IDs, severity levels, and matched instructions.
- Integrate Claude's structured output API via zodOutputFormat and client.messages.parse() to transform raw findings into scored, prioritized security reports with explanations and fix snippets.
- Design a system where AI augments but never replaces deterministic analysis, clearly separating confirmed findings from AI recommendations so security claims stay grounded in evidence.
- Secret Mission: Added iterative AI-assisted remediation with a one-click hardened Dockerfile generator that produces a corrected version alongside a changelog of what was fixed and why.
Ready to quiz yourself?