Architecture

SAST ↔ DAST Correlation

A dynamic finding says the app misbehaved. A static finding says the code looks wrong. Phase 9 pairs the two into one confirmed finding with boosted severity. Phase 9.1 runs the relationship forward: a code finding becomes a concrete request against the live app, a test no scanner would otherwise have sent.

Phase 9 + 9.1DASTSASTconfirmed = severity +1full mode only

Phase 9: two streams, one matcher

After the scanners finish, every finding carries a source and a category. The cross-referencer walks the DAST stream and, for each finding, looks for the first unused SAST finding whose category completes one of its pair rules and that concerns the same target (a shared endpoint word with its file path or text, with camelCase and PascalCase names split into words so /api/v1/accounts/7 meets AccountController.java, or the same leaked token). A hit produces a new confirmed finding that links both originals. Findings without a partner pass through untouched.

DAST findings · sources DAST_URL, DAST_AUTHENTICATED Cross-referencer · Phase 9 SAST findings · sources SAST_CODE, SAST_GIT_HISTORY IDORGET /api/tasks/42idor · HIGH · idor_scanner Missing auth checktasks/[id]/route.tsauth_weakness · HIGH · route_auth IDOR confirmed: exposed endpoint + no auth in coderule: idor (DAST) + auth_weakness (SAST)severity max(HIGH, HIGH) + 1 → CRITICAL · confidence 0.95 RLS bypass/rest/v1/orders?select=*rls_misconfiguration · CRITICAL Table without RLS policymigrations/orders.sqlrls_misconfiguration · HIGH RLS gap confirmed: live probing + migration analysisrule: rls_misconfiguration + rls_misconfigurationseverity max(CRITICAL, HIGH) + 1 → CRITICAL (already the top) SQL injectionPOST /rest/products/searchinjection_risk · HIGH Raw SQL concatenationroutes/search.ts:12injection_risk · MEDIUM Injection confirmed: active testing + code patternsrule: injection_risk + injection_riskseverity max(HIGH, MEDIUM) + 1 → CRITICAL · confidence 0.95 Secret in JS bundlemain.3f1a.jsexposed_secrets · MEDIUM Key in git history.env @ a1b2c3exposed_secrets · MEDIUM Secret exposure confirmed: JS bundle + git historyrule: exposed_secrets + exposed_secretsseverity max(MEDIUM, MEDIUM) + 1 → HIGH · confidence 0.95 Missing CSP headerGET /missing_headers · LOW Vulnerable dependencylodash 4.17.4dependency_vulnerability · HIGH no pair rule for either categoryboth pass through unchanged Pair rules (DAST category + SAST category) idor + auth_weakness idor + idor rls_misconfiguration + rls_misconfiguration injection_risk + injection_risk exposed_secrets + exposed_secrets exposed_api_endpoint + auth_weakness each DAST finding takes the first unused SAST partner a SAST finding is consumed at most once result: a new finding, source CROSS_REFERENCED severity = higher of the two, raised one level confidence 0.95 · endpoint from DAST · file from SAST related_finding_ids = [dast.id, sast.id] runs only in full mode: a target URL and a repository

1Two streams arrive. On the left, findings from live requests (sources DAST_URL and DAST_AUTHENTICATED); on the right, findings from reading code and git history. Each carries a category, and the category is what the matcher keys on.

2The pair table is fixed in code. The matcher walks the DAST list in order; for each finding it scans the SAST list for the first partner whose category completes a rule, and that partner is then spent. A category match alone never pairs two findings: the DAST endpoint must also share a distinctive word (a path segment such as orders, a query parameter name; generic words like api do not count) with the SAST file path or text, or both must quote the same long token. The exact route-to-endpoint alignment happens in the route matcher of Phase 9.1, shown in the next scene.

3IDOR + missing auth. The dynamic scanner read another user's task by swapping an id; the static analyzer found no auth check in the route file. The result is a new finding titled from the rule, with severity max(HIGH, HIGH) raised one level to CRITICAL and confidence 0.95.

4RLS bypass + missing RLS. The anon key returned rows and the migration never enabled row-level security. The boost is clamped: CRITICAL plus one is still CRITICAL.

5Injection + injection risk. The active scanner got a different response under a payload, and the code concatenates user input into SQL. The higher of the two (HIGH) is raised to CRITICAL.

6Secret exposure + secret in code. The same key shows up in a shipped bundle and in git history. Two MEDIUMs become one HIGH.

7A missing CSP header and a vulnerable dependency complete no rule, so nothing happens to them. The originals of matched pairs are also kept; the confirmed finding is added and points at both ids, so a reader can see what each side proved.

Where the endpoint ↔ route alignment lives

