AI-Assisted Secure Code Review API

Build an API that scans code for vulnerabilities and enriches findings with AI.

Introduction

30 Second Summary

Security scanners can detect vulnerabilities in your code, but their raw output is often cryptic and unhelpful. Developers need clear guidance on what went wrong, how it could be exploited, and exactly how to fix it.

In this project, you will build SentinelAI, an API that scans submitted source code for security vulnerabilities and uses AI to transform each finding into a developer-friendly report with root cause analysis, exploitation scenarios, and remediation code examples.

What You'll Build

SentinelAI is a FastAPI backend that orchestrates Semgrep static analysis and Gemini AI enrichment to produce structured security reports from submitted source code.

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

  • A FastAPI application with a /scan endpoint that accepts code submissions and returns enriched security findings.
  • A Semgrep integration layer that runs SAST (static analysis) locally, parses JSON output, and handles temporary file cleanup.
  • An LLM enrichment pipeline that generates root cause analysis, exploitation scenarios, impact assessments, remediation guidance, and OWASP/CWE mappings for each vulnerability.
  • Secret Mission: A /report endpoint that generates a full HTML security report with severity distribution charts and a prioritized remediation roadmap.

Are there any prerequisites?

You should have basic Python knowledge and familiarity with REST APIs. You will also need a free Google AI Studio API key, which you will set up in Step 1.

Before We Start

SentinelAI combines two powerful tools into one pipeline: a static analysis scanner that finds vulnerabilities in code, and an AI model that explains what each vulnerability means and how to fix it.

Before we can build any of that, we need to set up Python, install the right packages, and configure our API keys. This step gets your machine ready so you can focus on building the scanner logic in the following steps.

In this step, get ready to:
  • Install Python 3.10+ and create a virtual environment.
  • Install project dependencies and verify Semgrep works.
  • Set up the project directory structure and configure environment variables.
Install Python and create your project

Both Semgrep and the Google GenAI SDK require Python 3.10 or higher. Let's confirm you have the right version installed.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.10 or higher

You're all set. Move on to the next instruction below.

ⓧ I see an older version

Your system has an older Python installed. macOS often ships with an outdated version that python3 resolves to.

  • Download the latest Python 3 installer from python.org/downloads.
  • Run the installer and follow the prompts.
  • On Windows, check the box to add Python to PATH.
  • Close and reopen your terminal, then run the version check again.

ⓧ Command not found

Python is not installed on your machine yet.

  • Download the latest Python 3 installer from python.org/downloads.
  • Run the installer.
  • On Windows, check the box to add Python to PATH.
  • Close and reopen your terminal, then run the version check again to confirm it shows 3.10 or higher.

Now create your project directory. Every terminal command in this project starts from this folder.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir sentinelai
cd sentinelai
  • Create a virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python workspace. Packages you install here stay separate from your system Python, preventing version conflicts between projects.

Not seeing (venv) in your prompt?

Make sure you ran the activate command for your operating system. On macOS, it starts with source. On Windows, it uses a backslash path.

help me activate my Python virtual environment

Install dependencies and verify Semgrep

Your project depends on five packages: FastAPI for the web server, uvicorn to run it, the Google GenAI SDK for Gemini access, python-dotenv for environment variable loading, and Semgrep for static code analysis.

  • Create the requirements.txt file by running this command:

macOS

cat > requirements.txt << 'EOF'
fastapi==0.136.3
uvicorn==0.34.0
google-genai==2.8.0
python-dotenv==1.2.2
semgrep==1.164.0
EOF

Windows

@"
fastapi==0.136.3
uvicorn==0.34.0
google-genai==2.8.0
python-dotenv==1.2.2
semgrep==1.164.0
"@ | Out-File -Encoding utf8 requirements.txt

What does this file do?

A requirements.txt file lists every Python package your project needs, pinned to exact versions. This ensures everyone working on the project installs the same dependencies.

  • Install all the dependencies by running this command:
pip install -r requirements.txt

This may take a minute or two. Semgrep is the largest package and includes its own analysis engine.

  • Verify Semgrep installed correctly by running:
semgrep --version

✔️ I see a version number

Semgrep is ready. You should see 1.164.0 or similar output. Move on to the next substep.

ⓧ Command not found

This usually means the virtual environment is not active or the install failed.

  • Check that (venv) appears in your terminal prompt.
  • Re-run pip install -r requirements.txt and look for error messages in the output.
  • If you see errors about missing compilers or build tools, confirm you are on macOS, Linux, or Windows with Python 3.10+.

help me fix Semgrep installation issues

Set up the project structure and environment variables

SentinelAI organizes its code in an app/ package and keeps test files in a separate test_samples/ directory.

  • Create the project directories and the Python package marker file by running these commands:

macOS

mkdir app test_samples
touch app/__init__.py

Windows

mkdir app, test_samples
New-Item -Path app\__init__.py -ItemType File
  • Confirm the structure was created by running:
ls

You should see app, test_samples, requirements.txt, and venv in the output.

Why an empty __init__.py?

The __init__.py file tells Python that app/ is a package. Without it, Python cannot import modules from inside that directory.

Next, set up your environment variables. You need two keys: one to access the Gemini API, and one to protect your own API from unauthorized use.

  • Create a .env.example file that documents the required variables (this is safe to commit to version control):

macOS

cat > .env.example << 'EOF'
GEMINI_API_KEY=your-gemini-api-key-here
SENTINEL_API_KEY=your-sentinel-api-key-here
EOF

Windows

