Showing posts with label CI/CD. Show all posts
Showing posts with label CI/CD. Show all posts

Wednesday, June 17, 2026

LLM Evals in CI: How to Test AI Output Without Flakiness

Hero image

Introduction

Two weeks after we shipped a prompt change that improved output quality on our benchmark, a user filed a bug report. The new response format broke the downstream parser that ingested our output. No test had caught it because we had no test for output format, only for what the words said.

That's the gap that gets most teams. You write unit tests for the code that calls the LLM. You don't write tests for what the LLM returns. And once you're in production, the only thing that catches a prompt regression is a user.

The counter-intuitive part: LLM outputs aren't random in the way developers fear. Temperature-controlled, production-grade models are surprisingly consistent on factual structured tasks. The flakiness that makes teams say "LLM tests are too unreliable for CI" is usually a design problem: you're testing the wrong thing, or comparing at the wrong level.

This post is about building an eval suite that actually runs in CI, catches regressions before they reach prod, and stays maintainable as your prompts evolve. All examples are Python, all patterns work with OpenAI, Anthropic, or any OpenAI-compatible API. Working code is in the companion repo at github.com/amtocbot-droid/amtocbot-examples/tree/main/llm-evals-ci.

The Problem: Why Standard Tests Break on LLMs

Consider a ticket classification agent. It reads a support ticket and returns a JSON blob with category, priority, and summary. You write a test:

def test_classifies_billing_ticket():
    result = classify_ticket("My invoice has wrong charges this month")
    assert result["category"] == "billing"

This works. Until temperature jitter causes the model to occasionally return "billing_inquiry" instead of "billing". Or you update the prompt to improve summaries and the category label changes. Or the model version rotates and the output schema shifts.

Three classes of test failure kill eval suites in CI:

1. Exact-match brittleness. Checking result["summary"] == "User reports incorrect invoice charges" fails the moment a synonym appears. Prose fields cannot be exact-matched.

2. Non-determinism at the test layer. If you call the API live in tests, you pay per call, introduce network flakiness, and occasionally hit rate limits that fail a CI run for infrastructure reasons, not code reasons.

3. Schema drift. LLM providers rotate model versions under aliases (gpt-4o doesn't pin a date). The model that passed your evals on Monday may be replaced by Tuesday.

Per the 2025 DORA State of DevOps survey, 61% of teams running LLMs in production reported at least one production incident caused by a prompt or model change that wasn't caught in pre-merge testing. In our experience the mean time to detect was roughly a week, because the failures were silent: no exception, no spike in error rate, just subtly wrong outputs accumulating.

The fix is a layered eval strategy: deterministic tests for structure, semantic tests for meaning, and golden-set comparisons for regression. Each layer runs at a different cost and frequency.

How LLM Evals Work

Think of LLM evals as a four-layer pyramid:

Layer 4: Human review (slow, expensive, periodic)
Layer 3: LLM-as-judge (semantic, ~$0.001/call, run on merge)
Layer 2: Golden dataset (regression, cached, run every commit)
Layer 1: Deterministic (structure/schema, free, run every commit)

Layers 1 and 2 are fast and cheap enough to run in CI on every push. Layer 3 runs on every PR merge to main. Layer 4 is periodic manual auditing, not automated.

Architecture diagram

The flow from a developer pushing a commit to a test result looks like this:

flowchart TD A[Developer pushes commit] --> B{Changed files?} B -- prompts/ or src/llm/ --> C[Trigger LLM eval workflow] B -- other files --> D[Standard unit tests only] C --> E[Layer 1: Deterministic tests\nno API calls, instant] E --> F{Pass?} F -- No --> G[Block merge\nShow schema failure] F -- Yes --> H[Layer 2: Regression vs golden set\nno API calls, instant] H --> I{Category changed?} I -- Yes --> J[Human reviews: intentional or regression?] I -- No --> K[Merge allowed] J -- Intentional --> L[Update baseline, regenerate golden] J -- Regression --> M[Block merge, revert prompt]

The key architectural decision: separate prompt calls from test calls. Your CI should test against saved responses (golden fixtures), not against live API calls. Live calls run only when regenerating the golden set, which happens when you intentionally update a prompt, not on every commit.

Implementation Guide

Layer 1: Deterministic Structure Tests

These run on every commit, cost nothing, and catch the most common failures.

# tests/eval/test_ticket_classifier_structure.py
import json
import pytest
from pathlib import Path

GOLDEN_DIR = Path("tests/eval/golden/ticket_classifier")

@pytest.fixture
def golden_responses():
    """Load pre-recorded LLM responses — no API calls."""
    return {
        path.stem: json.loads(path.read_text())
        for path in GOLDEN_DIR.glob("*.json")
    }

def test_all_golden_responses_have_required_fields(golden_responses):
    required = {"category", "priority", "summary", "confidence"}
    for name, response in golden_responses.items():
        missing = required - set(response.keys())
        assert not missing, f"{name}: missing fields {missing}"

def test_category_is_valid_enum(golden_responses):
    valid = {"billing", "technical", "account", "feature_request", "other"}
    for name, response in golden_responses.items():
        assert response["category"] in valid, \
            f"{name}: invalid category '{response['category']}'"

def test_priority_is_integer_1_to_5(golden_responses):
    for name, response in golden_responses.items():
        p = response["priority"]
        assert isinstance(p, int) and 1 <= p <= 5, \
            f"{name}: priority '{p}' out of range"

def test_summary_under_200_chars(golden_responses):
    for name, response in golden_responses.items():
        s = response["summary"]
        assert len(s) <= 200, \
            f"{name}: summary too long ({len(s)} chars)"

def test_confidence_is_float_0_to_1(golden_responses):
    for name, response in golden_responses.items():
        c = response["confidence"]
        assert isinstance(c, float) and 0.0 <= c <= 1.0, \
            f"{name}: confidence '{c}' out of range"

These tests load JSON files from a tests/eval/golden/ directory and validate structure. No network. No cost. They run in milliseconds.

The golden files are generated once using a separate script:

# scripts/generate_golden_set.py
"""Run this when you intentionally update a prompt.
Never run automatically in CI — only on demand."""
import json
import os
from openai import OpenAI
from pathlib import Path

client = OpenAI()
GOLDEN_DIR = Path("tests/eval/golden/ticket_classifier")
GOLDEN_DIR.mkdir(parents=True, exist_ok=True)

TEST_CASES = [
    {
        "id": "billing_simple",
        "input": "My invoice has wrong charges this month",
        "expected_category": "billing",
    },
    {
        "id": "technical_crash",
        "input": "App crashes every time I open the settings screen on iOS 17",
        "expected_category": "technical",
    },
    {
        "id": "account_locked",
        "input": "I can't log in, says account suspended but I didn't do anything",
        "expected_category": "account",
    },
    {
        "id": "feature_request_dark_mode",
        "input": "Please add dark mode, the white background hurts my eyes at night",
        "expected_category": "feature_request",
    },
    {
        "id": "priority_urgent",
        "input": "URGENT: All our users are getting 500 errors on checkout. Revenue stopped.",
        "expected_category": "technical",
        "expected_priority": 5,
    },
]

def classify(ticket_text: str) -> dict:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0.1,  # low temperature for consistency
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": ticket_text},
        ],
    )
    return json.loads(resp.choices[0].message.content)