The matching rules above are the whole of FindingCrossReferencer: a category pair plus a target link, first related unused partner wins. The step that turns a code file into a live URL, RouteEndpointMatcher, belongs to Phase 9.1. It looks the finding's file up in the route map, converts the route pattern (/api/tasks/:id or /api/tasks/[id]) into a regex (/api/tasks/[^/]+/?$) and tests it against every discovered endpoint path. Paths are compared by suffix, so a relative finding path and an absolute route path still match.

Phase 9.1: six strategies, six generated requests

Each strategy subscribes to the SAST scanners it understands, picks the findings whose wording matches its trigger, maps the file to live endpoints, and emits concrete test cases. The runner drops tests whose endpoint and category are already a finding, orders the rest by the severity of the source finding, keeps the first 50, and sends them through the rate-limited client. A reply becomes a new finding linked to the code finding that caused it only when its body shows the weakness; a bare 2xx is not evidence.

SAST input · scanner · file Generated request Target response → new finding Route missing auth checkroute_auth_analyzer · tasks/[id]/route.ts Auth bypassGET /api/tasks/42 · no Authorization header 200 OK · CRITICAL→ auth_weakness Route missing ownership checkroute_auth_analyzer · tasks/[id]/route.ts IDOR targetedGET /api/tasks/42 · {"id": "00000000-…-0001"} 200 OK · HIGH→ idor Raw SQL concatenationinjection_pattern_trigger · search.ts:12 Injection targetedPOST /products/search · {"q": "' OR '1'='1' --"} 200 OK · CRITICAL→ injection_risk Schema has isAdmin columnprisma_schema_analyzer · schema.prisma Mass assignmentPOST /api/users · {"isAdmin": true} 201 Created · HIGH→ privilege_escalation LLM review flags TOCTOUllm_review · orders/route.ts Race conditionPOST /api/orders ×5 · identical, concurrent 200 ×3 of 5 · MEDIUM→ auth_weakness Table without RLS policyrls_policy_analyzer · migrations/orders.sql RLS bypassGET /rest/v1/orders?select=* · apikey: <anon> 200 OK · CRITICAL→ rls_misconfiguration RouteEndpointMatcher: file → route → live URL finding.file_path tasks/[id]/route.ts route_map entry /api/tasks/[id] pattern → regex /api/tasks/[^/]+/?$ discovered endpoint /api/tasks/42 ✓ paths compared by suffix: relative and absolute both match one SAST finding can map to several live endpoints Runner: skip tests whose endpoint:category is already a finding · order by source severity · keep 50 · 3 in flight, 0.3 s apart · body evidence required (no bare 2xx) · 401/403/404/405 = denied

1Every strategy starts the same way: the SAST finding names a file, the route map names the route that file serves, the route pattern becomes a regex, and the regex is tested against each discovered endpoint path. Without a live URL to hit, no test is generated.

2Auth bypass. A route_auth_analyzer finding whose text says "missing authentication", "missing authorization" or "no auth" becomes one GET/HEAD request (never a mutating method) to the route, sent with no auth headers at all. If the reply carries data and differs from the app's catch-all page, the guard the code lacks is also absent at runtime: CRITICAL, category auth_weakness. A distinct page without data is a low-confidence MEDIUM; the single-page-app shell is nothing.

3IDOR targeted. The same scanner's "ownership" or "object reference" findings become a request carrying a probe id, 00000000-0000-0000-0000-000000000001, under the endpoint's first path or query parameter name (or user_id, userId, id, uuid from the snippet). The probe travels in the query string for GET (a JSON body otherwise); the URL path itself is not rewritten. A JSON object that is not an HTML shell is an IDOR finding: MEDIUM, or LOW when the body is the same for any id.

4Injection targeted. Findings from injection_pattern_trigger or the LLM review that mention injection, unsanitized input, SQL or XSS name the parameter. Three read-only SQLi payloads, starting with ' OR '1'='1' --, and a set of XSS payloads are sent to that parameter (query string for GET). A database error that a benign value does not produce is CRITICAL for SQLi (a bare 5xx is only MEDIUM); the payload reflected unescaped is HIGH for XSS.

5Mass assignment. A Prisma or Drizzle schema finding that exposes role, isAdmin, admin, price, amount or permission* columns becomes a POST, PUT or PATCH (only the methods the route declares) carrying those exact field names with privileged values, here {"isAdmin": true}. The field echoed back is privilege_escalation, HIGH; a bare acknowledgement is LOW and marked unverified.

6Race condition. When the LLM review flags "race condition", "TOCTOU" or "time-of-check", five POSTs are fired concurrently. The finding is raised only if more than one succeeds with a non-HTML reply (MEDIUM when the bodies differ, otherwise LOW). Routes that mention payment, charge, checkout, billing or invoice are marked dry-run: logged, never sent.

