How I spent a few nights turning a pile of security scanners into one engine, and then let an agent do the part everyone gives up on: triage, prioritization, and writing the fix with a test that proves it.


IntroductionπŸ”—

Every AppSec team knows the failure mode. You wire a dozen scanners into CI, the first run returns 4,000 findings, nobody can tell the 12 that matter from the noise, and three sprints later someone quietly sets allow_failure: true. The scanning was never the hard part. Triage, prioritization, and remediation are. And they’re exactly where people give up and switch security scans off.

So I ran an experiment on a personal AppSec pet project: keep the finding part 100 % deterministic, reliable, reproducible, no hallucinations (plenty of false positives, though πŸ˜…), and hand the judgement part to an agent. Not β€œan LLM that greps your code”, but an agent that drives the real scanning engine through an MCP server and a few skills, then opens one review branch per fix, each with a test that proves the vulnerability is closed.

This post is the build log and the tutorial. The guiding principle throughout:

Let the deterministic engine find and normalize. Let the agent triage, prioritize, and remediate, and make every fix prove itself.

The whole thing is a PoC named Deep Scan link (rough edges everywhere, no auth, lots left to finetune), but the results were far more than promising, and the whole loop is reproducible on your own repos. Here’s how it’s put together.


1. One engine, many front-endsπŸ”—

The mistake would be to build β€œan AI security tool”. Instead, everything hangs off a single deterministic core, and the agent is just one more consumer of it: the same scanners, the same normalized model, the same reports the CLI produces in CI.

                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                      β”‚      deepscan-core            β”‚
                      β”‚  registry Β· secure install Β·  β”‚
                      β”‚  scanners Β· dedup Β· reporting β”‚
                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β–²          β–²           β–²
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚           └────────────┐
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  deepscan-mcp   β”‚   β”‚   deepscan-cli   β”‚   β”‚     deepscan-api     β”‚
   β”‚  agents / IDE   β”‚   β”‚  local & CI/CD   β”‚   β”‚  job queue + web app β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The two halves of this article map cleanly onto that diagram:

  • The deterministic half (Section 2), deepscan-core and the CLI: run scanners, normalize their output into one Finding model, deduplicate, render reports.
  • The agentic half (Sections 3–5), deepscan-mcp: the same engine exposed as tools an agent can call, driven by skills that turn raw findings into reviewable pull requests.

Everything shares one Finding model, one (location, message) deduplication, and one set of report formats. That single normalization is what makes the agentic half tractable. The agent reasons over one schema, not fourteen scanner-specific JSON dialects.


2. The deterministic half: scan, normalize, reportπŸ”—

Getting a scan out of itπŸ”—

It’s a uv workspace (Python 3.13). The tools it wraps are downloaded pinned and SHA-256-verified, never curl | bash-ed at runtime:

uv sync
uv run deepscan tools install all   # pinned, checksum-verified binaries

# Scan a directory (static) or a running URL (dynamic), auto-detected:
uv run deepscan scan ./my-project
uv run deepscan scan http://127.0.0.1:3000

Reports land in reports/scan_<timestamp>_<mode>/report.{json,md,html,sarif}. For CI, one flag turns it into a gate:

# exit 1 on any HIGH-or-above finding
uv run deepscan scan ./my-project --fail-on high

20+ scanners, one normalized modelπŸ”—

Under the hood it’s a registry of scanners grouped into families. Each scanner runs in a sandboxed subprocess, emits its native format (mostly SARIF), and gets parsed into the shared Finding model:

FamilyScanners
SASTopengrep, codeql, cisco-ai-skill-scanner
SCAtrivy, osv-scanner
Secretgitleaks, trufflehog, nosey_parker, kingfisher, …
IaCtrivy-misconfig, checkov
Licensetrivy-license
Malwareclamav, yara, mcp-threats
DASTnuclei, zaproxy
Pipelineplumber

