Deep Scan: pairing deterministic scanners with an agentic remediation loop
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-coreand the CLI: run scanners, normalize their output into oneFindingmodel, 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 high20+ 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:
| Family | Scanners |
|---|---|
| SAST | opengrep, codeql, cisco-ai-skill-scanner |
| SCA | trivy, osv-scanner |
| Secret | gitleaks, trufflehog, nosey_parker, kingfisher, β¦ |
| IaC | trivy-misconfig, checkov |
| License | trivy-license |
| Malware | clamav, yara, mcp-threats |
| DAST | nuclei, zaproxy |
| Pipeline | plumber |
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:
| Tool | What it does |
|---|---|
static_scan / dynamic_scan | scan an uploaded project or a live URL |
scan_family / scan_with | narrow to one family or one scanner |
list_scanners / tool_status / install_tool | inspect and provision the toolchain |
list_graph_questions / query_graph | interrogate 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:
- 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.
- 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. - 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:
- writing a
<branch>.demo.txtmarker file, or - calling one controlled, owner-operated
kubeek.comcanary 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:
| Deploy | Isolation | Use when |
|---|---|---|
| CLI / Compose | subprocess sandbox, pinned tools | local, trusted code, CI |
| Kubernetes | non-root, read-only rootfs, all caps dropped, default-deny egress | shared / semi-trusted estate |
| K8s + gVisor | a user-space kernel (runsc) contains kernel-exploit detonation; dep-resolution runs with no secrets and egress fenced to an internal mirror | genuinely 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
| Step | What the layer does | How you verify it |
|---|---|---|
| Scan (Β§2) | 20+ scanners β one Finding model, deduped, 6 formats | report.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 + skills | tool_status from the agent; the scan appears as a queue job |
| Triage & plan (Β§4) | drop FPs w/ evidence, rank P0βP3, group by fix | reports/remediation-plan.json is one PR per group |
| Patch (Β§5) | one branch per fix + a canary-based proof | each 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.