LLM Business Logic Scanner
A pattern matcher cannot know that your checkout should ignore a price sent by the client. This scanner hands your source and your endpoint list to a model, asks it to write attack plans as plain HTTP requests, runs those requests itself with two real logins, and asks a model whether the responses prove the flaw.
Four phases: two are code, two are the model
The scanner code picks the files and sends every request. The model is used twice: once to plan, once to judge. It never touches the network itself, and the scanner never decides on its own that an attack worked.
1Four phases. The two shaded boxes call a model; the other two are ordinary code. Nothing runs unless two logged-in users, the endpoint list and an LLM client all exist, so a scan without an API key never reaches this phase.
2Select. The scanner keeps source files whose path contains words like checkout, payment, order, webhook or cart, drops tests and node_modules, and stops at 30 files. Each file is kept whole: the size limit is switched off.
3Plan. One call. The model gets the file contents, up to 50 discovered endpoints and a framework guess, and must answer with JSON: attack plans, each a list of HTTP steps plus a success criterion. The prompt tells it to use real endpoint URLs from the list.
4Execute. The scanner, not the model, sends each step, with the bearer token of the user the plan names. For every step it records the status and the first 300 characters of the body.
5Judge. A second call, on the cheaper judgment client when one is configured, receives the plan and what actually came back. It answers confirmed or not, with evidence, severity and a fix.
6Only confirmed: true becomes a finding. A plan that errors, parses badly or is judged not confirmed leaves no finding in the report; refused steps and failed plans are logged, emitted as progress messages and kept in the scanner's skipped_steps.
One plan, start to finish
An illustrative plan for a checkout route that trusts a price from the request body. The shapes are the real ones from the prompts; the endpoint, values and file are made up for the picture.
1The model reads the checkout route and writes a plan: a setup request as the admin, then the attack request as the regular user, plus a success criterion in words. It also names the file and line it suspects.
2The scanner runs step 1 with the admin's token. The response gives a baseline: product 7 costs 40.
3Step 2 runs as the regular user with a client-chosen price of 1. The server answers 201 and charges 1. The scanner has only recorded this so far; no verdict exists yet.
4The plan, its success criterion and the recorded responses go to the judge. Each body is cut to 300 characters before it is sent.
5The judge answers confirmed: true and quotes the evidence. This becomes a HIGH finding on POST /api/checkout, located at the file and line from the plan.
6Against a fixed server the same plan returns the catalog price. The judge answers confirmed: false and the plan is dropped without a finding.
What guards the output
Model output is untrusted text at both ends. Four gates stand between it and a finding. The fifth box is what the code does not check, which is why the judge's verdict is the only proof.
1Before any call. All preconditions must hold: scan mode, both logins, endpoints, target URL, repository and an LLM client. Without an API key the phase is simply absent.
2The plan reply. Parsed as JSON, with code fences stripped and a fallback that hunts for the first {...} block. A plan that is not an object or has no steps is dropped, and a malformed step inside a plan is skipped. If nothing parses, no request is sent.
3While executing. Hard caps on files, endpoints, plans and steps. A request that fails is recorded as status 0 and the plan continues. A cooperative time budget stops the loop before the runner's 30 minute cancel would discard everything.
4The verdict. Only confirmed: true passes. Anything else, including an exception, gives no finding. Evidence and response preview are cut to 300 characters.
5What the code does not check. The judge's word is final: no code re-reads the body. Every step URL is resolved against the target and refused (logged, counted, no request, no token) if its scheme, host or port differs; redirects off the target origin are not followed, and a malformed step is skipped without aborting the scan. Methods are not restricted, so DELETE against the target itself is still possible. Read confirmed findings as strong leads.
Why it matters
The flaws this finds depend on what your app is for: a price the client may set, a workflow step that can be skipped, a webhook with no signature check, a tenant id nobody scopes by. They have no signature a regex could match. The planner prompt covers seven classes: authorization bypass, state machine violations, price manipulation, race conditions, cross-tenant access, webhook replay or forgery, and mass assignment. Because the plans run against the live app, a flaw that looks plausible in code is either shown in a response or discarded.
The model receives the full text of up to 30 source files and up to 50 endpoint lines, then the plan with the recorded status and a 300 character preview of each response. Those previews come from the live app under the admin and regular sessions, so they can contain real records. Use a test environment and test accounts, as for the other authenticated scanners.
Vulnerable vs fixed code
The shape of flaw a plan like the one above proves, and the fix. The server must derive the price itself.
// BAD: the total comes from the request body
export async function POST(request: Request) {
const { id, price } = await request.json()
const order = await db.insert("orders", { productId: id, total: price })
return NextResponse.json({ total: order.total, paid: true })
}// GOOD: look the price up; ignore any client value
export async function POST(request: Request) {
const { id } = await request.json()
const product = await db.get("products", id) // authoritative price
const order = await db.insert("orders", { productId: id, total: product.price })
return NextResponse.json({ total: order.total, paid: true })
}Limits and configuration
| Setting | Value | Note |
|---|---|---|
| Files sent to the planner | 30 | path must contain a business-logic word; extensions .ts .tsx .js .jsx .py .go .rs; whole file, no truncation |
| Endpoints in the prompt | 50 | as METHOD url lines |
| Plans · steps | 10 · 8 | asked for in the prompt and enforced by slicing in code |
| Step URLs | resolved against the target; scheme, host and port must match | off-origin steps are refused with no request and no token; up to 5 same-origin redirect hops are followed and re-checked, and a redirect that leaves the origin is not followed |
| Request timeout | 15 s | a failure becomes status 0 and the plan carries on |
| Body preview | 300 chars | what the judge sees per step, and the cap on stored evidence |
| Token limits | 20,480 · 2,048 | planner reply · judge reply |
| Plans run | one after another | the config declares MAX_CONCURRENT_PLANS = 3, but the loop awaits each plan in turn |
| Wall clock | 30 min runner timeout | the scanner stops cooperatively shortly before it |
| Reported as | category Auth weakness · source SAST-guided DAST | severity and confidence come from the judge |