Findings are then deduplicated on (location, message) and rendered to six formats from the same in-memory Report: JSON, Markdown, HTML, SARIF 2.1.0, PDF (for the humans who want a document) and JUnit XML (so CI test-report panels in GitLab or Jenkins light up on security findings).

Every scan (CLI, web, or agent-launched) lands in the same history with a per-severity breakdown, downloadable in any of the six formats.

Note. β€œDeterministic” does not mean β€œcorrect”. These tools produce false positives by design. They’d rather over-report than miss. That’s the whole reason the second half of this post exists: something has to separate the signal from the noise, and doing it by hand is where teams burn out.

Cutting the noise deterministically firstπŸ”—

Before any AI touches the results, there’s a deterministic pass that already kills a lot of noise: a work-in-progress, AI-free code-graph engine that builds a call/data-flow graph and runs taint, reachability and choke-point analysis. A SQL injection finding on a function no entry point can reach is still reported, but flagged unreachable, and that flag is evidence the agent can act on later.

# Fast, offline, AI-free code-graph taint analysis:
uv run deepscan scan ./my-project --scanner analysis

A companion reachable-deps does the same idea for SCA: a CVE in a dependency you import and actually reach outranks a CVE in a transitive package nothing calls. The graph is also queryable (endpoint→sink paths, unsanitized sources, vulnerable-dep→critical) and browsable as an interactive graph.

The interactive security graph: taint paths from sources to sinks, with reachability from entry points, all computed without an LLM.


3. The agentic half: the same engine, as MCP toolsπŸ”—

Here’s the key design decision: the agent doesn’t get a new, weaker scanner; it gets the exact same one, over MCP. The engine of Section 2 is exposed as a set of Model Context Protocol tools, so any agent (Claude in your IDE, or a headless CLI in CI) can call it.

Pointing an agent at the serverπŸ”—

Dropping an .mcp.json into a project is all it takes to connect:

{
  "mcpServers": {
    "deepscan": {
      "type": "http",
      "url": "http://127.0.0.1:8000/mcp"
    }
  }
}

The server exposes the engine as tools:

ToolWhat it does
static_scan / dynamic_scanscan an uploaded project or a live URL
scan_family / scan_withnarrow to one family or one scanner
list_scanners / tool_status / install_toolinspect and provision the toolchain
list_graph_questions / query_graphinterrogate the code graph for reachability

A scan launched here isn’t run in the agent’s context. It becomes a job on the shared queue that the worker fleet executes (materialize β†’ all scanners β†’ dedupe β†’ report) and returns inline. The agent orchestrates; the deterministic engine does the work.

An agent orchestrating the MCP tools: check the toolchain with tool_status, upload, static_scan through the job queue, then fetch the consolidated report.

Skills: encoding the workflow, not improvising itπŸ”—

An agent left to β€œgo find security bugs” wanders. The discipline comes from skills, here, three slash-commands that encode the pragmatic loop as an explicit procedure the agent follows every time:

  • /security-eval: scan, triage, and produce a grouped remediation plan. Changes no code.
  • /security-patch: implement that plan: one review branch per group, each with a demonstration test.
  • /security: run both, end to end.

That split matters. Evaluation is read-only and cheap to re-run; patching is the side-effecting step you gate behind human intent. The next two sections walk through each.


4. Triage & prioritization: from findings to a planπŸ”—

/security-eval is where the noise problem gets solved. The skill tells the agent to read every finding together with the deterministic evidence attached to it (the analysis engine’s reachable, reachable_from_entrypoint, entry_kind and call_path fields) and to use query_graph for the questions the static report can’t answer on its own.

Three things happen, in order:

  1. Triage. Findings the graph proves unreachable from any entry point are dropped from the action list, but recorded as candidate false positives with their evidence, never silently discarded. This is the step where 4,000 becomes a couple dozen.
  2. Prioritization. What survives is ranked P0–P3, ordered by risk Γ— ease of fix: a reachable RCE that’s a one-line fix outranks a theoretical medium buried behind three refactors.
  3. Grouping by fix. Findings are grouped by how you’d fix them, so each group is exactly one reviewable PR: all SQL sinks fixable at one shared helper become one group, not one PR per sink; each direct-dependency CVE gets its own group; secrets get a report-only group (the fix is removal + rotation, never committing the secret).

