Architecture

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.

20 phases16 DAST scanners18 SAST scannersLLM review + triage

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.

1 · Discover What is there to test? 1URL ingestionrender page + capture JS 2Endpoint discoveryJS · spec · forms · probing 3Authenticated crawllogin · pages · owned IDs 3.5OOB registrationcallbacks for blind SSRF 2 · Dynamic (DAST) Real requests to the live app 416 scanners in parallelXSS · SQLi · CSRF · SSRF… 5Authenticated DASTJWT · RLS · BOLA · privesc 5.5Probe analysispatterns across HTTP pairs 5.6OOB collectiondid anything call back? 3 · Static (SAST) Read the code without running it 6Repo ingestionclone · framework · routes 6.5Language servertsserver · pyright · jdtls 718 scanners in paralleltaint · auth · RLS · keys 7.5LSP validationtrace auth, drop FPs 8LLM code reviewhigh-risk routes only 4 · Correlate Prove it from both sides 9Cross-referenceDAST ↔ SAST on one route 9.1SAST-guided DASTcode findings → new tests 9.2LLM business logicplanned attacks on flows 9.4Injection adjudicationbaseline vs injected body 5 · Report Fewer, clearer, graded 9.5Triagededup · enrich · grade 10DeepScanReportJSON · HTML · SARIF · MD 11Fix generationoptional: patches + PRs

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.

Target app https://app.example shell HTML + bundles forms · spec · /api JS bundles <script src> and <link href=…js> 7 regexes: fetch, axios, from(), routes OpenAPI / Swagger 13 well-known spec paths per API base methods, {path} params, query params HTML forms & links <form action> + field names as params <a href> links carrying ?query Active probing /api · /graphql · /rest/v1/ … paths that no bundle mentions Endpoint inventory GET /api/Users GET /api/Feedbacks POST /api/orders/{id} GET /api/items?q= POST /login POST /profile POST /graphql GET /rest/v1/tasks categorised · ranked Ranked per attack INJECTION · IDOR · XSS CSRF · AUTH 1 /file-upload 2 /api/orders/{id} 3 /rest/v1/tasks 4 /api/Users tested first, within budget Juice Shop, url-only: 61 → 123 endpoints once <link> chunks were read

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.

Routes vs endpoints

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.

scannerprogress toward its own timeoutfindings xss3600s3 injection5400s5 csrf600s1 cors600s0 ssrf600s1 headers600s4 upload600s2 http_probe900s deadline 4 held 0 ✕ 4 ✓ http_probe at 899 s, holding an exposed /.env and three more findings Before: hard cancel at 901 s. A cancelled coroutine returns nothing. Four findings discarded. Now: the scanner sees budget.expired() in its endpoint loop and returns the four findings itself. Every lane completes or yields. The hard cancel is now a loud failure signal, not how scanners end.

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.

Why endpoint caps are gone

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.

Two caps that stay

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.

Shallow clonetemp dirnode_modules skipped Understand the repo FrameworkNext.js · Express · Spring BackendSupabase · Prisma · Drizzle Route mapperapp/api/**/route.ts → GET File indexsource read into memory 18 analyzers, parallel semgrep_taint source→sink route_auth missing guards rls_policy Supabase RLS git_secret history scan middleware coverage gaps dependency ×3 CVEs + OSV docker · k8s · iac prisma · drizzle · firebase mass_assignment · injection shell_script · openapi… LSP validation go-to-definition on each guard missing auth? guard → definition auth found → drop matched per route, not per file LLM review 5 triggers pick routes worth the cost 0 financial1 cross-flagged2 mutation3 risk pattern

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.

DASTIDOR on /api/tasks/{id}swapped id → another user's task SASTMissing auth · tasks/[id]/route.tsno getUser / ownership check CONFIRMED IDORcategory pair, both sidesconfidence ↑ · severity boosted SAST ONLYSchema has isAdmin · POST /api/usersno DAST test ran against this field GENERATED DAST TESTPOST {"isAdmin": true}one of six SAST→DAST strategies

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.

Inputs

    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.

    URL · repo · credentials URL ingestion + endpoint discovery DAST scanners16 in parallel Authenticated DASTJWT · IDOR · RLS · priv-esc Repo ingestionclone + index SAST scanners18 in parallel · LSP · LLM review Cross-reference → guided DAST Triage → DeepScanReport