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: 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.
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.
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.
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.
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
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.
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);
}export async function GET(req: Request, { params }: { params: { id: string } }) {
const user = await getUser(req); // resolves the session
if (!user) return new Response("Unauthorized", { status: 401 });
const task = await db.task.findFirst({ where: { id: params.id, ownerId: user.id } });
if (!task) return new Response("Not found", { status: 404 });
return Response.json(task);
}Limits and what the code actually checks
| Where | Rule | Consequence |
|---|---|---|
| Phase 9 and 9.1 | Run 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 key | Category 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 output | A 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 verdict | A 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 budget | Skip 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 targeted | Probe 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 condition | Five 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 bypass | Needs 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. |