Architecture

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.

3 entry points1 async generator19 phases27 context fields6 protocols

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.

CLIisitsecure scan URL --repo … Web UI serverPOST /api/scan → SSE stream MCP serverscan(path) · scan_url(url) factory.pycreate_deep_security_scan_agent(llm, depth,probe_writes, auth)all concrete classes live here DeepSecurityScanAgentasync def scan(…):ctx = ScanContext(mode…)async for e in phase(ctx):yield e…× 19 phasesyield COMPLETE(report)an async generator DeepScanEventphasemessageprogress 0–100data {…} CLI: live log linesserver: text/event-streamMCP: cache by scan_idlast event carries the report

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.

DeepSecurityScanAgent slots ingestion_service endpoint_scanner dast_scanners[16 + 3] sast_scanners[18 + 1] idor · jwt · rls_deep · priv_esc guided_dast_runner[6] cross_referencer repo_ingestion_service auth_provider llm_code_reviewer llm_triage · injection_adjudicator judgment_llm_client lsp_client (or NoOp) every slot is a protocol type if llm_client LLMCodeReviewer(planning model) LLMTriageService(judgment model) InjectionAdjudicator(judgment model) SemanticRuleVerifier → appended to SAST judgment model falls back to the primary if depth == DEEP + RateLimit · AuthBypass · PasswordReset injection gets time-based probes; XSS adds DOM pass _create_lsp_client() detects Node / pyright / jdtls / gopls absent → NoOp client, validation skipped Always on URLIngestionService (Playwright → httpx)EndpointDiscoveryScanner(probe_writes)IDORScanner(probe_writes)JWTScanner · RLSDeepScannerPrivilegeEscalationScannerFindingCrossReferencerSASTGuidedDASTRunner([6 strategies])16 DAST + 18 SAST scanners Open/closed: a new scanner is oneclass plus one list entry. No agentcode changes. Special scanners have non-standardscan() signatures (sessions, keys),so they get named slots instead.

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.

Reading the grid

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.

agent: launchfor s in dast_scanners:yield "running s…"ensure_future(_run(s)) task · xss_scanner task · injection_scanner task · http_probe_scanner … 16 tasks, one event loop run_scanner_safe(name, coro, timeout) with scanner_deadline(timeout): await wait_for(coro, timeout) except TimeoutError: warn, return [] except Exception: log, return [] deadline = contextvar · usable = 90 % of timeout inside the scanner budget = TimeBudget() # inherits it for ep in endpoints: if budget.expired(): break ProgressReporter.queue emit("probing /api/x") · ("__done__", n) agent: drain loop while done < total: msg, data = await queue.get() if data["_scanner_done"]: done += 1 yield "name: k findings (done/total)" else: yield msg # scanner narration await gather(*tasks) The reporter is a contextvar. Tasks capture the context at creation, so every scanner's emit() lands in this queue with no wiring. Progress advances per scanner finished, so one slow scanner running alone still narrates instead of looking hung.

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.

events yielded DAST_SCANNING "Running scanners" 25 … {scanner: xss, status: start} … "probing /api/users?q=" (emit) … {scanner: xss, findings: 3, status: done, done: 1, total: 16} COMPLETE "…" 100 {report: {…}} progress = base + band × done/total so the bar moves per scanner finished 12.4s ▶ Running 16 dynamic scanners 12.4s → xss_scanner… 12.4s → injection_scanner… 19.0s · probing /api/users?q= 41.2s ✓ xss_scanner — 3 finding(s) 42.8s ✓ cors_scanner — clean 1793.5s ▶ Compiling report report captured from the COMPLETE event; not printed as a log 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.

DeepFinding id · source · scanner_name · confidencecategory · severity · title · descriptiontechnical_detail · evidence · remediation_guidance endpoint_url · http_method · request_payloadresponse_preview · baseline_response_previewprobe_captures[] (curl-reproducible, secrets redacted) code_location { file_path, line_number, snippet, github_url } related_finding_ids[] impact · likelihood · priority 1–4theme_id fields a stage did not fill stay None or empty 1 · the scanner that found it (DAST or SAST) 2 · DAST evidence: the request, both responses, captures 3 · SAST location, with a GitHub permalink when the repo is remote 4 · cross-reference and guided DAST link findings to each other 5 · triage adds impact, likelihood, priority and a theme A stable fingerprint (identity.py) survives re-scans, so baselines and suppressions work.

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.

ProtocolLives inShapeImplemented by
DASTScannerProtocolengine/scanners/protocols.pyscanner_name, scan_categories, async scan(endpoints, snapshot) → list[DeepFinding]16 standard DAST scanners (+3 at deep)
CodeScannerProtocolengine/code_analysis/protocols.pyasync scan(repo_snapshot) → list[CodeFinding]18 SAST analyzers (+1 with an LLM)
RouteMapperProtocolengine/code_analysis/route_mapper.pyfiles → list[RouteEntry] with methods, pattern, has_auth_check, handler_sourceNext.js, Express, tRPC, GraphQL, Django, FastAPI, Spring, Go
GuidedDASTStrategyengine/guided_dast/protocols.pySAST finding → generated request → finding6 strategies
AuthProviderProtocolengine/auth/protocols.pycredentials → AuthSession (headers, cookie, user id)Supabase, Firebase, browser, REST token
LLMClientProtocolllm/protocol.pyprompt in, text out, token accountingAnthropic, Google adapters
Adding a DAST scanner

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.

Why special scanners have named slots

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
One rule per directory

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.

Where to look when something is wrong
  • 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.py and the scanner's budget checks.
  • A false positive survived: triage/injection_adjudicator.py or code_analysis/lsp/.
  • Dedup merged too much or too little: RemediationScope in enums.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.

MissingWhat happens
LLM API keyRule-based scanners only. No code review, semantic verification, adjudication or triage enrichment. Dedup still runs.
PlaywrightURL ingestion falls back to httpx. No authenticated browser crawl, no DOM XSS.
Language server for the project's languageLSP validation skipped. More false positives on missing-auth. A server for a different language is never substituted.
Repository URLSAST, cross-reference and guided DAST skipped. DAST-only scan.
Repository fails to cloneCode-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 crashesThe rest continue; recorded in scanner_errors; verdict “scan incomplete”, grade provisional; exit code unchanged.
Target URLSAST-only scan.
CredentialsAuthenticated scanners skipped. No cross-user IDOR, no privilege escalation.
semgrep binaryTaint analyzer no-ops. Injection detection rests on the LLM layer.