AI-assisted XSS scanner
RedSentinel
A scanner that decides where a payload would land before it decides which payload to fire — then refuses to report anything it could not execute in a real browser.
The problem
Signature matching guesses. Context tells you.
A reflected XSS scanner spends nearly all of its budget firing payloads that were never going to work. A payload that breaks out of an attribute value is inert inside a script string; one that works in HTML text does nothing in an href. Classical scanners handle this with signature lists and pattern matching, which means they either fire everything — slow, noisy, and loud enough to trip a WAF — or they fire a narrow list and miss.
RedSentinel puts a classification step in front of generation. The context module probes each reflection point and reports the context it landed in and which characters survived the round trip. Only then does payload generation run, against a bank filtered to that context and ordered by a ranker. The expensive stage operates on a shortlist instead of a catalogue.
Architecture
One core, three analysis services
The NestJS core is the only service that writes scan state. Everything expensive happens off a Redis-backed BullMQ queue, so a slow or hostile target never blocks the request that started the scan. The three Python services are stateless over HTTP and can be restarted independently of an in-flight scan.
| Service | Runtime | Port | Responsibility |
|---|---|---|---|
| Core API | NestJS | 3000 | scan, crawler, queue, report, health, auth, scanner-log |
| Dashboard | Next.js | 8080 | REST + Socket.IO, live scan progress |
| Context | FastAPI | 5001 | POST /analyze — reflection context, allowed characters |
| Payload-gen | FastAPI | 5002 | POST /generate, GET /ranker/info |
| Fuzzer | FastAPI | 5003 | POST /fuzz — reflection and execution checks |
| Queue | Redis / BullMQ | 6379 | background scan jobs |
| Store | PostgreSQL | 5432 | scans, vulnerabilities, audit entries |
Scan pipeline
Six stages, in order
The numbering here is real: each stage consumes what the previous one produced, and a scan cannot skip forward.
- 01
Auth
options.auth → POST /login (target site)Optional login to the target application so protected pages can be scanned. Deliberately separate from the credentials used to call RedSentinel's own API — scanning a logged-in surface must never widen access to the scanner.
- 02
Crawl
discover: urls · query params · forms · DOM sinks · WAFThe core walks the target and records every place user input can land, including DOM sinks the server never sees. WAF fingerprinting happens here, because knowing what filters the traffic changes which payloads are worth generating at all.
- 03
Context
POST :5001/analyze → reflection context + allowed charsThe context module probes each reflection location and reports the context it landed in and which characters survived the round trip. This is where the DistilBERT classifier runs. A reflection inside an attribute value and one inside a script string need entirely different payloads — getting this wrong wastes the whole budget downstream.
- 04
Payload-gen
POST :5002/generate → select · mutate · obfuscate · rankPayloads are selected against the reported context, mutated and obfuscated, then ranked. XGBoost ranking runs only when the ranker artifact is actually mounted; without it the service falls back to heuristic ranking rather than failing the scan. The payload bank comes from the curated dataset split — an empty bank returns 503 instead of silently scanning with nothing.
- 05
Fuzz
POST :5003/fuzz → reflect? → execute in headless browser?Each ranked payload is fired, checked for reflection, and — when verification is on — actually executed in a headless browser before it counts. That last step is why the strict evaluation reports zero false positives: a page that reflects input but never executes it produces no finding.
- 06
Report
score → persist → dedupe → html | json | pdfFindings are scored with a rule-based severity model, persisted to PostgreSQL, deduplicated, and rendered. The dashboard follows all of this live over Socket.IO instead of polling, and every state change lands in a per-scan audit log.
Results
What the evaluation actually measured
On the 47-endpoint evaluation the scanner records TP 44, FN 0, FP 0, TN 3 — F1 1.000. That number is only meaningful with its counting rule attached: a finding is counted only if the payload executes in a headless browser. Endpoints that reflect input but never execute it produce zero findings, which is where the zero false positives come from.
Cross-tool comparisons against ZAP, XSStrike and Dalfox use reflection-based counting instead, because that is how those tools decide. Under that rule the numbers move, and tools without browser verification pick up false positives. Both rules are in the repo, along with the scripts that reproduce them — the point of publishing both is that a single headline F1 with no methodology attached is not evidence of anything.
Reproduce itpython3 eval/run.py --output fresh-full-eval, then python3 eval/analysis/metrics.py fresh-full-eval. Archived runs live in eval/archive/.
The part I got wrong
My classifier was scoring 99.5%. It was cheating.
For most of the project the context classifier reported 99.53% context accuracy and 99.56% severity accuracy on a 3,632-sample test set. Those numbers went into the report. They were wrong, and the way they were wrong is the most useful thing I learned building this.
Validation accuracy had been sitting around 75% the whole time. A twenty-four-point gap between validation and test is not a good sign — a test set is supposed to be harder than validation, never easier. So I went looking for the leak instead of enjoying the number.
The splits overlapped badly. 161 of 206 unique test payloads — 78% — also appeared in training. Exact duplicate payloads ran at 78.2% between train and test, and even the test payloads with novel normalised forms averaged ~0.91 string similarity to their nearest training example. The model was not inferring context from payload syntax. It was recalling the label it had most often seen attached to that exact string.
I regenerated the splits with zero payload overlap and re-ran the evaluation. The honest numbers are 78.4% context accuracy and 38.2% severity accuracy on 306 clean test samples — consistent with the ~75.1% / ~35.4% validation figures, which is exactly what you want to see once the leak is closed.
Severity is the weak head, and it is weak for a defensible reason: severity is not a property of the payload string. It depends on where the reflection sits, what the surrounding application does with it, and whether it executes — runtime evidence the classifier never sees at training time. That is why runtime severity in the shipped scanner is rule-based and the classifier only advises. Fixing the head means fixing the labels, not the architecture.
Struck values were inflated by train/test overlap. Current values come from clean splits and are what the repo reports today.
Failure behaviour
What happens when a piece is missing
A security tool that silently reports a clean scan when its own components are broken is worse than no tool. Each degradation path is explicit.
- Ranker artifact not mounted
- Payload-gen falls back to heuristic ranking. The scan still completes; it just orders payloads less well.
- Payload bank empty
- POST /generate returns 503 rather than reporting a clean scan that never actually tested anything.
- Classifier model missing
- The context module's health endpoint reports the model as not loaded, so the failure is visible before a scan is trusted.
Limitations
Where it still falls over
Run against real applications — Juice Shop, WebGoat, the OWASP Benchmark — the scanner hits architectural limits rather than detection bugs: single-page apps that exchange JSON instead of form posts, multi-step authentication flows, and injection through POST bodies or the Referer header. These are crawler and transport gaps, not classifier gaps, and they are the honest next piece of work.
The severity head needs relabelling before it earns any weight in a report. And the dataset, at 59,122 curated entries drawn from AwesomeXSS, PayloadsAllTheThings, XSSGAI and PortSwigger material, is broad but skewed toward payloads people publish — which is not the same distribution as payloads that work.