The Scan Pipeline
One scan is a fixed sequence of phases. Each phase adds something the next one needs: discovery produces endpoints, dynamic tests produce findings, code analysis produces more, and the two sides are then matched against each other before the report is written.
One scan, end to end
The twenty phases fall into five stages. Phases skip themselves when their input is missing: no repository means no static analysis, no credentials means no cross-user tests. Press play, or step through with the arrows.
STARTA scan takes up to four inputs: a target URL, a repository, credentials and an LLM API key. Which stages run depends on which of those are present.
1 · DISCOVERPlaywright renders the page and captures every JavaScript bundle. Endpoints are pulled from the bundles, from any OpenAPI spec, from HTML forms, and by probing common API paths. With credentials, the crawler logs in and harvests the IDs the user owns.
2 · DYNAMICSixteen scanners send real requests in parallel, each inside its own timeout. Authenticated scanners run with two sessions so User B can try to reach User A's data. Blind vulnerabilities are caught by polling the out-of-band callback server.
3 · STATICThe repo is cloned and its framework detected. Route mappers list every API route, eighteen analyzers read the code, and a language server traces whether an auth guard really guards. Only high-risk routes are sent to the LLM.
4 · CORRELATEA dynamic finding and a static finding of paired categories confirm each other. Static findings also generate new dynamic tests that would not otherwise have run, and an LLM drops heuristic injection false positives.
5 · REPORTTriage collapses duplicates without an LLM, then enriches, calibrates severity and assigns an A–F grade. The report is written, and optionally turned into code patches and pull requests.
Discovery: finding the attack surface
A scanner can only test an endpoint it knows about. Single-page apps hide their API in JavaScript, server-rendered apps expose it only in forms, and bare REST services may publish nothing but a spec. Four strategies cover all three.
1Playwright loads the target and keeps the rendered HTML plus every JavaScript file it pulled in. The HTML of a single-page app is just a shell; the routes and API calls live in the bundles.
2JS bundles. Scripts come from <script src> and from <link href="….js"> (modulepreload, preload, prefetch). A code-split app ships every lazy route as a <link>, so a script-only sweep sees the shell and none of the routes. Seven regexes then extract fetch calls, axios requests, Supabase from() queries and route tables.
3OpenAPI. Thirteen well-known spec locations are probed for each API base. A spec yields methods, templated {id} path segments and query parameters, which is how a frontend-less REST API becomes testable.
4HTML forms and links. A stdlib HTML parser pulls <form action> targets with their input names, and links that carry query parameters. This is what a classic server-rendered app exposes instead of a bundle.
5Active probing hits common API bases such as /api, /graphql and /rest/v1/, for endpoints nothing else mentioned.
6Each endpoint is categorised (USER_DATA, RESOURCE_CRUD, AUTH, ADMIN, PAYMENT, FILE_ACCESS, PUBLIC) and ranked per attack dimension so the most likely-vulnerable endpoints are tested first. A file upload has no parameters at all, so FILE_ACCESS counts toward INJECTION: that moved /file-upload from 73rd to 6th and uncovered two XXE bugs behind it.
A bundle contains two kinds of path. {path: "/about", component: About} is a route the app's own router renders; fetch(host + "/file-upload") is an endpoint the app calls. Only the second is attack surface, and they are told apart by the request-making token that precedes the path, not by how interesting the path looks.
Parallel scanners and the time budget
Every dynamic scanner runs inside run_scanner_safe() with its own timeout, so one hanging scanner never stops the rest. But a cancelled coroutine cannot return anything, so a scanner that ran one second too long used to lose every finding it held. Scanners now stop themselves.
1Sixteen scanners start together, each wrapped in run_scanner_safe() with a generous per-scanner timeout: 600 s by default, 3600 s for XSS, 5400 s for injection. Time, not an endpoint cap, is the only bound.
2They progress at different rates. Snapshot-based scanners like headers and CORS finish in seconds; the ones that walk every endpoint take longer. All HTTP goes through a rate-limited client so the target's WAF is not tripped.
3http_probe reaches its deadline mid-inventory. It is holding four real findings, among them an exposed /.env.
4The old failure. The hard cancel fired at 901 s. A cancelled coroutine cannot hand anything back, so the four findings were silently lost. A measurement that looked like a tidy trade-off was one bug.
5The fix. The runner publishes its deadline; a TimeBudget() with no argument inherits it. Every scanner checks budget.expired() at the top of its per-endpoint loop and returns early with its findings. Fixed-cost checks run before the ones that scale with inventory.
6Findings from every lane merge. Reaching the hard cancel is now a failure signal that logs loudly, because it means a scanner blocked between budget checks rather than merely ran long.
Probing costs HTTP requests, not tokens. Measured over nine scans, a DAST run cost $0.12–$0.18 whether it covered 59 endpoints or 154. The endpoint was never the scarce thing, so the caps of 20 for XSS, 30 for injection and 5 for CORS, which silently decided which quarter of an app got tested, were removed.
MAX_ENDPOINTS_IN_PROMPT bounds LLM tokens and MAX_OOB_POST_ENDPOINTS bounds registrations against the external callback service. Both limit something genuinely scarce.
Code analysis: clone, map, read, verify
The static side starts from a shallow clone and ends with findings that a language server has tried to disprove. Everything in between is framework-specific plumbing that makes the analyzers language-agnostic.
1The repository is cloned shallow into a temp directory. Vendored and generated directories are pruned by their path relative to the scan root, so a clone under a directory named build or env is still scanned. Symlinked files and anything behind a symlinked directory are never read, and workspace globs that are absolute, contain .. or resolve outside the clone are ignored with a warning.
2package.json and friends identify the framework and backend. A framework-specific route mapper turns files into routes with methods. Express routes are searched everywhere, then filtered to the ones actually reachable through the import graph, so fixture code like Juice Shop's 135 unused route definitions is ignored.
3Eighteen analyzers run against the indexed code at once. The Semgrep taint layer gives a deterministic source-to-sink floor for injection; the rest look for missing auth, missing RLS, leaked secrets, CVEs and infrastructure misconfiguration.
4If a language server is available, every auth guard is resolved to its definition and read. A finding the tracer disproves is removed, matched back per route, so one guarded route in a file of a hundred cannot vouch for the rest.
5Only routes selected by one of five triggers reach the LLM. Financial routes always go; after that, routes flagged by two scanners, mutations, risk patterns, and shared helpers with high fan-in from risky routes.
Correlation: proving it from both sides
A dynamic finding says the app misbehaved. A static finding says the code looks wrong. When the two categories form a known pair, a confirmed finding is added and its severity rises. The pairing is by category plus a shared target (a distinctive word from the endpoint in the SAST file path or text, or the same leaked token); aligning an endpoint to its route file is the job of the guided-DAST matcher. isitsecure also runs the relationship forward: a code finding becomes a new dynamic test.
1Two independent findings arrive: the dynamic scanner read another user's task by swapping an id, and the static analyzer found no auth check in a route file.
2The cross-referencer pairs them by category and shared target: IDOR + missing auth, RLS bypass + missing RLS, injection + injection risk, secret exposure + secret in code. Same-category findings with no shared endpoint word stay separate. Each SAST finding is consumed once. The confirmed finding is added alongside the originals with higher confidence and boosted severity.
3Some static findings have no dynamic counterpart because no scanner thought to try. Here the schema exposes an isAdmin column on a route that accepts a body.
4SAST-guided DAST turns that into a test: POST {"isAdmin": true} to the endpoint. Six strategies exist (auth bypass, targeted IDOR, targeted injection, mass assignment, race condition, RLS bypass). Commercial tools match after the fact; this generates tests that would not otherwise run.
The full mechanics, including the injection adjudicator and the triage funnel, have their own pages: SAST ↔ DAST correlation and Triage and grading.
What runs with what you provide
The scanner works at any completeness level. Toggle the inputs to see which phases run, which fall back, and which are skipped. A missing input never fails the scan, with one exception: a repository that fails to clone exits non-zero, because a zero-finding report must never read as "your code is fine". A scanner that times out or crashes is recorded in scanner_errors; the verdict becomes "scan incomplete" and the grade provisional unless critical or high findings already decide it.
Data flow
The same pipeline drawn as data rather than time. Three parallel branches feed one merge point, and nothing after the merge talks to the target or the repository again.