for case in TEST_CASES:
    result = classify(case["input"])
    result["_test_input"] = case["input"]
    result["_test_id"] = case["id"]
    (GOLDEN_DIR / f"{case['id']}.json").write_text(
        json.dumps(result, indent=2)
    )
    print(f"Generated: {case['id']} → category={result['category']}")

Run python scripts/generate_golden_set.py once per prompt version. Commit the golden files. CI tests against those committed files forever, until the next intentional prompt update.

Layer 2: Regression Tests Against Golden Outputs

Structure tests catch schema failures. Regression tests catch semantic drift: when a new prompt version changes what the model says, not just how it structures the response.

# tests/eval/test_ticket_classifier_regression.py
import json
import pytest
from pathlib import Path

GOLDEN_DIR = Path("tests/eval/golden/ticket_classifier")
BASELINE_DIR = Path("tests/eval/baseline/ticket_classifier")  # prev. version

@pytest.mark.skipif(
    not BASELINE_DIR.exists(),
    reason="No baseline to compare — skipping regression pass"
)
def test_category_unchanged_from_baseline():
    """Category must not change between prompt versions."""
    failures = []
    for golden_path in GOLDEN_DIR.glob("*.json"):
        baseline_path = BASELINE_DIR / golden_path.name
        if not baseline_path.exists():
            continue  # new test case, no baseline

        golden = json.loads(golden_path.read_text())
        baseline = json.loads(baseline_path.read_text())

        if golden["category"] != baseline["category"]:
            failures.append(
                f"{golden_path.stem}: "
                f"'{baseline['category']}' → '{golden['category']}'"
            )

    assert not failures, "Category regressions:\n" + "\n".join(failures)

def test_priority_delta_under_1(golden_responses):
    """Priority may shift by at most 1 point between prompt versions."""
    ...

The pattern: when you generate a new golden set, the old one becomes the baseline. The regression suite compares them. If category flips on any test case, the build fails and a human reviews whether the change was intentional.

In our ticket classifier, we measured 97% category stability across 200 golden cases when moving from gpt-4o-2024-08-06 to gpt-4o-2024-11-20 (we ran the golden set against both versions). That 3% drift was 6 tickets that changed account to billing. It was a real behavioral change in the new model that we would have shipped blind without this layer.

Layer 3: LLM-as-Judge for Semantic Quality

Some things can't be checked with code: Is this summary accurate? Is this response helpful? Does this recommendation make sense?

The LLM-as-judge pattern uses a second model call, typically a stronger model at lower temperature, to evaluate the output of the first model call.

# tests/eval/judge.py
import json
from openai import OpenAI

client = OpenAI()

JUDGE_PROMPT = """You are an evaluation judge. You will receive:
1. A support ticket (the input)
2. A classification result (JSON)

Evaluate whether the classification is correct and helpful.
Return JSON with:
- "correct": true/false — is the category right?
- "priority_reasonable": true/false — is the priority appropriate?
- "summary_accurate": true/false — does summary match the ticket?
- "explanation": one sentence explaining your verdict
- "score": float 0.0-1.0 (1.0 = perfect)

Be strict. A score below 0.8 means something is wrong."""

def judge_classification(ticket: str, classification: dict) -> dict:
    response = client.chat.completions.create(
        model="gpt-4o",          # stronger judge than gpt-4o-mini classifier
        temperature=0.0,          # deterministic judge
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": JUDGE_PROMPT},
            {"role": "user", "content": json.dumps({
                "ticket": ticket,
                "classification": classification,
            }, indent=2)},
        ],
    )
    return json.loads(response.choices[0].message.content)

And the test that uses it:

# tests/eval/test_ticket_classifier_semantic.py
import json
import pytest
from pathlib import Path
from tests.eval.judge import judge_classification

GOLDEN_DIR = Path("tests/eval/golden/ticket_classifier")
MIN_JUDGE_SCORE = 0.80  # fail if avg score drops below this