@"
GEMINI_API_KEY=your-gemini-api-key-here
SENTINEL_API_KEY=your-sentinel-api-key-here
"@ | Out-File -Encoding utf8 .env.example
  • Now create your real .env file. You need your Gemini API key from Google AI Studio and a secret string of your choice for the SENTINEL_API_KEY.

macOS

cat > .env << 'EOF'
GEMINI_API_KEY=your-gemini-api-key-here
SENTINEL_API_KEY=your-sentinel-api-key-here
EOF

Windows

@"
GEMINI_API_KEY=your-gemini-api-key-here
SENTINEL_API_KEY=your-sentinel-api-key-here
"@ | Out-File -Encoding utf8 .env
  • Open the .env file in a text editor and replace your-gemini-api-key-here with your actual Gemini API key from Google AI Studio.
  • Replace your-sentinel-api-key-here with any secret string you choose (this is your project's own authentication key).

Where do I get my Gemini API key?

Go to Google AI Studio and click Create API key. Copy the key and paste it into your .env file.

The free tier provides free input and output tokens, so this project costs nothing to run.

Keep your .env file private

Never commit .env to version control. The .env.example file shows other developers what variables they need without exposing your actual keys.

Finally, open your project in a code editor.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor (or VS Code) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the sentinelai folder on your Desktop, then click Open.

You should see app/, test_samples/, requirements.txt, .env, and .env.example in the file explorer sidebar.

✔️ Awesome, I've got everything!

Great. Make sure your terminal still shows (venv) in the prompt. If you use the integrated terminal in VS Code or Cursor, you may need to reactivate the virtual environment there.

ⓧ I'd like to double check the full code

Here is what your requirements.txt should contain:

fastapi==0.136.3
uvicorn==0.34.0
google-genai==2.8.0
python-dotenv==1.2.2
semgrep==1.164.0

Your .env.example should contain:

GEMINI_API_KEY=your-gemini-api-key-here
SENTINEL_API_KEY=your-sentinel-api-key-here

Your .env should look the same, but with your actual API key values filled in.

Your environment is ready. Next up, you'll build the Semgrep scanner service that analyzes submitted code for security vulnerabilities.

Set Up Your Development Environment

Your Python environment is ready with all dependencies installed. The next piece is the project's internal structure.

SentinelAI has multiple services that work together: a scanner, an enrichment engine, and an API layer. Each one lives in its own module inside an app/ package. Before writing any logic, you'll scaffold these files and create a configuration module that safely loads your API keys.

In this step, get ready to:
  • Create the project's Python module files.
  • Write a configuration module that loads API keys from your environment.
Create the project file structure

Python treats a folder as an importable package when it contains an __init__.py file. You'll also create empty placeholder files for the modules you'll build in later steps.

  • In your terminal (with your virtual environment still active), create the module files by running these commands:
touch app/__init__.py app/config.py app/scanner.py app/enrichment.py app/main.py run.py

What does this command do?

The touch command creates empty files. You're scaffolding five modules inside the app/ package and one entry-point file at the project root.

  • app/__init__.py marks the folder as a Python package.
  • app/config.py will load environment variables.
  • app/scanner.py will run Semgrep scans.
  • app/enrichment.py will call Gemini for AI analysis.
  • app/main.py will define the FastAPI endpoints.
  • run.py will start the server.
  • Confirm the files exist by running:
ls app/

You should see __init__.py, config.py, enrichment.py, main.py, and scanner.py listed in the output.

Don't see all five files?

Check for typos in the touch command. Each filename must be separated by a space, and the app/ prefix matters. On Windows, use type nul > app\config.py for each file instead of touch.

Still stuck? Help me create these placeholder files.

Write the configuration module

Your .env file holds your API keys, but Python code can't read them automatically. The config module bridges that gap by loading .env values into environment variables and raising a clear error if any are missing.

  • In your editor, open app/config.py and add the following code:
import os
from dotenv import load_dotenv

# Load variables from .env into the process environment
load_dotenv()

# Read API keys from environment
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
SENTINEL_API_KEY = os.getenv("SENTINEL_API_KEY")

# Fail fast if keys are missing
if not GEMINI_API_KEY:
    raise ValueError("GEMINI_API_KEY environment variable is required")

if not SENTINEL_API_KEY:
    raise ValueError("SENTINEL_API_KEY environment variable is required")

What does this code do?

load_dotenv() reads your .env file and injects each key-value pair into os.environ. This means the rest of your app can access keys through standard environment variables.

The if not checks act as guardrails. If you forget to create your .env file or leave a key blank, the app crashes immediately with a helpful message instead of failing silently later.

  • Save app/config.py.
  • Verify the config loads correctly by running this in your terminal:
python -c "from app.config import GEMINI_API_KEY; print('Config loaded successfully')"

You should see Config loaded successfully printed. This confirms your .env file is being read and both keys are present.

Seeing a ValueError?

If you see GEMINI_API_KEY environment variable is required, your .env file is missing or the key name is misspelled. Open .env and check that GEMINI_API_KEY= has a value after the equals sign with no spaces around it.

If you see ModuleNotFoundError: No module named 'dotenv', your virtual environment may not be active. Reactivate it and try again.

Need more help? Help me debug my config module.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/config.py and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete app/config.py file:

import os
from dotenv import load_dotenv

load_dotenv()

GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
SENTINEL_API_KEY = os.getenv("SENTINEL_API_KEY")

if not GEMINI_API_KEY:
    raise ValueError("GEMINI_API_KEY environment variable is required")

if not SENTINEL_API_KEY:
    raise ValueError("SENTINEL_API_KEY environment variable is required")

Your project skeleton is in place and your configuration is loading cleanly. Next up, you'll build the Semgrep scanner service that analyzes submitted code for vulnerabilities.

Build the Semgrep Scanner Service

Your development environment is set up with all dependencies installed and Semgrep verified. Now it's time to build the first core service in SentinelAI's pipeline.

SentinelAI works in two stages: scan code for vulnerabilities, then enrich those findings with AI analysis. In this step, you'll build the scanning stage. This module wraps Semgrep into a reusable Python function that accepts raw code, writes it to a secure temporary file, runs a scan, and returns structured results.

In this step, get ready to:
  • Map programming languages to file extensions for Semgrep analysis.
  • Build a function that runs Semgrep against submitted code via subprocess.
  • Parse raw Semgrep JSON output into a clean, structured format.
Set up the scanner module

Semgrep needs a real file on disk to scan. Your scanner module will write submitted code to a temporary file with the correct extension so Semgrep can apply language-specific rules.

  • Open app/scanner.py in your editor.
  • Replace the placeholder content with the following imports and language extension mapping:
import json
import subprocess
import tempfile
import os


LANGUAGE_EXTENSIONS = {
    "python": ".py",
    "javascript": ".js",
    "typescript": ".ts",
    "java": ".java",
    "go": ".go",
    "ruby": ".rb",
    "php": ".php",
}

What does this code do?

The imports bring in modules for JSON parsing, running shell commands, creating secure temporary files, and file path operations.

The LANGUAGE_EXTENSIONS dictionary maps language names to file extensions. When code is submitted as "python", the scanner writes it to a file ending in .py so Semgrep can detect the language and apply the correct rule set.

You won't see a change yet. The next chunk builds the scan function that uses this mapping.

Write the scan execution function

The core of the scanner module is a function that creates a secure temporary file, runs Semgrep against it, and ensures cleanup happens regardless of success or failure.

  • Add the following function below the LANGUAGE_EXTENSIONS dictionary in app/scanner.py:
def run_semgrep_scan(code: str, language: str) -> dict:
    """Run Semgrep scan on submitted code and return parsed findings."""
    extension = LANGUAGE_EXTENSIONS.get(language, ".txt")

    tmp_dir = tempfile.mkdtemp(prefix="sentinel_")
    tmp_file_path = os.path.join(tmp_dir, f"input{extension}")

    try:
        with open(tmp_file_path, "w") as f:
            f.write(code)

        result = subprocess.run(
            [
                "semgrep",
                "scan",
                "--json",
                "--config",
                "auto",
                "--metrics=off",
                tmp_file_path,
            ],
            capture_output=True,
            text=True,
            timeout=60,
        )

        output = json.loads(result.stdout) if result.stdout else {"results": []}
        findings = parse_findings(output)
        return {"findings": findings, "files_scanned": 1, "errors": output.get("errors", [])}

    except subprocess.TimeoutExpired:
        return {"findings": [], "files_scanned": 0, "errors": [{"message": "Scan timed out after 60 seconds"}]}
    except json.JSONDecodeError:
        return {"findings": [], "files_scanned": 0, "errors": [{"message": "Failed to parse Semgrep output"}]}
    finally:
        if os.path.exists(tmp_file_path):
            os.unlink(tmp_file_path)
        if os.path.exists(tmp_dir):
            os.rmdir(tmp_dir)

What does this code do?

This function handles the full lifecycle of a Semgrep scan in four stages:

  • Temp file creation: tempfile.mkdtemp() creates a secure temporary directory with an unpredictable name. The code is written to a file inside it with the correct language extension.
  • Scan execution: subprocess.run() calls Semgrep with --json for structured output, --config auto to use the default ruleset, and --metrics=off to disable telemetry. A 60-second timeout prevents hanging.
  • Error handling: TimeoutExpired and JSONDecodeError are caught gracefully, returning empty findings with descriptive error messages instead of crashing.
  • Cleanup: The finally block guarantees the temporary file and directory are removed, even if the scan fails or times out.

Why use mkdtemp instead of NamedTemporaryFile?

Semgrep needs to read a file from a path on disk. Using tempfile.mkdtemp() gives you full control over the file name and extension, which is important because Semgrep uses the file extension to determine which language rules to apply.

The tradeoff is that you're responsible for cleanup. The finally block ensures files are removed even when exceptions occur.

You won't see a change yet. The next function completes the module by parsing Semgrep's raw output.

Parse Semgrep findings into structured data

Semgrep's JSON output contains deeply nested fields. The parsing function extracts only the fields SentinelAI needs for enrichment.

  • Add the following function below run_semgrep_scan in app/scanner.py:
def parse_findings(semgrep_output: dict) -> list[dict]:
    """Extract relevant fields from Semgrep JSON output."""
    findings = []
    for result in semgrep_output.get("results", []):
        finding = {
            "rule_id": result.get("check_id", "unknown"),
            "path": result.get("path", ""),
            "start_line": result.get("start", {}).get("line", 0),
            "end_line": result.get("end", {}).get("line", 0),
            "message": result.get("extra", {}).get("message", ""),
            "severity": result.get("extra", {}).get("severity", "WARNING"),
            "metadata": result.get("extra", {}).get("metadata", {}),
        }
        findings.append(finding)
    return findings

What does this code do?

This function loops through Semgrep's results array and extracts the fields that matter for security analysis:

  • rule_id comes from check_id (e.g. "python.lang.security.audit.eval-detected").
  • start_line and end_line locate the vulnerability in the source code.
  • severity indicates how serious the finding is (ERROR, WARNING, etc.).
  • metadata contains CWE and OWASP classifications when available.
  • Save app/scanner.py.
  • Verify the module loads without errors by running this in your terminal:
python -c "from app.scanner import run_semgrep_scan, parse_findings; print('Scanner module loaded successfully')"

You should see Scanner module loaded successfully printed in your terminal.

Seeing an ImportError or ModuleNotFoundError?

  • Make sure you're running the command from the project root directory (the folder containing the app/ directory).
  • Check that app/__init__.py exists. Python needs this file to treat app/ as a package.
  • Confirm your virtual environment is activated. You should see a (venv) prefix in your terminal prompt.

Help me debug this import error in my SentinelAI scanner module.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/scanner.py before moving on.

ⓧ I'd like to double check the full code

Here's the complete app/scanner.py file:

import json
import subprocess
import tempfile
import os


LANGUAGE_EXTENSIONS = {
    "python": ".py",
    "javascript": ".js",
    "typescript": ".ts",
    "java": ".java",
    "go": ".go",
    "ruby": ".rb",
    "php": ".php",
}


def run_semgrep_scan(code: str, language: str) -> dict:
    """Run Semgrep scan on submitted code and return parsed findings."""
    extension = LANGUAGE_EXTENSIONS.get(language, ".txt")

    tmp_dir = tempfile.mkdtemp(prefix="sentinel_")
    tmp_file_path = os.path.join(tmp_dir, f"input{extension}")

    try:
        with open(tmp_file_path, "w") as f:
            f.write(code)

        result = subprocess.run(
            [
                "semgrep",
                "scan",
                "--json",
                "--config",
                "auto",
                "--metrics=off",
                tmp_file_path,
            ],
            capture_output=True,
            text=True,
            timeout=60,
        )

        output = json.loads(result.stdout) if result.stdout else {"results": []}
        findings = parse_findings(output)
        return {"findings": findings, "files_scanned": 1, "errors": output.get("errors", [])}

    except subprocess.TimeoutExpired:
        return {"findings": [], "files_scanned": 0, "errors": [{"message": "Scan timed out after 60 seconds"}]}
    except json.JSONDecodeError:
        return {"findings": [], "files_scanned": 0, "errors": [{"message": "Failed to parse Semgrep output"}]}
    finally:
        if os.path.exists(tmp_file_path):
            os.unlink(tmp_file_path)
        if os.path.exists(tmp_dir):
            os.rmdir(tmp_dir)


def parse_findings(semgrep_output: dict) -> list[dict]:
    """Extract relevant fields from Semgrep JSON output."""
    findings = []
    for result in semgrep_output.get("results", []):
        finding = {
            "rule_id": result.get("check_id", "unknown"),
            "path": result.get("path", ""),
            "start_line": result.get("start", {}).get("line", 0),
            "end_line": result.get("end", {}).get("line", 0),
            "message": result.get("extra", {}).get("message", ""),
            "severity": result.get("extra", {}).get("severity", "WARNING"),
            "metadata": result.get("extra", {}).get("metadata", {}),
        }
        findings.append(finding)
    return findings

Your scanner service is ready. It can accept any supported language, run Semgrep against the code, and return clean, structured findings. Next up, you'll build the enrichment service that sends these findings to Gemini for detailed security analysis.

Build the LLM Enrichment Service

Your scanner can detect vulnerabilities, but its raw output is cryptic. Rule IDs like python.lang.security.audit.eval-detected tell experienced security engineers what's wrong, but most developers need more context to actually fix the problem.

In this step, you'll build an enrichment service that sends each Semgrep finding to Gemini. The LLM analyzes the vulnerability and returns a structured report with root cause analysis, exploitation scenarios, and remediation code.

In this step, get ready to:
  • Connect to the Gemini API using the google-genai SDK.
  • Design a prompt that extracts structured security analysis from each finding.
  • Parse Gemini's response into a consistent enrichment schema with fallback handling.
Set up the enrichment module and Gemini client

The enrichment module connects to Gemini using the API key you stored in .env during Step 1. It uses the google-genai SDK's centralized Client architecture to make API calls.

  • Create a new file called app/enrichment.py inside your app folder.
  • Add the imports and client initialization by pasting this code:
import json
from google import genai
from google.genai import types
from app.config import GEMINI_API_KEY


client = genai.Client(api_key=GEMINI_API_KEY)

What does this code do?

The from google import genai line imports the official Google GenAI SDK. This is the newer google-genai package (the legacy google-generativeai library was deprecated in November 2025).

The genai.Client(api_key=GEMINI_API_KEY) creates a reusable client that authenticates every request with your key from .env.

  • Save app/enrichment.py.
  • Verify the client initializes by running this in your terminal:
python -c "from app.enrichment import client; print('Gemini client created')"

You should see Gemini client created printed in the terminal.

Seeing a ValueError?

If you see GEMINI_API_KEY environment variable is required, your .env file is missing the key or is in the wrong directory. Make sure .env is in the project root (same level as app/) and contains your actual Gemini API key.

help me debug my Gemini client initialization error

Design the enrichment prompt template

The quality of Gemini's analysis depends entirely on the prompt. You need a template that provides the vulnerability context and instructs the LLM to return a specific JSON structure.

  • Add the following prompt template below the client line in app/enrichment.py:
ENRICHMENT_PROMPT = """You are a senior application security engineer. Analyze the following vulnerability finding from a static analysis scan and provide a structured security assessment.

**Finding:**
- Rule: {rule_id}
- Severity: {severity}
- Message: {message}
- Code location: lines {start_line}-{end_line}
- Code context:
```
{code_snippet}
```

Provide your analysis in the following JSON format (respond with ONLY the JSON, no markdown):
{{
    "root_cause": "Brief explanation of why this vulnerability exists",
    "exploitation_scenario": "How an attacker could exploit this vulnerability",
    "impact": "What damage could result from successful exploitation",
    "remediation": "Specific steps to fix this vulnerability",
    "secure_code_example": "A corrected version of the vulnerable code",
    "owasp_category": "The OWASP Top 10 2021 category (e.g., A03:2021 - Injection)",
    "cwe_id": "The most specific CWE ID (e.g., CWE-89)",
    "risk_rating": "CRITICAL, HIGH, MEDIUM, or LOW"
}}"""

What does this prompt do?

The prompt gives Gemini a specific role (senior AppSec engineer) and feeds it the raw finding data from Semgrep. The {rule_id}, {severity}, and other placeholders get filled with real finding data at runtime using Python's .format() method.

The {{ and }} are escaped braces. Python's .format() treats single braces as variable placeholders, so double braces produce literal braces in the output.

The instruction to respond with ONLY JSON (no markdown) prevents Gemini from wrapping its response in code fences, which would break JSON parsing.

You won't see a change yet. The next chunk uses this template inside the enrichment function.

Parse Gemini's response into a structured schema

Each Semgrep finding needs to be enriched individually. The function extracts the relevant code snippet, sends it to Gemini with the prompt, and parses the JSON response into a structured schema.

  • Add the enrich_finding function below the ENRICHMENT_PROMPT in app/enrichment.py:
def enrich_finding(finding: dict, code: str) -> dict:
    """Enrich a single finding with LLM-powered security analysis."""
    code_lines = code.split("\n")
    start = max(0, finding["start_line"] - 1)
    end = min(len(code_lines), finding["end_line"])
    code_snippet = "\n".join(code_lines[start:end])

    prompt = ENRICHMENT_PROMPT.format(
        rule_id=finding["rule_id"],
        severity=finding["severity"],
        message=finding["message"],
        start_line=finding["start_line"],
        end_line=finding["end_line"],
        code_snippet=code_snippet,
    )

    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            temperature=0.2,
            max_output_tokens=1024,
        ),
    )

    response_text = response.text

    try:
        enrichment = json.loads(response_text)
    except json.JSONDecodeError:
        enrichment = {
            "root_cause": "Analysis unavailable",
            "exploitation_scenario": "Analysis unavailable",
            "impact": "Analysis unavailable",
            "remediation": finding["message"],
            "secure_code_example": "",
            "owasp_category": "Unknown",
            "cwe_id": "Unknown",
            "risk_rating": finding["severity"],
        }

    return {**finding, "enrichment": enrichment}

