AI Learning Hub Roadmap Resources Blog Projects Paths
Advanced 6–8 hours · Python, OpenAI API, GitPython, Click

Build an AI Security Analyzer: Automated Vulnerability Detection

· 5 min read · AI Learning Hub

Project Overview

An AI-powered static application security testing (SAST) tool that scans Python, JavaScript, and other codebases for OWASP Top 10 vulnerabilities, hardcoded secrets, and insecure patterns. Generates prioritized remediation reports.


Learning Goals

  • Design security-focused LLM prompts with false positive control
  • Scan codebases efficiently with batching
  • Combine regex pattern matching with LLM analysis
  • Generate actionable security reports with CVSS-style scoring

Architecture

Codebase (directory / git repo / single file)
        ↓
File scanner: collect relevant source files
        ↓
Layer 1: Regex patterns (fast, free) — obvious secrets/anti-patterns
        ↓
Layer 2: LLM analysis per file (thorough, contextual)
        ↓
Deduplicate + prioritize findings
        ↓
HTML/Markdown security report

Implementation

Step 1: Setup

pip install openai click gitpython rich

Step 2: Pattern Scanner (Fast Layer)

# patterns.py
import re
from pathlib import Path

# High-confidence regex patterns for obvious issues
PATTERNS = [
    # Secrets
    {"id": "SEC-001", "name": "Hardcoded API Key", "severity": "critical",
     "regex": r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\']([A-Za-z0-9_\-]{20,})["\']',
     "description": "API key hardcoded in source code"},
    {"id": "SEC-002", "name": "AWS Secret Key", "severity": "critical",
     "regex": r'(?i)aws[_-]?secret[_-]?access[_-]?key\s*[=:]\s*["\']([A-Za-z0-9/+]{40})["\']',
     "description": "AWS secret key in source code"},
    {"id": "SEC-003", "name": "Hardcoded Password", "severity": "high",
     "regex": r'(?i)password\s*[=:]\s*["\'](?!.*\{)[A-Za-z0-9!@#$%^&*]{8,}["\']',
     "description": "Password hardcoded in source code"},
    {"id": "SEC-004", "name": "Private Key", "severity": "critical",
     "regex": r'-----BEGIN (RSA |EC )?PRIVATE KEY-----',
     "description": "Private key embedded in source file"},
    # Python-specific
    {"id": "SEC-010", "name": "SQL Injection Risk", "severity": "high",
     "regex": r'execute\s*\(\s*["\'].*%[s|d].*["\']|f["\'].*SELECT.*{.*}',
     "description": "Potential SQL injection via string formatting"},
    {"id": "SEC-011", "name": "Shell Injection Risk", "severity": "high",
     "regex": r'subprocess\.(call|run|Popen)\s*\(.*shell\s*=\s*True',
     "description": "Shell injection risk with shell=True"},
    {"id": "SEC-012", "name": "Pickle Deserialization", "severity": "medium",
     "regex": r'pickle\.(loads|load)\s*\(',
     "description": "Unsafe pickle deserialization"},
    {"id": "SEC-013", "name": "Eval Usage", "severity": "high",
     "regex": r'\beval\s*\(',
     "description": "Dangerous eval() usage"},
    # JavaScript
    {"id": "SEC-020", "name": "XSS Risk (innerHTML)", "severity": "high",
     "regex": r'\.innerHTML\s*=',
     "description": "Potential XSS via innerHTML assignment"},
    {"id": "SEC-021", "name": "dangerouslySetInnerHTML", "severity": "medium",
     "regex": r'dangerouslySetInnerHTML',
     "description": "React XSS risk via dangerouslySetInnerHTML"},
]

SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rb", ".php", ".java"}


