AI Kubernetes Security Reviewer
Scan Kubernetes manifests for security misconfigurations with AI-powered fixes.
Introduction
30 Second Summary
Security misconfigurations in Kubernetes manifests are one of the most common causes of container breaches. A single privileged: true buried in a YAML file can hand an attacker full access to the host machine.
In this project, you will build a web app that scans Kubernetes manifests for security vulnerabilities, flags exact line numbers, and uses Claude AI to explain attack scenarios and generate hardened replacements.
What You'll Build
Paste any Kubernetes manifest into a textarea and instantly see a prioritized security report with line-accurate findings, real-world attack scenarios, and one-click secure YAML fixes.
By the end of this project, you'll have:
- A deterministic security engine that checks 11 misconfigurations across your manifests, flagging each one with an exact line number, severity level, and the offending YAML snippet as evidence.
- A Claude-powered risk prioritization layer that scores findings by exploitability, describes how an attacker would exploit each one, and generates a secure YAML replacement you can paste straight in.
- A clear architecture separating confirmed findings (the source of truth from static analysis) from AI recommendations (labeled advisory), proving you can build systems where rule engines guarantee correctness and LLMs provide contextual intelligence.
- Secret Mission: Generate a complete hardened manifest with a visual diff view and one-click download.
Are there any prerequisites?
This project assumes familiarity with TypeScript, the Next.js App Router, and basic Kubernetes manifest structure (Deployments, Services, Pods). You will also need an Anthropic API key.
Before We Start
Before writing any code, let's commit to what you're building and why. This ensures every file you create in the next steps serves a clear purpose in the overall architecture.
Set Up the Project
The Kubernetes Security Reviewer needs three foundational pieces: a Next.js app for the UI and API routes, a YAML parser that tracks line numbers, and the Anthropic SDK for structured AI outputs.
In this step, you will scaffold the project, install all dependencies, and confirm the dev server runs cleanly before writing any security logic.
In this step, get ready to:
- Scaffold a Next.js app with TypeScript and App Router.
- Install the YAML parsing, AI SDK, and schema validation packages.
- Start the dev server and confirm it loads at localhost:3000.
Scaffold the Next.js app
Every file in this project lives inside a single Next.js app that provides both the frontend textarea and the backend API routes for scanning and analysis.
- Press Cmd+` (macOS) or Ctrl+` (Windows) to open the integrated terminal in VS Code.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project and move into it by running these commands:
npx create-next-app@latest k8s-security-reviewer --yes
cd k8s-security-reviewer
What does this command do?
The --yes flag accepts all the default options without prompting you. This scaffolds a Next.js 16.3 project with TypeScript, Tailwind CSS, ESLint, App Router, and Turbopack pre-configured.
The scaffold creates dozens of files (components, config, styles). You will replace most of them in later steps.
Prompted to install create-next-app?
If you see a message asking to install create-next-app, type y and press Enter to confirm. This only happens the first time.
You should see output ending with a success message and the project folder listed. Your terminal is now inside k8s-security-reviewer.
Seeing 'command not found' for npx?
Confirm Node.js is installed by running node --version in your terminal. You should see v22 or higher. If not, revisit your Node.js installation.
Help me troubleshoot this
Install project dependencies
The scanner needs three packages beyond what Next.js provides: a YAML parser with line-number tracking, the Anthropic SDK for Claude structured outputs, and Zod for schema validation.
- Install the three project-specific packages by running:
npm install yaml@2.9.0 @anthropic-ai/sdk@0.116.0 zod
What are these packages?
- yaml@2.9.0 parses multi-document Kubernetes YAML and exposes a LineCounter API for tracking line positions.
- @anthropic-ai/sdk@0.116.0 provides the Claude client with built-in structured output support via zodOutputFormat.
- zod defines TypeScript-first schemas that Claude's output must conform to.
You should see output confirming all three packages were added to package.json.
- Now create the environment file that holds your API key by running:
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env.local
- Open .env.local in VS Code and replace your-api-key-here with your actual Anthropic API key. Save the file.
Why .env.local?
Next.js automatically loads .env.local into process.env on the server side. The Anthropic SDK reads ANTHROPIC_API_KEY from the environment by default, so no extra config is needed.
This file is already listed in .gitignore by the Next.js scaffold, keeping your key out of version control.
npm install failing?
Check your internet connection. These packages download from the npm registry.
If you see peer dependency warnings, those are safe to ignore. Errors mentioning ERESOLVE mean a version conflict. Run npm install --legacy-peer-deps as a fallback.
Help me fix this install error
Start the dev server and verify
Before you run this, what do you expect to see at localhost:3000? You have not written any custom code yet, so think about what Next.js shows by default.
- Start the development server by running:
npm run dev
What does npm run dev do?
This starts the Next.js development server on port 3000. It takes over your current terminal session. To stop it later, press Ctrl+C.
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js starter page with the Next.js logo and links to documentation. This confirms your scaffold, dependencies, and dev server all work correctly.
Page not loading at localhost:3000?
Check your terminal for error messages. If you see a port conflict, another process is using port 3000. Stop it or run npx next dev -p 3001 to use a different port.
If the terminal shows a compilation error, confirm you are inside the k8s-security-reviewer folder by checking that package.json exists in your current directory.
Help me get the dev server running
Your project scaffold is ready and the dev server is confirmed working. Next up, you will build a YAML parser that extracts Kubernetes resource metadata with precise line numbers.
Build the YAML Parser and Input UI
Your project scaffold is running and dependencies are installed. Now you need to turn raw Kubernetes YAML into structured data the scanner can reason about.
In this step, you will build a multi-document YAML parser that extracts resource metadata with line numbers, then wire it into a page where you can paste manifests and see parsed results instantly.
In this step, get ready to:
- Define TypeScript interfaces for Kubernetes resources and security findings.
- Build a scanner page with a YAML textarea and a results panel.
- Create a YAML parser and API route that extracts resource metadata with line numbers.
Define the TypeScript interfaces
Every file in this project needs a shared vocabulary for what a Kubernetes resource looks like and what a security finding contains. These interfaces act as the contract between the parser, the rule engine, and the frontend.
- Create a new file called lib/types.ts inside your k8s-security-reviewer folder.
- Add the resource and container interfaces by pasting this code:
export interface K8sContainer {
name: string;
image?: string;
securityContext?: Record<string, unknown>;
resources?: Record<string, unknown>;
line: number;
}
export interface K8sResource {
kind: string;
name: string;
namespace: string;
containers: K8sContainer[];
raw: Record<string, unknown>;
line: number;
}
- Add the security finding and scan result interfaces below the resource interfaces:
export interface SecurityFinding {
ruleId: string;
category: string;
severity: "critical" | "high" | "medium" | "low";
resourceKind: string;
resourceName: string;
namespace: string;
containerName: string;
line: number;
message: string;
yamlEvidence: string;
}
export interface ScanResult {
resources: K8sResource[];
findings: SecurityFinding[];
}
What do these interfaces do?
K8sContainer and K8sResource represent the parsed shape of a Kubernetes manifest. Each container tracks its own line number so findings can point to exact locations in the source YAML.
SecurityFinding is the output format for every issue the scanner detects. It records which rule flagged it, what resource and container are affected, and includes a snippet of the offending YAML as evidence.
- Save lib/types.ts.
✔️ Awesome, I've got everything!
Great. Confirm you have saved lib/types.ts with all four interfaces.
ⓧ I'd like to double check the full code
Scroll up and confirm your file contains, in order:
- K8sContainer interface (6 fields including line).
- K8sResource interface (6 fields including containers and raw).
- SecurityFinding interface (10 fields).
- ScanResult interface with resources and findings arrays.
Build the scanner page
With your types defined, you can build the page the learner interacts with. This page needs a textarea for pasting YAML, a button to trigger scanning, and a results panel to display parsed resources.
- Open app/page.tsx in VS Code (this is the default page that currently shows the Next.js starter).
- Replace the entire file contents with the imports and sample YAML data by pasting this code:
"use client";
import { useState } from "react";
import { ScanResult } from "@/lib/types";
const SAMPLE_YAML = `apiVersion: apps/v1
kind: Deployment
metadata:
name: insecure-app
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: insecure-app
template:
metadata:
labels:
app: insecure-app
spec:
hostNetwork: true
containers:
- name: web
image: nginx:latest
securityContext:
privileged: true
allowPrivilegeEscalation: true
ports:
- containerPort: 80
- Add the rest of the sample manifest right below (this continues the same template literal string):
- name: sidecar
image: busybox
command: ["sleep", "infinity"]
---
apiVersion: v1
kind: Service
metadata:
name: insecure-service
spec:
type: LoadBalancer
selector:
app: insecure-app
ports:
- port: 80
targetPort: 80`;
What is SAMPLE_YAML?
This is a deliberately insecure Kubernetes manifest pre-filled in the textarea so you have something to scan immediately. It contains a Deployment with privileged: true, hostNetwork: true, mutable image tags, and a LoadBalancer Service.
The --- separator creates a multi-document YAML stream. Your parser will handle each document independently.
- Add the component function with state declarations and the scan handler below the closing backtick:
export default function Home() {
const [yamlInput, setYamlInput] = useState(SAMPLE_YAML);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleScan() {
setScanning(true);
setError(null);
try {
const res = await fetch("/api/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ yaml: yamlInput }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Scan failed");
}
const data: ScanResult = await res.json();
setScanResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Scan failed");
} finally {
setScanning(false);
}
}
What does handleScan do?
When the learner clicks the Scan button, handleScan sends the YAML text to your /api/scan endpoint via POST. The response contains parsed resources which get stored in scanResult state.
If the fetch fails, the error message is captured and displayed to the user. The finally block resets the loading state regardless of outcome.
- Add the JSX return with the input panel below the handleScan function:
return (
<main className="min-h-screen bg-gray-950 text-gray-100 p-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-2">
K8s Security Reviewer
</h1>
<p className="text-gray-400 mb-6">
Paste a Kubernetes manifest to scan for security misconfigurations
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Input Panel */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Kubernetes YAML Manifest
</label>
<textarea
value={yamlInput}
onChange={(e) => setYamlInput(e.target.value)}
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 font-mono text-sm text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Paste your Kubernetes YAML here..."
/>
<button
onClick={handleScan}
disabled={scanning || !yamlInput.trim()}
className="mt-4 px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 rounded-lg font-medium transition-colors"
>
{scanning ? "Scanning..." : "Scan Manifest"}
</button>
</div>
- Add the results panel and closing tags below the input panel div:
{/* Results Panel */}
<div className="space-y-4">
{error && (
<div className="p-4 bg-red-900/50 border border-red-700 rounded-lg text-red-200">
{error}
</div>
)}
{scanResult && (
<>
{/* Resources */}
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">
Resources Found ({scanResult.resources.length})
</h2>
<div className="space-y-2">
{scanResult.resources.map((r, i) => (
<div
key={i}
className="flex items-center gap-3 text-sm"
>
<span className="px-2 py-0.5 bg-gray-800 rounded text-gray-300">
{r.kind}
</span>
<span className="text-gray-200">{r.name}</span>
<span className="text-gray-500">
ns:{r.namespace}
</span>
<span className="text-gray-600">
line {r.line}
</span>
- Finish the component by adding the closing tags right below:
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
</div>
</main>
);
}
- Save app/page.tsx.
- Check your browser at localhost:3000. You should see a dark page with the title "K8s Security Reviewer", a large textarea pre-filled with the sample YAML, and a blue "Scan Manifest" button.
Page not showing or seeing errors?
- Check the terminal where npm run dev is running for any TypeScript compilation errors. A missing bracket or typo in the template literal is the most common cause.
- Confirm lib/types.ts exists and exports ScanResult. The page imports it.
- Make sure the SAMPLE_YAML backtick string closes with `; after the last line of YAML (targetPort: 80).
Still stuck? Help me fix a TypeScript error in my page.tsx file that imports ScanResult from lib/types.ts
Create the YAML parser and API route
The page renders but clicking Scan will fail because the /api/scan endpoint does not exist yet. You need a parser that turns raw YAML into the K8sResource array, and a route handler to call it.
- Create a new file lib/parser.ts and add the main parse function by pasting this code:
import { parseAllDocuments, LineCounter, Document } from "yaml";
import { K8sResource, K8sContainer } from "./types";
export function parseK8sYaml(yamlText: string): K8sResource[] {
const lineCounter = new LineCounter();
const docs = parseAllDocuments(yamlText, { lineCounter });
const resources: K8sResource[] = [];
for (const doc of docs) {
if (doc.errors.length > 0 || !doc.contents) continue;
const obj = doc.toJS() as Record<string, unknown>;
if (!obj || typeof obj !== "object" || !obj.kind) continue;
const metadata = (obj.metadata as Record<string, unknown>) || {};
const spec = (obj.spec as Record<string, unknown>) || {};
const templateSpec = getTemplateSpec(obj);
const docRange = doc.contents.range;
const docLine = docRange
? lineCounter.linePos(docRange[0]).line
: 1;
const containers = extractContainers(
templateSpec || spec,
doc,
lineCounter
);
- Add the resource assembly and close of the function right below:
resources.push({
kind: obj.kind as string,
name: (metadata.name as string) || "unnamed",
namespace: (metadata.namespace as string) || "default",
containers,
raw: obj,
line: docLine,
});
}
return resources;
}
What does parseK8sYaml do?
parseAllDocuments from the yaml package splits multi-document YAML (separated by ---) into individual documents. The LineCounter tracks byte offsets so you can convert any node position to a 1-indexed line number.
For each valid document, the function extracts the Kubernetes resource kind, name, namespace, and the list of containers. Each entry stores the raw object for later rule checks.
- Add the helper functions that extract nested Kubernetes spec data. Paste these below the parseK8sYaml function:
function getTemplateSpec(
obj: Record<string, unknown>
): Record<string, unknown> | null {
const spec = obj.spec as Record<string, unknown> | undefined;
if (!spec) return null;
const template = spec.template as Record<string, unknown> | undefined;
if (!template) return null;
return (template.spec as Record<string, unknown>) || null;
}
function extractContainers(
podSpec: Record<string, unknown>,
doc: Document,
lineCounter: LineCounter
): K8sContainer[] {
const containers: K8sContainer[] = [];
const rawContainers = (podSpec.containers as Record<string, unknown>[]) || [];
rawContainers.forEach((c, index) => {
const line = findContainerLine(doc, lineCounter, index);
containers.push({
name: (c.name as string) || `container-${index}`,
image: c.image as string | undefined,
securityContext: c.securityContext as Record<string, unknown> | undefined,
resources: c.resources as Record<string, unknown> | undefined,
line,
});
});
return containers;
}
What do these helpers do?
getTemplateSpec navigates the nested structure of a Deployment or StatefulSet to reach the pod template spec where containers live.
extractContainers iterates over the containers array and builds a K8sContainer for each one, capturing its image, securityContext, resource limits, and source line number.
- Add the final helper that resolves container line numbers. Paste this at the bottom of the file:
function findContainerLine(
doc: Document,
lineCounter: LineCounter,
index: number
): number {
try {
const containersNode = doc.getIn(
["spec", "template", "spec", "containers", index],
true
);
if (containersNode && "range" in containersNode && containersNode.range) {
return lineCounter.linePos(containersNode.range[0]).line;
}
const directNode = doc.getIn(["spec", "containers", index], true);
if (directNode && "range" in directNode && directNode.range) {
return lineCounter.linePos(directNode.range[0]).line;
}
} catch {
// Fall through to default
}
return 1;
}
How does line tracking work?
The yaml package's Document.getIn() method with true as the second argument returns the raw AST node (not the JS value). Each AST node has a range array containing byte offsets.
Passing that offset to lineCounter.linePos() gives you the 1-indexed line and column. The function tries the templated path first (for Deployments) and falls back to a direct path (for Pods).
- Save lib/parser.ts.
✔️ Awesome, I've got everything!
Confirm your file has four sections: imports, parseK8sYaml, getTemplateSpec + extractContainers, and findContainerLine.
ⓧ I'd like to double check the full code
Scroll up and confirm your lib/parser.ts contains these sections in order:
- Imports from "yaml" (parseAllDocuments, LineCounter, Document) and from "./types".
- Exported parseK8sYaml function that creates a LineCounter, iterates documents, and pushes resources.
- getTemplateSpec helper navigating to spec.template.spec.
- extractContainers helper iterating raw containers and building K8sContainer objects.
- findContainerLine helper using doc.getIn() with the AST node range.
Now create the API route that connects the parser to the frontend.
- Create the file app/api/scan/route.ts (you will need to create the api/scan/ directories inside app/).
- Add the route handler by pasting this code:
import { NextResponse } from "next/server";
import { parseK8sYaml } from "@/lib/parser";
import { ScanResult } from "@/lib/types";
export async function POST(request: Request) {
try {
const { yaml: yamlText } = (await request.json()) as { yaml: string };
if (!yamlText || typeof yamlText !== "string") {
return NextResponse.json(
{ error: "YAML text is required" },
{ status: 400 }
);
}
const resources = parseK8sYaml(yamlText);
if (resources.length === 0) {
return NextResponse.json(
{ error: "No valid Kubernetes resources found in YAML" },
{ status: 400 }
);
}
const result: ScanResult = { resources, findings: [] };
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ error: "Failed to parse YAML" },
{ status: 500 }
);
}
}
What does this route do?
This is a Next.js Route Handler. It exports a POST function that receives JSON with a yaml field, passes it through the parser, and returns the structured resources.
The findings array is empty for now. In the next step, you will plug in the security rule engine to populate it.
- Save app/api/scan/route.ts.
Seeing compile errors in the terminal?
- Check that lib/parser.ts exports the parseK8sYaml function (the word export must appear before function parseK8sYaml).
- Verify the directory structure: app/api/scan/route.ts must be exactly three levels deep inside app/.
- Confirm lib/types.ts exports ScanResult. Both the route and the page import it.
Need more help? Help me fix a Next.js route handler compilation error in app/api/scan/route.ts
Before you click Scan, what do you think will happen? Will you see an error or a list of resources?
- Switch to your browser at localhost:3000 and click the Scan Manifest button.
You should see a "Resources Found (2)" panel appear on the right. It lists two entries: a Deployment called insecure-app in namespace production and a Service called insecure-service in namespace default. Each shows its starting line number.
✔️ I see Resources Found (2)
Your parser correctly split the multi-document YAML and extracted both resources with their metadata. The line numbers prove the LineCounter integration is working.
ⓧ I see an error message
- If the error says "Failed to parse YAML", check lib/parser.ts for syntax errors. The parseAllDocuments import must come from "yaml" (the package you installed in Step 1).
- If the error says "No valid Kubernetes resources found", confirm the SAMPLE_YAML template literal has no extra characters before apiVersion. Indentation matters in YAML.
- If you see a 404, verify app/api/scan/route.ts exists at that exact path and exports an async function POST.
ⓧ Nothing happens when I click
- Open your browser DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for red error messages.
- Confirm the onClick={handleScan} prop is on the button element in your app/page.tsx.
- Check that "use client" is the very first line of app/page.tsx. Without it, React hooks and event handlers will not work.
Your parser handles multi-document YAML and extracts resource metadata with precise line numbers. Next, you will build the security rule engine that inspects these resources for misconfigurations.
Implement the Deterministic Security Engine
Your parser extracts structured resource data from Kubernetes YAML. Now you need something to actually check that data for security problems.
In this step, you'll build a deterministic rule engine that inspects each parsed resource and container for 11 common Kubernetes misconfigurations. Each finding carries a precise rule ID, severity level, line number, and YAML evidence snippet. The engine produces a flat list of findings with no prioritization or remediation guidance. That gap is intentional.
In this step, get ready to:
- Build a rule engine with 11 deterministic security checks covering container isolation, privilege escalation, host namespaces, and more.
- Wire the rule engine into the scan API and display findings in the frontend.
- Observe the gap between raw findings and actionable security intelligence.
Create the rule engine scaffold
Each security rule follows the same pattern: receive a resource and container, check for a specific misconfiguration, and return a structured finding if the check fails. You'll start by defining the shared types and helper function that all 11 rules use.
- Create a new file called lib/rules.ts in your project.
- Add the imports, rule type definition, and dangerous capabilities list by pasting this code:
import { K8sResource, K8sContainer, SecurityFinding } from "./types";
type RuleCheck = (
resource: K8sResource,
container: K8sContainer
) => SecurityFinding | null;
const DANGEROUS_CAPABILITIES = [
"SYS_ADMIN",
"NET_ADMIN",
"SYS_PTRACE",
"SYS_RAWIO",
"DAC_OVERRIDE",
"NET_RAW",
"ALL",
];
What does this code do?
The RuleCheck type defines the contract every security rule must follow: accept a resource and container, return either a finding or null.
The DANGEROUS_CAPABILITIES array lists Linux capabilities that grant near-root-level access to containers. Rules will check whether any container adds these capabilities.
- Below the DANGEROUS_CAPABILITIES array, add the makeFinding helper function that all rules use to construct their output:
function makeFinding(
ruleId: string,
category: string,
severity: SecurityFinding["severity"],
resource: K8sResource,
container: K8sContainer,
message: string,
yamlEvidence: string
): SecurityFinding {
return {
ruleId,
category,
severity,
resourceKind: resource.kind,
resourceName: resource.name,
namespace: resource.namespace,
containerName: container.name,
line: container.line,
message,
yamlEvidence,
};
}
What does this code do?
Every rule returns the same structured shape. Instead of repeating 10 assignment lines in each rule, makeFinding centralizes it. It maps the resource and container metadata into the SecurityFinding interface you defined in lib/types.ts.
Implement the container isolation and identity checks
The first rules target the most critical misconfigurations: containers running in privileged mode and containers running as root. These are the two most commonly exploited Kubernetes security gaps.
- Below makeFinding, add the privileged container check:
const checkPrivileged: RuleCheck = (resource, container) => {
const sc = container.securityContext;
if (sc?.privileged === true) {
return makeFinding(
"K8S-SEC-001",
"Container Isolation",
"critical",
resource,
container,
"Container runs in privileged mode, granting full host access",
"privileged: true"
);
}
return null;
};
What does this code do?
This checks the simplest and most dangerous misconfiguration: privileged: true on a container's securityContext. A privileged container has full access to the host kernel, making container escape trivial for an attacker.
- Add the root execution check below checkPrivileged:
const checkRunAsRoot: RuleCheck = (resource, container) => {
const sc = container.securityContext;
const podSc = getPodSecurityContext(resource);
const runAsNonRoot = sc?.runAsNonRoot ?? podSc?.runAsNonRoot;
const runAsUser = sc?.runAsUser ?? podSc?.runAsUser;
if (runAsNonRoot !== true && runAsUser !== 0 && runAsUser === undefined) {
if (sc?.runAsUser === 0) {
return makeFinding(
"K8S-SEC-002",
"Identity & Access",
"high",
resource,
container,
"Container explicitly runs as root (UID 0)",
"runAsUser: 0"
);
}
if (!runAsNonRoot) {
return makeFinding(
"K8S-SEC-002",
"Identity & Access",
"high",
resource,
container,
"Container does not enforce non-root execution (runAsNonRoot not set)",
"securityContext: # runAsNonRoot missing"
);
}
}
return null;
};
Why does this rule check two levels?
Kubernetes evaluates securityContext at both the pod level and the container level, with the container value taking precedence. This rule mirrors that fallback chain: it checks the container's securityContext first, then falls back to the pod-level context.
The getPodSecurityContext helper doesn't exist yet. You'll add it at the end of the file along with the runner function.
- Add the privilege escalation and dangerous capabilities checks:
const checkPrivilegeEscalation: RuleCheck = (resource, container) => {
const sc = container.securityContext;
if (!sc || sc.allowPrivilegeEscalation !== false) {
return makeFinding(
"K8S-SEC-003",
"Privilege Escalation",
"high",
resource,
container,
"allowPrivilegeEscalation is not explicitly set to false",
"allowPrivilegeEscalation: # not set to false"
);
}
return null;
};
const checkDangerousCapabilities: RuleCheck = (resource, container) => {
const caps = container.securityContext?.capabilities as
| Record<string, unknown>
| undefined;
if (!caps) return null;
const addCaps = (caps.add as string[]) || [];
const dangerous = addCaps.filter((c) =>
DANGEROUS_CAPABILITIES.includes(c.toUpperCase())
);
if (dangerous.length > 0) {
return makeFinding(
"K8S-SEC-004",
"Capabilities",
"critical",
resource,
container,
`Dangerous capabilities added: ${dangerous.join(", ")}`,
`capabilities:\n add: [${dangerous.join(", ")}]`
);
}
return null;
};
What do these rules check?
K8S-SEC-003 flags containers where allowPrivilegeEscalation is not explicitly set to false. Without this, a process inside the container can gain more privileges than its parent.
K8S-SEC-004 checks for Linux capabilities like SYS_ADMIN and NET_ADMIN that effectively give a container root-level powers over the host.
- Save lib/rules.ts.
Save frequently as you build
You'll continue adding rules to this file. Save periodically so you don't lose work. The dev server won't compile this file until it's imported somewhere, so you won't see errors yet.
- Add the host namespace and host path volume checks below checkDangerousCapabilities:
const checkHostNamespaces: RuleCheck = (resource, container) => {
const podSpec = getPodSpec(resource);
if (!podSpec) return null;
const violations: string[] = [];
if (podSpec.hostNetwork === true) violations.push("hostNetwork: true");
if (podSpec.hostPID === true) violations.push("hostPID: true");
if (podSpec.hostIPC === true) violations.push("hostIPC: true");
if (violations.length > 0) {
return makeFinding(
"K8S-SEC-005",
"Host Isolation",
"critical",
resource,
container,
`Pod uses host namespaces: ${violations.join(", ")}`,
violations.join("\n")
);
}
return null;
};
const checkHostPathVolumes: RuleCheck = (resource, container) => {
const podSpec = getPodSpec(resource);
if (!podSpec) return null;
const volumes = (podSpec.volumes as Record<string, unknown>[]) || [];
const hostPaths = volumes.filter((v) => v.hostPath);
if (hostPaths.length > 0) {
const paths = hostPaths.map(
(v) => (v.hostPath as Record<string, unknown>)?.path || "unknown"
);
return makeFinding(
"K8S-SEC-006",
"Volume Security",
"high",
resource,
container,
`hostPath volumes mounted: ${paths.join(", ")}`,
hostPaths
.map(
(v) =>
`- name: ${v.name}\n hostPath:\n path: ${(v.hostPath as Record<string, unknown>)?.path}`
)
.join("\n")
);
}
return null;
};
What do these rules check?
K8S-SEC-005 detects pods that share the host's network, PID, or IPC namespaces. An attacker in such a pod can see all host processes and network traffic.
K8S-SEC-006 flags hostPath volume mounts, which give a container direct access to files on the host node's filesystem.
- Add the resource limits and image tag checks:
const checkResourceLimits: RuleCheck = (resource, container) => {
const resources = container.resources as Record<string, unknown> | undefined;
const limits = resources?.limits as Record<string, unknown> | undefined;
if (!limits || (!limits.cpu && !limits.memory)) {
return makeFinding(
"K8S-SEC-007",
"Resource Management",
"medium",
resource,
container,
"Container has no resource limits set (CPU/memory)",
"resources: # limits not defined"
);
}
return null;
};
const checkMutableImageTag: RuleCheck = (resource, container) => {
const image = container.image;
if (!image) return null;
const hasTag = image.includes(":");
const tag = hasTag ? image.split(":").pop() : undefined;
if (!hasTag || tag === "latest") {
return makeFinding(
"K8S-SEC-008",
"Supply Chain",
"medium",
resource,
container,
image.includes(":")
? "Image uses mutable :latest tag"
: "Image has no tag (defaults to :latest)",
`image: ${image}`
);
}
return null;
};
What do these rules check?
K8S-SEC-007 catches containers without CPU or memory limits. Without limits, a compromised container can consume all node resources in a denial-of-service attack.
K8S-SEC-008 flags mutable image tags like :latest or missing tags entirely. Mutable tags mean the same manifest can pull different code at different times, creating a supply chain risk.
- Add the final three checks for exposed services, read-only filesystem, and missing security context:
const checkExposedServices: RuleCheck = (resource, container) => {
if (resource.kind !== "Service") return null;
const spec = resource.raw.spec as Record<string, unknown> | undefined;
const serviceType = spec?.type as string | undefined;
if (serviceType === "LoadBalancer" || serviceType === "NodePort") {
return makeFinding(
"K8S-SEC-009",
"Network Exposure",
"medium",
resource,
container,
`Service of type ${serviceType} exposes workload externally`,
`type: ${serviceType}`
);
}
return null;
};
const checkReadOnlyRootFilesystem: RuleCheck = (resource, container) => {
const sc = container.securityContext;
if (!sc || sc.readOnlyRootFilesystem !== true) {
return makeFinding(
"K8S-SEC-010",
"Filesystem Security",
"medium",
resource,
container,
"readOnlyRootFilesystem is not set to true",
"readOnlyRootFilesystem: # not set"
);
}
return null;
};
const checkWeakSecurityContext: RuleCheck = (resource, container) => {
if (!container.securityContext) {
return makeFinding(
"K8S-SEC-011",
"Security Context",
"medium",
resource,
container,
"Container has no securityContext defined",
"# securityContext is missing entirely"
);
}
return null;
};
What do these final rules check?
K8S-SEC-009 detects Services of type LoadBalancer or NodePort that expose workloads to external traffic without explicit intent.
K8S-SEC-010 catches containers where the root filesystem is writable. Attackers use writable filesystems to install tools or modify binaries.
K8S-SEC-011 is the broadest check: any container with no securityContext at all inherits insecure defaults from the cluster.
- Now add the rule arrays and the exported runSecurityChecks function that runs all rules against the parsed resources:
const containerRules: RuleCheck[] = [
checkPrivileged,
checkRunAsRoot,
checkPrivilegeEscalation,
checkDangerousCapabilities,
checkHostNamespaces,
checkHostPathVolumes,
checkResourceLimits,
checkMutableImageTag,
checkReadOnlyRootFilesystem,
checkWeakSecurityContext,
];
const resourceRules: RuleCheck[] = [checkExposedServices];
export function runSecurityChecks(resources: K8sResource[]): SecurityFinding[] {
const findings: SecurityFinding[] = [];
for (const resource of resources) {
// Service-level checks
if (resource.kind === "Service") {
const placeholder: K8sContainer = {
name: "service",
line: resource.line,
};
for (const rule of resourceRules) {
const finding = rule(resource, placeholder);
if (finding) findings.push(finding);
}
continue;
}
// Container-level checks
for (const container of resource.containers) {
for (const rule of containerRules) {
const finding = rule(resource, container);
if (finding) findings.push(finding);
}
}
}
return findings;
}
Why two separate rule arrays?
Services don't have containers, so container-level checks don't apply to them. The runner separates containerRules (run per container) from resourceRules (run per resource). For Services, a placeholder container is created so the makeFinding helper still gets a line number.
- Finally, add the two helper functions at the bottom of the file that checkRunAsRoot and checkHostNamespaces depend on:
function getPodSpec(resource: K8sResource): Record<string, unknown> | null {
const spec = resource.raw.spec as Record<string, unknown> | undefined;
if (!spec) return null;
const template = spec.template as Record<string, unknown> | undefined;
if (template?.spec) return template.spec as Record<string, unknown>;
if (resource.kind === "Pod") return spec;
return null;
}
function getPodSecurityContext(
resource: K8sResource
): Record<string, unknown> | null {
const podSpec = getPodSpec(resource);
if (!podSpec) return null;
return (podSpec.securityContext as Record<string, unknown>) || null;
}
What do these helpers do?
Kubernetes puts the pod spec in different locations depending on resource kind. getPodSpec handles this: Deployments put it under spec.template.spec, while Pods put it directly under spec.
The getPodSecurityContext helper extracts the pod-level security context so rules like checkRunAsRoot can check both container-level and pod-level settings.
- Save lib/rules.ts.
✔️ Awesome, I've got everything!
Great. Confirm you've saved the file and that it contains all 11 check functions plus the runSecurityChecks export.
ⓧ I'd like to double check the full code
Scroll through your lib/rules.ts and confirm these sections appear in order:
- Imports, RuleCheck type, DANGEROUS_CAPABILITIES array.
- The makeFinding helper function.
- 11 rule functions: checkPrivileged, checkRunAsRoot, checkPrivilegeEscalation, checkDangerousCapabilities, checkHostNamespaces, checkHostPathVolumes, checkResourceLimits, checkMutableImageTag, checkExposedServices, checkReadOnlyRootFilesystem, checkWeakSecurityContext.
- The containerRules and resourceRules arrays.
- The exported runSecurityChecks function.
- The getPodSpec and getPodSecurityContext helpers at the bottom.
Wire the rule engine into the scan route and frontend
The rules file is complete but nothing calls it yet. You need to connect it to the existing /api/scan route and update the frontend to display findings.
- In app/api/scan/route.ts, find this line:
import { parseK8sYaml } from "@/lib/parser";
- Add the rules import directly below it, so the imports look like this:
import { parseK8sYaml } from "@/lib/parser";
import { runSecurityChecks } from "@/lib/rules";
- Replace the entire POST function body with this updated version that runs the security checks after parsing:
export async function POST(request: Request) {
try {
const { yaml: yamlText } = (await request.json()) as { yaml: string };
if (!yamlText || typeof yamlText !== "string") {
return NextResponse.json(
{ error: "YAML text is required" },
{ status: 400 }
);
}
const resources = parseK8sYaml(yamlText);
if (resources.length === 0) {
return NextResponse.json(
{ error: "No valid Kubernetes resources found in YAML" },
{ status: 400 }
);
}
const findings = runSecurityChecks(resources);
const result: ScanResult = { resources, findings };
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ error: "Failed to parse YAML" },
{ status: 500 }
);
}
}
What changed?
Two new lines do all the work: const findings = runSecurityChecks(resources) runs all 11 rules against the parsed resources, and const result: ScanResult = { resources, findings } packages both into the response. The frontend already expects a ScanResult shape.
- Save app/api/scan/route.ts.
- Now update app/page.tsx to display the findings. Replace the entire file content with the updated version. First, paste the imports and state setup:
"use client";
import { useState } from "react";
import { ScanResult, SecurityFinding } from "@/lib/types";
import { SecurityAnalysis, AnalyzedFinding } from "@/lib/schemas";
const SAMPLE_YAML = `apiVersion: apps/v1
kind: Deployment
metadata:
name: insecure-app
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: insecure-app
template:
metadata:
labels:
app: insecure-app
spec:
hostNetwork: true
containers:
- name: web
image: nginx:latest
securityContext:
privileged: true
allowPrivilegeEscalation: true
ports:
- containerPort: 80
What is the sample YAML?
This manifest is intentionally insecure. It runs a privileged container, uses the :latest tag, enables hostNetwork, and has no resource limits. The scanner should flag multiple issues against it.
- Continue the sample YAML and add the state declarations below the previous code:
- name: sidecar
image: busybox
command: ["sleep", "infinity"]
---
apiVersion: v1
kind: Service
metadata:
name: insecure-service
spec:
type: LoadBalancer
selector:
app: insecure-app
ports:
- port: 80
targetPort: 80`;
export default function Home() {
const [yamlInput, setYamlInput] = useState(SAMPLE_YAML);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [analysis, setAnalysis] = useState<SecurityAnalysis | null>(null);
const [scanning, setScanning] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
- Add the scan and analyze handler functions:
async function handleScan() {
setScanning(true);
setError(null);
setAnalysis(null);
try {
const res = await fetch("/api/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ yaml: yamlInput }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Scan failed");
}
const data: ScanResult = await res.json();
setScanResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Scan failed");
} finally {
setScanning(false);
}
}
async function handleAnalyze() {
if (!scanResult) return;
setAnalyzing(true);
setError(null);
try {
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
findings: scanResult.findings,
yaml: yamlInput,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Analysis failed");
}
const data: SecurityAnalysis = await res.json();
setAnalysis(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Analysis failed");
} finally {
setAnalyzing(false);
}
}
Why include handleAnalyze now?
The handleAnalyze function calls the /api/analyze endpoint that doesn't exist yet. That's fine. You're setting up the frontend structure now so the "Analyze with Claude" button appears, but it won't do anything until Step 4.
- Add the JSX return for the main layout:
return (
<main className="min-h-screen bg-gray-950 text-gray-100 p-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-2">
K8s Security Reviewer
</h1>
<p className="text-gray-400 mb-6">
Paste a Kubernetes manifest to scan for security misconfigurations
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Input Panel */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Kubernetes YAML Manifest
</label>
<textarea
value={yamlInput}
onChange={(e) => setYamlInput(e.target.value)}
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 font-mono text-sm text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Paste your Kubernetes YAML here..."
/>
<button
onClick={handleScan}
disabled={scanning || !yamlInput.trim()}
className="mt-4 px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 rounded-lg font-medium transition-colors"
>
{scanning ? "Scanning..." : "Scan Manifest"}
</button>
</div>
- Add the results panel JSX that displays resources, findings, and the analyze button:
{/* Results Panel */}
<div className="space-y-4">
{error && (
<div className="p-4 bg-red-900/50 border border-red-700 rounded-lg text-red-200">
{error}
</div>
)}
{scanResult && (
<>
{/* Resources */}
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">
Resources Found ({scanResult.resources.length})
</h2>
<div className="space-y-2">
{scanResult.resources.map((r, i) => (
<div
key={i}
className="flex items-center gap-3 text-sm"
>
<span className="px-2 py-0.5 bg-gray-800 rounded text-gray-300">
{r.kind}
</span>
<span className="text-gray-200">{r.name}</span>
<span className="text-gray-500">
ns:{r.namespace}
</span>
<span className="text-gray-600">
line {r.line}
</span>
</div>
))}
</div>
</div>
{/* Findings */}
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">
Findings ({scanResult.findings.length})
</h2>
{!analysis && scanResult.findings.length > 0 && (
<button
onClick={handleAnalyze}
disabled={analyzing}
className="px-4 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-700 rounded-lg text-sm font-medium transition-colors"
>
{analyzing
? "Analyzing..."
: "Analyze with Claude"}
</button>
)}
</div>
{analysis ? (
<AnalysisResults analysis={analysis} />
) : (
<FindingsList findings={scanResult.findings} />
)}
</div>
</>
)}
</div>
</div>
</div>
</main>
);
}
- Add the FindingsList component below the Home component:
function FindingsList({ findings }: { findings: SecurityFinding[] }) {
return (
<div className="space-y-3 max-h-96 overflow-y-auto">
{findings.map((f, i) => (
<div
key={i}
className="p-3 bg-gray-800 rounded-lg border border-gray-700"
>
<div className="flex items-center gap-2 mb-1">
<SeverityBadge severity={f.severity} />
<span className="text-xs text-gray-400 font-mono">
{f.ruleId}
</span>
<span className="text-xs text-gray-500">
line {f.line}
</span>
</div>
<p className="text-sm text-gray-200 mb-1">{f.message}</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>
{f.resourceKind}/{f.resourceName}
</span>
{f.containerName !== "service" && (
<span>container: {f.containerName}</span>
)}
</div>
<pre className="mt-2 text-xs bg-gray-900 p-2 rounded text-gray-400 overflow-x-auto">
{f.yamlEvidence}
</pre>
</div>
))}
</div>
);
}
- Add the AnalysisResults, AnalyzedFindingCard, and SeverityBadge components. These render Claude's output in Step 4, but SeverityBadge is used by FindingsList right now:
function AnalysisResults({ analysis }: { analysis: SecurityAnalysis }) {
return (
<div className="space-y-4">
<div className="p-3 bg-gray-800 rounded-lg border border-gray-600">
<p className="text-sm text-gray-200">{analysis.summary}</p>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{analysis.findings.map((f, i) => (
<AnalyzedFindingCard key={i} finding={f} />
))}
</div>
{analysis.recommendations.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-semibold text-gray-300 mb-2">
Advisory Recommendations (AI-generated)
</h3>
<div className="space-y-2">
{analysis.recommendations.map((r, i) => (
<div
key={i}
className="p-2 bg-gray-800/50 rounded border border-gray-700/50 text-sm"
>
<span className="text-yellow-400 text-xs uppercase mr-2">
{r.priority}
</span>
<span className="text-gray-200">{r.title}</span>
<p className="text-gray-400 text-xs mt-1">
{r.description}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
function AnalyzedFindingCard({ finding }: { finding: AnalyzedFinding }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="p-3 bg-gray-800 rounded-lg border border-gray-700">
<div className="flex items-center gap-2 mb-1">
<SeverityBadge severity={finding.severity} />
<span className="text-xs text-gray-400 font-mono">
{finding.ruleId}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-300">
{finding.exploitability}
</span>
</div>
<p className="text-sm text-gray-200 mb-2">{finding.explanation}</p>
<p className="text-xs text-red-300 mb-2">
Attack: {finding.attackScenario}
</p>
<p className="text-xs text-green-300 mb-2">
Fix: {finding.remediation}
</p>
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-blue-400 hover:text-blue-300"
>
{expanded ? "Hide" : "Show"} secure YAML
</button>
{expanded && (
<pre className="mt-2 text-xs bg-gray-900 p-3 rounded text-green-300 overflow-x-auto">
{finding.secureYamlReplacement}
</pre>
)}
</div>
);
}
function SeverityBadge({
severity,
}: {
severity: "critical" | "high" | "medium" | "low";
}) {
const colors = {
critical: "bg-red-600 text-red-100",
high: "bg-orange-600 text-orange-100",
medium: "bg-yellow-600 text-yellow-100",
low: "bg-blue-600 text-blue-100",
};
return (
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${colors[severity]}`}
>
{severity}
</span>
);
}
What do these components do?
The FindingsList renders each deterministic finding as a card with a severity badge, rule ID, line number, message, and YAML evidence. SeverityBadge color-codes critical (red), high (orange), medium (yellow), and low (blue).
The AnalysisResults and AnalyzedFindingCard components are placeholders for Step 4. They won't render until you wire up the Claude analysis endpoint.
- Save app/page.tsx.
Seeing a build error about lib/schemas?
The page imports SecurityAnalysis and AnalyzedFinding from @/lib/schemas which you haven't created yet. Create an empty placeholder by adding lib/schemas.ts with temporary type exports. You'll fill this properly in Step 4.
help me fix the schemas import error in my page.tsx
- Create lib/schemas.ts with placeholder types so the page compiles:
import { z } from "zod";
export const AnalyzedFindingSchema = z.object({
ruleId: z.string(),
severity: z.enum(["critical", "high", "medium", "low"]),
exploitability: z.enum(["trivial", "moderate", "difficult"]),
attackScenario: z.string(),
explanation: z.string(),
remediation: z.string(),
secureYamlReplacement: z.string(),
});
export const SecurityAnalysisSchema = z.object({
findings: z.array(AnalyzedFindingSchema),
summary: z.string(),
recommendations: z.array(
z.object({
title: z.string(),
description: z.string(),
priority: z.enum(["high", "medium", "low"]),
})
),
});
export type AnalyzedFinding = z.infer<typeof AnalyzedFindingSchema>;
export type SecurityAnalysis = z.infer<typeof SecurityAnalysisSchema>;
Why create schemas now?
The frontend components reference SecurityAnalysis and AnalyzedFinding types. Defining the Zod schemas now lets the page compile. In Step 4, you'll use these same schemas with Claude's structured output API.
- Save lib/schemas.ts.
Run the scanner against an insecure manifest
Everything is wired up. The sample YAML pre-filled in the textarea is intentionally insecure. Before you click Scan, think about what you expect to see.
Given that the sample manifest has privileged: true, hostNetwork: true, image: nginx:latest, and no resource limits, how many findings do you think the scanner will produce? Which severity levels?
- Switch back to your browser at localhost:3000.
- Click Scan Manifest with the pre-filled sample YAML.
You should see a findings list with 5 or more security issues. Each finding shows a colored severity badge, a rule ID like K8S-SEC-001, a line number, a message, and a YAML evidence snippet.
Notice the problem: the findings are a flat, unsorted list. A critical finding like privileged: true sits alongside a medium finding like missing readOnlyRootFilesystem with no prioritization. There's no explanation of how an attacker would exploit each issue. There's no fix guidance. The "Analyze with Claude" button is there, but clicking it fails because the endpoint doesn't exist yet.
Why is this gap intentional?
The deterministic engine is the source of truth. It tells you exactly what's wrong and where. But it can't tell you which issues to fix first, what the real-world attack looks like, or how to write the remediated YAML.
That intelligence layer is what Claude will provide in the next step. The AI doesn't invent findings. It takes the deterministic results and adds prioritization, attack scenarios, and secure replacements.
Not seeing any findings?
Check that app/api/scan/route.ts imports runSecurityChecks from @/lib/rules and calls it after parseK8sYaml. Also verify the ScanResult import from @/lib/types is present.
If you see a compilation error in the browser, check that all 11 rule functions are defined before the containerRules and resourceRules arrays that reference them.
help me debug why my security scanner isn't showing findings
Your rule engine works. It flags real security issues with precise line numbers and evidence. In the next step, you'll add Claude to transform this flat list into a prioritized security report with attack scenarios and fixes.
Add Claude AI Risk Prioritization
Your deterministic scanner flags misconfigurations accurately, but the flat findings list gives no guidance on what to fix first. A manifest with privileged: true and a missing readOnlyRootFilesystem look equally important in the output.
In this step, you'll add Claude as an intelligence layer on top of the deterministic engine. Claude will prioritize findings by severity, rate exploitability, describe real-world attack scenarios, and generate secure YAML replacements. The deterministic findings stay as the source of truth. Claude's observations beyond those findings are labeled as advisory recommendations.
In this step, get ready to:
- Define Zod schemas that constrain Claude's structured output to a known shape.
- Create an API route that sends deterministic findings to Claude for risk analysis.
- Wire the frontend to display a prioritized security report with attack scenarios and fixes.
Define Zod schemas for Claude's structured output
Claude's structured outputs feature guarantees the response matches a schema you define. The Anthropic SDK's zodOutputFormat helper converts a Zod schema directly into the format Claude expects.
- Create a new file lib/schemas.ts and add the following schema definitions:
import { z } from "zod";
export const AnalyzedFindingSchema = z.object({
ruleId: z.string(),
severity: z.enum(["critical", "high", "medium", "low"]),
exploitability: z.enum(["trivial", "moderate", "difficult"]),
attackScenario: z.string(),
explanation: z.string(),
remediation: z.string(),
secureYamlReplacement: z.string(),
});
export const SecurityAnalysisSchema = z.object({
findings: z.array(AnalyzedFindingSchema),
summary: z.string(),
recommendations: z.array(
z.object({
title: z.string(),
description: z.string(),
priority: z.enum(["high", "medium", "low"]),
})
),
});
export type AnalyzedFinding = z.infer<typeof AnalyzedFindingSchema>;
export type SecurityAnalysis = z.infer<typeof SecurityAnalysisSchema>;
What does this code do?
AnalyzedFindingSchema defines the shape Claude must follow for each finding: a severity enum, an exploitability rating, a plain-language attack scenario, and a secure YAML replacement snippet.
SecurityAnalysisSchema wraps an array of analyzed findings, a summary, and a separate recommendations array for AI-only advisory observations that go beyond the deterministic findings.
- Save lib/schemas.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { z } from "zod";
export const AnalyzedFindingSchema = z.object({
ruleId: z.string(),
severity: z.enum(["critical", "high", "medium", "low"]),
exploitability: z.enum(["trivial", "moderate", "difficult"]),
attackScenario: z.string(),
explanation: z.string(),
remediation: z.string(),
secureYamlReplacement: z.string(),
});
export const SecurityAnalysisSchema = z.object({
findings: z.array(AnalyzedFindingSchema),
summary: z.string(),
recommendations: z.array(
z.object({
title: z.string(),
description: z.string(),
priority: z.enum(["high", "medium", "low"]),
})
),
});
export type AnalyzedFinding = z.infer<typeof AnalyzedFindingSchema>;
export type SecurityAnalysis = z.infer<typeof SecurityAnalysisSchema>;
Create the analysis API route
This route handler receives the deterministic findings plus the original YAML, then asks Claude to prioritize and explain each one using structured outputs.
- Create a new file app/api/analyze/route.ts and add the imports, client setup, and request validation:
import { NextResponse } from "next/server";
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { SecurityAnalysisSchema } from "@/lib/schemas";
import { SecurityFinding } from "@/lib/types";
// Initialize the Anthropic client with your API key
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
export async function POST(request: Request) {
try {
// Extract findings and raw YAML from the request body
const { findings, yaml: yamlText } = (await request.json()) as {
findings: SecurityFinding[];
yaml: string;
};
if (!findings || findings.length === 0) {
return NextResponse.json(
{ error: "No findings to analyze" },
{ status: 400 }
);
}
What does this code do?
The zodOutputFormat import from @anthropic-ai/sdk/helpers/zod converts your Zod schema into the format Claude's output_config parameter expects. The client is initialized with the ANTHROPIC_API_KEY from your .env.local file.
The POST handler destructures findings (the deterministic results from your rule engine) and yamlText (the original manifest) from the request body, then validates that findings are present.
- Directly below the validation block, add the Claude API call and response handling:
const response = await client.messages.parse({
model: "claude-sonnet-4-6",
max_tokens: 4096,
messages: [
{
role: "user",
content: `You are a Kubernetes security expert. Analyze these deterministic security findings from a static manifest scanner.
IMPORTANT RULES:
- The deterministic findings are the SOURCE OF TRUTH. Do not invent additional confirmed vulnerabilities.
- For each finding, provide: severity assessment, exploitability rating, a realistic attack scenario, explanation, remediation guidance, and a secure YAML replacement snippet.
- Any additional security observations beyond the deterministic findings must go in the "recommendations" array and are labeled as advisory only.
- Order findings by severity (critical first) and exploitability.
DETERMINISTIC FINDINGS:
${JSON.stringify(findings, null, 2)}
ORIGINAL YAML MANIFEST:
${yamlText}
Analyze each finding and provide prioritized security intelligence.`,
},
],
output_config: { format: zodOutputFormat(SecurityAnalysisSchema) },
});
return NextResponse.json(response.parsed_output);
} catch (error) {
console.error("Claude analysis failed:", error);
return NextResponse.json(
{ error: "AI analysis failed" },
{ status: 500 }
);
}
}
What does this code do?
client.messages.parse() sends the request and automatically validates Claude's response against your Zod schema. The output_config: { format: zodOutputFormat(SecurityAnalysisSchema) } parameter guarantees Claude's response conforms to the exact shape you defined.
The prompt instructs Claude to treat deterministic findings as the source of truth. Claude cannot invent new confirmed vulnerabilities. Any additional observations go into the recommendations array, clearly labeled as advisory.
- Save app/api/analyze/route.ts.
- Check your terminal where the dev server is running. It should recompile without errors.
Seeing a compilation error?
- Confirm the import path @/lib/schemas matches the file you created in the previous substep.
- Check that ANTHROPIC_API_KEY is set in your .env.local file. The SDK reads it at initialization.
- Make sure you installed @anthropic-ai/sdk and zod in Step 1.
Why is my analyze route failing to compile?
✔️ Awesome, I've got everything!
Great. Double check you've saved both chunks in the correct order in app/api/analyze/route.ts.
ⓧ I'd like to double check the full code
Your app/api/analyze/route.ts should contain the two code blocks you added above, in order:
- Imports, client initialization, and POST function with input validation (first block).
- The client.messages.parse() call with the prompt, output_config, return statement, and error handling (second block).
Scroll up and confirm both are present and in the correct sequence.
Wire the frontend to display prioritized analysis
The page already has the Analyze with Claude button and analysis state variable, but they are not connected yet. You need to add the handleAnalyze function and the display components.
- In app/page.tsx, find the import line at the top of the file. Add the schema type imports so it looks like this:
"use client";
import { useState } from "react";
import { ScanResult, SecurityFinding } from "@/lib/types";
import { SecurityAnalysis, AnalyzedFinding } from "@/lib/schemas";
- Inside the Home component, below the handleScan function, add the handleAnalyze function:
async function handleAnalyze() {
if (!scanResult) return;
setAnalyzing(true);
setError(null);
try {
// Send findings to the analysis endpoint
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
findings: scanResult.findings,
yaml: yamlInput,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Analysis failed");
}
const data: SecurityAnalysis = await res.json();
setAnalysis(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Analysis failed");
} finally {
setAnalyzing(false);
}
}
What does this code do?
handleAnalyze sends the deterministic findings and the raw YAML to your /api/analyze endpoint. When the response arrives, it stores the SecurityAnalysis object in state, which triggers the UI to switch from the flat findings list to the prioritized report.
- In the JSX where findings are displayed, find the FindingsList usage. Replace it with a conditional that shows AnalysisResults when analysis is available:
{analysis ? (
<AnalysisResults analysis={analysis} />
) : (
<FindingsList findings={scanResult.findings} />
)}
- Save app/page.tsx and check that the dev server recompiles. You will see a compile error because AnalysisResults does not exist yet. That is expected.
Now add the AnalysisResults component. This renders the summary, prioritized findings, and advisory recommendations section.
- Below the FindingsList function at the bottom of app/page.tsx, add the first part of AnalysisResults:
function AnalysisResults({ analysis }: { analysis: SecurityAnalysis }) {
return (
<div className="space-y-4">
<div className="p-3 bg-gray-800 rounded-lg border border-gray-600">
<p className="text-sm text-gray-200">{analysis.summary}</p>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{analysis.findings.map((f, i) => (
<AnalyzedFindingCard key={i} finding={f} />
))}
</div>
- Directly below, add the recommendations section and close the component:
{analysis.recommendations.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-semibold text-gray-300 mb-2">
Advisory Recommendations (AI-generated)
</h3>
<div className="space-y-2">
{analysis.recommendations.map((r, i) => (
<div
key={i}
className="p-2 bg-gray-800/50 rounded border border-gray-700/50 text-sm"
>
<span className="text-yellow-400 text-xs uppercase mr-2">
{r.priority}
</span>
<span className="text-gray-200">{r.title}</span>
<p className="text-gray-400 text-xs mt-1">
{r.description}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
What does AnalysisResults display?
The top section shows Claude's overall summary of the manifest's security posture. Below that, each finding is rendered by AnalyzedFindingCard (which you will add next).
The recommendations section only appears if Claude added advisory observations beyond the deterministic findings. Each recommendation shows its priority level and description, clearly labeled as AI-generated.
- Below AnalysisResults, add the first part of the AnalyzedFindingCard component:
function AnalyzedFindingCard({ finding }: { finding: AnalyzedFinding }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="p-3 bg-gray-800 rounded-lg border border-gray-700">
<div className="flex items-center gap-2 mb-1">
<SeverityBadge severity={finding.severity} />
<span className="text-xs text-gray-400 font-mono">
{finding.ruleId}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-300">
{finding.exploitability}
</span>
</div>
<p className="text-sm text-gray-200 mb-2">{finding.explanation}</p>
- Directly below, add the attack scenario, fix, expandable YAML, and close the component:
<p className="text-xs text-red-300 mb-2">
Attack: {finding.attackScenario}
</p>
<p className="text-xs text-green-300 mb-2">
Fix: {finding.remediation}
</p>
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-blue-400 hover:text-blue-300"
>
{expanded ? "Hide" : "Show"} secure YAML
</button>
{expanded && (
<pre className="mt-2 text-xs bg-gray-900 p-3 rounded text-green-300 overflow-x-auto">
{finding.secureYamlReplacement}
</pre>
)}
</div>
);
}
What does AnalyzedFindingCard display?
Each card shows the severity badge, rule ID, and exploitability rating in the header row. Below that, it displays Claude's explanation, a red-highlighted attack scenario, and green-highlighted remediation guidance.
The "Show secure YAML" button toggles an expandable block with the exact replacement YAML that fixes the vulnerability. This lets you copy a hardened version of the flagged field directly.
- Save app/page.tsx.
- Check your terminal. The dev server should now compile successfully with no errors.
Still seeing a compilation error?
- Verify the import line includes both SecurityAnalysis and AnalyzedFinding from @/lib/schemas.
- Confirm AnalysisResults and AnalyzedFindingCard are defined outside the Home component, not nested inside it.
- Check that the closing braces of each component are complete. A missing } or ) will cause a parse error.
Why am I getting a TypeScript error in my page.tsx after adding the analysis components?
✔️ Awesome, I've got everything!
Great. Double check you've saved app/page.tsx and that the dev server compiled without errors.
ⓧ I'd like to double check the full code
Your app/page.tsx should now include the following additions, in order from top to bottom:
- Import of SecurityAnalysis and AnalyzedFinding from @/lib/schemas (at the top).
- handleAnalyze function inside Home (after handleScan).
- Conditional rendering: analysis ? <AnalysisResults /> : <FindingsList /> in the findings section.
- AnalysisResults component with summary, findings map, and recommendations (below FindingsList).
- AnalyzedFindingCard component with severity badge, exploitability, attack scenario, remediation, and expandable YAML (below AnalysisResults).
Scroll through your file and confirm each piece is present in the correct position.
Test the full analysis pipeline
Before you click the button, think about what you expect to happen. The deterministic scanner already found the issues. What will Claude add on top?
- Switch to your browser at localhost:3000.
- The sample YAML should already be in the textarea from Step 3. Click Scan Manifest to regenerate the findings.
- You should see the flat findings list appear on the right, along with the Analyze with Claude button.
What do you think will happen when you click Analyze with Claude? Will the findings stay in the same order, or change?
- Click Analyze with Claude. Wait a few seconds for Claude to respond.
The flat list transforms into a prioritized security report. Critical findings rise to the top with exploitability badges, attack scenarios in red, and remediation in green.
- Click Show secure YAML on the most critical finding. You should see a hardened securityContext block that replaces the insecure configuration.
- Scroll down to see the Advisory Recommendations section. These are AI-only observations, clearly separated from the confirmed findings.
Button says Analyzing but nothing appears?
- Open the browser DevTools Network tab and look at the /api/analyze request. A 500 response means Claude could not process the request.
- Check your terminal for "Claude analysis failed" with an error message. The most common cause is an invalid or missing ANTHROPIC_API_KEY in .env.local.
- If you see a timeout, the manifest may be too large. Try with the default sample YAML first.
Why is my Claude analysis request failing with a 500 error?
Your scanner now combines deterministic correctness with AI-powered intelligence. The rule engine guarantees what is flagged, and Claude explains why it matters and how to fix it. Next up, the Secret Mission challenges you to generate a complete hardened manifest with a visual diff.
Secret mission
Generate a Hardened Manifest
Individual fixes are useful, but what if you could generate a complete hardened manifest with a visual diff showing every security improvement at a glance? Take on this challenge to add a one-click hardening flow with before/after comparison and file download.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs when the dev server is stopped. Claude API charges only apply when you actively run an analysis.
Resources you used:
- Local Next.js development server running at localhost:3000.
- Project folder k8s-security-reviewer on your Desktop.
- Claude API usage (approximately $0.03-0.10 per analysis depending on manifest size).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with different manifests.
- The project runs locally with npm run dev. No ongoing costs apply when you stop the server.
- Your Anthropic API key stays in .env.local inside the project folder. This file is gitignored by default.
- Claude API charges only occur when you click Analyze with Claude or Generate Hardened Manifest.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Resume anytime with a single command.
- In the terminal running the dev server, press Ctrl+C to stop it.
- To resume later, open a terminal in the k8s-security-reviewer folder and run npm run dev.
Delete - I don't want to use this again
Remove all project files from your machine. No cloud resources need to be cleaned up.
- In the terminal running the dev server, press Ctrl+C to stop it.
- Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf k8s-security-reviewer
On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force k8s-security-reviewer
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see k8s-security-reviewer listed.
- Your Anthropic API key itself still exists in your Anthropic account. No further API charges will occur since the project code that calls Claude has been removed.
Nice Work!
Nice Work!
You just built a production-grade Kubernetes security reviewer that combines deterministic static analysis with AI-powered risk intelligence. Paste any manifest in and get a prioritized security report with attack scenarios and hardened YAML fixes.
You've learned how to:
- Built a multi-document YAML parser with line-number tracking using the yaml package's LineCounter and parseAllDocuments APIs, then implemented a deterministic security rule engine with 11 checks covering privileged containers, root execution, dangerous capabilities, host namespaces, and more.
- Integrated Claude structured outputs via zodOutputFormat() to transform flat findings into a prioritized report with severity scores, exploitability ratings, real-world attack scenarios, and secure YAML replacements.
- Architected a system where deterministic analysis provides correctness guarantees while AI provides contextual intelligence, with clear labeling separating confirmed findings from advisory recommendations.
- Secret Mission: Generated complete hardened manifests with a visual side-by-side diff and one-click download.
Ready to quiz yourself?