What does this function do?

  • It extracts the vulnerable code snippet using the start_line and end_line from the Semgrep finding (adjusting for zero-based indexing).
  • It formats the prompt with real finding data and sends it to gemini-2.5-flash with a low temperature (0.2) for consistent, factual responses.
  • If Gemini returns invalid JSON, the except json.JSONDecodeError block provides fallback values so the pipeline never crashes.
  • The return merges the original finding with the new enrichment dictionary using {**finding, "enrichment": enrichment}.
  • Add the enrich_findings wrapper function below enrich_finding:
def enrich_findings(findings: list[dict], code: str) -> list[dict]:
    """Enrich all findings with LLM analysis."""
    enriched = []
    for finding in findings:
        enriched_finding = enrich_finding(finding, code)
        enriched.append(enriched_finding)
    return enriched

Why a separate wrapper function?

The enrich_findings function iterates over all findings from a scan and enriches each one. Keeping the loop separate from the single-finding logic makes the code easier to test and extend later (for example, adding parallel processing).

  • Save app/enrichment.py.
  • Verify the full module loads by running:
python -c "from app.enrichment import enrich_findings; print('Enrichment module ready')"

You should see Enrichment module ready in the terminal.

Seeing an ImportError?

If Python reports it cannot find google.genai, make sure your virtual environment is activated and that you installed the dependencies with pip install -r requirements.txt in Step 1.