The output is a machine-readable plan the patch step consumes:

{
  "groups": [
    {
      "id": "g1",
      "title": "Parameterize SQL queries in src/python",
      "branch": "security/sql-injection-src-python",
      "severity": "high",
      "kind": "code",
      "files": ["src/python/sql_injection.py"],
      "findings": ["GP-SAST-001"],
      "fix": "Use parameterized queries via one shared safe query helper.",
      "fix_location": "src/python/sql_injection.py:run_query"
    }
  ]
}

The remediation plan: findings grouped into fixes, each confirmed reachable via the taint engine and query_graph, with a concrete fix and a demonstration per group, plus a separate table of candidate false positives, recorded but not patched.

Note what the agent is and isn’t doing here. It is not deciding whether a taint path exists. The deterministic engine did that. It’s doing the judgement-shaped work on top: is this reachable path actually exploitable, what’s the smallest fix that closes the whole class, and what order should a human tackle them in.


5. Remediation: one branch per fix, each proving itselfπŸ”—

/security-patch turns the plan into branches. For each group, from a clean tree: git switch -c <branch>, apply the fix at the single best fix_location (prefer one shared sanitizer over editing every sink), add a demonstration test, commit as fix(security): <title>, and leave the branch for review, never auto-merge. Dependency bumps are done iteratively (bump, then migrate the breaking API usage, then add the pinning tests), one commit each, so the migration is reviewable.

One review branch per group: here yaml.load() β†’ yaml.safe_load() (CWE-502), committed with a standalone demonstration test.

The β€œrm -rf /” problemπŸ”—

This is the part I kept staring at. To prove a fix works, a demonstration test has to show the vulnerable path was exploitable before and is contained after. But the honest proof of a command-injection sink is feeding it something like rm -rf /, and you can watch that fire in a sandbox, and it’s always a little 😱 the first time. You do not want that in a test suite, on a dev laptop, or in CI. I did a dedicated article about this topic the agent payloads canary.

The way out is a hard safety contract baked into the skill, plus a set of inert canary endpoints. The only side effects a demonstration is ever allowed to produce are:

  1. writing a <branch>.demo.txt marker file, or
  2. calling one controlled, owner-operated kubeek.com canary endpoint (an IP-echo, a method beacon, an inert β€œdownload”) via a canary helper that can’t hit anything else.

So instead of rm -rf /, the injected payload is a call to a benign, owned beacon. The test asserts the unfixed path would have reached the canary (capability proven) and the fixed path no longer does (containment proven). Same epistemics, zero blast radius. Everything else (real reverse shells, exfiltration to any other host, destructive commands) is explicitly forbidden, in prose, at the top of the skill:

The safety contract, verbatim from the skill. β€œThe goal is to fix vulnerabilities and demonstrate the fix, never to weaponize them… If you cannot demonstrate something without a real exploit, describe it in prose instead; do not implement the exploit.”

Writing that contract into the skill rather than hoping the model behaves is the difference between a demo and something you’d let near a real repo. (There’s a longer war story here about watching the first payloads fire. I’ll save it for its own post.)


6. Scaling out: CLI, Compose, Kubernetes, and the sandbox problemπŸ”—

The same engine scales from a laptop to an estate without changing the scanners or the report format.

Continuous scanning and a nascent ASPM viewπŸ”—

Beyond ad-hoc scans there’s a Postgres-backed worker fleet (kruncher) that scans whole estates change-driven, plus the beginning of a start of an ASPM dashboard: posture, findings by severity, trend over time, most-exposed sources.

The ASPM dashboard: sources scanned vs at-risk, active-findings trend, by-scope severity, and the most exposed sources, the whole-estate view, still very much a work in progress.

