Architecture Deep Dive
The pipeline page shows what a scan does. This page shows how it runs: the three entry points that share one engine, how the engine is wired, the single state object every phase reads and writes, how scanners run in parallel without losing their work, and how progress and findings flow out to a terminal, a browser, or an AI coding tool.
Three entry points, one engine
The CLI, the web UI's server and the MCP server never contain scanning logic. Each one builds an agent through the factory, calls scan(), and consumes the events it yields. The engine does not know which of the three is listening.
1Three front doors. The CLI command, the FastAPI server behind the web UI, and the local MCP server used by AI coding tools. None of them scans anything themselves.
2Each one calls the same factory function with the options it has: an LLM client or none, quick or deep depth, whether writes may be probed, and an auth provider if credentials were given.
3The factory instantiates every concrete scanner, strategy and service and injects them into one DeepSecurityScanAgent. The agent itself only knows protocols, never concrete classes.
4scan() is an async generator. It builds a ScanContext, then runs the phases in a fixed order, forwarding each phase's events as they happen. Two phases can abort the whole scan: URL ingestion and repo ingestion.
5Every consumer reads the same stream. The CLI prints phase headers and per-scanner lines, the server relays it as server-sent events (behind a Host and Origin guard that answers 421 or 403 to anything that is not the local UI), and the MCP server keeps the final report in an in-process cache keyed by scan_id. The final COMPLETE event carries the whole report as JSON.
How the agent is wired
Dependency injection happens in one place. The scanner lists are plain Python lists, so adding a scanner is appending to a list. Three things are conditional: anything LLM-powered needs a client, three aggressive scanners need deep depth, and the language server is auto-detected and replaced by a no-op when absent.
1The agent is a container of slots. Each slot is typed by a protocol, so the agent can be unit-tested with fakes and never imports a concrete scanner.
2The factory fills the unconditional slots: ingestion, discovery, the two scanner lists, the four special scanners with their own signatures, the cross-referencer, the guided-DAST runner with its six strategies, and the repo and auth services.
3With an LLM client four more objects exist: the code reviewer on the planning model, and triage, the injection adjudicator and the semantic rule verifier on the cheaper judgment model. Without one, these slots are None and their phases are no-ops.
4Deep depth appends the three slow or aggressive scanners to the DAST list and switches the injection scanner into time-based mode. Quick depth is the default and finishes in a fraction of the time.
5The language server client is detected from what is installed. If nothing usable is found the slot holds a no-op client, so the validation phase runs and does nothing rather than failing.
What each phase reads and writes
Every phase is a method taking one ScanContext. Its 27 fields are the only state that crosses a phase boundary, which is what makes the order of phases checkable: a phase can only depend on fields an earlier phase wrote. This grid was generated from the agent's source, not drawn by hand. Step through it to watch the context fill up.
R the phase reads the field. W the phase sets or appends to it. RW it rewrites what it read: validation removes disproved findings, adjudication drops benign ones, triage deduplicates. Two phases, cross-probe analysis and injection adjudication, read nothing but the findings list itself. The inputs on the top rows are written once, by scan(), before the first phase.
Parallel scanners that narrate and yield
Phases 4 and 7 run their scanner lists concurrently. Two mechanisms make that safe to watch and safe to stop: an ambient progress reporter that scanners write to without holding a reference to the agent, and a cooperative deadline each scanner inherits so it can return its findings before the hard timeout cancels it.
1The agent announces each scanner as it launches and schedules it as a task. All of them share one asyncio event loop; concurrency comes from awaiting I/O, not threads.
2Sixteen tasks are now in flight. Each wraps its scanner's scan() coroutine, and each scanner sends its HTTP through the shared rate-limited client.
3run_scanner_safe() is the wrapper. It publishes the scanner's deadline into a context variable, awaits with a hard timeout, and turns any timeout or exception into an empty result and a log line. One scanner failing never fails the scan.
4Inside the scanner, TimeBudget() with no argument picks up that deadline at 90% of the timeout. The per-endpoint loop checks expired() and returns what it has. This is why a hard cancel is now a loud failure signal rather than the normal way a scanner ends.
5Scanners narrate through emit(), which posts to the ambient reporter's queue. When a task completes it posts a done marker with its finding count. No scanner holds a reference to the agent.
6The agent drains the queue until every task has reported done, yielding an event per message, then gathers the tasks and concatenates their findings in list order. The same shape runs the SAST list in phase 7.
The event stream, as the CLI shows it
A DeepScanEvent has four fields: phase, message, progress and a data dict. The CLI's renderer only looks at three things in the data: a status of start or done, a scanner name, and whether a report is present. Everything else is a sub-step line.
1A new phase value produces a header line. The CLI tracks the last phase it printed, so a phase emits one header no matter how many events it yields.
2An event whose data says status: start prints an in-flight arrow for that scanner. They appear together because the launches happen before any scanner has had a chance to run.
3Any other message inside the current phase is a sub-step. These come from scanners calling emit() deep inside their loops, which is what keeps a long scan legible.
4status: done prints the tick with the finding count, or "clean". The progress number is computed from how many scanners have finished, not from time.
5The last event carries the report under data["report"]. The CLI validates it back into a DeepScanReport and renders the table, JSON, HTML, SARIF or fix plan the user asked for. If no report arrives, the last error message is printed instead of a shrug.
How a finding is assembled
One model, DeepFinding, carries every result regardless of where it came from. Different stages fill different field groups, so a finding's shape tells you its history.
1Every finding starts with identity: which scanner raised it, its source (DAST, SAST, LLM, guided, cross-reference), category, severity, confidence, and the three text registers: plain description, technical detail, remediation.
2A dynamic finding attaches the request it sent and the response it got. Borderline injection findings also keep the baseline response, which is what the adjudicator later compares against. Probe captures make the finding reproducible with curl.
3A static finding attaches a code location. When the repository is remote the location includes a permalink at the scanned commit, so the report links straight to the line.
4Correlation stages link findings by id rather than merging them: a confirmed cross-reference finding and a guided-DAST finding both point back at what produced them.
5Triage fills the last group. Impact and likelihood come from the model, priority from the matrix, and each finding is assigned to a theme the owner summary groups by.
Protocols: what a new scanner has to be
The engine depends on interfaces, not implementations. Each is a typing.Protocol, so a class satisfies it by shape with no inheritance.
| Protocol | Lives in | Shape | Implemented by |
|---|---|---|---|
DASTScannerProtocol | engine/scanners/protocols.py | scanner_name, scan_categories, async scan(endpoints, snapshot) → list[DeepFinding] | 16 standard DAST scanners (+3 at deep) |
CodeScannerProtocol | engine/code_analysis/protocols.py | async scan(repo_snapshot) → list[CodeFinding] | 18 SAST analyzers (+1 with an LLM) |
RouteMapperProtocol | engine/code_analysis/route_mapper.py | files → list[RouteEntry] with methods, pattern, has_auth_check, handler_source | Next.js, Express, tRPC, GraphQL, Django, FastAPI, Spring, Go |
GuidedDASTStrategy | engine/guided_dast/protocols.py | SAST finding → generated request → finding | 6 strategies |
AuthProviderProtocol | engine/auth/protocols.py | credentials → AuthSession (headers, cookie, user id) | Supabase, Firebase, browser, REST token |
LLMClientProtocol | llm/protocol.py | prompt in, text out, token accounting | Anthropic, Google adapters |
Write a class with the three members above, append it to dast_scanners in the factory, and check budget.expired() at the top of any loop over endpoints. Add a doc under docs/scanners/. Nothing in the agent changes.
IDOR, JWT, RLS deep and privilege escalation need sessions, keys or two users, so their scan() signatures differ from the protocol. They are injected individually and called by the phases that hold the inputs they need.
Package map
isitsecure/
├── cli/ scan · fix · badge · launch · mcp · setup (Typer)
│ └── run.py drives agent.scan() and narrates the log
├── server/app.py FastAPI: POST /api/scan → SSE; bundled UI in static/
├── mcp_server.py stdio MCP: scan · explain · fix · verify · export · scan_url
├── llm/ LLMClientProtocol + Anthropic / Google adapters
└── engine/
├── agent.py the 19 phases and scan()
├── scan_context.py the 27 fields that cross a phase boundary
├── factory.py all wiring; the only place concrete classes meet
├── models.py enums.py DeepFinding, DeepScanReport, DeepScanEvent …
├── identity.py stable finding fingerprint
├── suppression.py .isitsecureignore
├── baseline.py show only new findings
├── reverify.py per-finding re-check
├── cross_referencer.py DAST ↔ SAST pairing
├── ingestion/ URL snapshot (Playwright → httpx fallback)
├── scanners/ DAST scanners + endpoint discovery + crawler
├── code_analysis/ SAST analyzers, route mappers, lsp/, semgrep_rules/
├── guided_dast/ route↔endpoint matcher, runner, 6 strategies
├── auth/ Supabase · Firebase · browser · REST token providers
├── shared/ rate limiter, OOB callbacks, time budget, progress
├── triage/ LLM triage, priority matrix, injection adjudicator
├── reporting/ JSON · HTML · SARIF · plain-English grading
└── fixes/ safety net, apply, verify, PR flow
scanners/ and code_analysis/ contain only things that produce findings. shared/ contains only things more than one scanner needs. triage/ and reporting/ never talk to a target or a repository. factory.py is the only module allowed to import everything.
- A scanner found nothing it should have: its module, then
endpoint_discovery.py(did it know the endpoint?). - A scan silently lost findings:
shared/scanner_runner.pyand the scanner's budget checks. - A false positive survived:
triage/injection_adjudicator.pyorcode_analysis/lsp/. - Dedup merged too much or too little:
RemediationScopeinenums.py.
Graceful degradation
The engine runs at any completeness level. The interactive version of this table, where you toggle inputs and watch phases switch off, is on the pipeline page.
| Missing | What happens |
|---|---|
| LLM API key | Rule-based scanners only. No code review, semantic verification, adjudication or triage enrichment. Dedup still runs. |
| Playwright | URL ingestion falls back to httpx. No authenticated browser crawl, no DOM XSS. |
| Language server for the project's language | LSP validation skipped. More false positives on missing-auth. A server for a different language is never substituted. |
| Repository URL | SAST, cross-reference and guided DAST skipped. DAST-only scan. |
| Repository fails to clone | Code-only: scan stops, exit 1, no report. Full: DAST findings kept, the reason recorded in ingestion_errors, exit 1. A zero-finding report must never read as clean: the launch verdict shows “scan incomplete” and the grade is labelled provisional. The same applies when a scanner times out or crashes (recorded in scanner_errors). |
| Scanner times out or crashes | The rest continue; recorded in scanner_errors; verdict “scan incomplete”, grade provisional; exit code unchanged. |
| Target URL | SAST-only scan. |
| Credentials | Authenticated scanners skipped. No cross-user IDOR, no privilege escalation. |
semgrep binary | Taint analyzer no-ops. Injection detection rests on the LLM layer. |