help me fix my enrichment module import error

✔️ Awesome, I've got everything!

Great. Double check you've saved app/enrichment.py and the import test passed.

ⓧ I'd like to double check the full code

import json
from google import genai
from google.genai import types
from app.config import GEMINI_API_KEY


client = genai.Client(api_key=GEMINI_API_KEY)


ENRICHMENT_PROMPT = """You are a senior application security engineer. Analyze the following vulnerability finding from a static analysis scan and provide a structured security assessment.

**Finding:**
- Rule: {rule_id}
- Severity: {severity}
- Message: {message}
- Code location: lines {start_line}-{end_line}
- Code context:
```
{code_snippet}
```

Provide your analysis in the following JSON format (respond with ONLY the JSON, no markdown):
{{
    "root_cause": "Brief explanation of why this vulnerability exists",
    "exploitation_scenario": "How an attacker could exploit this vulnerability",
    "impact": "What damage could result from successful exploitation",
    "remediation": "Specific steps to fix this vulnerability",
    "secure_code_example": "A corrected version of the vulnerable code",
    "owasp_category": "The OWASP Top 10 2021 category (e.g., A03:2021 - Injection)",
    "cwe_id": "The most specific CWE ID (e.g., CWE-89)",
    "risk_rating": "CRITICAL, HIGH, MEDIUM, or LOW"
}}"""


