AI GitHub Actions Security Scanner

Build a web app that scans GitHub Actions workflows for security vulnerabilities.

Introduction

30 Second Summary

Every time you push code to GitHub, a workflow YAML file decides what runs, what permissions it has, and what secrets it can access. A single misconfigured line can let an attacker inject commands through a PR title, steal secrets from a privileged trigger, or hijack your deployment through an unpinned action.

In this project, you will build a security scanner that catches real GitHub Actions vulnerabilities from pasted workflow YAML. A deterministic engine flags the problems with exact line numbers. Then Claude explains why each one is dangerous, shows how an attacker would exploit it, and provides the exact YAML fix.

What You'll Build

Paste any GitHub Actions workflow into a single-page web app and instantly receive a professional security audit, complete with severity scores, attack scenarios, and copy-ready fixes you can drop straight into your repository.

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

  • A working scanner that flags vulnerabilities with exact line numbers and YAML evidence across 6 security categories, from excessive permissions to script injection.
  • An AI-powered analysis layer where Claude structured outputs transform each raw finding into a prioritized advisory with a risk score, exploitability rating, and plain-English attack scenario.
  • A polished security report UI with color-coded severity badges and copy-ready secure YAML replacements that fix each vulnerability while preserving your workflow's functionality.
  • Secret Mission: Add a "Fix All" button that generates a complete hardened workflow and shows a diff highlighting every change and why it was made.

Are there any prerequisites?

You'll need Node.js 22.x installed, an Anthropic API key, and familiarity with TypeScript and the Next.js App Router. Prior experience with the Claude SDK (specifically zodOutputFormat) is expected.

Before We Start

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

Set Up the Project

Every security tool starts with a solid foundation. Before you can scan GitHub Actions workflows for vulnerabilities, you need a running web application that can accept YAML input and process it.

In this step, you will scaffold a Next.js app with TypeScript and install the libraries that power the scanner: a YAML parser, the Anthropic SDK for Claude, and Zod for schema validation.

In this step, get ready to:
  • Scaffold a Next.js project with TypeScript, Tailwind CSS, and App Router.
  • Install the YAML parser, Anthropic SDK, and Zod schema library.
  • Verify the development server runs successfully.
Scaffold the Next.js app

The scanner needs a full-stack framework to serve a frontend page and handle API routes. Next.js gives you both in a single 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
  • Create the actions-scanner project by running this command:
npx create-next-app@latest actions-scanner --ts --tailwind --eslint --app --use-npm

What does this command do?

  • npx create-next-app@latest downloads and runs the latest Next.js scaffolding tool.
  • actions-scanner sets the project folder name.
  • --ts enables TypeScript, --tailwind adds Tailwind CSS, --eslint configures ESLint, --app uses App Router, and --use-npm locks the package manager to npm.
  • Wait for the scaffold to finish. This takes about 30 seconds depending on your internet speed.
  • Move into the new project folder by running:
cd actions-scanner
  • Confirm the project files exist by running:
ls

You should see files like package.json, tsconfig.json, and an app folder.

Command not found or permission error?

  • Make sure Node.js is installed by running node --version. You should see v22 or higher.
  • If npx is not found, reinstall Node.js from the official website.
  • help me troubleshoot why npx create-next-app is failing on my machine
Install project dependencies

The scanner relies on three additional libraries beyond what Next.js provides: a YAML parser to read workflow files, the Anthropic SDK to call Claude, and Zod to define strict output schemas.

  • Install the runtime and development dependencies by running:
npm install @anthropic-ai/sdk js-yaml zod && npm install -D @types/js-yaml

What are these packages?

  • @anthropic-ai/sdk is the official TypeScript SDK for calling Claude's API.
  • js-yaml parses YAML text into JavaScript objects.
  • zod defines and validates data schemas. Claude's structured outputs use it to guarantee type-safe responses.
  • @types/js-yaml adds TypeScript type definitions for the YAML parser.

You should see a success message once all packages finish installing.

  • Now create the environment file that stores your API key. Run:
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env.local
  • Open .env.local in a text editor and replace your-api-key-here with your actual Anthropic API key.
  • Save the file.

Why .env.local?

Next.js automatically loads environment variables from .env.local and keeps them server-side only. The Anthropic SDK reads ANTHROPIC_API_KEY from the environment by default, so you never need to hardcode it in your source files.

Don't have an Anthropic API key?

  • Visit console.anthropic.com and create an account.
  • Navigate to the API Keys section and generate a new key.
  • help me set up an Anthropic API key for this project
Verify the dev server starts

Before you write any scanner code, confirm the scaffold is healthy and serves a page.

  • Start the development server by running:
npm run dev

Before you check the browser, what do you expect to see at http://localhost:3000?

  • Open your browser and navigate to http://localhost:3000.

You should see the default Next.js starter page with the Next.js logo and some getting-started links.

Page not loading?

  • Check your terminal for error messages. A common issue is port 3000 already being in use by another process.
  • Make sure you are inside the actions-scanner folder when running npm run dev.
  • help me fix my Next.js dev server that won't start

✔️ Awesome, I've got everything!

Great. Double check you see the default Next.js page at localhost:3000 and that .env.local contains your real API key.

ⓧ I'd like to double check the full code

The only file you created manually in this step is .env.local. Confirm it looks like this:

ANTHROPIC_API_KEY=your-api-key-here

Replace your-api-key-here with your actual Anthropic API key. Everything else was generated by create-next-app.

Your project foundation is in place. Next up, you will build the YAML paste interface that accepts workflow input and proves the parser is working.

Build the YAML Paste Interface

Your Next.js app is running, but it still shows the default starter page. Before you can scan for vulnerabilities, you need a way to accept YAML input and prove the app can parse it.

In this step, you will build a paste interface where users drop in their workflow YAML, wire it to an API route that parses the YAML with js-yaml, and display the parsed metadata on screen. Seeing the workflow name, triggers, and job names rendered instantly proves the foundation is solid.

In this step, get ready to:
  • Build a page component with a YAML textarea and a Scan button.
  • Create an API route that parses YAML and returns workflow metadata.
  • Test the full loop by pasting a workflow and seeing parsed results.
Replace the default page with your scanner UI

The default Next.js starter page is generated boilerplate. You will replace it with a client component that holds the YAML input state and renders the scan interface.

  • Open app/page.tsx in your editor and delete all of its contents.
  • Paste the following code to set up the component's imports, type definition, and state variables:
"use client";

import { useState } from "react";

interface ScanMetadata {
  name: string;
  triggers: string[];
  jobs: string[];
}

export default function Home() {
  const [yamlInput, setYamlInput] = useState("");
  const [metadata, setMetadata] = useState<ScanMetadata | null>(null);
  const [scanning, setScanning] = useState(false);
  const [error, setError] = useState("");

What does this code do?

"use client" tells Next.js this component runs in the browser (needed for useState and event handlers).

The ScanMetadata interface defines the shape of data you expect back from the scan API: a workflow name, a list of triggers, and a list of job names. The four state variables track the YAML text the user types, the parsed metadata, a loading flag, and any error message.

  • Directly below the state declarations (still inside the Home function), add the scan handler that will call your API:
  async function handleScan() {
    setScanning(true);
    setError("");
    setMetadata(null);
    try {
      const res = await fetch("/api/scan", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ yamlText: yamlInput }),
      });
      const data = await res.json();
      if (!res.ok) { setError(data.error); return; }
      setMetadata(data.metadata);
    } catch (e) {
      setError("Failed to connect to scanner");
    } finally {
      setScanning(false);
    }
  }

