xssmap 2.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. xssmap-2.8.0/PKG-INFO +850 -0
  2. xssmap-2.8.0/README.md +833 -0
  3. xssmap-2.8.0/pyproject.toml +73 -0
  4. xssmap-2.8.0/setup.cfg +4 -0
  5. xssmap-2.8.0/tests/test_bench.py +112 -0
  6. xssmap-2.8.0/tests/test_brute.py +262 -0
  7. xssmap-2.8.0/tests/test_budget.py +228 -0
  8. xssmap-2.8.0/tests/test_charset.py +171 -0
  9. xssmap-2.8.0/tests/test_csp.py +338 -0
  10. xssmap-2.8.0/tests/test_domscan.py +76 -0
  11. xssmap-2.8.0/tests/test_domstate.py +376 -0
  12. xssmap-2.8.0/tests/test_efficiency.py +159 -0
  13. xssmap-2.8.0/tests/test_exploit.py +371 -0
  14. xssmap-2.8.0/tests/test_fuzz.py +162 -0
  15. xssmap-2.8.0/tests/test_honesty.py +178 -0
  16. xssmap-2.8.0/tests/test_inline.py +146 -0
  17. xssmap-2.8.0/tests/test_jslib.py +247 -0
  18. xssmap-2.8.0/tests/test_leads.py +180 -0
  19. xssmap-2.8.0/tests/test_mxss.py +95 -0
  20. xssmap-2.8.0/tests/test_ops.py +197 -0
  21. xssmap-2.8.0/tests/test_params.py +232 -0
  22. xssmap-2.8.0/tests/test_recon_report.py +206 -0
  23. xssmap-2.8.0/tests/test_session.py +292 -0
  24. xssmap-2.8.0/tests/test_subdomains.py +440 -0
  25. xssmap-2.8.0/tests/test_units.py +185 -0
  26. xssmap-2.8.0/tests/test_waf.py +218 -0
  27. xssmap-2.8.0/xssmap/__init__.py +10 -0
  28. xssmap-2.8.0/xssmap/__main__.py +6 -0
  29. xssmap-2.8.0/xssmap/browser.py +900 -0
  30. xssmap-2.8.0/xssmap/brute.py +563 -0
  31. xssmap-2.8.0/xssmap/callback.py +161 -0
  32. xssmap-2.8.0/xssmap/charset.py +117 -0
  33. xssmap-2.8.0/xssmap/cli.py +1375 -0
  34. xssmap-2.8.0/xssmap/contexts.py +131 -0
  35. xssmap-2.8.0/xssmap/crawler.py +462 -0
  36. xssmap-2.8.0/xssmap/csp.py +674 -0
  37. xssmap-2.8.0/xssmap/domscan.py +1098 -0
  38. xssmap-2.8.0/xssmap/domstate.py +620 -0
  39. xssmap-2.8.0/xssmap/efficiency.py +196 -0
  40. xssmap-2.8.0/xssmap/exploit.py +619 -0
  41. xssmap-2.8.0/xssmap/filters.py +329 -0
  42. xssmap-2.8.0/xssmap/fuzz.py +236 -0
  43. xssmap-2.8.0/xssmap/htmlparse.py +437 -0
  44. xssmap-2.8.0/xssmap/jslib.py +436 -0
  45. xssmap-2.8.0/xssmap/mxss.py +250 -0
  46. xssmap-2.8.0/xssmap/net.py +636 -0
  47. xssmap-2.8.0/xssmap/params.py +419 -0
  48. xssmap-2.8.0/xssmap/payloads.py +585 -0
  49. xssmap-2.8.0/xssmap/report.py +863 -0
  50. xssmap-2.8.0/xssmap/repro.py +259 -0
  51. xssmap-2.8.0/xssmap/sarif.py +260 -0
  52. xssmap-2.8.0/xssmap/scanner.py +1663 -0
  53. xssmap-2.8.0/xssmap/session.py +472 -0
  54. xssmap-2.8.0/xssmap/state.py +133 -0
  55. xssmap-2.8.0/xssmap/stored.py +407 -0
  56. xssmap-2.8.0/xssmap/subdomains.py +469 -0
  57. xssmap-2.8.0/xssmap/verify.py +113 -0
  58. xssmap-2.8.0/xssmap/waf.py +604 -0
  59. xssmap-2.8.0/xssmap.egg-info/PKG-INFO +850 -0
  60. xssmap-2.8.0/xssmap.egg-info/SOURCES.txt +62 -0
  61. xssmap-2.8.0/xssmap.egg-info/dependency_links.txt +1 -0
  62. xssmap-2.8.0/xssmap.egg-info/entry_points.txt +2 -0
  63. xssmap-2.8.0/xssmap.egg-info/requires.txt +4 -0
  64. xssmap-2.8.0/xssmap.egg-info/top_level.txt +1 -0