def enrich_finding(finding: dict, code: str) -> dict:
    """Enrich a single finding with LLM-powered security analysis."""
    code_lines = code.split("\n")
    start = max(0, finding["start_line"] - 1)
    end = min(len(code_lines), finding["end_line"])
    code_snippet = "\n".join(code_lines[start:end])

    prompt = ENRICHMENT_PROMPT.format(
        rule_id=finding["rule_id"],
        severity=finding["severity"],
        message=finding["message"],
        start_line=finding["start_line"],
        end_line=finding["end_line"],
        code_snippet=code_snippet,
    )

    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            temperature=0.2,
            max_output_tokens=1024,
        ),
    )

    response_text = response.text

    try:
        enrichment = json.loads(response_text)
    except json.JSONDecodeError:
        enrichment = {
            "root_cause": "Analysis unavailable",
            "exploitation_scenario": "Analysis unavailable",
            "impact": "Analysis unavailable",
            "remediation": finding["message"],
            "secure_code_example": "",
            "owasp_category": "Unknown",
            "cwe_id": "Unknown",
            "risk_rating": finding["severity"],
        }

    return {**finding, "enrichment": enrichment}


def enrich_findings(findings: list[dict], code: str) -> list[dict]:
    """Enrich all findings with LLM analysis."""
    enriched = []
    for finding in findings:
        enriched_finding = enrich_finding(finding, code)
        enriched.append(enriched_finding)
    return enriched