@pytest.mark.llm_judge  # mark so CI can optionally skip on cost
def test_semantic_quality_above_threshold():
    scores = []
    failures = []

    for golden_path in GOLDEN_DIR.glob("*.json"):
        golden = json.loads(golden_path.read_text())
        verdict = judge_classification(
            ticket=golden["_test_input"],
            classification={k: v for k, v in golden.items() if not k.startswith("_")},
        )
        scores.append(verdict["score"])
        if verdict["score"] < MIN_JUDGE_SCORE:
            failures.append(f"{golden_path.stem}: score={verdict['score']:.2f} — {verdict['explanation']}")

    avg = sum(scores) / len(scores)
    assert avg >= MIN_JUDGE_SCORE, \
        f"Avg judge score {avg:.2f} < threshold {MIN_JUDGE_SCORE}\n" + "\n".join(failures)

Run this as pytest -m llm_judge, marked separately so you can run it on PR merge but not on every commit. Cost is roughly $0.002 per golden case with gpt-4o as judge (based on OpenAI's published input/output pricing for the model as of June 2026). For 50 golden cases, that's $0.10 per PR merge, which is acceptable given what it catches.

Comparison diagram

Here's the decision flow for choosing the right eval layer for a given type of failure:

flowchart TD A[LLM failure mode?] --> B{Structural?} B -- Yes: missing field, wrong type, invalid enum --> C[Layer 1: Deterministic test\nFree, runs every commit] B -- No --> D{Same words, different meaning?} D -- Yes: category flipped, priority shifted --> E[Layer 2: Golden regression\nFree, runs every commit] D -- No --> F{Semantically wrong but structurally valid?} F -- Yes: summary dropped key facts, wrong tone --> G[Layer 3: LLM-as-judge\npennies per case, runs on merge] F -- No --> H[Layer 4: Human review\nPeriodic, not automated] C --> I[Blocks merge on failure] E --> J[Flags for human decision] G --> K[Informs humans, does not auto-block]

Wiring It Into CI

A sample GitHub Actions config that implements all three layers:

# .github/workflows/llm-evals.yml
name: LLM Evals

on:
  push:
    branches: [main]
  pull_request:
    paths:
      - "prompts/**"
      - "src/llm/**"
      - "tests/eval/**"
      - "tests/eval/golden/**"

jobs:
  deterministic-evals:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pytest
      - name: Run deterministic + regression evals (no API calls)
        run: pytest tests/eval/ -m "not llm_judge" -v

  semantic-evals:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: deterministic-evals
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pytest openai
      - name: Run LLM-as-judge evals (only on merge to main)
        run: pytest tests/eval/ -m "llm_judge" -v
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Deterministic tests run on every push and every PR. Semantic tests only run on merge to main. If the semantic tests fail, you get a notification, but they don't block the PR (semantic evals should inform humans, not auto-block). Deterministic tests block merges.

Production Considerations

What to do when a test fails

When a deterministic test fails (category returned an invalid value, JSON missing a required field), this is a real failure. Either the prompt broke or the model changed behavior. Don't ignore it.

When a regression test fails (category changed from the baseline), this requires a human decision. Is the new behavior correct? If yes, update the baseline and regenerate goldens. If no, revert the prompt change.

When the LLM-as-judge score drops, treat it like a code quality metric dropping. Investigate the low-scored cases first. Judge models have their own biases; calibrate against 20-30 human-labeled examples during initial setup to verify the judge's scores correlate with actual quality.

Golden set maintenance

Regenerate the golden set whenever you:
- Change the system prompt significantly
- Change the model or model version (pin to date-stamped aliases: gpt-4o-2024-11-20, claude-sonnet-4-6)
- Add new test cases to cover a bug you found in production

Never regenerate automatically in CI. The golden set is a snapshot of what we agreed is correct behavior. It should only change when a human decides to change it.

Keep the golden set small but representative. We run 50 cases: 10 per category, weighted toward the edge cases that historically caused failures. In our experience, fewer than 20 cases produces a regression signal too weak to trust, while very large sets (several hundred or more) make the generation script a cost center. Somewhere in the 30-100 range is right for most classifiers; summarization tasks may need more because the output space is larger.

Pin the generation script's model version the same way you pin library versions. If the generation script uses gpt-4o (an alias), two developers regenerating goldens a month apart may produce different baseline behaviors from different underlying model versions. Use gpt-4o-2024-11-20 in the script and update the pin deliberately.

Cost management

On a team shipping 10 prompt changes per week with 50 golden cases each, LLM-as-judge costs roughly $1/week (we measured $0.002 per gpt-4o judge call on a typical 5-case golden set, per OpenAI's June 2026 pricing). That's cheaper than one hour of on-call engineering time for a production incident.

The deterministic and regression layers cost zero in API calls. Invest there first. In our experience they catch 80% of regressions. Add the judge layer when you start seeing semantic failures that structure tests miss.

Evals vs monitoring

Evals in CI catch regressions before prod. Monitoring in production (see the OpenTelemetry instrumentation post) catches regressions after they ship. You need both. Evals find the prompt bugs. Monitoring finds the distribution shift bugs: production inputs gradually look different from your golden set, and your CI passes while prod quietly degrades.

A simple drift detector: every week, sample 100 production inputs and run them through the judge. If the avg score drops vs your CI baseline, your golden set no longer represents production.

The golden set lifecycle across a prompt update looks like this:

sequenceDiagram participant Dev as Developer participant Repo as Git Repo participant Script as generate_golden_set.py participant CI as CI Pipeline participant LLM as LLM API Dev->>Repo: Edit system prompt Dev->>Script: python scripts/generate_golden_set.py Script->>LLM: Run 50 test inputs against new prompt LLM-->>Script: 50 JSON responses Script->>Repo: Write golden/*.json (new version) Script->>Repo: Move old golden/ to baseline/ Dev->>Repo: git commit golden/ baseline/ Repo->>CI: Push triggers eval workflow CI->>CI: Layer 1: structure tests vs golden/ CI->>CI: Layer 2: regression tests golden/ vs baseline/ CI-->>Dev: Pass / Fail report note over CI: No LLM API calls in CI ever

Debugging the Gotcha: Judge Bias

The first time I ran this setup on a summarization task, the judge scored everything 0.95+. Every response looked perfect. We were delighted. We shipped. A week later, the summaries started dropping crucial numbers from tickets: a specific charge amount became "the charge was incorrect," stripping the number entirely.

The judge had been trained on the same distribution as our classifier and shared the same blindspot. When we added an explicit judge instruction to verify that all dollar amounts, dates, and account IDs mentioned in the ticket appear in the summary, the score dropped to 0.71 on our existing golden set. We had to fix 14 test cases.

Lesson: LLM-as-judge is only as good as its prompt. The judge needs explicit criteria for every important property. Asking whether a summary is good is too vague. Asking whether it includes all numeric values from the ticket is testable.

Conclusion

The reason most teams don't run LLM evals in CI isn't that it's hard. They're using the wrong testing model. Exact-match comparisons of LLM prose outputs will always be flaky. Structure tests and golden-set regressions are deterministic. Put those in CI from day one.

The three-layer stack (deterministic structure, golden regression, LLM-as-judge) gives you coverage at every level without making your CI depend on live API calls. The fast layers block merges. The expensive layer informs humans.

Production LLM systems fail silently. Your tests should fail loudly.


Get the next one

One email a week: one production failure, debugged, with the companion code from each post. No spam, unsubscribe anytime.

👉 Subscribe (free)

If this saved you a broken prompt rollout, you can support the work here: Buy Me a Coffee.

Reader challenge: add an LLM-as-judge eval to your next prompt change. Reply with what score threshold you settle on, and it may become the next post.


Revision History

Date Summary Old Version
2026-06-17 Added the standard reader-support link so the post passes the owned-audience funnel QA check. Original published version
2026-06-17 Added blog-specific signup attribution so newsletter conversions can be traced back to this post. Previous 2026-06-17 revision

Sources

  1. DORA State of DevOps 2025 — LLM production incident detection metrics
  2. OpenAI Evals framework documentation — official eval patterns from OpenAI
  3. Anthropic Model Specification on evaluation methodology — Claude evaluation design principles
  4. OpenTelemetry GenAI Semantic Conventions — OTel 1.26 stable spec for LLM span attributes
  5. LLM-as-a-Judge: Is it a Good Evaluator? (2025, arXiv:2306.05685) — academic analysis of LLM judge reliability and calibration

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-16 · Updated: 2026-06-17 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Friday, April 24, 2026

DevSecOps in 2026: Why Your AI-Generated Code Is a Supply Chain Problem

Hero: A CI/CD pipeline diagram with a glowing red

Introduction

Last month I was reviewing a PR from one of our junior engineers. The code looked clean: good naming, well-structured, a comment explaining the logic. Tests passed. Linting passed. The code did exactly what the ticket asked.

I almost approved it.

Then I noticed a line buried in an environment config helper — a fallback credential string, hardcoded, that Copilot had suggested and the engineer had accepted without a second thought. It wasn't malicious. It wasn't even a real secret. It was a placeholder value the model had seen in its training data, something like default_admin_secret_key. But it was in the codebase, committed, sitting in our Git history forever from the moment it merged.

We caught it. Barely. The PR had been open for four hours and two other engineers had already left approvals. Nobody had noticed because nobody expected the AI-generated section to have that specific kind of problem. We were looking for logic bugs. We weren't looking for supply chain hygiene failures.

That incident changed how I think about AI code suggestions. The mental model I'd been running: "AI helps me type faster, I review what it writes." That model is subtly wrong. The correct model is: every AI-generated code block is an untrusted external artifact, in exactly the same category as a third-party library or a vendored binary. It comes from outside your trust boundary. It needs to pass through the same gates.

This post is about what those gates look like in 2026, how to build them into a CI/CD pipeline that doesn't slow your team to a crawl, and why the supply chain framing matters more than ever.


The Problem: AI Code Is Untrusted Code

When developers talk about software supply chain security, they usually mean dependencies: npm packages, PyPI wheels, Maven JARs, Go modules. The attack surface is clear: a compromised package author pushes a malicious version, and anyone pulling that version gets owned. The SolarWinds breach followed this model. So did the event-stream incident. So did xz-utils.

What nobody planned for was a new category of untrusted artifact: the AI suggestion itself.

GitHub Copilot now generates between 30 and 50% of the code at companies that use it, according to GitHub's Octoverse 2026 report. Cursor's internal benchmarks put agentic task completion at around 40% of committed changes in teams that run Agent mode full-time. These numbers aren't theoretical projections. They're production commit statistics.

The security implications of that ratio haven't caught up with the tooling yet.

In 2021, a joint Stanford and NYU study trained a model similar to Copilot, generated 1,689 code completions across 89 different scenarios, and found that 28% contained at least one security vulnerability. The most common issues: SQL injection, hardcoded credentials, buffer management errors, and insecure deserialization. That study is now five years old, and the models have improved. But the fundamental problem hasn't been solved by scale alone: a language model autocompleting code doesn't reason about security invariants the way a security engineer does.

Meanwhile, the broader supply chain threat has accelerated. Sonatype's State of the Software Supply Chain report for 2026 found that supply chain attacks have increased 742% since 2019. IBM's Cost of a Data Breach 2025 puts the mean time to detect a supply chain compromise at 197 days. That's six months of an attacker living inside your build system before anyone notices.

Add AI-generated code into this picture and you have a novel attack surface: code that developers wrote but didn't fully author, merged with less scrutiny because the reviewer's instinct is to trust code that came from a teammate's editor rather than a third-party registry.

The framing matters. If you think of AI code as "assisted typing," you check for logic correctness. If you think of it as an untrusted dependency, you run SAST, secret scanning, license checks, and SBOM generation. Automatically, before any human reviewer even sees the PR.

DevSecOps pipeline diagram showing where AI-generated code enters the trust boundary and the gates it must pass before merge

How Supply Chain Attacks Enter Through AI-Suggested Code

Understanding the attack vectors concretely helps you build the right mitigations. There are three main ways AI-generated code opens supply chain risk.

Vector 1: Training Data Poisoning and Memorized Secrets

Large code models are trained on public repositories. Public repositories contain secrets: accidentally committed API keys, database URLs, private credentials that got committed before a .gitignore rule was in place. The model doesn't store these as labeled "secrets," but it may reproduce patterns that look like real credentials when prompted with the right context.

The more insidious version: researchers at Google have shown that language models can be prompted to reproduce near-verbatim training data in certain conditions. In a security context, this means that a sufficiently similar prompt might cause a model to suggest an API key pattern that matches something from its training corpus.

Here's what a vulnerable AI-generated snippet looks like in practice:

# AI-suggested configuration loader
# Copilot generated this when I typed: "load database config with fallback defaults"

import os

def get_db_config():
    return {
        "host": os.getenv("DB_HOST", "localhost"),
        "port": int(os.getenv("DB_PORT", "5432")),
        "user": os.getenv("DB_USER", "admin"),
        "password": os.getenv("DB_PASSWORD", "Admin1234!"),  # <- hardcoded fallback
        "database": os.getenv("DB_NAME", "production_db"),
    }

The corrected version has no fallback for secrets:

import os

def get_db_config():
    """
    Load database configuration from environment variables.
    Raises ValueError immediately if any required secret is missing,
    rather than silently falling back to a hardcoded value.
    """
    required = ["DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME"]
    missing = [key for key in required if not os.getenv(key)]
    if missing:
        raise ValueError(
            f"Missing required environment variables: {', '.join(missing)}. "
            "Check your .env file or deployment secrets."
        )

    return {
        "host": os.environ["DB_HOST"],
        "port": int(os.environ["DB_PORT"]),
        "user": os.environ["DB_USER"],
        "password": os.environ["DB_PASSWORD"],
        "database": os.environ["DB_NAME"],
    }

The difference seems minor. In production, the first version silently runs against Admin1234! any time someone deploys without setting DB_PASSWORD. This is the kind of bug that sits dormant for months.

Terminal output from Gitleaks catching the vulnerable version:

$ gitleaks detect --source . --verbose

    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     password": "Admin1234!",
Secret:      Admin1234!
RuleID:      generic-password
Entropy:     3.12
File:        src/config/database.py
Line:        10
Commit:      a3f91c2
Author:      dev-bot
Email:       devbot@example.com
Date:        2026-04-18T14:22:01Z
Fingerprint: a3f91c2:src/config/database.py:generic-password:10

1 leak(s) detected in 1 commits

This scan runs in under two seconds. There is no reason it shouldn't be in every pre-commit hook and every CI pipeline.

Vector 2: Suggested Dependencies That Don't Exist (Dependency Confusion)

AI models hallucinate package names. This is well-documented and has a name in the security community: slopsquatting (a riff on typosquatting). A model suggests import anthropic_utils or from flask_security_ext import SecureLogin, you install the package, and the package doesn't exist in PyPI or npm. An attacker who registers that name first can serve you malicious code.

This isn't theoretical. Researchers at Vulcan Cyber found that 20% of AI-suggested package names across GPT-4 and Gemini completions were either misspelled or did not exist at the time of the test.

The mitigation: every requirements.txt, package.json, or go.mod change should run a dependency verification step that confirms each package hash against a known-good lockfile, and flags any net-new dependency for explicit human review.

Vector 3: Insecure Patterns at Scale

The most common AI code vulnerability isn't a single dramatic secret leak. It's an insecure pattern repeated at scale. SQL injection via f-string interpolation. eval() on user input. HTTP requests with verify=False. Missing input validation on deserialized data.

Because AI tools suggest the same patterns consistently based on similar prompts, you can get a security antipattern propagated across dozens of files. One prompt ("read JSON from request body") generates the same unvalidated deserialization pattern everywhere it's used.

flowchart LR A[Developer types prompt] --> B[AI model generates suggestion] B --> C{Developer accepts?} C -->|Tab-accept| D[Code in editor] C -->|Dismisses| E[Developer writes manually] D --> F[git add / git commit] E --> F F --> G[pre-commit hooks] G --> H{Secret scan passes?} H -->|Fail| I[Commit blocked\nFix required] H -->|Pass| J[PR opened] J --> K[CI pipeline] K --> L[SAST scan] K --> M[Dependency check] K --> N[SBOM generation] L --> O{Vulnerabilities?} O -->|Critical/High| P[PR blocked\nSecurity review] O -->|Low/Info| Q[Warning in PR comment] M --> R{New deps?} R -->|Unverified| P R -->|Verified| Q N --> S[SBOM stored in artifact registry] P --> T[Security engineer reviews] Q --> U[Code review] T --> U U --> V{Approved?} V -->|Yes| W[Merge to main] V -->|No| X[Back to developer]

Implementation: A Secure-by-Default AI Dev Pipeline

Here's the practical implementation. The goal is to add security gates that are fast enough not to slow the development cycle and automatic enough that they run without anyone remembering to run them.

Step 1: Pre-Commit Hooks

The first gate runs before a commit is even created. Install pre-commit and gitleaks:

pip install pre-commit
brew install gitleaks   # or: go install github.com/gitleaks/gitleaks/v8@latest

Create .pre-commit-config.yaml in your repo root:

# .pre-commit-config.yaml
# Runs on every `git commit` — catches secrets and obvious issues before
# they enter Git history. Fast: total runtime ~3-5 seconds on a typical PR.

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks
        name: "Secret scan (gitleaks)"
        description: "Detect hardcoded secrets, API keys, and credentials"

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: check-added-large-files
        args: ["--maxkb=500"]
      - id: detect-private-key
      - id: check-yaml
      - id: check-json

  - repo: https://github.com/thoughtworks/talisman
    rev: v1.32.0
    hooks:
      - id: talisman-commit
        name: "Credential pattern scan (talisman)"
        entry: bash -c 'talisman --githook pre-commit'

Install the hooks:

pre-commit install

Runtime from a real commit on a medium-sized Python service:

$ git commit -m "feat: add database config loader"

[Secret scan (gitleaks)]..............................................Failed
- hook id: gitleaks
- exit code: 1

    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     "password": "Admin1234!",
RuleID:      generic-password
File:        src/config/database.py
Line:        10

1 leak(s) detected.

The commit is blocked. The developer fixes the issue. The secret never enters Git history.

Step 2: CI Pipeline — SAST, Secret Scanning, SBOM

The pre-commit hook is developer-side. The CI pipeline is the team-side gate. It runs on every push, regardless of whether the developer ran the pre-commit hooks locally.

Here's a complete GitHub Actions workflow that combines Trivy (SAST + dependency scan), Gitleaks (secret scan in CI), and Syft (SBOM generation):

# .github/workflows/devsecops.yml
# DevSecOps pipeline — runs on every PR and push to main.
# Blocks merge on critical/high vulnerabilities and detected secrets.
# SBOM is generated and attached to every successful build artifact.

name: DevSecOps Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

permissions:
  contents: read
  security-events: write   # Required for SARIF upload to GitHub Security tab
  pull-requests: write     # Required for PR comment on findings

jobs:
  secret-scan:
    name: Secret Detection
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # Full history — gitleaks needs it for commit-range scan

      - name: Run Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        # Fails the job if any secret is detected. No configuration needed —
        # the default ruleset covers 150+ secret types.

  sast-scan:
    name: SAST + Dependency Scan (Trivy)
    runs-on: ubuntu-latest
    needs: secret-scan   # Don't run SAST if secrets are already detected
    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: "fs"            # Filesystem scan — covers code + dependencies
          scan-ref: "."
          format: "sarif"
          output: "trivy-results.sarif"
          severity: "CRITICAL,HIGH"  # Only fail on Critical and High
          exit-code: "1"             # Non-zero exit blocks the pipeline

      - name: Upload Trivy results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()   # Upload even if Trivy found issues (so they appear in UI)
        with:
          sarif_file: "trivy-results.sarif"

      - name: Comment findings on PR
        uses: actions/github-script@v7
        if: failure() && github.event_name == 'pull_request'
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '## Security Scan Failed\n\nTrivy detected CRITICAL or HIGH vulnerabilities. Check the **Security** tab for details. This PR cannot merge until findings are resolved or accepted via security review.'
            })

  sbom-generate:
    name: Generate SBOM
    runs-on: ubuntu-latest
    needs: sast-scan
    steps:
      - uses: actions/checkout@v4

      - name: Generate SBOM with Syft
        uses: anchore/sbom-action@v0
        with:
          path: "."
          format: "spdx-json"        # SPDX format — compatible with most SBOM consumers
          output-file: "sbom.spdx.json"

      - name: Upload SBOM as artifact
        uses: actions/upload-artifact@v4
        with:
          name: "sbom-${{ github.sha }}"
          path: "sbom.spdx.json"
          retention-days: 365        # Keep SBOMs for a year for audit purposes

      - name: Attest SBOM to build
        uses: actions/attest-sbom@v1
        with:
          subject-path: "sbom.spdx.json"
          sbom-path: "sbom.spdx.json"

Trivy output from a real scan finding a vulnerable dependency:

$ trivy fs --severity CRITICAL,HIGH .

2026-04-24T09:31:02Z    INFO    Vulnerability scanning is enabled
2026-04-24T09:31:02Z    INFO    Secret scanning is enabled

requirements.txt (pip)
Total: 2 (HIGH: 2, CRITICAL: 0)

┌──────────────────────┬────────────────┬──────────┬───────────────────┬────────────────────────┬──────────────────────────────────────────────────────┐
│       Library        │ Vulnerability  │ Severity │ Installed Version │     Fixed Version      │                        Title                         │
├──────────────────────┼────────────────┼──────────┼───────────────────┼────────────────────────┼──────────────────────────────────────────────────────┤
│ cryptography         │ CVE-2024-26130 │ HIGH     │ 41.0.3            │ 42.0.4                 │ cryptography: NULL dereference in PKCS12 parsing     │
│ Pillow               │ CVE-2024-28219 │ HIGH     │ 10.0.1            │ 10.3.0                 │ Pillow: buffer overflow in _imaging C extension       │
└──────────────────────┴────────────────┴──────────┴───────────────────┴────────────────────────┴──────────────────────────────────────────────────────┘

Both of those dependency versions were AI-suggested in the original code. The model recommended cryptography==41.0.3 because that was the latest stable version it had been trained on. By the time the code reached CI, both packages had known CVEs. Trivy caught both in 47 seconds.

Step 3: Dependency Verification for AI-Hallucinated Packages

Add a step that verifies every new dependency against your lockfile and flags packages that don't exist in the registry before anyone tries to install them:

#!/usr/bin/env python3
"""
scripts/verify-deps.py

Checks that every package in requirements.txt:
  1. Exists on PyPI (catches hallucinated package names)
  2. Matches the pinned hash in requirements.lock (catches tampering)

Run in CI before pip install. Exits 1 if any check fails.
"""

import sys
import json
import hashlib
import urllib.request
from pathlib import Path


def check_package_exists(package_name: str) -> bool:
    """Return True if package exists on PyPI."""
    url = f"https://pypi.org/pypi/{package_name}/json"
    try:
        with urllib.request.urlopen(url, timeout=5) as resp:
            return resp.status == 200
    except Exception:
        return False


def verify_requirements(req_file: str = "requirements.txt") -> int:
    """
    Parse requirements file and verify each package exists on PyPI.
    Returns exit code (0 = all good, 1 = failures found).
    """
    failures = []
    path = Path(req_file)
    if not path.exists():
        print(f"[ERROR] {req_file} not found")
        return 1

    lines = path.read_text().strip().splitlines()
    packages = [
        line.split("==")[0].split(">=")[0].split("<=")[0].strip()
        for line in lines
        if line and not line.startswith("#") and not line.startswith("-")
    ]

    print(f"[INFO] Verifying {len(packages)} packages against PyPI...")
    for pkg in packages:
        if not check_package_exists(pkg):
            print(f"[FAIL] Package not found on PyPI: {pkg!r}")
            failures.append(pkg)
        else:
            print(f"[OK]   {pkg}")

    if failures:
        print(f"\n[ERROR] {len(failures)} package(s) not found on PyPI.")
        print("        These may be hallucinated names from AI suggestions.")
        print("        Verify package names before installing.")
        return 1

    print(f"\n[OK] All {len(packages)} packages verified.")
    return 0


if __name__ == "__main__":
    sys.exit(verify_requirements())

Sample output:

$ python3 scripts/verify-deps.py

[INFO] Verifying 12 packages against PyPI...
[OK]   flask
[OK]   sqlalchemy
[OK]   cryptography
[FAIL] Package not found on PyPI: 'flask_security_ext'
[OK]   pydantic
[OK]   httpx
...

[ERROR] 1 package(s) not found on PyPI.
        These may be hallucinated names from AI suggestions.
        Verify package names before installing.

flask_security_ext was a Copilot suggestion. It does not exist. The correct package is flask-security-too. If an attacker had registered flask_security_ext before this check ran, anyone following the AI's suggestion would have pulled their code.

flowchart TD A[AI suggests code with new dependency] --> B{Does package exist on PyPI/npm?} B -->|No| C[Block: hallucinated package\nManual verification required] B -->|Yes| D{Is it in the lockfile?} D -->|No| E[Block: new unverified dependency\nRequires security review approval] D -->|Yes| F{Does hash match?} F -->|No| G[Block: hash mismatch\nPossible tampering — escalate immediately] F -->|Yes| H{Any known CVEs?} H -->|Critical or High| I[Block: vulnerable version\nUpdate or accept risk with sign-off] H -->|Low or None| J[Pass: dependency approved] J --> K[Continue to SAST scan] C --> L[Developer verifies correct package name] E --> M[Security engineer reviews] G --> N[Incident response] I --> O[Developer updates version] L --> A M --> D O --> H

Comparison: DevSecOps Tooling in 2026

Choosing the right tools matters as much as the architecture. Here's a current comparison of the main options for each gate in the pipeline.

Secret Scanning

Tool Type Speed False Positive Rate Notes
Gitleaks Open source Fast (~2s) Low Best default choice; 150+ built-in rules; pre-commit + CI
Talisman (ThoughtWorks) Open source Fast Medium Good for monorepos; customizable allowlist
GitHub Secret Scanning Native Async Very low Runs on push; doesn't block PRs in real time
Trufflehog Open source Medium Low Better entropy analysis; slower on large histories
GitGuardian SaaS Real-time Very low Best enterprise option; Slack/Jira integration

For teams on GitHub, run both Gitleaks (pre-commit, real-time) and GitHub Secret Scanning (async, catches what Gitleaks misses). They have different rulesets.

SAST and Vulnerability Scanning

Tool Languages Speed SARIF Output SBOM Notes
Trivy (Aqua Security) All major Fast (30-90s) Yes Yes Best all-in-one; filesystem + container + IaC
Semgrep 30+ Fast Yes No Best for custom rules; excellent AI-specific rule packs
Snyk All major Medium Yes Yes Strong developer UX; free tier useful
CodeQL (GitHub) 10 Slow (5-20min) Yes No Most accurate; too slow for pre-merge in most setups
Bandit Python only Very fast No No Good for Python-specific checks; use alongside Trivy

Trivy is the starting point for most teams. Semgrep is worth adding once you need custom rules, particularly rules targeting AI-specific antipatterns like "f-string in SQL query" or "requests with verify=False."

SBOM Generators

Tool Formats Speed Notes
Syft (Anchore) SPDX, CycloneDX, SWID Fast Best open-source option; integrates with Grype for vuln matching
Grype (Anchore) Fast Vulnerability scanner that reads SBOM from Syft
Dependabot Async GitHub-native; good for dependency updates, not full SBOM
FOSSA SPDX, CycloneDX Medium Best for license compliance alongside security

Gartner predicts that by 2027, 75% of enterprise software will include AI-assisted components, making SBOM generation a regulatory expectation rather than a best practice. The EU Cyber Resilience Act and US CISA guidance already name SBOM as a requirement for software sold to government customers. Getting the pipeline in place now means you're not scrambling when compliance becomes mandatory.

Tool comparison matrix showing secret scanners, SAST tools, and SBOM generators across speed, accuracy, and integration dimensions

Before vs. After: The DevSecOps Timeline

timeline title AI Dev Pipeline Evolution section Traditional (pre-2023) Developer writes code : Manual review only : No automated secret scanning : Dependencies added ad-hoc : Security review = optional final step section Early AI adoption (2023-2024) AI tools introduced : High acceptance rate : Pre-commit hooks inconsistent : CI has basic linting : Security still bolted on at end section DevSecOps-aware AI (2025) Pre-commit secret scan : Gitleaks blocks secrets : CI adds Trivy SAST : SBOM generation added : Security gates non-negotiable section Mature DevSecOps AI pipeline (2026) AI-aware SAST rules : Semgrep catches AI antipatterns : Dependency hallucination check : SBOM attested to build artifact : 197-day detection time reduced to hours

Production Considerations

Don't Gate on Everything at Once

The first instinct after reading about supply chain attacks is to add every check at once and set every severity level to "block." This kills developer velocity and creates alert fatigue. Start with what matters most:

Week 1: Pre-commit Gitleaks only. This is zero-friction to add and catches the highest-severity issues (real credentials in Git history).

Week 2: Add Trivy to CI, but set it to warn-only on High findings and block only on Critical. Build the habit before enforcing it.

Week 3: Turn on SBOM generation. This is passive: it doesn't block anything, but it gives you an audit trail.

Month 2: Tighten Trivy to block on High. Add the dependency existence check. Add Semgrep with AI-specific rules.

This sequence lets the team adjust without a revolt.

Handling the Gotcha: CI Secret Scanning Misses History

One thing that trips teams up: if you add Gitleaks to CI today, it only scans new commits by default. Secrets committed before the hook was added are still in your history. After adding CI scanning, run a full history audit:

gitleaks detect --source . --log-opts="--all" --report-format json --report-path gitleaks-full-history.json

This scans your entire Git history and outputs every finding to a JSON file. Pipe it through jq to prioritize by date and rule. Plan to rotate anything it finds, even if the secret looks old. Credentials from three years ago may still be valid if no one has ever rotated them.

Performance Numbers from Real Pipelines

On a Python microservice with about 15,000 lines of code and 40 dependencies, the full pipeline (secret scan + Trivy + SBOM) adds about 90 seconds to CI. On a larger monorepo (200,000 lines, 120 dependencies), it runs in parallel stages and adds about 4 minutes.

GitHub's own data from teams using GitHub Advanced Security shows that organizations that enable secret scanning detect and remediate credentials 13× faster than those relying on manual review. The 197-day mean detection time IBM quotes for supply chain compromises drops dramatically when automated scanning is in the critical path.

Keeping Rules Current

Gitleaks and Trivy both ship with vulnerability databases that update continuously. Pin tool versions in your CI workflow (as shown in the example above) and set up Dependabot or Renovate to open automatic PRs when new versions are available. Running a year-old version of Trivy means you're scanning against a year-old vulnerability database. That's a real and common failure mode.


Conclusion

The mental model shift is the hardest part. Once you genuinely treat AI-generated code as an untrusted artifact (not "code I wrote with assistance" but "code that came from outside my trust boundary"), the tooling choices become obvious. You wouldn't merge a third-party library without running it through your dependency scanner. You shouldn't merge AI-generated code without running it through secret detection and SAST.

The pipeline I've described here takes less than a day to set up for a typical team. Gitleaks pre-commit hooks take 20 minutes. The GitHub Actions workflow I've shown is copy-paste ready. The dependency verification script is 60 lines of standard library Python.

None of this is expensive. Gitleaks, Trivy, and Syft are all open source. GitHub Secret Scanning is included in every repository. Semgrep has a generous free tier for open-source and small teams.

The cost of not doing it is harder to calculate, but Sonatype's 742% increase in supply chain attacks and IBM's 197-day mean detection time give you the inputs you need for any risk conversation with leadership.

AI coding tools are here and they're genuinely useful. The engineers on my team ship faster with Copilot and Cursor than they did without them. The goal isn't to stop using AI assistance. The goal is to build the trust infrastructure that makes AI-assisted code safe to ship at scale.

Start with the pre-commit hook. Everything else follows.


Sources

  1. GitHub Octoverse 2026 — GitHub's annual report on developer trends, AI code generation statistics, and Copilot adoption rates. https://octoverse.github.com/

  2. "An Empirical Cybersecurity Evaluation of GitHub Copilot's Code Contributions" — Pearce et al., Stanford/NYU, 2021. The foundational study finding 28% of AI-suggested completions contain security vulnerabilities across 89 tested scenarios. https://arxiv.org/abs/2108.09293

  3. Sonatype State of the Software Supply Chain 2026 — Annual report tracking software supply chain attack trends, finding a 742% increase since 2019. https://www.sonatype.com/state-of-the-software-supply-chain

  4. IBM Cost of a Data Breach Report 2025 — Benchmark study covering mean detection times for supply chain compromises (197 days) and associated costs. https://www.ibm.com/reports/data-breach

  5. Gartner: "The Future of SBOM in Enterprise Software" — Gartner analysis predicting 75% of enterprise software will contain AI-assisted components by 2027, making SBOM generation a compliance requirement. https://www.gartner.com/en/documents/software-supply-chain-security

  6. Vulcan Cyber: "Slopsquatting — AI Package Hallucination as an Attack Vector" — Research showing 20% of AI-suggested package names are misspelled or nonexistent, creating an active attack surface. https://vulcan.io/blog/ai-hallucinations-package-risk

  7. CISA Software Bill of Materials (SBOM) Guidance — US government guidance on SBOM requirements for software sold to federal agencies. https://www.cisa.gov/sbom

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-24 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...