7RLS bypass. A table named in an rls_policy_analyzer finding is queried directly at the Supabase REST endpoint found during discovery, with the anon key read from the repository. If RLS were on, the reply would be an empty array or 401; rows coming back is CRITICAL, an empty array is nothing.

8Each confirmed probe becomes a finding with source sast_guided_dast, confidence 0.95, the request payload attached, and related_finding_ids pointing back at the code finding that generated it. The runner's bounds are shown below the rows.

Generative, not post-hoc

Commercial tools call DAST ↔ SAST correlation "IAST" and sell it as matching after the fact. Matching can only re-label findings the two sides already produced. Phase 9.1 adds a second direction: a code finding that no dynamic scanner would have thought to test becomes a request, and that request can produce a finding that did not exist on either side.

Post-hoc matching (what vendors sell as IAST) DAST findingsfrom tests that already ran SAST findingsfrom reading the codeschema has isAdmin ·nothing tested it same category, both sides Phase 9 confirms the overlap and raises severity. The set of tests that ran never changes. Everything outside the overlap stays exactly as the scanners left it. Generative (Phase 9.1) SAST-only findingschema has isAdmin Generated testPOST {"isAdmin": true} New DAST findingprivilege_escalation · HIGH A test no scanner would have sent, because no scanner knew the column existed. The DAST set grows. The new finding links back to the schema finding, so the proof chain is visible in the report.

1Both approaches start from the same two sets: the dynamic scanners ran their fixed repertoire, the static analyzers read the code. A schema column named isAdmin is in the SAST set and nothing in the DAST set.

2Post-hoc matching finds the overlap, where both sides reported the same category, and relabels it as confirmed. That is useful, and it is also all it can do: no request is ever sent because of a code finding.

3Generative. The mass-assignment strategy turns the schema finding into a concrete POST with {"isAdmin": true} at the route the schema serves. The request exists only because the code was read first.

4If the target accepts the field, a new finding appears on the DAST side: privilege escalation, proven live, with a pointer back to the schema finding. The overlap did not merely get relabelled; the dynamic side learned something it could not have found alone.

Why it matters

Two weak signals, one strong one

A missing auth check in code might be covered by middleware the analyzer cannot see. A changed response under an id swap might be a 404 page that looks different. Either alone is a plausible false positive. The same route failing both ways is proven, which is why the matcher raises severity one level and sets confidence to 0.95 rather than averaging two guesses.

Tests nobody would have written

A dynamic scanner cannot guess that a users table has an isAdmin column, which table has no RLS policy, or which handler the LLM thinks has a check-then-act race. The code knows. Every one of the six strategies is a request that is only sendable because the repository was read first.

Vulnerable vs fixed: the route the IDOR pairing lands on

The first row of the matcher scene. The static analyzer flags the handler because no user is resolved and no ownership is checked; the auth-bypass and IDOR-targeted strategies then hit it without a session and with a foreign id.

// Anyone who knows an id can read the task. No session, no owner check.
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const task = await db.task.findUnique({ where: { id: params.id } });
  return Response.json(task);
}

Limits and what the code actually checks

WhereRuleConsequence
Phase 9 and 9.1Run only in full mode: a target URL and a repository. 9.1 also needs at least one SAST finding and one discovered endpoint.URL-only or code-only scans have neither phase.
Phase 9 keyCategory pair plus a target link (shared endpoint word or token); first related unused SAST partner wins; each SAST finding is consumed once.Unrelated findings of one category stay separate and unboosted. A link rests on shared words, so a pair whose file and endpoint share no distinctive word is not confirmed.
Phase 9 outputA new finding is appended; the originals stay. Severity is the higher of the two plus one, clamped at CRITICAL.The report shows three related findings, not one merged one; triage dedup runs later.
Phase 9.1 verdictA 2xx is necessary, never sufficient: the body must show the weakness (SQL error, reflected payload, returned rows, data differing from the catch-all page, echoed field). 401, 403, 404, 405 means denied.Ambiguous evidence is kept as a low-confidence MEDIUM or LOW finding rather than dropped; an empty body, an empty array or a single-page-app shell is not a finding.
Phase 9.1 budgetSkip tests whose endpoint and category already have a finding; sort by source-finding severity; keep 50; 3 requests in flight, 0.3 s apart, 15 s timeout.On a large repo, lower-severity code findings may never be tested.
IDOR targetedProbe id is sent as a JSON payload keyed by the first path or query parameter name; the URL path is unchanged.Routes that only read the id from the path are not exercised the way a manual id swap would.
Race conditionFive concurrent identical POSTs; finding only when more than one returns 2xx. Payment, charge, checkout, billing, invoice routes are dry-run.A real double-spend on a checkout route is never attempted by this phase.
RLS bypassNeeds a Supabase REST base among the discovered endpoints and an anon key found in the repository.No key in the repo means no test, even with the table name in hand.