Your enrichment pipeline is ready. Next up, you'll build the FastAPI application that ties the scanner and enrichment services together into a single API endpoint.

Build the FastAPI Application

You now have two powerful services sitting in your project: a scanner that detects vulnerabilities and an enrichment module that explains them. But without an API layer, there's no way for anyone to actually submit code and receive a report.

In this step, you'll create a FastAPI application that ties the scanner and enrichment services together behind a single /scan endpoint. You'll also add API key authentication so only authorized callers can trigger scans.

In this step, get ready to:
  • Create a FastAPI app with request and response models.
  • Add API key authentication using a custom header.
  • Wire the scanner and enrichment services into the endpoint handler.
Create the FastAPI app and define data models

Every API needs a clear contract: what data comes in, and what data goes out. Pydantic models define this contract so FastAPI can validate requests automatically.

  • Create a new file called main.py inside your app folder.
  • Add the following imports, app instance, and Pydantic models:
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
from app.config import SENTINEL_API_KEY
from app.scanner import run_semgrep_scan
from app.enrichment import enrich_findings


# Initialize the FastAPI application
app = FastAPI(
    title="SentinelAI",
    description="AI-assisted secure code review platform",
    version="1.0.0",
)


# Define the shape of incoming scan requests
class ScanRequest(BaseModel):
    code: str
    language: str = "python"


# Define the shape of scan responses
class ScanResponse(BaseModel):
    findings_count: int
    files_scanned: int
    findings: list[dict]
    errors: list[dict]

What does this code do?

The FastAPI() call creates your application instance with metadata that appears in the auto-generated API docs.

ScanRequest defines what callers must send: a code string and an optional language field that defaults to "python". ScanResponse defines the structure callers receive back: a count of findings, files scanned, the findings list, and any errors.

  • Save app/main.py.
Add API key authentication

Without authentication, anyone who discovers your API endpoint could run unlimited scans. A simple API key check via a custom header prevents unauthorized access.

  • In app/main.py, add the following function below the ScanResponse class:
def verify_api_key(x_api_key: str = Header(...)):
    # Compare the provided header value against our stored key
    if x_api_key != SENTINEL_API_KEY:
        raise HTTPException(status_code=401, detail="Invalid API key")

What does this code do?

The Header(...) declaration tells FastAPI to extract the value of the X-API-Key header from incoming requests. FastAPI automatically converts the Python parameter name x_api_key to the header name X-API-Key.

If the provided key doesn't match SENTINEL_API_KEY from your .env file, the function raises a 401 Unauthorized error.

  • Save app/main.py.
Wire the scanner and enrichment services into the endpoint

This is where everything comes together. The /scan endpoint will receive code, validate it, run the Semgrep scan, enrich any findings with Gemini, and return a structured report.

  • In app/main.py, add the following endpoint functions below the verify_api_key function:
@app.post("/scan", response_model=ScanResponse)
def scan_code(request: ScanRequest, x_api_key: str = Header(...)):
    """Scan code for security vulnerabilities and enrich findings with AI analysis."""
    # Verify the caller's API key
    verify_api_key(x_api_key)

    # Reject empty submissions
    if not request.code.strip():
        raise HTTPException(status_code=400, detail="Code cannot be empty")

    # Reject oversized submissions
    if len(request.code) > 100_000:
        raise HTTPException(status_code=400, detail="Code exceeds maximum length of 100,000 characters")

    # Run Semgrep static analysis
    scan_results = run_semgrep_scan(request.code, request.language)

    # Enrich findings with AI analysis if any were found
    if scan_results["findings"]:
        enriched_findings = enrich_findings(scan_results["findings"], request.code)
    else:
        enriched_findings = []

    return ScanResponse(
        findings_count=len(enriched_findings),
        files_scanned=scan_results["files_scanned"],
        findings=enriched_findings,
        errors=scan_results["errors"],
    )


@app.get("/health")
def health_check():
    """Health check endpoint."""
    return {"status": "healthy", "service": "SentinelAI"}

What does this code do?

The /scan endpoint performs four operations in sequence:

  • Verifies the API key to block unauthorized callers.
  • Validates the submitted code is not empty and is under 100,000 characters.
  • Calls run_semgrep_scan() from your scanner module to detect vulnerabilities.
  • Calls enrich_findings() from your enrichment module to add AI analysis (only if Semgrep found something).

The /health endpoint is a simple status check that confirms the API is running.

  • Save app/main.py.

Seeing import errors?