xssmap-2.8.0/PKG-INFO ADDED
@@ -0,0 +1,850 @@
1
+ Metadata-Version: 2.4
2
+ Name: xssmap
3
+ Version: 2.8.0
4
+ Summary: Context-aware XSS scanner: crawler, browser verification, mXSS, charset and stored-XSS stages
5
+ Author: xssmap
6
+ Keywords: security,xss,scanner,pentest,sarif
7
+ Classifier: Environment :: Console
8
+ Classifier: Intended Audience :: Information Technology
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Topic :: Security
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ Provides-Extra: dev
15
+ Requires-Dist: ruff>=0.6; extra == "dev"
16
+ Requires-Dist: mypy>=1.10; extra == "dev"
17
+
18
+ # xssmap
19
+
20
+ A context-aware cross-site scripting scanner with a crawler, built in the spirit of
21
+ sqlmap: point it at a URL, it crawls, enumerates every injection point, works out the
22
+ exact reflection context, fires payloads aimed at that context, and proves which ones
23
+ actually execute in a real browser.
24
+
25
+ - **Zero dependencies.** Pure Python 3.9+ standard library (no requests, no BeautifulSoup,
26
+ no Playwright, no Node). Drop the folder anywhere and run it.
27
+ - **Crawler included.** Bounded BFS, robots.txt and sitemap seeding, forms, inline and
28
+ external JavaScript endpoint extraction, JSON/AJAX hints, scope and exclusion rules.
29
+ - **Context-aware payloads.** HTML text, double/single/unquoted attributes, URL
30
+ attributes, JS string (double/single/template), JS code, `<script>` data blocks,
31
+ comments, `<textarea>`/`<title>`/`<style>` RCDATA, tag space.
32
+ - **Filter fingerprinting.** Each point is probed to learn which characters and keywords
33
+ survive (`<`, `>`, quotes, space, `onerror`, `script`, `javascript:`), so payloads that
34
+ cannot possibly work are pruned and the ones that can are preferred.
35
+ - **Mutation engine.** When a filter blocks the obvious payload, candidates are mutated
36
+ (case, entity, nested keyword, unclosed tag, null byte, double encoding, comment
37
+ padding, event swap) and the survivors are verified like any other payload.
38
+ - **WAF fingerprinting and evasion.** Identifies the vendor in front of each host from
39
+ headers, block pages and probe behaviour, then routes payloads through that vendor's
40
+ bypass vocabulary - with a bounded number of extra attempts per point. What the target
41
+ actually lets through reorders that vocabulary for the rest of the scan: proven shapes
42
+ first, filtered ones last.
43
+ - **Filter brute force.** For a point that reflects but mangles everything, learns which
44
+ characters, keyword spellings and multi-character sequences survive, finds where the
45
+ value gets truncated, and synthesises a payload from the survivors instead of guessing.
46
+ - **Hidden parameters and fuzzing.** Probes undocumented query, form and JSON-body
47
+ parameters, confirms a hit in the same direction before adding it, and classifies what
48
+ each one does to the response (raw, encoded, template-evaluated, stripped) against its
49
+ own baseline, so exploration is evidence rather than noise.
50
+ - **Client-side CVE hints.** Fingerprints jQuery, Bootstrap, AngularJS, DOMPurify and
51
+ friends from body or filename, compares versions against each advisory's affected range
52
+ (so 2.3.2 is not blamed for a 3.4.1 fix) and can merge fresh advisories from osv.dev
53
+ with `--osv`.
54
+ - **Leads, not silence.** A parameter that reflects but swallows every payload is recorded
55
+ as an unconfirmed lead with the measured filter and how much of the probe survived
56
+ (verbatim, recased, escaped, truncated), in its own report section - never presented as a
57
+ finding and never left looking clean. Detection and measurement are separate: a sink that
58
+ mangles the value is still reported as a sink.
59
+ - **mXSS round trip.** Parser-differential payloads are proven by serialising the DOM,
60
+ re-parsing it and re-running it, so the proof comes from the page rather than from the
61
+ scanner's belief about the page.
62
+ - **Multibyte charset escapes.** GBK/Shift_JIS/BIG5 trail-byte payloads are generated per
63
+ charset and confirmed the same way, with a UTF-8 control route to catch false positives.
64
+ - **DOM XSS analysis.** Static source-to-sink taint tracing over crawled scripts
65
+ (`location.search/hash/name`, `document.referrer`, `postMessage` into `innerHTML`,
66
+ `eval`, `document.write`, `location`), then real browser confirmation of each flow.
67
+ Two-step flows are followed across resources: a value written to
68
+ `localStorage`/`sessionStorage` on one page and rendered as HTML on another, and a
69
+ server-rendered state blob (`__INITIAL_STATE__`, `__NEXT_DATA__`, `data-*`) that the
70
+ bundle re-reads after hydration, are replayed step by step in the browser instead of
71
+ being left as unprovable guesses.
72
+ - **Stored and blind XSS.** Plants a uniquely marked payload in every writable form,
73
+ finds where it reappears, and proves execution - in the page itself, a viewer URL you
74
+ supply, or out-of-band through a built-in callback listener.
75
+ - **Browser proof.** Drives a local Chrome/Chromium/Edge over the DevTools protocol
76
+ (stdlib WebSocket) and asks the live DOM whether the injected node was created,
77
+ which turns "reflected" into "confirmed".
78
+ - **Exploit stage (`--verify`).** Replays the proven vector with a weaponised script and
79
+ measures what it reaches: cookie bytes and names, anti-CSRF token readability, a
80
+ same-origin read, or an out-of-band callback. Proven capabilities raise severity and
81
+ CVSS; an inconclusive replay stays `unproven` with the browser's own reason.
82
+ - **Honest by construction.** Every payload ends in one of three states, and a
83
+ verification that could not run is never reported as a clean result. CSP that blocks
84
+ execution is detected and downgraded instead of being reported as a high-severity hit,
85
+ and a `strict-dynamic` page is annotated with the script-creating gadgets and allowlisted
86
+ JSONP endpoints a manual bypass review should start from.
87
+ - **Reports.** JSON, Markdown, self-contained HTML, SARIF 2.1.0 for CI, one evidence
88
+ file per finding, CVSS 3.1 vector and a ready-to-paste `curl` reproduction.
89
+
90
+ ## Install
91
+
92
+ ```bash
93
+ git clone <this folder> xssmap && cd xssmap # or just copy the directory
94
+ python run.py --help
95
+ ```
96
+
97
+ Optional, for a `xssmap` command on PATH:
98
+
99
+ ```bash
100
+ python -m pip install .
101
+ xssmap --version
102
+ ```
103
+
104
+ Windows users can call `xssmap.cmd`, POSIX users `./xssmap.sh`. Both are thin wrappers
105
+ around `python run.py` that resolve their own directory, so they work from anywhere.
106
+ The `.cmd` wrapper skips the Microsoft Store `python` stub and propagates the real exit
107
+ code.
108
+
109
+ ## Quick start
110
+
111
+ ```bash
112
+ # single URL, crawl by default (depth 2, 150 pages)
113
+ python run.py -u "https://target.tld/search?q=test"
114
+
115
+ # crawl deeper, more threads, write reports (--depth is an alias)
116
+ python run.py -u https://target.tld --depth 3 --max-pages 200 --threads 8
117
+
118
+ # hand it a bare domain and let it find and test everything
119
+ python run.py --domain example.com --autonomous
120
+
121
+ # no crawling, only the URL you gave
122
+ python run.py -u "https://target.tld/page?name=x" --no-crawl
123
+
124
+ # a list of targets
125
+ python run.py -m urls.txt --threads 10
126
+
127
+ # POST body / JSON body
128
+ python run.py -u https://target.tld/api/login --data 'user=a&pass=b'
129
+ python run.py -u https://target.tld/api/login --json-body '{"user":"a","pass":"b"}'
130
+
131
+ # DOM XSS + stored XSS with an out-of-band listener, SARIF for CI
132
+ python run.py -u https://target.tld --crawl --dom --stored --callback \
133
+ --sarif out/xssmap.sarif --evidence out/evidence
134
+
135
+ # authenticated scan, header/cookie injection points, browser proof, CI gate
136
+ python run.py -u https://target.tld \
137
+ --cookie "sid=abc" --header "X-Api-Key: ..." \
138
+ --headers-points --browser --fail-on-findings
139
+
140
+ # log in first, keep the session, and resume an interrupted run later
141
+ python run.py -u https://target.tld --login-url https://target.tld/login \
142
+ --login-user bob --login-pass 's3cret' --login-check https://target.tld/account \
143
+ --state out/scan.state --save-cookies out/cookies.txt
144
+ ```
145
+
146
+ ## Whole-domain scans
147
+
148
+ `--domain` takes a hostname instead of a URL: it picks a working scheme (https, falling
149
+ back to http), discovers subdomains, then crawls and tests everything it found.
150
+
151
+ ```bash
152
+ # discover + crawl + scan everything under example.com
153
+ python run.py --domain example.com --autonomous
154
+
155
+ # the same sweep with explicit budgets
156
+ python run.py --domain example.com --subdomains --max-hosts 30 --depth 3 --max-pages 500
157
+
158
+ # passive discovery only: no DNS wordlist sweep
159
+ python run.py --domain example.com --no-subdomain-brute
160
+ ```
161
+
162
+ Discovery uses two independent sources:
163
+
164
+ 1. **Certificate transparency** via crt.sh. Passive: the target sees no traffic from it.
165
+ 2. **A DNS wordlist** of roughly 80 high-signal labels (`admin`, `api`, `staging`, `dev`,
166
+ `sso`, ...), resolved concurrently and bounded by `--max-hosts` and a wall-clock
167
+ timeout. `--subdomain-wordlist FILE` adds your own labels.
168
+
169
+ A random name is resolved first to detect wildcard DNS. Against a wildcard every candidate
170
+ looks live, so the wordlist sweep is skipped and reported instead of inventing a hundred
171
+ phantom hosts. Discovered names that do not resolve right now are dropped, and each
172
+ survivor gets an HTTPS probe with an HTTP fallback. The page budget is shared across all
173
+ hosts, so `--max-pages` remains a real limit rather than a per-host one.
174
+
175
+ When discovery finds other hosts, scope widens from `host` to `subdomain` automatically and
176
+ says so. Pass `--scope host` explicitly to keep the crawl on one host; the discovered hosts
177
+ are then left out and the log tells you why.
178
+
179
+ ### Autonomous mode
180
+
181
+ `--autonomous` is the one-flag sweep: subdomain discovery, depth-3 crawl, POST forms,
182
+ stored planting, DOM analysis, browser confirmation, header and cookie points, common
183
+ paths, screenshots, and a 30-minute soft budget. Every flag you pass yourself wins,
184
+ including the off switches:
185
+
186
+ ```bash
187
+ python run.py --domain example.com --autonomous # full sweep
188
+ python run.py --domain example.com --autonomous --no-stored # read-only
189
+ python run.py --domain example.com --autonomous --max-pages 80 --depth 2
190
+ ```
191
+
192
+ It prints what it enabled and what it kept from your command line. Because it plants
193
+ stored payloads and submits forms by default, it warns about that once at startup; use
194
+ `--no-stored` and/or `--no-forms` on anything you must not modify.
195
+
196
+ `--max-minutes N` is a wall-clock budget enforced between phases *and* inside them: the
197
+ scanner checks the deadline before every injection point, and the stored stage checks it
198
+ before every plant and every proof. Once it is reached, queued work is dropped, the points
199
+ already in flight are allowed to finish, and the log reports how many points were actually
200
+ tested ("time budget reached mid-scan; 13 point(s) tested"). The report then counts only
201
+ those points under **Points tested**, lists the run as `"partial"` in JSON, and points
202
+ skipped by the budget are deliberately kept out of `--state` so the next run re-tests them.
203
+ The remaining overshoot is bounded by a single in-flight point, not by a whole phase.
204
+
205
+ ## How detection works
206
+
207
+ For every injection point the engine runs a fixed pipeline:
208
+
209
+ 1. **Baseline.** Request the point with a benign value, record the response.
210
+ 2. **Marker.** Send a unique alphanumeric token (`xs` + 8 hex chars) and locate every
211
+ occurrence with the HTML parser, which tracks byte offsets, enclosing tag,
212
+ attribute, quote style and script/comment ranges.
213
+ 3. **Classify.** Each reflection gets a context and a snippet, so the payload can be
214
+ built for that exact position rather than sprayed blindly.
215
+ 4. **Fingerprint.** Probe once more to learn the filter: which break-out characters
216
+ arrive raw and which keywords are stripped. The answer decides which payloads are
217
+ even feasible, and it is stored on the finding as evidence of *why* it worked.
218
+ 5. **Fire.** Context-matched payloads are rendered with the marker and JS variant, then
219
+ filtered by `--level` and `--risk`. Blocked ones are retried as mutations.
220
+ 6. **Verify the reflection.** The payload must appear verbatim and the break-out
221
+ characters (`"`, `'`, space, `<`, `-->`, backtick) must survive raw. Quotes preceded
222
+ by an odd run of backslashes are rejected as escaped, not broken out. Payloads built
223
+ for a non-UTF-8 charset are re-encoded in that charset before this check.
224
+ 7. **Stability.** The same request is re-sent; flaky or one-off reflections are dropped.
225
+ 8. **Execution proof (optional).** A DOM-writing payload variant is loaded in a headless
226
+ browser and the engine checks `document.getElementById(...)` in the live page, with
227
+ console, network and CSP-violation observers attached. If the node exists, the payload
228
+ really ran.
229
+
230
+ A payload that is reflected but never executes is reported as *reflected*, not as a
231
+ confirmed vulnerability, and the note says why.
232
+
233
+ ### What the sink did to the value (`efficiency`)
234
+
235
+ Every reflecting point produces a verdict, not just the ones that happen to match a payload.
236
+ The last stage measures how much of the probe came back and scores it (the same idea as
237
+ XSStrike's `fuzz.partial_ratio`, made explicit instead of hidden in a 90-point special case):
238
+
239
+ | Kind | Score | Meaning |
240
+ | --- | --- | --- |
241
+ | `verbatim` | 100 | the value arrived exactly as sent; the payload ladder is worth running |
242
+ | `case` | 99 | the sink changed the case (`XS…`); executable, but payloads must be rebuilt |
243
+ | `escaped` | 90 | one character was rewritten or escaped (`\'`); a break-out may still exist |
244
+ | `partial` | 50 | only the head survived ("only the first 5 characters survive") |
245
+ | `absent` | 0 | the value is not in the body |
246
+
247
+ A point whose value is mangled is not silently dropped: it becomes a lead carrying the
248
+ measurement (`filter_profile.efficiency`, `lead_kind` in the JSON), so "filtered" and "never
249
+ reached the sink" stay distinguishable. Two details matter in practice:
250
+
251
+ - **Recased sinks are detected without an extra request.** Markers are lowercase, so the
252
+ comparison ignores the body's case; a sink that uppercases the value used to look unreflected.
253
+ - **Digit-stripping or truncating sinks get a second, shorter probe.** When the first marker
254
+ does not come back intact (absent, escaped or partial) and `--level 2` or higher is active,
255
+ one letter-only marker is sent. A sink that loses exactly one character is the case that used
256
+ to slip through: 9 of 10 characters still scores 90%, which reads as `escaped` and looks like
257
+ a value that survived. If the short probe reflects, the point is measured and attacked with a
258
+ marker the sink can carry instead of being written off.
259
+
260
+ `lead_kind` names why the point stayed a lead: `unproven` (reflected, nothing executed),
261
+ `partial-reflection` (the probe itself was mangled) or `non-html` (the sink returns JSON or
262
+ text, so a browser cannot execute it).
263
+
264
+ ### Verification is tri-state, and "unknown" is not "clean"
265
+
266
+ This is the rule the scanner is built around. Every payload that reaches the proof stage
267
+ carries one of:
268
+
269
+ | State | Meaning |
270
+ | --- | --- |
271
+ | `confirmed` | The page itself produced the effect: artifact node present, console message seen, callback received, or CSP violation reported. |
272
+ | `not_observed` | The check genuinely ran, the page loaded and executed, and the payload produced nothing. A real negative. |
273
+ | `not_tested` | The check could not run: no browser, navigation failure, DevTools error, timeout, blocked download. **Unknown, not clean.** |
274
+
275
+ Anything that fails to observe records *why* (`verify_reason`), and `not_tested` findings
276
+ are never counted as false-positive-free evidence. A run with no browser reports
277
+ `execution reported not_tested` rather than implying the site is safe. Conflating those
278
+ two states was the single biggest honesty defect in earlier versions: a browser crash and
279
+ a blocked payload both used to look like a quiet negative, and both silently dropped a
280
+ finding from high to medium confidence.
281
+
282
+ `--fail-on-findings` exits 1 on findings. If you want a CI gate that also fails when
283
+ verification was impossible, gate on the `verify_state` counts in the JSON report.
284
+
285
+ ### Mutation XSS
286
+
287
+ The mXSS stage takes a payload that a browser accepted as inert markup, serialises the
288
+ resulting DOM, re-parses that serialisation, and checks whether the second parse now
289
+ executes. The payload is only reported when the round trip itself produces execution,
290
+ so a browser that normalises differently from the server cannot manufacture a finding.
291
+ A control route that does not round-trip is asserted to stay silent.
292
+
293
+ ### Multibyte charsets
294
+
295
+ Where the response declares or implies a multibyte charset in the GBK family
296
+ (`gbk`, `gb2312`, `gb18030`, `cp936`), Big5 (`big5`, `cp950`), or Shift_JIS family
297
+ (`shift_jis`, `sjis`, `cp932`, `windows-31j`), the generator produces the classic
298
+ trail-byte form: a lead byte followed by a byte that the server's encoder consumes out
299
+ of the quote or angle bracket, so `\` or `"` disappears from the byte stream and the
300
+ attribute breaks out. Payloads are encoded in the target charset rather than sent as
301
+ UTF-8, and a UTF-8 route with the same payload is used as a control: if the control
302
+ fires, the finding is a plain encoding issue rather than a charset escape.
303
+ Charsets outside those three families are not modelled and are reported as ordinary
304
+ reflection, not as an escape.
305
+
306
+ ### CSP
307
+
308
+ The `Content-Security-Policy` header is parsed into directives, sources and nonces.
309
+ A payload that the policy would block is reported with `csp_blocked` and a downgraded
310
+ severity, and the report names the directive that stopped it plus any observed
311
+ weakening (`unsafe-inline`, `*`, `data:`, a missing `object-src`, report-only mode).
312
+ `Content-Security-Policy-Report-Only` is treated as *not enforced* - it changes nothing
313
+ about exploitability, so it must not silence a real finding.
314
+
315
+ ### DOM XSS
316
+
317
+ Crawled scripts are parsed for sources (`location.search`, `location.hash`,
318
+ `location.pathname`, `document.referrer`, `window.name`, `postMessage`, storage,
319
+ framework router hooks and server-rendered state blobs) and sinks (`innerHTML`,
320
+ `outerHTML`, `insertAdjacentHTML`, `document.write`, `eval`, `Function`,
321
+ `setTimeout(string)`, `location`). A flow is reported only when the tainted value
322
+ reaches the sink along an assignment chain, and confirmed only when the browser shows
323
+ the payload's own artifact node or the payload calls home. Flows that need the fragment
324
+ are delivered through `location.hash` with an exact reload, not by assuming a
325
+ navigation happened.
326
+
327
+ The flow does not have to live in one script or travel in one request:
328
+
329
+ - **Storage joins.** `localStorage.setItem('saved-search', term)` on one page and
330
+ `el.innerHTML = localStorage.getItem('saved-search')` on another are joined into a
331
+ single flow. Verification drives the real sequence - open the write page with the
332
+ payload, confirm the value actually landed in storage, then load the read page - so a
333
+ write that never happened is reported as "flow not exercised", not as "clean". Keys
334
+ are origin-scoped: a write from another origin is dropped rather than replayed.
335
+ - **Hydration sources.** A state blob is not JavaScript, so it is read through the id
336
+ of its `<script>` element (`__INITIAL_STATE__`, `__PRELOADED_STATE__`, `__NEXT_DATA__`,
337
+ `__NUXT__`, `__APOLLO_STATE__`, `__REDUX_STATE__`) or through `el.dataset.*` /
338
+ `getAttribute('data-…')`. The blob itself is excluded from the taint pass, and the
339
+ parameter that feeds it is taken from the page's own query names before any guess.
340
+ - **Every inline block counts.** All executable `<script>` blocks of a page are merged
341
+ into one virtual resource before analysis, so a variable assigned in one block and
342
+ sunk in the next is still a flow - and a non-executable block can no longer shadow the
343
+ script that reads it.
344
+
345
+ ### Stored and blind XSS
346
+
347
+ `--stored` plants one uniquely marked payload per form field, re-fetches the crawled
348
+ pages to find where the marker reappears, then opens those pages and waits for the
349
+ payload's handler to create a node. The same handler also beacons the callback listener
350
+ when `--callback` is running, which is what makes blind XSS (payload executed by an
351
+ admin view you cannot see) provable instead of guessed.
352
+
353
+ Three refinements matter in practice:
354
+
355
+ - **`--viewer-url`** names the page that renders submitted content (a moderation queue,
356
+ a profile page, an admin inbox). Blind stored XSS is exactly the case where the payload
357
+ renders for someone else, so without a viewer URL it can only be proven by callback.
358
+ - **`--stored-json URL [--stored-json-field NAME]`** plants into JSON APIs, which is how
359
+ most modern forms actually submit. Form-encoded planting misses them entirely.
360
+ - **`--capture-cookies`** makes the callback beacon include `document.cookie`. It is
361
+ **off by default**: harvesting a session cookie is an exfiltration step, not a
362
+ detection step, and it should be a deliberate choice on an authorised test.
363
+
364
+ ### Authenticated sessions
365
+
366
+ `--login-url` submits the site's own login form (auto-detecting the username, password
367
+ and any CSRF token fields, overridable with `--login-user-field`, `--login-pass-field`
368
+ and `--login-data K=V`), keeps the resulting cookie jar for every later request, and
369
+ `--login-check URL` confirms the session really took by requiring that URL to stop
370
+ redirecting to the login page. If the check fails the scan says so instead of silently
371
+ crawling a login page.
372
+
373
+ Anti-CSRF tokens are re-read before each submission, because a token captured once is
374
+ usually stale by the third POST (`--no-csrf-refresh` disables this). `--cookie-jar`
375
+ preloads a Netscape `cookies.txt`, and `--save-cookies` writes the final jar back out;
376
+ that file holds live session secrets, and the scanner says so when it writes it.
377
+
378
+ ## WAF, parameters and library intelligence
379
+
380
+ Five stages run alongside detection. They are observation-only: they decide what gets
381
+ tested and what the report explains, never what counts as a finding.
382
+
383
+ ### WAF fingerprinting and evasion (`--waf`, `--evade`)
384
+
385
+ `--waf` asks the target a small fixed set of questions - four requests: a benign baseline
386
+ and three probes shaped like a script tag, a SQL injection and a traversal - then reads the
387
+ vendor from headers, cookies, block pages and probe behaviour. Evidence from every response
388
+ is kept and merged, so confidence rises only when the answers agree; one odd 403 is a
389
+ low-confidence hint, not a WAF. Detection is per host and cached for the run.
390
+
391
+ When a blocking vendor is identified, `--evade` routes payloads through that vendor's
392
+ vocabulary of variants (case, whitespace, entity and comment tricks). Every reflection gets
393
+ its own share - two to six variants, depending on the detection's confidence and whether
394
+ the vendor was seen blocking - and `--evasion-budget` (default 240) caps the total added
395
+ across the whole scan, so one hostile point cannot starve the rest. `--no-waf` and
396
+ `--no-evade` switch the stages off.
397
+
398
+ Two deliberate limits: a variant that only appears to bypass because the scanner decoded it
399
+ twice is not counted - every variant still has to pass the same reflection and execution
400
+ gates as a normal payload - and the budget is a cap, not a promise.
401
+
402
+ ### Character-level brute force (`--brute`)
403
+
404
+ When a point reflects but every payload is mangled, `--brute` measures the filter instead of
405
+ guessing. Four probe families run in sequence, all marker-wrapped so a rejected value cannot
406
+ hide which part caused it:
407
+
408
+ - one probe per break-out character, to learn what survives raw, is encoded, or is stripped;
409
+ - keyword probes in every casing (`alert`, `ALERT`, `AlErT`, `script`, `svg`), so a filter
410
+ that only blocks the lowercase spelling is measured as passable - HTML does not care about
411
+ case even when the filter does; handlers are probed too (`onload`, `onerror` before the rest,
412
+ because a synthesised vector needs a handler the filter actually allows);
413
+ - multi-character sequences (`<script`, `</script`, `<svg`, `onerror=`, `onload=`,
414
+ `javascript:`, `<>`, `"><`, `=(`, `(){}`), because `<` surviving on its own says nothing
415
+ about `<script` being blocked as a token;
416
+ - a truncation search that finds the length where the target starts cutting the value, so a
417
+ payload silently sliced in half is not mistaken for one whose characters were removed.
418
+
419
+ The character map then drives synthesis: vectors are built only from characters that
420
+ survived, using the spelling of each keyword that came back verbatim and skipping banned
421
+ sequences, and they are tested like any other payload. A word blocked in every casing is
422
+ never emitted: if no callable or handler survives, the vector is dropped (a `<script>` block
423
+ needs no handler and is still tried). `--brute-budget` caps the probes for the whole scan
424
+ (default 40 - raise it when a scan has several hostile points); findings produced this way
425
+ carry the character map, the synthesised vector, the blocked sequences and the truncation
426
+ limit as evidence.
427
+
428
+ Brute force also runs when the reflection could not be classified at all (`inferred`): the
429
+ payload ladder is skipped, the measured survivors are synthesised, and a hit is reported as a
430
+ finding because the marker came back inside the synthesised vector - the filter was measured,
431
+ not assumed.
432
+
433
+ ### Undocumented parameters (`--discover-params`)
434
+
435
+ Parameters are probed in batches with a unique alphanumeric marker. A name is reported when
436
+ its marker is reflected, or when the response contains a validation-style message naming it;
437
+ promising names are re-probed with `name=1` and their response shape compared against the
438
+ baseline so a length change alone is not mistaken for a parameter, and confirmed again in
439
+ the same direction before a name is added (a shape that flips between requests is drift, not
440
+ a parameter). JSON and form bodies are searched too: every candidate key is injected into the
441
+ body and the response is compared against a body-only baseline, so a property that is only
442
+ read by the server - and never appears in the URL - is still found, while keys the request
443
+ already documents are not reported as hidden. `--param` still filters which names may be
444
+ added to the scan. A user-supplied wordlist is tested before the built-in candidates, so a
445
+ long list cannot be silently truncated by the built-in budget.
446
+
447
+ ### Fuzz mode (`--fuzz`)
448
+
449
+ Exploration rather than detection: each parameter is probed with a fixed matrix (quotes,
450
+ angle brackets, comment and script closers, backticks, parentheses, `javascript:` URLs,
451
+ template syntax) and every response is classified as raw, encoded, evaluated or stripped -
452
+ always against that parameter's own baseline, so punctuation that already exists in the page
453
+ boilerplate is not reported as a reflection. It produces no findings; it produces the table
454
+ that tells you which payload family a parameter will accept, and `--fuzz-budget` bounds the
455
+ requests (default 200).
456
+
457
+ ### Client-side library CVEs (`--js-libs`)
458
+
459
+ Script sources and inline bodies are matched against a signature table (jQuery, jQuery UI,
460
+ Bootstrap, AngularJS, Lodash, Moment.js, DOMPurify, Handlebars, Underscore, Axios, React,
461
+ Vue, marked). A version is used only when it was read from the resource itself - the body
462
+ first, then the filename - and compared numerically against each advisory's affected range
463
+ (introduced version up to, but not including, the version that fixed it). Ranges matter:
464
+ Bootstrap 3.0.0-3.4.0 and 4.0.0-4.3.0 are affected by the tooltip/popover issues fixed in
465
+ 3.4.1 and 4.3.1, while 2.3.2 and 3.3.7 are not, and a single "version 3.4.1" string would
466
+ get both cases wrong. `--osv` additionally asks osv.dev about every identified version and
467
+ merges the advisories it returns (lookups are capped and a failure is ignored rather than
468
+ changing a verdict). Libraries whose version could not be determined are not queried at all:
469
+ a versionless OSV query returns every advisory for the package, which would read as "this
470
+ build is vulnerable to all of them". Long advisory lists stay complete in the JSON and are
471
+ summarised in the console line (`+N more`). The output is an indicator: "CVE-2020-11022
472
+ applies to this version" is not a claim that this page is exploitable.
473
+
474
+ OSV enrichment is verified against the live service, not only against fixtures. A bounded run
475
+ (`max_lookups=3`, 0.64 s) merged advisories for jQuery 1.8.3 (5 CVEs), Bootstrap 3.3.7 (7)
476
+ and Lodash 4.17.4 (9) into the local verdicts, then stopped: the fourth library was never
477
+ queried and stayed unchanged, so the cap is visibly enforced.
478
+
479
+ ### Browser cost
480
+
481
+ `--browser` reuses one browser and one page per run. The report's `browser_verification`
482
+ line states how many checks ran, how many launches were needed and the average milliseconds
483
+ per check, so the price of execution proof is visible rather than implied.
484
+
485
+ ## Flags
486
+
487
+ | Flag | Meaning |
488
+ | --- | --- |
489
+ | `-u URL`, `--domain DOMAIN`, `-m FILE` | single URL, a bare domain to enumerate, or a file of URLs |
490
+ | `--autonomous` | one-flag sweep; explicit flags always win |
491
+ | `--max-minutes N` | wall-clock budget, enforced between and inside phases (0 = unlimited) |
492
+ | `--crawl` / `--no-crawl` | force crawling on or off (crawl is on by default) |
493
+ | `--crawl-depth N`, `--depth N` | BFS depth (default 2) |
494
+ | `--max-pages N` | page cap for the crawl, shared across hosts (default 150) |
495
+ | `--scope {host,subdomain,any}` | how far the crawler may wander |
496
+ | `--subdomains` | discover subdomains via CT + DNS and crawl them too |
497
+ | `--no-subdomain-brute` | passive CT only, no DNS wordlist sweep |
498
+ | `--subdomain-wordlist FILE` | extra DNS labels, one per line |
499
+ | `--subdomain-base {auto,host,registrable}` | how the enumeration base is derived (default `auto`) |
500
+ | `--public-suffix-list FILE` | real PSL file to replace the built-in suffix table |
501
+ | `--max-hosts N` | cap on discovered hosts crawled per target (default 25) |
502
+ | `--exclude REGEX` / `--include REGEX` | limit what gets crawled (repeatable) |
503
+ | `--no-js`, `--robots`, `--common-paths` | JS endpoint mining off, honour robots.txt, probe conventional paths |
504
+ | `--forms` / `--no-forms` | also submit POST forms as reflected points (can create records) |
505
+ | `--stored` / `--no-stored` | plant payloads in forms and prove stored XSS (writes data) |
506
+ | `--stored-max-forms N` | cap on forms used for stored planting (default 20) |
507
+ | `--stored-json URL`, `--stored-json-field NAME` | plant stored payloads through a JSON endpoint |
508
+ | `--viewer-url URL` | page that renders stored content (moderation queue, profile) |
509
+ | `--callback [PORT]` | loopback listener for blind XSS proof (random port by default) |
510
+ | `--callback-bind ADDR` | interface the listener binds to (default `127.0.0.1`) |
511
+ | `--callback-url URL` | public URL the target should reach, when the listener is behind NAT |
512
+ | `--capture-cookies` | let the callback beacon send `document.cookie` (off by default) |
513
+ | `--method`, `--data`, `--json-body` | test a specific request body |
514
+ | `-p`, `--param`, `--param-value` | only these parameters / custom injection placeholder |
515
+ | `--headers-points` | add Referer, User-Agent, X-Forwarded-Host, Origin |
516
+ | `--cookie`, `-H`, `-A`, `--random-agent` | request identity |
517
+ | `--login-url`, `--login-user`, `--login-pass` | sign in through the site's login form first |
518
+ | `--login-user-field`, `--login-pass-field`, `--login-data K=V` | override login field names / add fields |
519
+ | `--login-check URL` | URL that must stop redirecting to login |
520
+ | `--cookie-jar FILE`, `--save-cookies FILE` | load / save a Netscape cookies.txt (secrets) |
521
+ | `--no-csrf-refresh` | do not re-read anti-CSRF tokens before each submission |
522
+ | `--level 1..3` | payload breadth: 1 classic, 2 JS/RCDATA/forms, 3 polyglots/headers/cookies |
523
+ | `--risk 1..3` | 1 non-interactive, 2 adds autofocus/onfocus, 3 adds interaction payloads |
524
+ | `--no-profile`, `--mutations N` | disable filter fingerprinting / cap mutations per point |
525
+ | `--waf` / `--no-waf` | fingerprint the WAF/CDN in front of each host (default on) |
526
+ | `--evade` / `--no-evade` | route payloads through the detected vendor's bypass vocabulary (default on) |
527
+ | `--evasion-budget N` | cap on evasion variants added across the whole scan (default 240) |
528
+ | `--brute`, `--brute-budget N` | character/keyword brute force for hostile points; probe budget for the whole scan (default 40) |
529
+ | `--discover-params` | probe for undocumented query and body parameters and add the responsive ones to the scan |
530
+ | `--fuzz`, `--fuzz-budget N` | classify what each parameter does to the response; request budget (default 200) |
531
+ | `--js-libs` / `--no-js-libs` | fingerprint client-side library versions and list applicable CVEs (default on) |
532
+ | `--osv` | merge advisories from osv.dev into the library results (capped lookups, network) |
533
+ | `--browser` / `--no-browser` | headless execution proof (auto-detects Chrome/Edge) |
534
+ | `--verify` / `--no-verify` | exploit stage: replay each finding with a weaponised payload and measure impact |
535
+ | `--verify-max N` | cap on findings replayed by `--verify` (default 10) |
536
+ | `--dom` / `--no-dom`, `--dom-flows N` | DOM analysis and its flow cap (default 15) |
537
+ | `--headless new\|old` | headless mode passed to the browser |
538
+ | `--screenshots` | capture a PNG for verified findings |
539
+ | `--threads N`, `--delay S`, `--timeout S`, `--retries N` | pacing and reliability |
540
+ | `--proxy URL`, `--insecure` | `http://`/`socks5://` proxy for scanner and browser; skip TLS verification |
541
+ | `--max-points N` | stop after N injection points |
542
+ | `--state FILE` | resume file: skip points already completed, carry findings over |
543
+ | `-o FILE`, `--html FILE`, `--markdown FILE`, `--output-dir DIR` | report destinations |
544
+ | `--sarif FILE` | SARIF 2.1.0 report for CI/code scanning integrations |
545
+ | `--evidence DIR` | one evidence file per finding |
546
+ | `-q`, `-v`, `--no-color` | verbosity |
547
+ | `--fail-on-findings` | exit code 1 when something is found (CI gate) |
548
+ | `--list-payloads` | print the payload catalogue and exit |
549
+ | `--version` | print the version and exit |
550
+
551
+ ## Exit codes
552
+
553
+ | Code | Meaning |
554
+ | --- | --- |
555
+ | `0` | run completed, nothing found (or findings without `--fail-on-findings`) |
556
+ | `1` | `--fail-on-findings` was set and something was found |
557
+ | `2` | usage error, bad arguments, or no usable Python interpreter (launcher) |
558
+ | `130` | interrupted (Ctrl-C) - **reports and `--state` are still written first** |
559
+
560
+ Ctrl-C used to abort mid-run and lose everything that had already been proven. It now
561
+ stops the workers, flushes reports, saves resume state and exits 130, so an interrupted
562
+ long scan is still worth something.
563
+
564
+ ## Reports
565
+
566
+ Each run writes the configured files (timestamped in `--output-dir` by default):
567
+
568
+ - `*.json` - machine readable: metadata, crawl stats, statistics, filter profiles and
569
+ every finding with payload, context, severity, confidence, `browser_verified`,
570
+ `browser_checked`, `verify_state`, `verify_reason`, `csp_blocked`, `cvss`,
571
+ `reproduction` and `severity_change_conditions`. Findings also carry `template`,
572
+ `payload_name`, the recorded `request`, and an `exploit` block when `--verify` ran;
573
+ the run's overall exploit counts sit in the top-level `verification` key.
574
+ - `*.md` - summary table plus per-finding sections for tickets and write-ups, including
575
+ a "Exploit (proof of impact)" block with the measured capabilities. A final
576
+ "Reconnaissance" section lists the WAF verdict and evidence, client-side libraries and
577
+ their CVE hints, discovered parameters, the fuzzing matrix and brute-force character maps.
578
+ Points that reflect but swallow every payload get their own "Leads (unconfirmed)" table:
579
+ parameter, context, confidence and the measured filter, kept separate from findings so a
580
+ hostile-but-unproven point is never read as a vulnerability or as a clean result.
581
+ - `*.html` - self-contained report with severity counters (including an "Exploit-proven"
582
+ tally), collapsible evidence and the same Reconnaissance section as tables.
583
+ - `*.sarif` - SARIF 2.1.0, one rule per context, with parameter, payload, confidence,
584
+ verification state and the `exploit` verdict in `properties`. The same reconnaissance
585
+ data rides in `runs[0].properties.reconnaissance` for pipelines that read more than
586
+ `results`. Unconfirmed leads ride along in the run's `leads` metadata (capped at 200).
587
+
588
+ `--evidence DIR` additionally writes `NNN-context-parameter.txt` per finding: target,
589
+ parameter, payload, filter profile, the request that triggered it, the response
590
+ observation that confirmed it, a CVSS 3.1 vector, and a `curl` line that reproduces it.
591
+ The captured request is reproduced with `Cookie` and `Authorization` values replaced by
592
+ `<redacted>`, and the `curl` line omits those headers entirely with a comment telling you
593
+ where to add your own session - a report is a document, not a credential spill.
594
+
595
+ ## Exploit stage (`--verify`)
596
+
597
+ Detection answers "did this input reach an execution context?". `--verify` answers the
598
+ next question a report has to answer: **what could that script reach on this origin?**
599
+
600
+ It re-renders the vector that already worked -- same template, same parameter, same
601
+ request body -- with the inert DOM artifact swapped for a weaponised script, replays that
602
+ request in the same browser, and asks the live page what the script observed:
603
+
604
+ | Measured capability | What it establishes |
605
+ | --- | --- |
606
+ | `dom_control` | the injected script ran and owns the document |
607
+ | `cookie_bytes` / `cookie_names` | how much `document.cookie` the origin exposes, and which cookies (**names only, never values**) |
608
+ | `csrf_token_readable` / `csrf_token_length` | the script can read the page's anti-CSRF token, so it can script state-changing requests |
609
+ | `same_origin_status` / `same_origin_bytes` | a same-origin GET issued as the victim returned a readable response |
610
+ | callback hit | the payload reached an out-of-band listener (`--callback`) |
611
+
612
+ ```bash
613
+ python -m xssmap -u "https://target.tld/search?q=1" --verify --browser
614
+ python -m xssmap -u https://target.tld --verify --verify-max 5 --callback 9000
615
+ ```
616
+
617
+ Against the loopback lab used for the regression tests:
618
+
619
+ ```
620
+ [*] exploit stage: replaying up to 1 of 1 finding(s) with a weaponised payload
621
+ [+] exploit proven on q (html_text): same-vector - dom_control, csrf_token_readable
622
+ [+] exploit: 1 finding(s) proven to reach cookies, tokens or same-origin data
623
+ exploit PROVEN (same-vector): Arbitrary script execution in the target origin ...
624
+ The script read 28 bytes of cookie data (names: sid) ...
625
+ The script read the page's anti-CSRF token (14 chars) ...
626
+ A same-origin GET issued as the victim returned HTTP 200 (161 bytes readable) ...
627
+ ```
628
+
629
+ The stage refuses to inflate a finding: a replayed payload that produced no observable
630
+ effect stays `unproven`, with the browser's own reason quoted (CSP refusal, console
631
+ error, or "rendered but no effect"), and the finding keeps its detection severity. Only
632
+ measured capabilities upgrade severity and CVSS.
633
+
634
+ Two implementation details matter more than they look:
635
+
636
+ - The weaponised script contains **no whitespace and no angle brackets**. Most vectors
637
+ put `{JS}` in an unquoted attribute (`<svg onload={JS}>`), where the first space or
638
+ `>` ends the attribute value; a normal-looking script would be truncated mid-expression
639
+ and never run. `var/**/x` keeps the parser's token separator without emitting a space,
640
+ and the token hunt uses `indexOf(...)+1` instead of `... > -1`. As a fallback, an
641
+ unquoted slot is quoted when a script still needs either character.
642
+ - It records cookie **names and byte counts**, never values, so the report demonstrates
643
+ exposure without becoming a credential dump.
644
+
645
+ When the target sends CSP, `--verify` makes one bounded attempt at nonce reuse: if the
646
+ same nonce appears in two responses, an injected `<script nonce=...>` is tried. That is a
647
+ fixed, single candidate -- there is no policy brute force. Everything else about CSP is
648
+ reported as an observed block, not as an exploit.
649
+
650
+ ## Verification
651
+
652
+ `tests/selftest.py` starts a purpose-built vulnerable lab (`testapp/vuln_app.py`) on a
653
+ random port, scans it over real HTTP and scores the output against the app's manifest.
654
+ It exits non-zero if any expectation fails, so it is usable as a gate.
655
+
656
+ ```bash
657
+ python tests/selftest.py # crawl + direct scan
658
+ python tests/selftest.py --forms --stored # no browser required
659
+ python tests/selftest.py --browser --forms --stored # full, needs Chrome/Edge
660
+ ```
661
+
662
+ Eight phases: crawl and scan, direct per-endpoint scan, POST form with CSRF token,
663
+ machine-readable outputs, mXSS round trip, multibyte charset escaping, authenticated
664
+ session with JSON planting, and resume state with viewer pages.
665
+
666
+ Latest full run (`--browser --forms --stored`):
667
+
668
+ ```
669
+ phase 1 - crawl and scan (default level 2)
670
+ TP /attr?name attr_double browser-confirmed
671
+ TP /attr-single?name attr_single browser-confirmed
672
+ TP /attr-unquoted?q attr_unquoted browser-confirmed
673
+ TP /charset?q charset browser-confirmed
674
+ TP /comment?x comment browser-confirmed
675
+ TP /csp?q html_text csp-blocked-execution
676
+ TP /csp-report-only?q html_text browser-confirmed
677
+ TP /dom?q dom_xss browser-confirmed
678
+ TP /dom-hash?q dom_xss browser-confirmed
679
+ TP /dom-hydrate?q dom_xss browser-confirmed
680
+ TP /dom-state-read?q dom_xss browser-confirmed
681
+ TP /feedback?message stored_xss browser-confirmed
682
+ TP /filter?q html_text browser-confirmed
683
+ TP /guestbook?comment stored_xss browser-confirmed
684
+ TP /js?name js_string_double browser-confirmed
685
+ TP /js-single?name js_string_single browser-confirmed
686
+ TP /misconfig?q html_text browser-confirmed
687
+ TP /search?q html_text browser-confirmed
688
+ TP /textarea?msg textarea browser-confirmed
689
+ TP /title?q title browser-confirmed
690
+ TN /charset-safe?q - utf-8 decoding keeps the escape
691
+ TN /encoded?q - html_escape
692
+ TN /stripped?q - character stripping
693
+ TN /safe?q - json script escaping
694
+ TN /json?q - json content-type + nosniff
695
+ -> 20/20 detected (100% recall), 0 false positive(s) (100% precision)
696
+ crawl: 43 pages, 27 parameterised URLs, 12 forms; 413 requests
697
+
698
+ phase 2 - direct per-endpoint scan (reflected only)
699
+ -> 14/14 detected (100% recall), 0 false positive(s) (100% precision)
700
+
701
+ phase 3 - POST form with CSRF token token handled
702
+ phase 4 - machine-readable outputs SARIF 2.1.0 valid, evidence written
703
+ phase 5 - mutation XSS round trip round trip re-executed, high/confirmed
704
+ phase 6 - multibyte charset escaping trail byte proven; no-browser run not_tested
705
+ phase 7 - authenticated session, JSON planting tokens refreshed: 2, stored proven: 4
706
+ phase 8 - resume state and viewer pages point skipped, finding carried over
707
+ all checks passed
708
+ ```
709
+
710
+ The lab deliberately includes traps: HTML-escaped output, a character-stripping filter,
711
+ JSON inside a non-executable `<script type="application/json">` block, JSON served with
712
+ `nosniff`, a UTF-8 route sharing the charset payload, and a
713
+ `Content-Security-Policy-Report-Only` header that must *not* be treated as enforcement.
714
+ All of them stay clean.
715
+
716
+ ### Second, independent lab
717
+
718
+ `bench/` scores the scanner against `bench/lab2/app.py`, a second vulnerable application
719
+ written separately from `testapp/`, with its own ground truth (`bench/verify_lab.py`)
720
+ and a scorer that reports precision/recall rather than a pass/fail count:
721
+
722
+ ```bash
723
+ python bench/run_bench.py # no browser
724
+ python bench/run_bench.py --browser # with execution proof
725
+ ```
726
+
727
+ Latest browser run: 9 true positives, 0 false positives, 0 false negatives, 4 true
728
+ negatives (precision 1.00, recall 1.00, F1 1.00) over 13 labelled routes. Having two
729
+ labs written independently of each other is the point: a payload tuned to one app's
730
+ response shape shows up as a miss on the other.
731
+
732
+ ## Tests and CI
733
+
734
+ ```bash
735
+ python -m unittest discover -s tests -p "test_*.py" -v # 445 tests, no network
736
+ python -m ruff check . # lint
737
+ python -m mypy xssmap # types on shipped package
738
+ ```
739
+
740
+ Units cover the parts that are hard to eyeball: the tri-state verifier and its
741
+ "exception is not a negative" rule, the CSP parser (including report-only), the mXSS
742
+ round-trip gate, charset trail-byte generation and the UTF-8 control, throttle and
743
+ `Retry-After` handling, resume state, report/SARIF rendering, subdomain parsing, suffix
744
+ derivation and scope matching, the autonomous preset's precedence rules, DOM source and
745
+ delivery inference (`location.hash`/`location.search` outranking the source table),
746
+ storage write/read joins including the cross-origin drop, hydration and `dataset`
747
+ sources, parameter guessing for flows that name none, inline-script merging across
748
+ `<script>` blocks, payload surgery and request replay for the exploit stage (including
749
+ the no-whitespace/no-angle-bracket rule), session re-login and captured-cookie
750
+ precedence, `strict-dynamic` gadget and JSONP notes, adaptive evasion ordering, the
751
+ transport deadline clip, and the bench ground truth.
752
+
753
+ `ruff` and `mypy` settings live in `pyproject.toml`; the ignores are documented inline
754
+ next to the rule they silence rather than in a bare list. mypy is scoped to `xssmap/`
755
+ (the code users run) and does not cover `tests/` or `bench/` fixtures.
756
+
757
+ `.github/workflows/ci.yml` runs five jobs: lint, types, units on a
758
+ 3.9/3.12/Windows matrix, the browser-free selftest on Linux and Windows, and the full
759
+ browser harness on Linux (advisory, since headless timing on shared runners varies).
760
+
761
+ ## Limitations
762
+
763
+ - DOM analysis is static taint tracing plus runtime confirmation. It recognises common
764
+ framework sources and sinks (`useSearchParams`/`useLocation`, `dangerouslySetInnerHTML`,
765
+ `v-html`, `bypassSecurityTrustHtml`, `createContextualFragment`, jQuery `.parseHTML`,
766
+ `innerHTML`, `eval`, `document.write`, `location`) and follows a value across resources
767
+ through web storage, server-rendered state blobs, `data-*` attributes, component state
768
+ (`setState({...})`, `this.state.x`, `useState`), reducer dispatch (`dispatch({type:...})`
769
+ joined to the matching `case` handler, including spread state updates) and a pub/sub bus
770
+ (`emit`/`publish` joined to `on`/`subscribe` handlers). Values carried in an object
771
+ property or an array (`holder.value`, `parts.push`, `arr.join`) are tracked to the sink
772
+ as well. It is still not a general data-flow analysis: a flow that is rendered by a
773
+ server template the crawler never fetches, or that is assembled by string operations the
774
+ taint model does not name, stays a miss. A storage join also needs both pages inside the
775
+ crawl budget (`--max-pages`, `--crawl-depth`), and a blob needs the script that reads it.
776
+ A component-state, reducer or event-bus join is only replayed when the write and the read
777
+ happen on the same page, because an in-memory store is reset by a second navigation; a
778
+ cross-page join is reported as a lead with that reason. Only flows proven in the browser
779
+ are reported, so a miss stays a gap, never a false positive.
780
+ - A lead means the parameter reflects and every payload the scanner tried was filtered,
781
+ encoded or cut. It is recorded with the measured filter (surviving characters, blocked
782
+ keywords and sequences, truncation limit, efficiency score) and low confidence; it is not a
783
+ finding, and it is not proof that a bypass exists - only a reason to look by hand. The
784
+ measurement explains *where* the value landed and how much of it survived; it cannot
785
+ explain why an application-level filter chose to rewrite it.
786
+ - Stored scanning writes data through forms. It is opt-in (`--stored`) and should only be
787
+ used on systems you are authorised to modify.
788
+ - Browser proof and `--verify` cover classic inline execution. CSP handling is bounded on
789
+ purpose: one nonce-reuse attempt when the header really repeats a nonce, then the
790
+ observed block is reported. `strict-dynamic` is analysed rather than assumed: the finding
791
+ notes count the page's own script-creating gadgets (`createElement('script')`,
792
+ `innerHTML`/`document.write` with `<script>`, `insertAdjacentHTML`), because that trust
793
+ model still executes a script a trusted gadget builds from attacker data, and they list
794
+ allowlisted JSONP endpoints that take a callback parameter (skipped under
795
+ `strict-dynamic`, where a host allowlist does not apply). Those are leads for a manual
796
+ gadget check, not an execution claim: the scanner does not weaponise a gadget on its own,
797
+ so a target behind a well-built CSP will usually stay `unproven` - which is a statement
798
+ about the measurement, not about safety.
799
+ - `--verify` replays the recorded request with the run's session. When a login was
800
+ performed (`--login-url`/`--login-user`/`--login-pass`), the credentials are kept in
801
+ memory only for the run: if a replay lands on a login wall (401/403 or a login path),
802
+ the scanner logs in again once, re-syncs the browser cookies and retries, and the
803
+ finding records `reauth`. A captured `Cookie` header from the original request is
804
+ dropped while a session exists so the fresh cookie is the one that is sent. A replay
805
+ still fails as `unproven` if the second login is refused, and the reason says so.
806
+ - The exploit stage proves what the script could read *at that moment*: cookie byte
807
+ counts and names, token readability and one same-origin GET. It does not attempt
808
+ state-changing requests, so it demonstrates capability rather than performing writes.
809
+ - `--callback` binds to loopback by default. Use `--callback-bind` for another interface,
810
+ or `--callback-url` when the listener sits behind NAT and the target must reach a
811
+ public address; the listener is unauthenticated by design and should be short-lived.
812
+ - mXSS and charset stages prove what the *scanner's* browser does with a payload. A
813
+ different browser or version may round-trip differently, so treat a positive as
814
+ "this payload executed here", not "every client is affected".
815
+ - `--save-cookies` writes live session secrets to disk in Netscape format. The file is
816
+ created `0600` on POSIX; Windows does not enforce mode bits, so rely on NTFS ACLs there.
817
+ Treat the file like a password either way.
818
+ - Subdomain discovery is best-effort reconnaissance, not a complete asset inventory. The
819
+ enumeration base is public-suffix aware for the common `co.uk`/`com.au`-style suffixes
820
+ built in, and `--public-suffix-list FILE` loads the real PSL (`*.` and `!` rules
821
+ supported) when you need full coverage; without it, an unusual multi-label suffix falls
822
+ back to the simple rule. Certificate transparency is queried through three independent
823
+ providers in order (crt.sh, certspotter, hackertarget): a failing or rate-limited
824
+ provider is recorded and skipped, the next one is asked, and the per-source yield is
825
+ reported, so one bad day at crt.sh no longer empties the result. Coverage is still
826
+ best-effort, and the DNS step uses a short built-in wordlist - feed your own recon in
827
+ through `--subdomain-wordlist` for a full inventory.
828
+ - `--max-minutes` is enforced inside phases as well as between them (crawl, scan pool,
829
+ DOM, stored and exploit stages all check a deadline), and the transport clips each
830
+ request's socket timeout to the budget that is left, with retry sleeps and throttle
831
+ pacing stopping at the deadline. A request already in flight can still exceed the budget
832
+ by at most the remaining slice (floored at 0.25s), and the report is then marked
833
+ `partial` rather than pretending the sweep finished.
834
+ - Heavy crawls against large sites should be bounded with `--max-pages`, `--delay` and
835
+ `--threads`; the scanner honours those limits for every request it makes.
836
+ - WAF evasion is signature-driven at first and adaptive afterwards: each strategy that
837
+ makes a payload reflect verbatim is tried first on later points, and one that only
838
+ produces mangled or blocked responses moves to the end of the budget (the per-scan
839
+ ranking is written to the report as `evasion_learned`). A proprietary or heavily
840
+ customised filter may still be misidentified or missed entirely, and the vendor
841
+ vocabulary only helps when the blocking rule is one of the known shapes. A failed bypass
842
+ is a failed bypass, not proof that the target is safe.
843
+ - `--brute`, `--discover-params` and `--fuzz` are request-hungry by design. Each has an
844
+ explicit budget and stops at it; the report marks a truncated fuzz run instead of
845
+ implying it covered every parameter.
846
+ - JavaScript CVE hints are version indicators, not exploit proof, and only cover the
847
+ libraries in the signature table. A current library can still be vulnerable through the
848
+ application's own code, and a library version read from a filename can be wrong.
849
+
850
+ Only test systems you are authorised to test.