Architecture · phases 1 to 3

Endpoint Discovery

A scanner can only test an endpoint it knows about. This page follows one target from its first HTML response to a ranked inventory: which files get read, which paths count as attack surface, where methods and parameters come from, and what an authenticated crawl adds.

DAST input4 sources7 regex families5 ranking dimensions--probe-writes

Reading a code-split single-page app

The served HTML of a single-page app is a shell. The routes and the API calls live in JavaScript, and a bundler splits that JavaScript into an entry bundle plus lazy chunks. Which of those files the scanner fetches decides what the rest of discovery can see.

Shell HTML, as rendered <!doctype html> <script src=main.js> <script src=runtime.js> <script src=polyfills.js> <link rel=modulepreload href=c1.js> <link rel=preload href=c2.js> <link rel=prefetch href=c3.js> … 7 more <link … href=cN.js> <div id=app></div> empty shell: no routes, no API calls here 13 JavaScript files 3 named by <script src> main.js runtime.js polyfills.js 10 named only by <link href=….js> c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 not fetched yet fetched: 3 of 13 unseen: 10 of 13 fetched: 13 of 13, in document order /api/Users and /api/Feedbacks are called only from these chunks: no regex sees them. match <link href="….js"> on the href, whatever the rel: it exists to load that JS. Url-only inventory 61 123 endpoints on Juice Shop GET /api/Users GET /api/Feedbacks + 60 more from the chunks same regexes, twice the input Playwright renders the page; the HTML and every JavaScript URL it references are kept for the next steps. Script-only sweep: SCRIPT_SRC_PATTERN finds the three <script src> files. The ten <link> chunks are never requested. A scanner cannot test an endpoint nothing told it about: the users and feedback APIs are simply absent from the scan. JAVASCRIPT_REF_PATTERN walks the document once, scripts and chunks interleaved, so a script-heavy page cannot starve the chunks out of the asset cap. Measured on Juice Shop, url-only, same regexes: 61 endpoints before, 123 after. Every later phase tests what this inventory contains.

1Playwright renders the target and keeps the HTML. For a single-page app that HTML is a shell: an empty <div id=app>, a few <script src> tags, and a list of <link> tags that preload the lazy route chunks.

2A script-only sweep fetches what <script src> names: on Juice Shop, three files. The route chunks are referenced only by <link rel="modulepreload|preload|prefetch">, so nothing fetches them.

3The gap: 10 of Juice Shop's 13 JavaScript files never reached the regexes, and with them /api/Users and /api/Feedbacks. A scanner cannot test an endpoint nothing told it about.

4The fix matches on the href rather than on a list of rel values: a <link> whose href ends in .js or .mjs exists to load that JavaScript. Scripts and chunks are collected in document order, de-duplicated, so the asset cap truncates the tail of the page rather than all of the chunks.

5Url-only discovery on Juice Shop went from 61 endpoints to 123, with no change to the regexes themselves.

Routes vs endpoints inside a bundle

A bundle names two kinds of path. The ones the app's own router renders are pages; the ones the app sends requests to are endpoints. Only the second is attack surface, and the difference is visible in how each is written.

routes: [{path: "/about" , component: About}, {path: "/contact", component: Contact}] this.uploader = new Es({ url: Z. hostServer + "/file-upload" , maxFileSize: 1e5, …}) ← no request token: dropped ← url: and hostServer: kept Which quoted path is attack surface? The route regex matches both. What decides is the 60 characters before each match: a request-making token keeps the path as an endpoint, anything else drops it as a page. No vocabulary of interesting names is involved. Router route /about the app's router renders it; nothing is requested from the server at that path Endpoint, kept GET /file-upload · requested_path the app makes a request to it; method unknown from a bundle, so GET for now The old filter: a name list /dashboard /api /apps /admin /account kept 11 of 55 Juice Shop paths and discarded, among 44 others: /file-upload /dataerasure /profile /data-export a name list only recognises vocabulary someone thought of in advance Four file-upload vulnerabilities sit behind /file-upload dropped: unreachable, however good the file-upload scanner was kept: in the inventory, categorised FILE_ACCESS, ranked, tested

1Two lines from a minified bundle. {path: "/about", component: About} is a route the app's own router renders. new Es({url: Z.hostServer + "/file-upload"}) is an endpoint the app sends a request to.