def scan_file_patterns(filepath: str) -> list[dict]:
    """Run regex patterns against a single file."""
    path = Path(filepath)
    if path.suffix not in SOURCE_EXTENSIONS:
        return []

    try:
        content = path.read_text(encoding="utf-8", errors="ignore")
    except Exception:
        return []

    findings = []
    for pattern in PATTERNS:
        for match in re.finditer(pattern["regex"], content, re.MULTILINE):
            line_num = content[:match.start()].count("\n") + 1
            findings.append({
                "id": pattern["id"],
                "name": pattern["name"],
                "severity": pattern["severity"],
                "description": pattern["description"],
                "file": str(path),
                "line": line_num,
                "match": match.group(0)[:100],
                "source": "pattern",
            })
    return findings


def collect_source_files(directory: str, max_files: int = 200) -> list[str]:
    """Recursively collect source files, skipping common noise."""
    skip_dirs = {"node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build", ".next"}
    files = []
    for path in Path(directory).rglob("*"):
        if any(part in skip_dirs for part in path.parts):
            continue
        if path.is_file() and path.suffix in SOURCE_EXTENSIONS:
            files.append(str(path))
    return files[:max_files]

Step 3: LLM Security Analyzer

# llm_analyzer.py
import json
from openai import OpenAI

client = OpenAI()

SECURITY_PROMPT = """You are an expert security engineer. Analyze this code for security vulnerabilities.

Focus on OWASP Top 10 and common security issues:
- Injection (SQL, command, LDAP)
- Authentication/authorization flaws
- Sensitive data exposure
- Security misconfigurations
- Cryptographic failures
- Insecure deserialization
- Missing input validation

Return JSON:
{{
  "findings": [
    {{
      "id": "LLM-001",
      "name": "vulnerability name",
      "severity": "critical" | "high" | "medium" | "low" | "info",
      "cwe": "CWE-xxx if applicable",
      "line_range": "approximate line range",
      "description": "clear description of the vulnerability",
      "exploit_scenario": "brief attack scenario",
      "remediation": "specific fix with code example if applicable"
    }}
  ],
  "overall_risk": "critical" | "high" | "medium" | "low" | "none",
  "summary": "2-3 sentence security assessment"
}}

If no issues found, return empty findings array.

File: {filename}
Language: {language}
Code:
{code}"""


def analyze_file_security(filepath: str) -> dict:
    """LLM security analysis of a single file."""
    from pathlib import Path
    from patterns import SOURCE_EXTENSIONS

    path = Path(filepath)
    if path.suffix not in SOURCE_EXTENSIONS:
        return {"findings": [], "overall_risk": "none"}

    try:
        content = path.read_text(encoding="utf-8", errors="ignore")
    except Exception as e:
        return {"findings": [], "error": str(e)}

    if len(content) > 8000:
        content = content[:8000] + "\n# ... (truncated)"

    lang_map = {".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
                ".go": "Go", ".java": "Java", ".rb": "Ruby", ".php": "PHP"}
    language = lang_map.get(path.suffix, "code")

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": SECURITY_PROMPT.format(
            filename=path.name,
            language=language,
            code=content,
        )}],
        response_format={"type": "json_object"},
        temperature=0.1,
    )
    result = json.loads(response.choices[0].message.content)
    result["file"] = filepath
    return result

Step 4: Scanner + Report

# scanner.py
from patterns import scan_file_patterns, collect_source_files
from llm_analyzer import analyze_file_security
from pathlib import Path