Make sure app/__init__.py exists (even if it's empty). Without it, Python won't recognize app as a package and the from app.config import ... lines will fail.

help me debug import errors in my FastAPI application

✔️ Awesome, I've got everything!

Great. Double check you've saved app/main.py.

ⓧ I'd like to double check the full code

from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
from app.config import SENTINEL_API_KEY
from app.scanner import run_semgrep_scan
from app.enrichment import enrich_findings


app = FastAPI(
    title="SentinelAI",
    description="AI-assisted secure code review platform",
    version="1.0.0",
)


class ScanRequest(BaseModel):
    code: str
    language: str = "python"


class ScanResponse(BaseModel):
    findings_count: int
    files_scanned: int
    findings: list[dict]
    errors: list[dict]


def verify_api_key(x_api_key: str = Header(...)):
    if x_api_key != SENTINEL_API_KEY:
        raise HTTPException(status_code=401, detail="Invalid API key")


@app.post("/scan", response_model=ScanResponse)
def scan_code(request: ScanRequest, x_api_key: str = Header(...)):
    """Scan code for security vulnerabilities and enrich findings with AI analysis."""
    verify_api_key(x_api_key)

    if not request.code.strip():
        raise HTTPException(status_code=400, detail="Code cannot be empty")

    if len(request.code) > 100_000:
        raise HTTPException(status_code=400, detail="Code exceeds maximum length of 100,000 characters")

    scan_results = run_semgrep_scan(request.code, request.language)

    if scan_results["findings"]:
        enriched_findings = enrich_findings(scan_results["findings"], request.code)
    else:
        enriched_findings = []

    return ScanResponse(
        findings_count=len(enriched_findings),
        files_scanned=scan_results["files_scanned"],
        findings=enriched_findings,
        errors=scan_results["errors"],
    )


@app.get("/health")
def health_check():
    """Health check endpoint."""
    return {"status": "healthy", "service": "SentinelAI"}

Now you need a way to start the server. A small entry-point script keeps the startup command simple and enables auto-reload during development.

  • Create a new file called run.py in the root of your project folder (at the same level as requirements.txt).
  • Add the following code:
import uvicorn

if __name__ == "__main__":
    # Start the FastAPI server with auto-reload for development
    uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

What does this code do?

uvicorn is the ASGI server that runs your FastAPI app. The string "app.main:app" tells uvicorn to look inside the app package, find main.py, and use the app variable as the application.

Setting reload=True means the server restarts automatically whenever you change a file. Port 8000 is the default for local API development.

  • Save run.py.
  • Start the server by running:
python run.py
  • You should see output showing the server is running at http://0.0.0.0:8000.
  • Open your browser and navigate to http://localhost:8000/health.
  • You should see the JSON response {"status": "healthy", "service": "SentinelAI"}.

Server won't start?

If you see a ModuleNotFoundError, make sure your virtual environment is activated and you've run pip install -r requirements.txt. If you see a ValueError about missing environment variables, confirm your .env file exists with both GEMINI_API_KEY and SENTINEL_API_KEY set.

help me debug why my FastAPI server won't start

Your API is live and accepting requests. Next up, you'll feed it intentionally vulnerable code and see the full scan-and-enrich pipeline in action.

Test with Vulnerable Code Samples

Your API is fully wired up. The scanner finds vulnerabilities, Gemini enriches them, and FastAPI ties it all together behind an authenticated endpoint.

Now it is time to prove the system works. You will create intentionally vulnerable code, feed it through the pipeline, and confirm you get structured security reports back.

In this step, get ready to:
  • Create sample vulnerable Python files with common security flaws.
  • Run the full pipeline end-to-end and verify enriched output.
  • Test error handling for invalid submissions and authentication failures.
Create sample vulnerable Python files

Security scanners need something to find. You will create a file packed with real-world vulnerability patterns that Semgrep knows how to detect.

  • Create the test_samples directory inside your project by running this command:
mkdir test_samples
  • Create a file called vulnerable.py inside test_samples/ and add the following code:
import sqlite3
import os
import subprocess


def get_user(username):
    """SQL Injection vulnerability - user input directly in query."""
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
    return cursor.fetchone()


def run_command(user_input):
    """Command Injection vulnerability - unsanitized input in shell command."""
    result = os.system(f"echo {user_input}")
    return result


def process_file(filename):
    """Command Injection via subprocess with shell=True."""
    output = subprocess.check_output(f"cat {filename}", shell=True)
    return output


# Hardcoded secret
API_KEY = "sk-secret-key-12345-do-not-share"
DATABASE_PASSWORD = "admin123"

What vulnerabilities are in this file?

  • get_user() uses an f-string to build a SQL query directly from user input. An attacker can inject arbitrary SQL commands through the username parameter.
  • run_command() passes user input straight into os.system(). An attacker can chain shell commands using characters like &&.
  • process_file() uses subprocess.check_output() with shell=True, which enables command injection through the filename.
  • API_KEY and DATABASE_PASSWORD are hardcoded secrets that should never exist in source code.
  • Save test_samples/vulnerable.py.
  • Confirm the file exists by running:
ls test_samples/

You should see vulnerable.py listed in the output.

Don't see the file?

Make sure you created the test_samples directory first with mkdir test_samples. If the file is there but empty, double check that you saved after pasting the code.

Help me troubleshoot my missing file

Start the server and run the full pipeline

With vulnerable code ready, you will start SentinelAI and send the code through the entire scan-and-enrich pipeline.

  • Start the FastAPI server by running this command:
python run.py

You should see output indicating the server is running at http://0.0.0.0:8000.

Why keep this terminal open?

The uvicorn server runs in the foreground and must stay running while you test. You will need a second terminal window or tab for the curl commands that follow.

  • Open a new terminal window or tab.
  • Navigate back to your project folder (sentinelai on your Desktop).
  • Send vulnerable code to the /scan endpoint by running this command:
curl -s -X POST http://localhost:8000/scan \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-sentinel-api-key-here" \
  -d '{"code": "import os\n\ndef run_command(user_input):\n    result = os.system(f\"echo {user_input}\")\n    return result\n", "language": "python"}'

What does this command do?

  • curl -s -X POST sends a silent POST request to your local server.
  • -H "X-API-Key: ..." authenticates with the API key you set in your .env file.
  • -d sends a JSON body containing vulnerable Python code with a command injection flaw.

Replace your-sentinel-api-key-here with the actual SENTINEL_API_KEY value from your .env file.

The response takes 10-20 seconds because Semgrep scans the code and then Gemini analyzes each finding. You should see a JSON response containing findings_count, files_scanned, and an array of enriched findings.

What should I see in the response?

Each finding in the response includes the original Semgrep data (rule_id, severity, message) plus an enrichment object from Gemini.

The enrichment object contains root_cause, exploitation_scenario, remediation, and owasp_category. This is the core value of SentinelAI. Raw scanner output tells you there is a problem. The enrichment tells you why it matters and exactly how to fix it.

Getting a connection refused error?

Make sure your server is still running in the other terminal. If it crashed, check for import errors or missing environment variables in the server output. Restart it with python run.py.

Help me debug my connection error

Test error handling for invalid requests

A secure API rejects bad input gracefully. You will verify that your validation and authentication logic works correctly.

  • Test that empty code returns a 400 error by running this command:
curl -s -X POST http://localhost:8000/scan \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-sentinel-api-key-here" \
  -d '{"code": "", "language": "python"}'

You should see a response containing "detail":"Code cannot be empty".

  • Test that an invalid API key returns a 401 error by running this command:
curl -s -X POST http://localhost:8000/scan \
  -H "Content-Type: application/json" \
  -H "X-API-Key: wrong-key" \
  -d '{"code": "print(1)", "language": "python"}'

You should see a response containing "detail":"Invalid API key".

Why test error handling?

Input validation and authentication are the first line of defense for any API. The 400 response prevents wasted compute on invalid scans. The 401 response ensures only authorized users can trigger scans and consume Gemini API tokens.

Seeing unexpected responses?

If the empty code test returns a 422 instead of 400, check that you are sending an empty string ("") and not omitting the code field entirely. If the invalid key test returns 422, make sure the X-API-Key header is spelled exactly as shown.

Help me debug my unexpected status codes

✔️ Awesome, I've got everything!

Great. Double check you have saved test_samples/vulnerable.py and confirmed both your successful scan and error handling tests pass.

ⓧ I'd like to double check the full code

import sqlite3
import os
import subprocess


def get_user(username):
    """SQL Injection vulnerability - user input directly in query."""
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
    return cursor.fetchone()


def run_command(user_input):
    """Command Injection vulnerability - unsanitized input in shell command."""
    result = os.system(f"echo {user_input}")
    return result


def process_file(filename):
    """Command Injection via subprocess with shell=True."""
    output = subprocess.check_output(f"cat {filename}", shell=True)
    return output


# Hardcoded secret
API_KEY = "sk-secret-key-12345-do-not-share"
DATABASE_PASSWORD = "admin123"

Secret mission

HTML Security Report Generator

Your JSON output is great for developers integrating with other tools. But what about security leads who need a visual overview of the codebase's risk posture? Build a /report endpoint that generates a complete HTML security report with severity distribution charts and a prioritized remediation roadmap.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses the Gemini API free tier. There are no ongoing costs to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The running uvicorn server (FastAPI application on port 8000).
  • The sentinelai project folder on your Desktop (includes all code, virtual environment, and .env file with API keys).
  • Your Gemini API key in Google AI Studio.

Keep everything running

No action needed. Choose this if you are still actively building or testing your SentinelAI API.

  • Your FastAPI server continues running locally on port 8000 and costs nothing.
  • The Gemini API free tier has no charges for your usage level.
  • Your .env file stays on disk with your API keys stored locally.

Pause - I'll come back to this later

Shut down running processes to free up your terminal. Everything stays on disk for when you return.

  • Switch to the terminal running your FastAPI server and press Ctrl+C to stop uvicorn.
  • That is all you need to do. Your project folder, virtual environment, and .env file remain on disk ready for next time.
  • To resume later, activate your virtual environment and run python run.py again from your project folder.

Delete - I don't want to use this again

Remove all project resources and revoke your API key. Follow each step below.

  • Stop the FastAPI server by pressing Ctrl+C in the terminal running uvicorn.
  • Deactivate your virtual environment by running:
deactivate
  • Delete the entire project folder by running:
cd ~/Desktop
rm -rf sentinelai

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q sentinelai
  • Delete your Gemini API key so it cannot be used by anyone else. Open the Google Cloud API Credentials page at https://console.cloud.google.com/apis/credentials.
  • Find the API key you used for this project and click the Actions icon.
  • Select Delete API key.
  • In the confirmation modal, click Delete. It takes a few minutes to propagate.

Nice Work!

Nice Work!

You just built SentinelAI, an AI-assisted secure code review platform that transforms raw vulnerability scanner output into actionable developer guidance.

You've learned how to:

  • Build a FastAPI backend that orchestrates static analysis and AI enrichment into a unified security scanning pipeline with API key authentication and input validation.
  • Integrate Semgrep as a local vulnerability detection engine with structured JSON output parsing and secure temporary file handling.
  • Create an LLM enrichment pipeline using Gemini to generate root cause analysis, exploitation scenarios, remediation guidance, and OWASP Top 10 and CWE mappings for every finding.
  • Generate HTML security reports with severity distribution summaries and prioritized remediation roadmaps.

Ready to quiz yourself?