2The route regex matches any quoted path of three or more characters. For each match the scanner looks back 60 characters for a request-making token: fetch, axios, XMLHttpRequest, .open, .get/.post/.put/.patch/.delete, url:, baseURL, hostServer, apiUrl, endpoint. Before "/about" there is only {path:, so it is dropped.

3Before "/file-upload" sit url: and hostServer. Kept, as GET /file-upload with source requested_path. The window is deliberately short: widening it starts catching the previous statement's call.

4This replaced a list of interesting-looking directory names. On Juice Shop that list kept 11 of 55 paths and discarded /file-upload, /dataerasure, /profile and /data-export. The filter now reads how a path is used, which needs no vocabulary.

5Four file-upload vulnerabilities sit behind /file-upload. With the path dropped they were unreachable no matter how good the file-upload scanner was; with it kept, it enters the inventory and the ranking in section 06.

# a path was kept if it started with a word someone thought of
INTERESTING = ("/dashboard", "/api", "/apps", "/admin", "/account")
for route in re.findall(ROUTE_PATH_PATTERN, content):
    if not route.startswith(INTERESTING):   # /file-upload: dropped
        continue
    add(route)

Seven regex families over the bundle

The HTML and every fetched JavaScript file are concatenated and run through seven families of patterns. Each match is resolved against the target, filtered, and stored under a METHOD:url key so the same endpoint found twice is kept once.

family what it matches in the bundle endpoint added source_pattern fetch() fetch("/api/Users")fetch("/api/Feedbacks", {method: "POST"}) GET /api/UsersPOST /api/Feedbacks fetchfetch_with_method axios axios.post("/rest/user/login", body) POST /rest/user/loginaxios XMLHttpRequest xhr.open("PUT", "/api/Users/1") PUT /api/Users/1xhr API path literals "/rest/products/search"`${server}/rest/basket` GET /rest/products/searchGET /rest/basket api_pathtemplate_api_path Supabase supabase.from("profiles").select()supabase.rpc("get_me") GET /rest/v1/profilesPOST /rest/v1/rpc/get_me supabase_fromsupabase_rpc Parameterised paths "/users/:id""/items/{id}" GET /users/:id path: idGET /items/{id} path: id parameterized_path Route definitions in request context new Es({url: Z.hostServer + "/file-upload"}) GET /file-uploadrequested_path

1fetch. fetch("url") is a GET. fetch("url", {method: "POST"}) carries its method, which is the only place a bundle states one explicitly. A method the map does not know falls back to GET.

2axios. axios.get/post/put/patch/delete("url"): the method is the function name.

3XMLHttpRequest. .open("METHOD", "url") gives both the method and the path.

4API path literals. Any quoted path under /api, /rest, /graphql or /v1, plus the same shapes inside a template literal after a ${…} interpolation. Both become GET for now.

5Supabase. .from("table") becomes GET /rest/v1/table, .rpc("fn") becomes POST /rest/v1/rpc/fn. The project URL and anon key found in the same bundle are used later to read the project's own OpenAPI listing of tables.

6Parameterised paths. /users/:id and /items/{id} declare a path parameter, so the endpoint is created with has_path_params set and the parameter named. IDOR and per-parameter injection need exactly that.

7Route definitions in request context, the pass from section 02. Every match goes through the same gate: _resolve_url drops unresolved template literals and resolves relative api/, rest/, graphql/, v1/ paths against the target; _should_include drops static assets, /_next/-style prefixes and known third-party hosts.

Spec, forms and probing: the three other sources

Regexes over a bundle find what a JavaScript frontend calls. A REST API with no frontend, a server-rendered app with no bundle, and an API base no bundle mentions each need their own source. All four feed the same inventory.

OpenAPI / Swagger spec 13 well-known paths, per API base: /openapi.json /swagger.json/v3/api-docs /v2/api-docs/swagger/v1/swagger.json … 200 with a paths object, parsed: GET /users/{id} path: id GET /items query: q POST /orders PUT /orders/{id} real methods, {templated} ids, query params · first spec per host wins HTML forms and links <form action="/search" method="get"> <input name="q"> <a href="/items?sort=price"> <form action="/login" method="post"> email · password · hidden csrf GET /search [q] GET /items [sort] POST /login [email, password] stdlib HTMLParser · hidden and submit inputs skipped · links without ?params are pages, not endpoints Active probing on the target and any api./backend. host named in the bundle: GET /api 200 json kept GET /graphql 200 json kept GET /users 404 html dropped GET /me 200 html dropped GET /rest/v1/ on a Supabase project: its OpenAPI lists every table kept only when status < 400 and the body is JSON: an HTML 200 is a page One inventory, keyed METHOD:url then /collection/1 twins for each REST collection, so IDOR and per-parameter injection have an object-level target

1OpenAPI. For each API base the scanner tries the well-known spec locations (13 per the architecture doc). A 200 whose body parses as JSON with a paths object yields every declared operation with its real method, path parameters including {templated} segments, and query parameters. This is how a REST API with no browsable frontend becomes testable.

2HTML forms and links. A standard-library HTMLParser turns each <form action> into an endpoint at its resolved action, GET or POST by the form's method, with its named input/select/textarea fields as parameters; submit, button and hidden inputs are skipped. An <a href> counts only when it carries query parameters. If JS and spec discovery found fewer than 10 endpoints, a bounded same-origin crawl of up to 15 pages follows links to more forms.

3Active probing. Common API paths such as /api and /graphql are requested on the target origin and on any api./backend. host found in the bundle. A probe is kept when the status is below 400 and the body is JSON by content type or by its first character. A Supabase project URL is probed at /rest/v1/, whose OpenAPI listing names every exposed table.

4Everything merges under a METHOD:url key. Then each GET collection such as /api/Products gets an object-level twin /api/Products/1 with a path parameter named id. Only after that are parameters detected and categories assigned.

The authenticated crawl

With credentials, a headless browser logs in through the real login form and walks the app behind it. This adds the endpoints that only appear after login, the session every later scanner reuses, and the ids the authenticated user owns.

Login page <input name="q" type="text"> search <form action="/login"> <input name="userName" type="text">alice@example.com <input name="pw" type="password">•••••••••••• Sign in 1 the visible password field 2 its closest("form") is the scope 3 first visible text/email/tel input in it the header search box is outside the form: ignored real input events, so framework-controlled fields register session read back: token from localStorage/sessionStorage, or the Authorization/cookie headers of intercepted calls What the crawl adds after login: endpoints only visible when logged in, the session every scanner reuses, and the ids this user owns for the IDOR pass. Network interception page.on("response"): API-looking URLs /api/ /rest/v1/ /rpc/ /graphql /trpc/ … GET /api/me 200 GET /api/tasks 200 ids 42,43 POST /api/tasks 201 id 44 GET /rest/v1/profiles 200 7f3a… GET /api/orders?user=7 200 id 9 request headers + body, response status, 2000-char preview, ids pulled from JSON keys named id / *_id (up to 500 calls) BFS over pages queue: app routes + common auth paths (/dashboard /profile /settings /billing …) ✓ /dashboard ✓ /settings → /orders /tasks/42 /account/billing ≤ 50 pages · ≤ 30 links per page · forms and ?links read from each page's HTML · new API calls land in the column to the left Owned resource ids owned_resource_ids = {"/api/tasks": ["42","43","44"], "profiles": ["7f3a…"], …} grouped by path or Supabase table · IDOR later asks: can User B fetch User A's 42?

1With credentials, Playwright opens the login page. It has more than one text input: a search box in the header and the login form itself. A hardcoded selector such as input[name=email] finds nothing here, because the identity field is called userName.

2Form-scoped detection: find the visible password field, scope to its enclosing <form>, and take the first visible text/email/tel input in that same form as the identity field, whatever it is named: email, userName, login, handle. The search box sits outside the form, so it is never grabbed.

3Both fields are filled through real input events so framework-controlled inputs register, the submit button is clicked, and the session is read back: a token from browser storage, or the auth headers seen on intercepted calls. That session is handed to every auth-aware DAST scanner afterwards.

4Every response is intercepted. URLs that look like API calls are kept with their request headers and body, status, a 2000-character response preview, and the ids found in the body: UUIDs anywhere, plus JSON keys named id or ending in _id.

5The crawler walks pages breadth-first from a queue seeded with the app's own routes and common authenticated paths, up to 50 pages and 30 links per page. The HTML form extractor from section 04 runs after each page load, because a server-rendered form makes no network call to intercept.

6Ids are aggregated per path, or per Supabase table, as the authenticated user's owned resource ids. The IDOR scanner later asks whether User B can fetch User A's.

Categorise, then rank per attack dimension

Every endpoint gets a semantic category from its path. Before the scanners run, a shared prioritizer scores the inventory for the vulnerability class each scanner cares about, so what a time budget reaches first is the endpoint most likely to carry that bug.

Category: first path rule that matches POST /rest/user/login GET /api/admin/users GET /profile GET /file-upload GET /api/payment GET /api/Products/1 GET /health GET /rest/products/search?q= AUTH · /login ADMIN · /admin USER_DATA · /profile FILE_ACCESS · /upload PAYMENT · /payment RESOURCE_CRUD · id PUBLIC · /health UNKNOWN rule order matters: /api/admin/users is ADMIN, not USER_DATA INJECTION score ?query +3 · {id} +2 · search/query/find/filter/lookup/list word +3 · body method +1 /rest/products/search?q= /api/Products/1 POST /rest/user/login /file-upload ?q +3 · "search" +3 {id} +2 POST body +1 6 2 1 no query, no {id}, no body 0 FILE_ACCESS → INJECTION +3 3 /file-upload: 0 points → 73rd of 77 on Juice Shop under the old cap of 30, never tested: two XXE bugs unreachable /file-upload: 3 points → 6th of 77 its payload is the file itself, so a parameter-shaped signal never fires dimensionsignals (generic REST conventions only) IDORid param +4 · high-value category +3 · requires auth +2 XSS?query +3 · search word +2 · GET or POST/PUT +1 CSRFPOST/PUT/PATCH/DELETE +4 · auth +2 · high-value +1 AUTHauth word in path +4 · AUTH category +3

1One category per endpoint, first matching rule wins: /auth /login /signup /oauth → AUTH, /admin → ADMIN, /user /profile /account /me → USER_DATA, /file /upload /download /media → FILE_ACCESS, /payment /invoice /billing /charge → PAYMENT, an id parameter → RESOURCE_CRUD, /health /status /version /ping → PUBLIC, otherwise UNKNOWN.

2Before the scanners run, endpoint_prioritizer.rank() scores each endpoint for one dimension. For INJECTION: a query string +3, a path parameter +2, a search/query/find/filter/lookup/list word in the path +3, a body-carrying method +1. The sort is stable, so ties keep discovery order.

3A file upload takes no query string, no path parameter and no body field; its payload is the file. Every parameter-shaped signal scored it zero. On Juice Shop /file-upload ranked 73rd of 77, below the old cap of 30, so the two XXE bugs behind it were unreachable however good the probe was.

4The rule: FILE_ACCESS counts +3 toward INJECTION. /file-upload moved to 6th of 77. No app-specific string is involved; the category came from the path word /upload.

5The other dimensions use the same kind of generic signal. The injection, XSS, IDOR, CSRF, auth-bypass and HTTP-probe scanners all take their order from this one function, and that order decides what a time budget reaches first.

--probe-writes: reaching the mutation surface

A path in a bundle carries no HTTP method, so everything the regexes find is a GET. Any scanner that tests a POST or PUT then has nothing to work on. --probe-writes derives the state-changing methods from REST shape, at the cost of writing to the target.

Inventory GET /api/Feedbacks collection GET /api/Products collection GET /api/Products/1 id variant GET /profile POST /api/Feedbacks rest_method POST /api/Products rest_method PUT /api/Products/1 rest_method POST /profile rest_method DELETE /api/Products/1 never emitted Scanners that need a body stored XSS · mass assignment CSRF · SSTI filter: method in {POST, PUT} 0 4 endpoints to test a bundle states no method, so every path arrived as GET collection → POST · item → PUT the same shape /collection/1 reads inventory roughly doubles Juice Shop v20.1.1, url-only recall without → with RESULTS.md 22/45 → 28/45 (README and docs agree) CSRF 0/1 → 1/1 XSS 1/7 → 4/7 IDOR 0/6 → 1/6 mass assign. 0/1 → 1/1 time 27 min → ~72 (README, RESULTS.md) lost nothing · figures from benchmarks/RESULTS.md Off by default, and it should stay off for anything you do not own: the scan writes to the target. It creates records; an endpoint that sends email or charges a card will do so. DELETE would destroy a record to prove it could, so it is never sent. The first measurement read as a wash: six gained, six lost. The six lost were exposed_data and info_disclosure, both scored off http_probe. http_probe was running 901 s against a 900 s timeout; the hard cancel discarded every finding it held. The doubled inventory pushed it over. One bug wearing the costume of a trade-off. With the cooperative TimeBudget the scanner returns its findings itself: six gained, nothing lost. Worth remembering when a measurement shows a suspiciously tidy wash. Everything the regexes find is a GET, and the id variant /api/Products/1 is a GET too. Stored XSS, mass assignment, CSRF and SSTI all live behind a state-changing request. REST says which methods a shape accepts: POST to a collection, PUT and DELETE to an item. No list of paths is involved, only the shape already used for /collection/1. Gains: CSRF, three of the five XSS challenges, one IDOR (an unauthenticated PUT accepted on /api/Products and /api/Hints), mass assignment. Nothing lost. Time roughly doubles with the inventory.

1Url-only discovery produces GET endpoints only, because a path in a bundle carries no method. The scanners for stored XSS, mass assignment, CSRF and SSTI filter on POST/PUT and find nothing to test.

2--probe-writes derives state-changing twins from REST shape: POST to a collection, PUT to an item, the same convention that already produced /api/Products/1. DELETE is derived too and deliberately never emitted. Each twin carries source rest_method; the inventory roughly doubles.

3It is off by default. The scan now writes to whatever it is pointed at: it creates records, and an endpoint that sends email or charges a card will do so. A scanner that deletes a customer's record to prove it could is not worth the finding.

4Measured on Juice Shop v20.1.1, url-only. The benchmark (RESULTS.md, README, architecture doc) reports 22/45 → 28/45 and ~72 minutes against 27. What was gained: CSRF, three XSS challenges, one IDOR and mass assignment; nothing was lost.

5The first measurement did not look like that. It read six gained, six lost, and the six lost were exposed_data and info_disclosure, both scored off http_probe. That scanner was running 901 s against a 900 s timeout, and the hard cancel discarded every finding it held. The doubled inventory had pushed it over.

6One bug wearing the costume of a trade-off. With scanners checking budget.expired() in their per-endpoint loop and returning early, the measurement reads six gained, nothing lost. The mechanism is animated on the scan pipeline page.

Coverage limits: time is the bound, not a count

Scanners used to stop after a fixed number of endpoints. Against an app with 77 endpoints those caps decided which quarter got examined, in ranked order, and nothing said so.

Why the endpoint caps are gone

Probing costs HTTP requests rather than tokens. Measured over nine scans, a DAST run cost $0.12 to $0.18 whether it covered 59 endpoints or 154, while a SAST run is a flat ~$1.28 because its cost is the LLM reading files. The endpoint was never the scarce thing, so the caps of 20 for XSS, 30 for injection and 5 for CORS were removed. MAX_ENDPOINTS_TO_DISCOVER is now unbounded.

What bounds a scan now

Time, per scanner, through TimeBudget, with the per-scanner timeouts raised so that timing out means something is wrong rather than that the app was large. Every scanner that walks endpoints emits per-endpoint progress, so a long run is legible rather than silent. A scan that takes half an hour and finishes beats one that takes ten minutes having skipped two thirds of the surface.

capboundedstatuswhy
XSS: 20 endpointswhich endpoints got testedremovedHTTP requests are not scarce; ranking plus time decides coverage
injection: 30 endpointswhich endpoints got testedremovedthis cap is what hid /file-upload at 73rd of 77
CORS: 5 endpointswhich endpoints got testedremovedsame
MAX_ENDPOINTS_IN_PROMPT = 50endpoints listed in the business-logic LLM promptkeptLLM tokens are genuinely scarce
MAX_OOB_POST_ENDPOINTS = 10POST endpoints registered for out-of-band callbackskeptregistrations against an external callback service are genuinely scarce
When a scanner runs out of time

run_scanner_safe cancels a scanner at its hard timeout, and a cancelled coroutine cannot hand anything back. Scanners therefore stop themselves: the runner publishes its deadline, a TimeBudget() with no argument inherits it, and the check sits in the per-endpoint loop rather than between phases, because one phase can walk the whole inventory. http_probe runs its fixed-cost checks before the ones that scale with the inventory, so a large app cannot spend its whole budget on method tampering and never reach the check that finds an exposed .env. The lane-by-lane animation is on the scan pipeline page.