What does this function do?

handleScan sends the textarea content to /api/scan as a POST request. If the response is OK, it stores the parsed metadata in state. If the server returns an error (like invalid YAML), it stores the error message instead.

  • Below handleScan, add the component's return statement with the textarea, button, and results area:
  return (
    <main className="max-w-5xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-2">GitHub Actions Security Scanner</h1>
      <p className="text-gray-600 mb-6">Paste a workflow YAML to scan for security vulnerabilities</p>
      <div className="mb-4">
        <textarea
          value={yamlInput}
          onChange={(e) => setYamlInput(e.target.value)}
          placeholder="Paste your GitHub Actions workflow YAML here..."
          className="w-full h-64 font-mono text-sm p-4 border border-gray-300 rounded-lg"
        />
      </div>
      <div className="flex gap-3 mb-6">
        <button onClick={handleScan} disabled={!yamlInput.trim() || scanning}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
          {scanning ? "Scanning..." : "Scan"}
        </button>
      </div>
      {error && <div className="p-4 mb-6 bg-red-50 border border-red-200 rounded-lg text-red-700">{error}</div>}
      {metadata && (
        <div className="p-4 mb-6 bg-gray-50 border border-gray-200 rounded-lg">
          <h2 className="font-semibold mb-2">Workflow Metadata</h2>
          <p><span className="text-gray-500">Name:</span> {metadata.name}</p>
          <p><span className="text-gray-500">Triggers:</span> {metadata.triggers.join(", ")}</p>
          <p><span className="text-gray-500">Jobs:</span> {metadata.jobs.join(", ")}</p>
        </div>
      )}
    </main>
  );
}

What does this JSX render?

  • A monospace textarea bound to yamlInput state so every keystroke updates React.
  • A Scan button that disables itself when the textarea is empty or a scan is in progress.
  • A red error banner that appears only when error state is non-empty.
  • A metadata card that appears only when the API returns valid results, showing the workflow name, triggers, and jobs.
  • Save app/page.tsx.
  • Switch to your browser at http://localhost:3000. You should see the heading "GitHub Actions Security Scanner", a large textarea, and a grayed-out Scan button.

Don't see the new page?

  • Make sure you saved the file. The dev server hot-reloads on save.
  • Check your terminal for a compile error. A missing closing brace or parenthesis is the most common cause.
  • Confirm you deleted all the original starter content before pasting. Leftover JSX from the template will cause conflicts.

Why isn't my page showing up after saving?

✔️ Awesome, I've got everything!

Great. Double check you have saved app/page.tsx and the browser shows the scanner heading and textarea.

ⓧ I'd like to double check the full code

Your app/page.tsx should have these three sections in order:

  • Imports, interface, and state declarations (the first chunk above).
  • The handleScan async function (the second chunk above).
  • The return statement with textarea, button, error banner, and metadata card (the third chunk above).

Scroll up and confirm each chunk is present and in order. The closing } at the very end closes the Home function.

Create the scan API route

The frontend calls /api/scan, but that endpoint does not exist yet. You need a Next.js route handler that receives YAML text, parses it with js-yaml, and returns the workflow metadata.

  • Create the folder path app/api/scan/ inside your project.
  • Create a new file called route.ts inside that folder and paste the following code:
import { NextRequest, NextResponse } from "next/server";
import yaml from "js-yaml";