def scan_directory(directory: str, use_llm: bool = True, max_llm_files: int = 20) -> dict:
    """Full security scan of a directory."""
    print(f"Scanning: {directory}")
    files = collect_source_files(directory)
    print(f"Found {len(files)} source files")

    all_findings = []

    # Layer 1: Pattern scan all files
    print("Running pattern scan...")
    for filepath in files:
        findings = scan_file_patterns(filepath)
        all_findings.extend(findings)

    print(f"Pattern scan: {len(all_findings)} findings")

    # Layer 2: LLM scan (limited to top files by size/importance)
    if use_llm:
        # Prioritize files with pattern findings, then by recency
        files_with_findings = {f["file"] for f in all_findings}
        priority_files = sorted(files, key=lambda f: (f not in files_with_findings, -Path(f).stat().st_size))
        llm_files = priority_files[:max_llm_files]

        print(f"Running LLM analysis on {len(llm_files)} files...")
        for i, filepath in enumerate(llm_files):
            print(f"  [{i+1}/{len(llm_files)}] {Path(filepath).name}")
            result = analyze_file_security(filepath)
            for finding in result.get("findings", []):
                finding["file"] = filepath
                finding["source"] = "llm"
                all_findings.append(finding)

    # Sort by severity
    severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
    all_findings.sort(key=lambda f: severity_order.get(f.get("severity", "info"), 5))

    # Stats
    from collections import Counter
    severity_counts = Counter(f.get("severity", "info") for f in all_findings)

    return {
        "directory": directory,
        "files_scanned": len(files),
        "total_findings": len(all_findings),
        "severity_counts": dict(severity_counts),
        "findings": all_findings,
    }


def generate_report(scan_result: dict, output_path: str = None) -> str:
    """Generate a Markdown security report."""
    r = scan_result
    counts = r["severity_counts"]

    lines = [
        f"# Security Analysis Report",
        f"\n**Target:** `{r['directory']}`",
        f"**Files Scanned:** {r['files_scanned']}",
        f"**Total Findings:** {r['total_findings']}",
        f"\n## Severity Summary",
        f"| Severity | Count |",
        f"|----------|-------|",
    ]
    for sev in ["critical", "high", "medium", "low", "info"]:
        count = counts.get(sev, 0)
        if count:
            emoji = {"critical": "🚨", "high": "⚠️", "medium": "🟡", "low": "🔵", "info": "ℹ️"}[sev]
            lines.append(f"| {emoji} {sev.capitalize()} | {count} |")

    if r["findings"]:
        lines.append("\n## Findings")
        for f in r["findings"]:
            sev = f.get("severity", "info").upper()
            lines.append(f"\n### [{sev}] {f.get('name', 'Finding')}")
            lines.append(f"**File:** `{f.get('file', 'unknown')}`")
            if f.get("line"):
                lines.append(f"**Line:** {f['line']}")
            lines.append(f"\n{f.get('description', '')}")
            if f.get("remediation"):
                lines.append(f"\n**Fix:** {f['remediation']}")
    else:
        lines.append("\n✅ No security issues found!")

    report = "\n".join(lines)
    if output_path:
        Path(output_path).write_text(report, encoding="utf-8")
        print(f"Report saved to {output_path}")
    return report

Step 5: CLI

# main.py
import click
from scanner import scan_directory, generate_report


@click.command()
@click.argument("path")
@click.option("--output", "-o", help="Save report to file")
@click.option("--no-llm", is_flag=True, help="Skip LLM analysis (faster, less thorough)")
@click.option("--max-files", default=20, help="Max files for LLM analysis")
def main(path, output, no_llm, max_files):
    """AI Security Analyzer — scan code for vulnerabilities."""
    result = scan_directory(path, use_llm=not no_llm, max_llm_files=max_files)
    report = generate_report(result, output)
    print("\n" + report[:3000])
    if result["severity_counts"].get("critical", 0) > 0:
        import sys
        sys.exit(1)  # Non-zero exit for CI integration


if __name__ == "__main__":
    main()

Step 6: Run

# Full scan
python main.py ./my_project --output security_report.md

# Fast pattern-only scan
python main.py ./my_project --no-llm

# Single file
python main.py ./app.py --no-llm

Extension Ideas

  1. CI/CD integration — GitHub Action that fails PRs with critical findings
  2. Dependency scanning — check requirements.txt for known vulnerable packages
  3. SARIF output — export in SARIF format for GitHub Security tab integration
  4. Custom rules — YAML-based custom pattern definitions
  5. Trend tracking — compare scan results over time to track security debt

What to Learn Next

← Back to all projects