Generating the prevention stepπŸ”—

Detection is only half of DevSecOps; the other half is stopping it recurring. A five-step wizard generates a ready-to-commit CI pipeline for your platform (GitHub Actions, GitLab CI, Bitbucket, Azure, Jenkins…), and the tool list comes straight from the live scanner registry, so the generated pipeline never drifts from what the CLI actually runs.

The CI-template wizard: pick capabilities and exact tools, then copy or download a generated pipeline for your platform.

The uncomfortable part: scanning untrusted code is dangerousπŸ”—

Here’s a detail that’s easy to miss until it bites: resolving a project’s dependencies runs arbitrary package-manager code, and scanners parse hostile input. Scanning an untrusted repo is closer to detonating malware than reading a file. Deployment options escalate accordingly:

DeployIsolationUse when
CLI / Composesubprocess sandbox, pinned toolslocal, trusted code, CI
Kubernetesnon-root, read-only rootfs, all caps dropped, default-deny egressshared / semi-trusted estate
K8s + gVisora user-space kernel (runsc) contains kernel-exploit detonation; dep-resolution runs with no secrets and egress fenced to an internal mirrorgenuinely untrusted repos
# spin up an ephemeral kind cluster and exercise the hardened path end-to-end:
./k8s-test.sh

The riskiest step, dependency resolution, can be split into its own container with no tokens mounted and egress restricted to an internal package mirror, so a hostile build script sees nothing to steal and nowhere to send it.


7. What it cost, and where this is goingπŸ”—

The point of the experiment wasn’t the tool; it was the economics. This is a PoC one person built on night-time and a single month of a personal Claude subscription (~$90 + VAT), running Fable 5 and Opus 4.8. No team, no quarter of roadmap. A few tokens and some evenings.

Deterministic:  20+ scanners Β· 1 Finding model Β· 6 report formats Β· AI-free code-graph
Agentic:        MCP server + 3 skills β†’ triage β†’ P0–P3 β†’ plan β†’ 1 branch/fix + proof
Deploy:         CLI Β· Docker Compose Β· Kubernetes + gVisor Β· continuous fleet Β· ASPM
Cost:           1 dev Β· ~$90 Β· a handful of nights

And this direction isn’t niche. When you see initiatives like Microsoft’s MDASH(link) and peers, the arc is clear: agentic AI is moving toward analyzing code security deeply and merging PR mitigations autonomously. The interesting question is no longer whether an agent can remediate. It’s how you keep it deterministic where it must be (finding, reachability, the safety contract) and agentic only where judgement actually helps (triage, prioritization, the fix).


SummaryπŸ”—

Deep Scan is available here

StepWhat the layer doesHow you verify it
Scan (Β§2)20+ scanners β†’ one Finding model, deduped, 6 formatsreport.sarif opens in any SARIF viewer; --fail-on high gates CI
Reachability (Β§2)AI-free taint/graph flags unreachable findings--scanner analysis; browse the interactive graph
Connect (Β§3)same engine exposed as MCP tools + skillstool_status from the agent; the scan appears as a queue job
Triage & plan (Β§4)drop FPs w/ evidence, rank P0–P3, group by fixreports/remediation-plan.json is one PR per group
Patch (Β§5)one branch per fix + a canary-based proofeach branch’s demo test passes on the fix, trips the canary on the flaw
Scale (Β§6)Compose / K8s+gVisor / continuous fleet / CI gen./k8s-test.sh runs the hardened path in kind

The four principles to remember: normalize once so the agent reasons over one schema; stay deterministic for finding and reachability; encode the workflow as skills instead of improvising it; and make every fix prove itself, safely, behind a contract, never with a live payload.


This is a PoC, not a product: no auth, plenty left to finetune and test, and the code-graph engine is a work in progress. Treat it as a demonstration that the deterministic/agentic split is worth building around, and, honestly, as proof of how far one person can get on a few tokens and a few nights. It was a genuinely fun experiment.