export async function POST(request: NextRequest) {
  try {
    const { yamlText } = await request.json();
    if (!yamlText || typeof yamlText !== "string") {
      return NextResponse.json({ error: "YAML text is required" }, { status: 400 });
    }
    let parsed;
    try {
      parsed = yaml.load(yamlText);
    } catch (e) {
      const yamlError = e as Error;
      return NextResponse.json({ error: `Invalid YAML: ${yamlError.message}` }, { status: 400 });
    }
    if (!parsed || typeof parsed !== "object") {
      return NextResponse.json({ error: "YAML must be a mapping (object), not a scalar or sequence" }, { status: 400 });
    }
    const workflow = parsed as Record<string, unknown>;
    return NextResponse.json({
      metadata: {
        name: workflow.name || "(unnamed workflow)",
        triggers: workflow.on ? Object.keys(typeof workflow.on === "string" ? { [workflow.on]: {} } : (workflow.on as object)) : [],
        jobs: workflow.jobs ? Object.keys(workflow.jobs as object) : [],
      },
    });
  } catch (e) {
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

What does this route handler do?

  • It exports a POST function, which tells Next.js to handle POST requests at /api/scan.
  • yaml.load() parses the text into a JavaScript object. If the YAML is malformed, it throws and the route returns a 400 with the parse error.
  • It extracts three pieces of metadata: the workflow name, the on trigger keys, and the jobs keys.
  • The on field can be a string (on: push) or an object (on: { push: {}, pull_request: {} }), so the code normalizes both forms into an array of trigger names.
  • Save app/api/scan/route.ts.

Seeing a module not found error?

  • If the terminal shows an error about js-yaml, confirm you ran the install command in Step 1. Run npm list js-yaml to check.
  • Make sure the file is at app/api/scan/route.ts (not app/api/scan.ts). Next.js requires the route.ts filename inside a folder for route handlers.

Help me debug a module not found error in my Next.js API route.

✔️ Awesome, I've got everything!

Great. Your API route is ready to receive scan requests.

ⓧ I'd like to double check the full code

import { NextRequest, NextResponse } from "next/server";
import yaml from "js-yaml";

export async function POST(request: NextRequest) {
  try {
    const { yamlText } = await request.json();
    if (!yamlText || typeof yamlText !== "string") {
      return NextResponse.json({ error: "YAML text is required" }, { status: 400 });
    }
    let parsed;
    try {
      parsed = yaml.load(yamlText);
    } catch (e) {
      const yamlError = e as Error;
      return NextResponse.json({ error: `Invalid YAML: ${yamlError.message}` }, { status: 400 });
    }
    if (!parsed || typeof parsed !== "object") {
      return NextResponse.json({ error: "YAML must be a mapping (object), not a scalar or sequence" }, { status: 400 });
    }
    const workflow = parsed as Record<string, unknown>;
    return NextResponse.json({
      metadata: {
        name: workflow.name || "(unnamed workflow)",
        triggers: workflow.on ? Object.keys(typeof workflow.on === "string" ? { [workflow.on]: {} } : (workflow.on as object)) : [],
        jobs: workflow.jobs ? Object.keys(workflow.jobs as object) : [],
      },
    });
  } catch (e) {
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}
Test the full scan flow

Both pieces are in place: the frontend sends YAML to the API, and the API parses it and returns metadata. Time to prove the connection works end-to-end.

  • Switch to your browser at http://localhost:3000.
  • Paste the following sample workflow YAML into the textarea:
name: CI Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Hello"

Before you click Scan, what do you expect the API to return for this workflow?

  • Click the Scan button.

You should see a metadata card appear below the button showing: Name as "CI Pipeline", Triggers as "push, pull_request", and Jobs as "build".

What just happened?

Your frontend serialized the textarea content as JSON, sent it to /api/scan, where js-yaml parsed it into a JavaScript object. The route extracted the name, on, and jobs keys and sent them back. The component stored that response in state, which triggered a re-render showing the metadata card.

Scan button does nothing or shows an error?

  • If you see "Failed to connect to scanner", check that your dev server is still running in the terminal.
  • If you see a red error about invalid YAML, make sure there are no stray tabs in your pasted text. YAML requires spaces for indentation, not tabs.
  • Open your browser's DevTools Network tab, click Scan again, and check the response from /api/scan for details.

Help me debug why my Scan button isn't returning metadata.

Your scanner can now accept any GitHub Actions workflow and prove it parses correctly. Next up, you will build the deterministic security engine that actually finds vulnerabilities in that parsed YAML.

Implement the Deterministic Security Scanner

Your app can parse YAML and show workflow metadata. But parsing alone tells you nothing about whether a workflow is safe.

In this step, you will build a deterministic scanner that inspects every line of a workflow for real security vulnerabilities. It catches problems with exact line numbers and YAML evidence. The output is flat and unprioritized on purpose. That limitation is exactly what Claude will solve in the next step.

In this step, get ready to:
  • Build a scanner module with 6 vulnerability detection categories covering permissions, pinning, injection, triggers, secrets, and third-party actions.
  • Wire the scanner into the API route and display findings in a table.
  • Verify all 6 categories fire against a purpose-built vulnerable workflow.
Create the scanner module with type definitions and constants

The scanner performs line-by-line string analysis against the raw YAML text. This is the same approach used by production security linters. It does not need a full YAML AST because the patterns it detects are visible at the text level.

  • Create a new file at lib/scanner.ts in your actions-scanner project folder.
  • Add the type definitions and constants that define what the scanner looks for:
export type Category =
  | "EXCESSIVE_PERMISSIONS"
  | "UNPINNED_ACTION"
  | "SCRIPT_INJECTION"
  | "RISKY_TRIGGER"
  | "SECRET_EXPOSURE"
  | "UNSAFE_THIRD_PARTY";

export interface Finding {
  ruleId: string;
  category: Category;
  severity: string;
  line: number;
  evidence: string;
  message: string;
}

const TRUSTED_ORGS = ["actions/", "github/", "aws-actions/"];

const DANGEROUS_CONTEXTS = [
  "github.event.issue.title",
  "github.event.issue.body",
  "github.event.pull_request.title",
  "github.event.pull_request.body",
  "github.event.comment.body",
  "github.event.review.body",
  "github.event.head_commit.message",
  "github.event.commits",
  "github.head_ref",
  "github.event.pull_request.head.ref",
  "github.event.pull_request.head.label",
];

What do these constants define?

TRUSTED_ORGS lists GitHub Actions publishers whose actions are considered safe (GitHub's own and AWS's official actions). Anything from another org gets flagged for review.

DANGEROUS_CONTEXTS lists expression contexts that an attacker controls. If these appear inside a run: block, it is a script injection vector.

  • Below the constants, add the scanWorkflow function that orchestrates all detection rules:
export function scanWorkflow(yamlText: string): Finding[] {
  const lines = yamlText.split("\n");
  const findings: Finding[] = [];
  let inRunBlock = false;
  let runBlockIndent = 0;
  let hasWorkflowPermissions = false;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const lineNum = i + 1;
    const trimmed = line.trim();
    if (/^permissions:/.test(trimmed)) { hasWorkflowPermissions = true; }
    checkPermissions(trimmed, lineNum, line, findings);
    checkUnpinnedActions(trimmed, lineNum, line, findings);
    checkRiskyTriggers(trimmed, lineNum, line, findings);
    checkSecretExposure(trimmed, lineNum, line, findings);
    if (/^run:\s*\|/.test(trimmed) || /^run:\s*[^|]/.test(trimmed)) {
      inRunBlock = true;
      runBlockIndent = line.indexOf("run:");
      checkScriptInjection(trimmed, lineNum, line, findings);
    } else if (inRunBlock) {
      const currentIndent = line.search(/\S/);
      if (currentIndent > runBlockIndent && trimmed !== "") {
        checkScriptInjection(trimmed, lineNum, line, findings);
      } else if (trimmed !== "") {
        inRunBlock = false;
      }
    }
    checkUnsafeThirdParty(trimmed, lineNum, line, findings);
  }
  • Immediately below (still inside lib/scanner.ts), add the closing section of scanWorkflow that flags missing permissions and returns findings:
  if (!hasWorkflowPermissions) {
    findings.push({
      ruleId: "PERM-002",
      category: "EXCESSIVE_PERMISSIONS",
      severity: "medium",
      line: 1,
      evidence: "(no permissions key found)",
      message:
        "Workflow has no top-level permissions declaration. Without explicit permissions, the GITHUB_TOKEN may receive overly broad default permissions depending on repository settings.",
    });
  }

  return findings;
}

What does scanWorkflow do?

The function splits the YAML into lines and walks through each one. For every line, it calls specialized check functions that look for specific vulnerability patterns.

The inRunBlock tracking is key for script injection detection. It identifies multi-line run: blocks by indentation so the scanner can flag dangerous expressions inside shell commands, not just anywhere in the YAML.

After the loop, a final check flags workflows that never declare a permissions key at all. Without one, the GITHUB_TOKEN may get overly broad defaults.

  • Now add the first pair of helper functions below scanWorkflow. These detect excessive permissions and unpinned action references:
function checkPermissions(
  trimmed: string, lineNum: number, line: string, findings: Finding[]
): void {
  if (/permissions:\s*write-all/i.test(trimmed)) {
    findings.push({
      ruleId: "PERM-001",
      category: "EXCESSIVE_PERMISSIONS",
      severity: "medium",
      line: lineNum,
      evidence: line.trimEnd(),
      message:
        "Workflow grants write-all permissions to GITHUB_TOKEN. This violates the principle of least privilege and expands the blast radius if the workflow is compromised.",
    });
  }
  const writePermPattern =
    /^\s*(contents|packages|actions|security-events|issues|pull-requests|deployments|id-token|pages|statuses|checks):\s*write/;
  if (writePermPattern.test(trimmed)) {
    findings.push({
      ruleId: "PERM-003",
      category: "EXCESSIVE_PERMISSIONS",
      severity: "medium",
      line: lineNum,
      evidence: line.trimEnd(),
      message: `Grants write permission for ${trimmed.split(":")[0].trim()}. Verify this scope is actually required by the workflow steps.`,
    });
  }
}
  • Add the checkUnpinnedActions helper directly below:
function checkUnpinnedActions(
  trimmed: string, lineNum: number, line: string, findings: Finding[]
): void {
  const usesMatch = trimmed.match(/^-?\s*uses:\s*([^\s#]+)/);
  if (!usesMatch) return;
  const actionRef = usesMatch[1];
  if (actionRef.startsWith("./")) return;
  const shaMatch = actionRef.match(/@([a-f0-9]+)$/);
  if (shaMatch && shaMatch[1].length === 40) return;
  const tagMatch = actionRef.match(/@(.+)$/);
  if (tagMatch) {
    findings.push({
      ruleId: "PIN-001",
      category: "UNPINNED_ACTION",
      severity: "medium",
      line: lineNum,
      evidence: line.trimEnd(),
      message: `Action "${actionRef}" uses mutable tag "@${tagMatch[1]}" instead of a full commit SHA. A compromised action repository could push malicious code to this tag.`,
    });
  }
}
  • Add the next pair of helpers that detect script injection and risky triggers:
function checkScriptInjection(
  trimmed: string, lineNum: number, line: string, findings: Finding[]
): void {
  const expressionMatches = line.match(/\$\{\{\s*([^}]+)\s*\}\}/g);
  if (!expressionMatches) return;
  for (const expr of expressionMatches) {
    const inner = expr.replace(/\$\{\{\s*/, "").replace(/\s*\}\}/, "");
    for (const dangerous of DANGEROUS_CONTEXTS) {
      if (inner.includes(dangerous)) {
        findings.push({
          ruleId: "INJ-001",
          category: "SCRIPT_INJECTION",
          severity: "medium",
          line: lineNum,
          evidence: line.trimEnd(),
          message: `Untrusted expression "${expr}" flows into a shell command. An attacker can craft a PR title, body, or branch name to inject arbitrary commands.`,
        });
        break;
      }
    }
  }
}
  • Add the checkRiskyTriggers helper directly below:
function checkRiskyTriggers(
  trimmed: string, lineNum: number, line: string, findings: Finding[]
): void {
  if (/pull_request_target/.test(trimmed)) {
    findings.push({
      ruleId: "TRIG-001",
      category: "RISKY_TRIGGER",
      severity: "medium",
      line: lineNum,
      evidence: line.trimEnd(),
      message:
        "pull_request_target runs in the context of the base branch with access to secrets. If the workflow checks out and executes code from the PR head, an attacker can exfiltrate secrets via a fork PR.",
    });
  }
  if (/workflow_run/.test(trimmed) && !/^#/.test(trimmed)) {
    findings.push({
      ruleId: "TRIG-002",
      category: "RISKY_TRIGGER",
      severity: "medium",
      line: lineNum,
      evidence: line.trimEnd(),
      message:
        "workflow_run triggers have access to secrets and run in a privileged context. Artifacts from the triggering workflow should be treated as untrusted data.",
    });
  }
}
  • Add the final pair of helpers that detect secret exposure and unsafe third-party actions:
function checkSecretExposure(
  trimmed: string, lineNum: number, line: string, findings: Finding[]
): void {
  const secretPatterns = [
    /(?:password|token|secret|api_key|apikey):\s*["']?[A-Za-z0-9_\-]{20,}/i,
    /ghp_[A-Za-z0-9]{36}/,
    /github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}/,
    /AKIA[0-9A-Z]{16}/,
  ];
  for (const pattern of secretPatterns) {
    if (pattern.test(trimmed)) {
      findings.push({
        ruleId: "SEC-001",
        category: "SECRET_EXPOSURE",
        severity: "medium",
        line: lineNum,
        evidence: line.trimEnd(),
        message:
          "Possible hardcoded credential detected. Secrets should be stored in GitHub Secrets and referenced via ${{ secrets.NAME }}.",
      });
      break;
    }
  }
  if (/echo.*\$\{\{\s*secrets\./.test(trimmed)) {
    findings.push({
      ruleId: "SEC-002",
      category: "SECRET_EXPOSURE",
      severity: "medium",
      line: lineNum,
      evidence: line.trimEnd(),
      message:
        "Secret value is echoed to workflow logs. While GitHub redacts known secrets, this pattern risks exposure if redaction fails or secrets are constructed dynamically.",
    });
  }
}
  • Add the checkUnsafeThirdParty helper as the final function:
function checkUnsafeThirdParty(
  trimmed: string, lineNum: number, line: string, findings: Finding[]
): void {
  const usesMatch = trimmed.match(/^-?\s*uses:\s*([^\s#]+)/);
  if (!usesMatch) return;
  const actionRef = usesMatch[1];
  if (actionRef.startsWith("./")) return;
  const isTrusted = TRUSTED_ORGS.some((org) => actionRef.startsWith(org));
  if (!isTrusted) {
    const orgName = actionRef.split("/")[0];
    findings.push({
      ruleId: "TPM-001",
      category: "UNSAFE_THIRD_PARTY",
      severity: "medium",
      line: lineNum,
      evidence: line.trimEnd(),
      message: `Action from third-party organization "${orgName}". Review the action source code, check its maintenance status, and consider pinning to a verified commit SHA.`,
    });
  }
}

How do the 6 categories map to real attacks?

  • EXCESSIVE_PERMISSIONS: A compromised step with write-all can push code, create releases, and modify secrets.
  • UNPINNED_ACTION: An attacker who compromises an action repo can push malicious code to mutable tags like @v1 or @main.
  • SCRIPT_INJECTION: Attacker-controlled PR titles or branch names containing shell metacharacters execute arbitrary commands in run: blocks.
  • RISKY_TRIGGER: pull_request_target runs with base-branch secrets, letting fork PRs exfiltrate them.
  • SECRET_EXPOSURE: Hardcoded credentials in YAML or secrets printed to logs bypass GitHub's secret masking.
  • UNSAFE_THIRD_PARTY: Actions from unknown orgs could contain supply-chain compromises.
  • Save lib/scanner.ts.
  • Confirm TypeScript is happy with the new file by running:
npx tsc --noEmit

You should see no output at all. No output means no type errors.

Seeing type errors?

  • If you see an error about a missing module, confirm the file is saved at lib/scanner.ts (not src/lib/scanner.ts or another path).
  • If you see syntax errors, check for missing closing braces. The scanWorkflow function has nested if/else blocks inside a for loop. Count the braces carefully.

help me fix TypeScript errors in my scanner.ts file

Wire the scanner into the API route and display findings

The scanner module is ready but nothing calls it yet. You will import it into your API route and update the frontend to show findings in a table.

  • In app/api/scan/route.ts, find this line at the top:
import yaml from "js-yaml";
  • Add the scanner import directly below it so the imports look like this:
import yaml from "js-yaml";
import { scanWorkflow } from "@/lib/scanner";
  • In the same file, find these two lines near the bottom of the POST function:
    const workflow = parsed as Record<string, unknown>;
    return NextResponse.json({
  • Replace them with the following, which calls the scanner and includes findings in the response:
    const workflow = parsed as Record<string, unknown>;
    const findings = scanWorkflow(yamlText);
    return NextResponse.json({
      metadata: {
        name: workflow.name || "(unnamed workflow)",
        triggers: workflow.on
          ? Object.keys(
              typeof workflow.on === "string"
                ? { [workflow.on]: {} }
                : (workflow.on as object)
            )
          : [],
        jobs: workflow.jobs
          ? Object.keys(workflow.jobs as object)
          : [],
      },
      findings,
    });
  • Save app/api/scan/route.ts.

Now update the frontend to display findings in a table. The page needs the Finding type, a new state variable, and the table JSX.

  • In app/page.tsx, find the existing import line:
import { useState } from "react";
  • Add the Finding import directly below it:
import { Finding } from "@/lib/scanner";
  • Inside the Home component, find the line that declares the scanning state:
  const [scanning, setScanning] = useState(false);
  • Add a findings state variable directly above it:
  const [findings, setFindings] = useState<Finding[]>([]);
  • In the handleScan function, find this line:
    setMetadata(null);
  • Add setFindings([]); directly below it to clear previous findings on each scan.
  • Still in handleScan, find this line:
      setMetadata(data.metadata);
  • Add setFindings(data.findings); directly below it.
  • Now add the findings table. Find the closing of the metadata section just above </main>:
      )}
    </main>
  • Add the following findings table between those two lines, so it renders below the metadata card:
      {findings.length > 0 && (
        <div className="mb-6">
          <h2 className="text-xl font-semibold mb-3">
            Findings ({findings.length})
          </h2>
          <div className="overflow-x-auto">
            <table className="w-full text-sm border-collapse">
              <thead>
                <tr className="bg-gray-100">
                  <th className="p-2 text-left border">Rule</th>
                  <th className="p-2 text-left border">Category</th>
                  <th className="p-2 text-left border">Line</th>
                  <th className="p-2 text-left border">Evidence</th>
                  <th className="p-2 text-left border">Message</th>
                </tr>
              </thead>
              <tbody>
                {findings.map((f, i) => (
                  <tr key={i} className="hover:bg-gray-50">
                    <td className="p-2 border font-mono text-xs">
                      {f.ruleId}
                    </td>
                    <td className="p-2 border text-xs">{f.category}</td>
                    <td className="p-2 border text-center">{f.line}</td>
                    <td className="p-2 border font-mono text-xs max-w-xs truncate">
                      {f.evidence}
                    </td>
                    <td className="p-2 border text-xs">{f.message}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      )}

What does this table display?

Each row represents one vulnerability finding. The columns show the machine-readable rule ID, the vulnerability category, the exact line number in the YAML where the issue occurs, the vulnerable YAML snippet as evidence, and a human-readable explanation.

  • Save app/page.tsx.
  • Switch to your browser at http://localhost:3000.
  • Paste a simple workflow into the textarea to confirm the scanner is connected:
name: Test
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
  • Click Scan.

You should see the metadata card appear, plus a findings table with at least 2 findings: PERM-002 (missing permissions declaration) and PIN-001 (the unpinned @v4 tag).

Not seeing any findings?

  • If the metadata appears but no findings table, check that route.ts calls scanWorkflow(yamlText) and includes findings in the response JSON.
  • If you see a 500 error, check your terminal for a compile error in lib/scanner.ts. A missing brace or typo in a regex will cause this.
  • If the findings array is empty, confirm you added setFindings(data.findings); in the handleScan function after setMetadata(data.metadata);.

help me debug why my scanner is not returning findings

Verify all 6 categories fire with a test workflow

Your scanner works, but you have only confirmed a couple of categories. A purpose-built test workflow will exercise all 6 rule categories at once.

  • In app/page.tsx, find the line interface ScanMetadata { and add the following test workflow constant above it:
const TEST_WORKFLOW = `name: CI Pipeline
on:
  pull_request_target:
    branches: [main]

permissions: write-all

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: \${{ github.event.pull_request.head.sha }}

      - uses: some-org/deploy-action@main

      - name: Greet PR author
        run: |
          echo "PR Title: \${{ github.event.pull_request.title }}"
          echo "Processing PR from \${{ github.head_ref }}"

      - name: Deploy
        env:
          AWS_ACCESS_KEY: AKIAIOSFODNN7EXAMPLE
        run: ./deploy.sh
`;

What vulnerabilities does this test workflow contain?

This workflow is deliberately insecure. It uses pull_request_target (risky trigger), grants write-all permissions (excessive), references actions with mutable tags (unpinned), injects attacker-controlled expressions into shell commands (script injection), hardcodes an AWS key (secret exposure), and uses a non-verified org's action (unsafe third-party).

  • Now add a button to load this test workflow. In the JSX, find the closing </button> of the Scan button and add a second button directly after it:
        <button onClick={() => setYamlInput(TEST_WORKFLOW)}
          className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">
          Load Test Workflow
        </button>
  • Save app/page.tsx and return to your browser.

Before you click anything, think about this: how many findings do you expect the scanner to produce from this test workflow? Which categories should fire?

  • Click Load Test Workflow to fill the textarea.
  • Click Scan.

You should see a findings table with 6 or more rows. The table includes findings from all 6 categories: EXCESSIVE_PERMISSIONS, UNPINNED_ACTION, SCRIPT_INJECTION, RISKY_TRIGGER, SECRET_EXPOSURE, and UNSAFE_THIRD_PARTY. Each finding shows the exact line number and the vulnerable YAML snippet as evidence.

Seeing fewer than 6 findings?

  • Check that the test workflow constant uses backtick template literals with the escaped \${{ expressions. Without the backslash escape, JavaScript evaluates the expression instead of passing it through as literal text.
  • If SCRIPT_INJECTION findings are missing, verify the checkScriptInjection function checks line (the raw line with original indentation), not trimmed, for the regex match.

help me figure out why some vulnerability categories are not triggering

✔️ Awesome, I've got everything!

Great. Confirm you have saved lib/scanner.ts, app/api/scan/route.ts, and app/page.tsx.

ⓧ I'd like to double check the full code

Your lib/scanner.ts should contain these sections in order. Scroll up and confirm each is present:

  • The Category type union and Finding interface (both exported).
  • TRUSTED_ORGS and DANGEROUS_CONTEXTS constant arrays.
  • The exported scanWorkflow function with the for-loop and missing-permissions check.
  • Six helper functions: checkPermissions, checkUnpinnedActions, checkScriptInjection, checkRiskyTriggers, checkSecretExposure, checkUnsafeThirdParty.

Here is the full app/api/scan/route.ts for comparison:

import { NextRequest, NextResponse } from "next/server";
import yaml from "js-yaml";
import { scanWorkflow } from "@/lib/scanner";

export async function POST(request: NextRequest) {
  try {
    const { yamlText } = await request.json();
    if (!yamlText || typeof yamlText !== "string") {
      return NextResponse.json(
        { error: "YAML text is required" },
        { status: 400 }
      );
    }
    let parsed;
    try {
      parsed = yaml.load(yamlText);
    } catch (e) {
      const yamlError = e as Error;
      return NextResponse.json(
        { error: `Invalid YAML: ${yamlError.message}` },
        { status: 400 }
      );
    }
    if (!parsed || typeof parsed !== "object") {
      return NextResponse.json(
        { error: "YAML must be a mapping (object), not a scalar or sequence" },
        { status: 400 }
      );
    }
    const workflow = parsed as Record<string, unknown>;
    const findings = scanWorkflow(yamlText);
    return NextResponse.json({
      metadata: {
        name: workflow.name || "(unnamed workflow)",
        triggers: workflow.on
          ? Object.keys(
              typeof workflow.on === "string"
                ? { [workflow.on]: {} }
                : (workflow.on as object)
            )
          : [],
        jobs: workflow.jobs
          ? Object.keys(workflow.jobs as object)
          : [],
      },
      findings,
    });
  } catch (e) {
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

Your scanner catches the vulnerabilities, but the flat list gives no sense of which ones are most dangerous, how an attacker would exploit them, or what the fix looks like. Next up, you will feed these findings to Claude and get back a prioritized security report with risk scores, attack scenarios, and copy-ready YAML fixes.

Integrate Claude Structured Outputs

Your deterministic scanner catches the vulnerabilities and presents them in a flat table. But a developer looking at that table cannot tell which finding is most dangerous, how an attacker would actually exploit each pattern, or what the fix looks like.

In this step, you'll use Claude structured outputs to transform the raw findings into a prioritized security report. You'll define a Zod schema that guarantees the response shape, then send the findings to Claude with constraints that produce severity scores, attack scenarios, and secure YAML replacements for every vulnerability.

In this step, get ready to:
  • Define a Zod schema for the security report structure.
  • Create an API route that sends findings to Claude with structured output constraints.
  • Add an Analyze button to the frontend that triggers Claude's analysis and displays the result.
Define the Zod schema for Claude's output

Before calling Claude, you need to define the exact shape of data you expect back. The Zod library lets you declare a schema that the SDK enforces at the API level. Claude must return data matching this schema or the request fails.

  • Create a new file lib/schemas.ts and add the following schema definition:
import { z } from "zod";

// Schema for each finding's AI-enriched analysis
export const FindingAnalysisSchema = z.object({
  ruleId: z.string(),
  severityScore: z.number(),
  exploitability: z.enum(["low", "medium", "high", "critical"]),
  attackScenario: z.string(),
  explanation: z.string(),
  secureReplacement: z.string(),
});

// Schema for the overall security report
export const SecurityReportSchema = z.object({
  overallRiskScore: z.number(),
  summary: z.string(),
  findings: z.array(FindingAnalysisSchema),
});

// Export inferred types for use in components
export type SecurityReport = z.infer<typeof SecurityReportSchema>;
export type FindingAnalysis = z.infer<typeof FindingAnalysisSchema>;

What does this schema do?

  • FindingAnalysisSchema defines what Claude must return for each finding: a severity score (1-10), an exploitability rating, a concrete attack scenario, an explanation, and a secure YAML replacement.
  • SecurityReportSchema wraps all finding analyses with an overall risk score and summary.
  • The exported types (SecurityReport, FindingAnalysis) give you type safety in the frontend without manually writing interfaces.
  • Save lib/schemas.ts.
Create the analyze API route

This route receives the original YAML and the deterministic findings, constructs a prompt that constrains Claude to analyze only those findings, and uses zodOutputFormat to guarantee the response matches your schema.

  • Create a new file app/api/analyze/route.ts and add the imports, client initialization, and system prompt:
import { NextRequest, NextResponse } from "next/server";
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { SecurityReportSchema } from "@/lib/schemas";
import { Finding } from "@/lib/scanner";

// Client reads ANTHROPIC_API_KEY from environment automatically
const client = new Anthropic();

const SYSTEM_PROMPT = `You are a senior GitHub Actions security engineer performing a workflow security audit.

You will receive:
1. The raw YAML text of a GitHub Actions workflow
2. A list of deterministic security findings with rule IDs, categories, line numbers, and evidence

Your job is to analyze ONLY the provided findings. Do NOT invent new vulnerabilities beyond what the deterministic scanner found. For each finding:

- Assign a severity score (1-10) based on real-world exploitability
- Rate exploitability as "low", "medium", "high", or "critical"
- Write a concrete attack scenario showing how an attacker would exploit this specific pattern
- Explain WHY this pattern is dangerous, referencing GitHub Actions security concepts
- Provide a secure YAML replacement that fixes the vulnerability while preserving workflow functionality

Also provide an overall risk score (1-10) and a brief summary of the workflow's security posture.

Ground your analysis in GitHub's documented security best practices: least-privilege GITHUB_TOKEN permissions, pinning actions to full commit SHAs, avoiding script injection from untrusted contexts, careful use of privileged triggers, proper secrets management, and supply-chain verification of third-party actions.`;

What does this system prompt do?

The prompt constrains Claude to act as a security auditor and analyze ONLY the findings your deterministic scanner already caught. This prevents Claude from hallucinating new vulnerabilities. It also specifies the exact fields Claude must fill for each finding, aligning with the Zod schema you defined.

  • Below the system prompt in the same file, add the POST handler:
export async function POST(request: NextRequest) {
  try {
    const { yamlText, findings } = (await request.json()) as { yamlText: string; findings: Finding[] };
    if (!yamlText || !findings || findings.length === 0) {
      return NextResponse.json({ error: "YAML text and findings are required" }, { status: 400 });
    }
    const userMessage = `## Workflow YAML

\`\`\`yaml
${yamlText}
\`\`\`

## Deterministic Findings

${findings.map((f) => `- [${f.ruleId}] ${f.category} (line ${f.line}): ${f.message}\n  Evidence: ${f.evidence}`).join("\n\n")}

Analyze each finding and provide your structured security assessment.`;
    const response = await client.messages.parse({
      model: "claude-sonnet-4-6",
      max_tokens: 4096,
      system: SYSTEM_PROMPT,
      messages: [{ role: "user", content: userMessage }],
      output_config: { format: zodOutputFormat(SecurityReportSchema) },
    });
    return NextResponse.json(response.parsed_output);
  } catch (e) {
    const error = e as Error;
    console.error("Claude API error:", error.message);
    return NextResponse.json({ error: `Analysis failed: ${error.message}` }, { status: 500 });
  }
}

What does this handler do?

  • client.messages.parse() calls Claude and automatically validates the response against your Zod schema.
  • output_config: { format: zodOutputFormat(SecurityReportSchema) } tells Claude the exact JSON shape to return. If the response does not match, the SDK throws an error.
  • response.parsed_output contains the fully typed, schema-validated result. No manual JSON parsing needed.
  • Save app/api/analyze/route.ts.

Seeing a TypeScript import error?

If your editor underlines the @anthropic-ai/sdk/helpers/zod import, make sure you installed @anthropic-ai/sdk and zod in Step 1. Both packages must be present for the helper to resolve.

help me fix my @anthropic-ai/sdk import error in app/api/analyze/route.ts

Wire up the Analyze button in the frontend

The frontend needs new state for the Claude report, an handleAnalyze function that calls the new route, and conditional rendering to show the structured JSON response.

  • In app/page.tsx, find the import line at the top:
import { Finding } from "@/lib/scanner";
  • Replace it with these two imports to also bring in the report type:
import { Finding } from "@/lib/scanner";
import { SecurityReport } from "@/lib/schemas";
  • Inside the Home component, find the existing state declarations and add report and analyzing state. Your state block should now look like this:
const [yamlInput, setYamlInput] = useState("");
const [metadata, setMetadata] = useState<ScanMetadata | null>(null);
const [findings, setFindings] = useState<Finding[]>([]);
const [report, setReport] = useState<SecurityReport | null>(null);
const [scanning, setScanning] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [error, setError] = useState("");
  • In the handleScan function, find the line setError(""); and add setReport(null); directly after it. This clears any previous analysis when a new scan starts.
  • Below the handleScan function, add the new handleAnalyze function:
async function handleAnalyze() {
  setAnalyzing(true);
  setError("");
  try {
    const res = await fetch("/api/analyze", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ yamlText: yamlInput, findings }),
    });
    const data = await res.json();
    if (!res.ok) { setError(data.error); return; }
    setReport(data);
  } catch (e) {
    setError("Failed to connect to analyzer");
  } finally {
    setAnalyzing(false);
  }
}

What does handleAnalyze do?

It sends the original YAML text plus the deterministic findings to /api/analyze. The route passes both to Claude, and the response comes back as a fully structured SecurityReport object.

  • In the JSX, find the findings section that starts with:
{findings.length > 0 && (
  • Replace that condition with {findings.length > 0 && !report && ( so the findings table hides once you have an analysis report.
  • Inside that same block, add the Analyze button between the heading and the table. Replace the existing <h2> line with a flex container holding both the heading and button:
<div className="flex items-center justify-between mb-3">
  <h2 className="text-xl font-semibold">Findings ({findings.length})</h2>
  <button onClick={handleAnalyze} disabled={analyzing}
    className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
    {analyzing ? "Analyzing with Claude..." : "Analyze with Claude"}
  </button>
</div>
  • After the findings section's closing tags, add the report display section that shows the raw JSON:
{report && (
  <div className="mb-6">
    <h2 className="text-xl font-semibold mb-3">Claude Analysis (Raw JSON)</h2>
    <pre className="p-4 bg-gray-50 border rounded-lg text-xs overflow-x-auto">
      {JSON.stringify(report, null, 2)}
    </pre>
  </div>
)}
  • Save app/page.tsx.

Switch to your browser and confirm the page reloads without errors.

Page not loading?

Check your terminal for TypeScript compilation errors. The most common cause is a missing import for SecurityReport or a typo in the handleAnalyze function name.

help me fix a compilation error in app/page.tsx after adding the analyze integration

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: lib/schemas.ts, app/api/analyze/route.ts, and app/page.tsx.

ⓧ I'd like to double check the full code

Scroll back through the chunks you added in this step and confirm each is present and in order:

  • lib/schemas.ts: the full schema file with FindingAnalysisSchema, SecurityReportSchema, and the two type exports.
  • app/api/analyze/route.ts: chunk 1 (imports, client, SYSTEM_PROMPT) followed by chunk 2 (the POST handler).
  • app/page.tsx: updated imports, new state variables (report, analyzing), setReport(null) in handleScan, the handleAnalyze function, the conditional && !report guard, the Analyze button, and the report JSON display block.
Test the full analysis flow

Before you run the analysis, think about what you expect to happen. Claude receives the flat findings and must return structured JSON with severity scores, attack scenarios, and fixes for each one.

  • In your browser, click the **Load Test Workflow** button to fill the textarea.
  • Click **Scan** to run the deterministic scanner. You should see the findings table appear.

You now see the findings table with an **Analyze with Claude** button on the right.

  • Before clicking Analyze, predict: will the response be unstructured text, or will it be valid JSON matching your Zod schema with fields like overallRiskScore, severityScore, and attackScenario?
  • Click **Analyze with Claude**.

After a few seconds, the findings table disappears and a raw JSON block appears. The JSON matches your Zod schema exactly: an overallRiskScore at the top, a summary string, and a findings array where each entry has severityScore, exploitability, attackScenario, explanation, and secureReplacement.

Why structured outputs instead of free-form text?

Without structured outputs, you would need to parse Claude's natural language response and hope it matches a consistent format. With zodOutputFormat, the API guarantees the response shape at the protocol level. If Claude cannot produce valid JSON for your schema, the request fails rather than silently returning malformed data.

This makes your frontend code simple: you receive typed data and render it directly, with no fragile regex parsing in between.

Seeing 'Analysis failed' error?

Check that your ANTHROPIC_API_KEY in .env.local is a valid key. The Anthropic client reads this environment variable automatically. If you recently created the key, wait a moment for it to activate.

Also confirm your dev server was restarted after creating .env.local. Next.js only reads environment files at startup.

help me debug a Claude API error in my analyze route

The scanner now has its brain. Raw findings go in, and a prioritized security report with attack scenarios and fixes comes out. Next, you'll render this structured data as a polished, scannable report with severity badges and copy-ready fixes.

Display the Security Report

Claude's structured output contains everything needed for a professional security report. Right now, though, your app dumps it as raw JSON, which makes it impossible to quickly triage vulnerabilities.

In this step, you'll transform that raw JSON into a polished report with color-coded severity badges, expandable attack scenarios, and copy-ready YAML fixes for each finding.

In this step, get ready to:
  • Add a severity color helper for exploitability badges.
  • Build a polished report display with expandable finding cards.
  • Verify the full scan-to-report flow end to end.
Add the severity color helper

Each finding from Claude comes with an exploitability rating: critical, high, medium, or low. You need a function that maps each rating to the right Tailwind color classes for the badge.

  • In app/page.tsx, find the handleAnalyze function's closing brace. Add this new function between handleAnalyze and the return statement:
  function getSeverityColor(exploitability: string): string {
    switch (exploitability) {
      case "critical": return "bg-red-100 text-red-800 border-red-300";
      case "high": return "bg-orange-100 text-orange-800 border-orange-300";
      case "medium": return "bg-yellow-100 text-yellow-800 border-yellow-300";
      case "low": return "bg-green-100 text-green-800 border-green-300";
      default: return "bg-gray-100 text-gray-800 border-gray-300";
    }
  }

What does this code do?

This function takes an exploitability string from Claude's analysis and returns a set of Tailwind utility classes. Critical findings get red badges, high gets orange, medium gets yellow, and low gets green. The result is applied to a <span> element to produce a color-coded badge.

  • While you're here, update the <textarea> element's className to add focus styling. Find this line:
className="w-full h-64 font-mono text-sm p-4 border border-gray-300 rounded-lg"
  • Replace it with:
className="w-full h-64 font-mono text-sm p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
  • Also find the Scan button's className and add disabled:cursor-not-allowed at the end of the existing classes, so the full className becomes:
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
  • Save app/page.tsx and refresh your browser.
  • Click into the textarea. You should see a blue ring appear around it on focus.

No blue ring on focus?

Make sure you saved the file. Check that the focus:ring-2 and focus:ring-blue-500 classes are inside the same string as the rest of the className.

Help me debug why my textarea focus styles aren't showing.

Build the report header and findings list

The raw JSON dump at the bottom of your page currently renders inside a {report && (...)} block. You'll replace the entire contents of that block with a polished report layout.

  • In app/page.tsx, scroll to the bottom of the JSX and find this block:
      {report && (
        <div className="mb-6">
          <h2 className="text-xl font-semibold mb-3">Claude Analysis (Raw JSON)</h2>
          <pre className="p-4 bg-gray-50 border rounded-lg text-xs overflow-x-auto">{JSON.stringify(report, null, 2)}</pre>
        </div>
      )}
  • Delete that entire block and replace it with the report header section below. This renders the overall risk score and summary paragraph, then starts the findings loop with severity badges in the <summary> of each expandable card:
      {report && (
        <div className="mb-6">
          <div className="flex items-center gap-4 mb-4">
            <h2 className="text-xl font-semibold">Security Report</h2>
            <span className="px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm font-medium">
              Risk Score: {report.overallRiskScore}/10
            </span>
          </div>
          <p className="text-gray-700 mb-4">{report.summary}</p>
          <div className="space-y-4">
            {report.findings
              .sort((a, b) => b.severityScore - a.severityScore)
              .map((finding, i) => {
                const deterministicFinding = findings.find((f) => f.ruleId === finding.ruleId);
                return (
                  <details key={i} className="border rounded-lg overflow-hidden">
                    <summary className="p-4 cursor-pointer hover:bg-gray-50 flex items-center gap-3">
                      <span className={`px-2 py-0.5 text-xs font-medium rounded border ${getSeverityColor(finding.exploitability)}`}>
                        {finding.exploitability.toUpperCase()}
                      </span>
                      <span className="font-mono text-sm text-gray-500">{finding.ruleId}</span>
                      <span className="text-sm">{deterministicFinding?.message}</span>
                      <span className="ml-auto text-xs text-gray-400">
                        Line {deterministicFinding?.line} | Score: {finding.severityScore}/10
                      </span>
                    </summary>

What does this code do?

  • The risk score badge shows Claude's overall assessment of the workflow's security posture (1-10).
  • Findings are sorted by severityScore descending so the most dangerous vulnerabilities appear first.
  • Each finding becomes a <details> element. The <summary> line shows the color-coded exploitability badge, rule ID, description, and line number at a glance.
  • The getSeverityColor function you just added drives the badge styling.
  • Immediately after the closing </summary> tag, add the expandable card content. This renders the attack scenario, explanation, and a copy-ready YAML fix for each finding:
                    <div className="p-4 border-t bg-gray-50 space-y-3">
                      <div>
                        <h4 className="text-sm font-semibold text-red-700 mb-1">Attack Scenario</h4>
                        <p className="text-sm text-gray-700">{finding.attackScenario}</p>
                      </div>
                      <div>
                        <h4 className="text-sm font-semibold text-gray-700 mb-1">Why This Is Dangerous</h4>
                        <p className="text-sm text-gray-700">{finding.explanation}</p>
                      </div>
                      <div>
                        <h4 className="text-sm font-semibold text-green-700 mb-1">Secure Replacement</h4>
                        <pre className="p-3 bg-green-50 border border-green-200 rounded text-xs font-mono overflow-x-auto">{finding.secureReplacement}</pre>
                        <button onClick={() => navigator.clipboard.writeText(finding.secureReplacement)}
                          className="mt-2 text-xs text-green-700 hover:text-green-900 underline">
                          Copy fix
                        </button>
                      </div>
                    </div>
                  </details>
                );
              })}
          </div>
        </div>
      )}

What does this code do?

  • When the learner clicks a finding's summary row, the <details> element expands to reveal three sections: an attack scenario in red, a danger explanation, and the secure YAML replacement in green.
  • The secure replacement is rendered inside a <pre> block so the YAML whitespace is preserved.
  • The "Copy fix" button uses navigator.clipboard.writeText to copy the replacement YAML to the clipboard so the developer can paste it directly into their workflow file.
  • Save app/page.tsx.

Seeing a syntax error in the terminal?

The most common issue is a mismatched brace or missing closing tag. Make sure every <details> has a matching </details>, and the .map() callback closes with ); followed by })}.

Help me debug a JSX syntax error in my report section.

Run the full end-to-end flow

Everything is wired up. Time to test the full pipeline from YAML paste to polished security report.

  • Switch back to your browser at http://localhost:3000.
  • Click Load Test Workflow to fill the textarea with the vulnerable sample.
  • Click Scan. You should see the findings table appear with 6+ rows.
  • Click Analyze with Claude.

Before the response arrives, predict: will you see raw JSON or something different this time?

After a few seconds, the findings table disappears and your polished Security Report takes its place. You should see a red "Risk Score" badge at the top, a summary paragraph, and a list of finding cards. Each card shows a color-coded exploitability badge (CRITICAL in red, HIGH in orange, and so on) alongside the rule ID and line number.

  • Click on any finding card to expand it. You should see three sections: Attack Scenario (in red), Why This Is Dangerous, and Secure Replacement (in green with a "Copy fix" link).
  • Click Copy fix on any finding, then paste into a text editor to confirm the YAML replacement was copied to your clipboard.

Still seeing raw JSON?

Make sure you deleted the entire old {report && (...)} block that contained the <pre>{JSON.stringify(report, null, 2)}</pre> element. If both blocks exist, the old one renders instead of the new one.

Help me figure out why my polished report isn't rendering.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/page.tsx and that the full scan-to-report flow works.

ⓧ I'd like to double check the full code

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

  • The "use client" directive and imports (useState, Finding, SecurityReport).
  • The TEST_WORKFLOW constant with vulnerable YAML.
  • The ScanMetadata interface and Home component with all state hooks.
  • handleScan and handleAnalyze async functions.
  • The getSeverityColor function returning Tailwind classes for each exploitability level.
  • The JSX return with textarea, buttons, error display, metadata display, findings table, and the polished report section with expandable <details> cards.

Your scanner now produces a professional security audit from raw YAML. Next up is the Secret Mission, where you'll add a "Fix All" button that generates a complete hardened workflow with a visual diff.

Secret mission

Add "Fix All" with Diff View

Your scanner finds vulnerabilities and Claude explains how to fix each one. But a developer reviewing a workflow with 8 findings does not want to copy-paste 8 separate fixes. Can you add a single "Fix All" button that applies every secure replacement at once, then displays a color-coded diff showing exactly what changed and why?

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 are per-request only.

Resources you used:

  • The actions-scanner project directory (source code, node_modules, and .env.local with your API key).
  • The Next.js dev server process running in your terminal.
  • Claude API usage (~$0.01-0.05 per scan, charged per-request with no ongoing cost).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep scanning workflows.

  • Your project files stay in the actions-scanner folder on your Desktop.
  • The dev server only runs when you start it with npm run dev. There are no background processes or costs when it is stopped.
  • Claude API costs are per-request only. You are not charged unless you click Analyze with Claude.

Pause - I'll come back to this later

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

  • Switch to the terminal running the dev server and press Ctrl+C to stop it.
  • Your project files remain in the actions-scanner folder. To resume later, open a terminal in that folder and run npm run dev.

Delete - I don't want to use this again

Remove all project files from your machine. This deletes the source code, dependencies, and your local .env.local file containing the API key reference.

  • Stop the dev server by pressing Ctrl+C in the terminal where it is running.
  • Navigate to your Desktop and remove the project directory by running:

macOS

cd ~/Desktop
rm -rf actions-scanner

Windows

cd ~\Desktop
Remove-Item -Recurse -Force actions-scanner
  • Confirm the folder is gone by running ls (macOS) or dir (Windows). You should no longer see actions-scanner in the output.
  • If you want to fully revoke your Anthropic API key, visit the API Keys page in your Anthropic dashboard and delete or rotate the key you used for this project.

Nice Work!

Nice Work!

You just built a professional-grade GitHub Actions security scanner that catches real CI/CD vulnerabilities and explains exactly how to fix them.

You've learned how to:

  • Build a deterministic security scanner that performs line-level YAML analysis across 6 vulnerability categories, producing findings with exact line numbers, rule IDs, and evidence strings.
  • Use Claude structured outputs with Zod schemas and zodOutputFormat to transform flat findings into type-safe security advisories with risk scores, attack scenarios, and secure YAML replacements.
  • Render a polished security report UI with color-coded severity badges, expandable attack scenarios, and copy-ready YAML fixes that make each vulnerability actionable.
  • Secret Mission: Generated a complete hardened workflow with a visual diff showing every security improvement and why it was made.

Ready to quiz yourself?