xssmap 2.8.1__tar.gz → 2.9.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 (71) hide show
  1. {xssmap-2.8.1 → xssmap-2.9.0}/CHANGELOG.md +39 -0
  2. {xssmap-2.8.1/xssmap.egg-info → xssmap-2.9.0}/PKG-INFO +29 -1
  3. {xssmap-2.8.1 → xssmap-2.9.0}/README.md +28 -0
  4. xssmap-2.9.0/tests/test_crawlperf.py +230 -0
  5. xssmap-2.9.0/tests/test_tech.py +150 -0
  6. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/__init__.py +1 -1
  7. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/cli.py +17 -0
  8. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/crawler.py +86 -39
  9. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/net.py +85 -11
  10. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/report.py +40 -4
  11. xssmap-2.9.0/xssmap/tech.py +261 -0
  12. {xssmap-2.8.1 → xssmap-2.9.0/xssmap.egg-info}/PKG-INFO +29 -1
  13. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/SOURCES.txt +3 -0
  14. {xssmap-2.8.1 → xssmap-2.9.0}/LICENSE +0 -0
  15. {xssmap-2.8.1 → xssmap-2.9.0}/MANIFEST.in +0 -0
  16. {xssmap-2.8.1 → xssmap-2.9.0}/pyproject.toml +0 -0
  17. {xssmap-2.8.1 → xssmap-2.9.0}/setup.cfg +0 -0
  18. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_bench.py +0 -0
  19. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_brute.py +0 -0
  20. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_budget.py +0 -0
  21. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_charset.py +0 -0
  22. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_csp.py +0 -0
  23. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_domscan.py +0 -0
  24. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_domstate.py +0 -0
  25. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_efficiency.py +0 -0
  26. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_exploit.py +0 -0
  27. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_fuzz.py +0 -0
  28. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_honesty.py +0 -0
  29. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_inline.py +0 -0
  30. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_jslib.py +0 -0
  31. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_leads.py +0 -0
  32. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_mxss.py +0 -0
  33. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_ops.py +0 -0
  34. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_params.py +0 -0
  35. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_recon_report.py +0 -0
  36. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_session.py +0 -0
  37. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_subdomains.py +0 -0
  38. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_units.py +0 -0
  39. {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_waf.py +0 -0
  40. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/__main__.py +0 -0
  41. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/browser.py +0 -0
  42. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/brute.py +0 -0
  43. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/callback.py +0 -0
  44. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/charset.py +0 -0
  45. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/contexts.py +0 -0
  46. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/csp.py +0 -0
  47. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/domscan.py +0 -0
  48. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/domstate.py +0 -0
  49. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/efficiency.py +0 -0
  50. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/exploit.py +0 -0
  51. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/filters.py +0 -0
  52. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/fuzz.py +0 -0
  53. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/htmlparse.py +0 -0
  54. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/jslib.py +0 -0
  55. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/mxss.py +0 -0
  56. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/params.py +0 -0
  57. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/payloads.py +0 -0
  58. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/py.typed +0 -0
  59. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/repro.py +0 -0
  60. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/sarif.py +0 -0
  61. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/scanner.py +0 -0
  62. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/session.py +0 -0
  63. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/state.py +0 -0
  64. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/stored.py +0 -0
  65. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/subdomains.py +0 -0
  66. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/verify.py +0 -0
  67. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/waf.py +0 -0
  68. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/dependency_links.txt +0 -0
  69. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/entry_points.txt +0 -0
  70. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/requires.txt +0 -0
  71. {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/top_level.txt +0 -0
@@ -8,6 +8,45 @@ Releases before 2.8.0 predate this file; their history is in the git log.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [2.9.0] - 2026-11-13
12
+
13
+ ### Added
14
+
15
+ - Server-stack fingerprinting. `Server`, `X-Powered-By`, `X-AspNet-Version`,
16
+ `X-Generator`, `<meta name="generator">` and session cookie names are read
17
+ from the seed response the crawl already fetched, so the pass sends no extra
18
+ request. Rows are printed in the console and rendered in the Markdown and
19
+ HTML reports under "Server stack".
20
+ - Outdated-version detection for components that advertise a version, using a
21
+ deliberately short policy table: Apache 2.4.49/2.4.50 (CVE-2021-41773,
22
+ CVE-2021-42013), nginx before 1.20.1 (CVE-2021-23017), PHP branches before
23
+ 8.2 (out of security support) and IIS 6.0-8.5 (shipped only with an
24
+ end-of-life Windows Server). A flagged version is a lead for the operator,
25
+ never a finding on its own.
26
+ - 35 tests pinning the new crawl scheduling, the transport cap, the throttle
27
+ rules and the stack policy table.
28
+
29
+ ### Changed
30
+
31
+ - Crawl scheduling is continuous instead of level-by-level. `--depth` counts
32
+ hops from the seed and `--max-pages` is a hard stop, so one slow branch no
33
+ longer holds back pages that are already queued behind it.
34
+ - `robots.txt` and `sitemap.xml` are fetched concurrently, turning two round
35
+ trips into one.
36
+ - `Throttle` no longer reads an HTTP 500 as backpressure. An application error
37
+ is not load, so only 429/503 count, and only after a short streak; a target
38
+ that answers a fraction of URLs with 500 used to stack seconds of sleep into
39
+ the crawl and dominate its duration.
40
+ - Binary bodies are read up to a 256 KB cap and marked truncated instead of
41
+ being buffered whole. A truncated body drops its connection rather than
42
+ returning a half-drained socket to the pool.
43
+
44
+ ### Fixed
45
+
46
+ - Crawling a target that 500s part of its URL space is ~24x faster at the same
47
+ page count: 18.90 s to 0.79 s for 68 requests in the bundled benchmark
48
+ (identical page sets, deterministic error injection).
49
+
11
50
  ## [2.8.1] - 2026-11-13
12
51
 
13
52
  Packaging release. No scanner behaviour changed.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xssmap
3
- Version: 2.8.1
3
+ Version: 2.9.0
4
4
  Summary: Context-aware XSS scanner: crawler, browser verification, mXSS, charset and stored-XSS stages
5
5
  Author: Ian Mattas
6
6
  License-Expression: MIT
@@ -58,6 +58,12 @@ actually execute in a real browser.
58
58
  friends from body or filename, compares versions against each advisory's affected range
59
59
  (so 2.3.2 is not blamed for a 3.4.1 fix) and can merge fresh advisories from osv.dev
60
60
  with `--osv`.
61
+ - **Server stack and outdated versions.** Reads what each host says about itself - `Server`,
62
+ `X-Powered-By`, `X-AspNet-Version`, `X-Generator`, `<meta name="generator">` and session
63
+ cookie names - from the seed response the crawl already received, so the pass costs no
64
+ extra request. Versions whose exact build is known-vulnerable or out of support (Apache
65
+ 2.4.49/2.4.50, nginx before 1.20.1, PHP before 8.2, IIS 6.0-8.5) are flagged with the
66
+ CVE, and every row names the header it came from.
61
67
  - **Leads, not silence.** A parameter that reflects but swallows every payload is recorded
62
68
  as an unconfirmed lead with the measured filter and how much of the probe survived
63
69
  (verbatim, recased, escaped, truncated), in its own report section - never presented as a
@@ -483,6 +489,28 @@ OSV enrichment is verified against the live service, not only against fixtures.
483
489
  and Lodash 4.17.4 (9) into the local verdicts, then stopped: the fourth library was never
484
490
  queried and stayed unchanged, so the cap is visibly enforced.
485
491
 
492
+ ### Server stack and outdated versions
493
+
494
+ The headers a host sends are also an inventory: `Server: nginx/1.18.0` is a version, not
495
+ just a name. The crawler keeps the headers and the `<meta name="generator">` value of the
496
+ first HTML response it already fetched, and the stack pass reads them without a second
497
+ request. Three things make the result usable rather than noisy:
498
+
499
+ - A row always cites its evidence (`Server`, `X-Powered-By`, `X-AspNet-Version`,
500
+ `X-Generator`, `meta generator`, or `cookie <name>`), so a claim can be checked against
501
+ the response that produced it.
502
+ - Versions are compared numerically, so nginx 1.20.10 is newer than 1.20.9 and is not
503
+ blamed for a fix that shipped in 1.20.1, and the IIS rules match `8.5` without also
504
+ matching `10.0`.
505
+ - The policy table only holds issues that are public, fixed and cheap to verify: Apache
506
+ 2.4.49/2.4.50 (CVE-2021-41773, CVE-2021-42013), nginx before 1.20.1 (CVE-2021-23017),
507
+ PHP branches before 8.2 (out of security support) and IIS 6.0-8.5 (shipped only with an
508
+ end-of-life Windows Server). A CMS such as WordPress is reported with its disclosed
509
+ version and no end-of-life claim, because that judgement is not the scanner's to make.
510
+
511
+ A flagged version is a lead: it says the build has a published problem, not that this
512
+ host is exploitable through it. The finding standard for XSS is unchanged.
513
+
486
514
  ### Browser cost
487
515
 
488
516
  `--browser` reuses one browser and one page per run. The report's `browser_verification`
@@ -34,6 +34,12 @@ actually execute in a real browser.
34
34
  friends from body or filename, compares versions against each advisory's affected range
35
35
  (so 2.3.2 is not blamed for a 3.4.1 fix) and can merge fresh advisories from osv.dev
36
36
  with `--osv`.
37
+ - **Server stack and outdated versions.** Reads what each host says about itself - `Server`,
38
+ `X-Powered-By`, `X-AspNet-Version`, `X-Generator`, `<meta name="generator">` and session
39
+ cookie names - from the seed response the crawl already received, so the pass costs no
40
+ extra request. Versions whose exact build is known-vulnerable or out of support (Apache
41
+ 2.4.49/2.4.50, nginx before 1.20.1, PHP before 8.2, IIS 6.0-8.5) are flagged with the
42
+ CVE, and every row names the header it came from.
37
43
  - **Leads, not silence.** A parameter that reflects but swallows every payload is recorded
38
44
  as an unconfirmed lead with the measured filter and how much of the probe survived
39
45
  (verbatim, recased, escaped, truncated), in its own report section - never presented as a
@@ -459,6 +465,28 @@ OSV enrichment is verified against the live service, not only against fixtures.
459
465
  and Lodash 4.17.4 (9) into the local verdicts, then stopped: the fourth library was never
460
466
  queried and stayed unchanged, so the cap is visibly enforced.
461
467
 
468
+ ### Server stack and outdated versions
469
+
470
+ The headers a host sends are also an inventory: `Server: nginx/1.18.0` is a version, not
471
+ just a name. The crawler keeps the headers and the `<meta name="generator">` value of the
472
+ first HTML response it already fetched, and the stack pass reads them without a second
473
+ request. Three things make the result usable rather than noisy:
474
+
475
+ - A row always cites its evidence (`Server`, `X-Powered-By`, `X-AspNet-Version`,
476
+ `X-Generator`, `meta generator`, or `cookie <name>`), so a claim can be checked against
477
+ the response that produced it.
478
+ - Versions are compared numerically, so nginx 1.20.10 is newer than 1.20.9 and is not
479
+ blamed for a fix that shipped in 1.20.1, and the IIS rules match `8.5` without also
480
+ matching `10.0`.
481
+ - The policy table only holds issues that are public, fixed and cheap to verify: Apache
482
+ 2.4.49/2.4.50 (CVE-2021-41773, CVE-2021-42013), nginx before 1.20.1 (CVE-2021-23017),
483
+ PHP branches before 8.2 (out of security support) and IIS 6.0-8.5 (shipped only with an
484
+ end-of-life Windows Server). A CMS such as WordPress is reported with its disclosed
485
+ version and no end-of-life claim, because that judgement is not the scanner's to make.
486
+
487
+ A flagged version is a lead: it says the build has a published problem, not that this
488
+ host is exploitable through it. The finding standard for XSS is unchanged.
489
+
462
490
  ### Browser cost
463
491
 
464
492
  `--browser` reuses one browser and one page per run. The report's `browser_verification`
@@ -0,0 +1,230 @@
1
+ """Crawl scheduling and transport cost.
2
+
3
+ Three behaviours are pinned here, all of them things that made a crawl slower
4
+ than the work it was doing:
5
+
6
+ * the crawl queue is fed continuously, so ``max_depth`` counts hops from the
7
+ seed and ``max_pages`` is a hard stop;
8
+ * a 500 from the target is not read as backpressure (only 429/503 are, and
9
+ only after a short streak);
10
+ * binary bodies are read up to a cap and their connection is dropped instead
11
+ of being returned to the pool half-drained.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import threading
18
+ import unittest
19
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ from xssmap.crawler import Crawler, Scope
24
+ from xssmap.net import HTTP, MAX_BINARY_BODY, Throttle
25
+
26
+ SEED = "http://app.test/"
27
+
28
+
29
+ class QuietLog:
30
+ def info(self, *_a, **_k):
31
+ pass
32
+
33
+ def debug(self, *_a, **_k):
34
+ pass
35
+
36
+ def warn(self, *_a, **_k):
37
+ pass
38
+
39
+ def ok(self, *_a, **_k):
40
+ pass
41
+
42
+ def action(self, *_a, **_k):
43
+ pass
44
+
45
+
46
+ class PageResp:
47
+ """Enough of :class:`xssmap.net.Response` for the crawler to parse."""
48
+
49
+ def __init__(self, url, status=200, text="", content_type="text/html"):
50
+ self.url = url
51
+ self.status = status
52
+ self.text = text
53
+ self.body = text.encode()
54
+ self.content_type = content_type
55
+ self.history: list = []
56
+
57
+ @property
58
+ def is_html(self):
59
+ return self.content_type.startswith("text/html")
60
+
61
+ @property
62
+ def is_javascript(self):
63
+ return "javascript" in self.content_type
64
+
65
+
66
+ class RouterHTTP:
67
+ def __init__(self, pages):
68
+ self.pages = pages
69
+ self.urls: list[str] = []
70
+
71
+ def get(self, url, headers=None, **_kwargs):
72
+ self.urls.append(url)
73
+ if url.endswith("/robots.txt") or url.endswith("/sitemap.xml"):
74
+ return PageResp(url, 404, "not found", "text/plain")
75
+ text = self.pages.get(url)
76
+ if text is None:
77
+ return PageResp(url, 404, "not found", "text/html")
78
+ ctype = "application/javascript" if url.endswith(".js") else "text/html"
79
+ return PageResp(url, 200, text, ctype)
80
+
81
+
82
+ def crawl(pages, depth, max_pages=50):
83
+ http = RouterHTTP(pages)
84
+ crawler = Crawler(http, Scope(SEED), QuietLog(), max_depth=depth,
85
+ max_pages=max_pages, crawl_js=True)
86
+ return crawler.run([SEED]), http
87
+
88
+
89
+ class DepthTests(unittest.TestCase):
90
+ PAGES = {
91
+ SEED: '<a href="/a">a</a><a href="/b">b</a>',
92
+ "http://app.test/a": '<a href="/a1">a1</a>',
93
+ "http://app.test/b": '<a href="/b1">b1</a>',
94
+ "http://app.test/a1": '<a href="/a2">a2</a>',
95
+ "http://app.test/b1": "leaf",
96
+ "http://app.test/a2": "leaf",
97
+ }
98
+
99
+ def test_depth_counts_hops_from_seed(self):
100
+ _result, http = crawl(self.PAGES, depth=1)
101
+ fetched = {u for u in http.urls if not u.endswith(("/robots.txt", "/sitemap.xml"))}
102
+ self.assertEqual(fetched, {SEED, "http://app.test/a", "http://app.test/b"})
103
+ self.assertNotIn("http://app.test/a1", fetched)
104
+
105
+ def test_seed_is_depth_zero(self):
106
+ result, _http = crawl(self.PAGES, depth=1)
107
+ self.assertEqual(result.pages[SEED].depth, 0)
108
+ self.assertEqual(result.pages["http://app.test/a"].depth, 1)
109
+
110
+ def test_page_budget_stops_the_crawl(self):
111
+ _result, http = crawl(self.PAGES, depth=5, max_pages=2)
112
+ fetched = [u for u in http.urls if not u.endswith(("/robots.txt", "/sitemap.xml"))]
113
+ self.assertEqual(len(fetched), 2)
114
+
115
+ def test_boundary_pages_are_fetched_but_not_expanded(self):
116
+ result, _http = crawl(self.PAGES, depth=2)
117
+ # The seed is hop 0, so depth 2 reaches /a1 at hop 2 and stops there.
118
+ self.assertEqual(result.pages["http://app.test/a1"].depth, 2)
119
+ self.assertNotIn("http://app.test/a2", result.pages)
120
+
121
+
122
+ class ThrottleTests(unittest.TestCase):
123
+ def test_application_error_is_not_backpressure(self):
124
+ throttle = Throttle(delay=0.0)
125
+ for _ in range(6):
126
+ throttle.on_status(500)
127
+ self.assertEqual(throttle.delay, 0.0)
128
+
129
+ def test_single_load_signal_does_not_slow_the_scan(self):
130
+ throttle = Throttle(delay=0.0)
131
+ throttle.on_status(503)
132
+ self.assertEqual(throttle.delay, 0.0)
133
+
134
+ def test_repeated_load_signals_slow_then_recover(self):
135
+ throttle = Throttle(delay=0.0)
136
+ throttle.on_status(429)
137
+ throttle.on_status(429)
138
+ slowed = throttle.delay
139
+ self.assertGreater(slowed, 0.0)
140
+ for _ in range(6):
141
+ throttle.on_status(200)
142
+ self.assertLess(throttle.delay, slowed)
143
+ self.assertGreaterEqual(throttle.delay, throttle.base_delay)
144
+
145
+ def test_adaptive_delay_is_capped(self):
146
+ throttle = Throttle(delay=0.0)
147
+ for _ in range(20):
148
+ throttle.on_status(503)
149
+ self.assertLessEqual(throttle.delay, 3.0)
150
+
151
+
152
+ class _BodyHandler(BaseHTTPRequestHandler):
153
+ protocol_version = "HTTP/1.1"
154
+ disable_nagle_algorithm = True
155
+
156
+ def log_message(self, *_a):
157
+ pass
158
+
159
+ def do_GET(self):
160
+ if self.path == "/big.bin":
161
+ body = b"\x00" * (MAX_BINARY_BODY + 500_000)
162
+ ctype = "application/octet-stream"
163
+ elif self.path == "/small.bin":
164
+ body = b"\x00" * 2048
165
+ ctype = "application/octet-stream"
166
+ else:
167
+ body = b"<html><body>hello</body></html>"
168
+ ctype = "text/html"
169
+ self.send_response(200)
170
+ self.send_header("Content-Type", ctype)
171
+ self.send_header("Content-Length", str(len(body)))
172
+ self.end_headers()
173
+ self.wfile.write(body)
174
+
175
+
176
+ class _QuietServer(ThreadingHTTPServer):
177
+ daemon_threads = True
178
+
179
+ def handle_error(self, request, client_address):
180
+ # A client that abandons a capped binary body leaves the server thread
181
+ # writing into a closed socket; that is the behaviour under test.
182
+ pass
183
+
184
+
185
+ class BodyCapTests(unittest.TestCase):
186
+ @classmethod
187
+ def setUpClass(cls):
188
+ cls.server = _QuietServer(("127.0.0.1", 0), _BodyHandler)
189
+ cls.server.daemon_threads = True
190
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
191
+ cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}"
192
+
193
+ @classmethod
194
+ def tearDownClass(cls):
195
+ cls.server.shutdown()
196
+
197
+ def test_binary_body_is_capped_and_flagged(self):
198
+ http = HTTP(timeout=10, retries=0, throttle=Throttle())
199
+ resp = http.get(self.base + "/big.bin")
200
+ self.assertTrue(resp.truncated)
201
+ self.assertEqual(len(resp.body), MAX_BINARY_BODY)
202
+
203
+ def test_truncated_connection_is_not_pooled(self):
204
+ http = HTTP(timeout=10, retries=0, throttle=Throttle())
205
+ http.get(self.base + "/big.bin")
206
+ self.assertEqual(len(http._pool()), 0)
207
+
208
+ def test_full_binary_body_is_pooled(self):
209
+ # Under the cap nothing is truncated, so the socket stays reusable.
210
+ http = HTTP(timeout=10, retries=0, throttle=Throttle())
211
+ resp = http.get(self.base + "/small.bin")
212
+ self.assertFalse(resp.truncated)
213
+ pool = http._pool()
214
+ self.assertEqual(len(pool), 1)
215
+ for _key, conn, _used in pool:
216
+ conn.close()
217
+
218
+ def test_text_body_is_read_in_full_and_pooled(self):
219
+ http = HTTP(timeout=10, retries=0, throttle=Throttle())
220
+ resp = http.get(self.base + "/page.html")
221
+ self.assertFalse(resp.truncated)
222
+ self.assertIn(b"hello", resp.body)
223
+ pool = http._pool()
224
+ self.assertEqual(len(pool), 1)
225
+ for _key, conn, _used in pool:
226
+ conn.close()
227
+
228
+
229
+ if __name__ == "__main__":
230
+ unittest.main()
@@ -0,0 +1,150 @@
1
+ """Server-stack fingerprinting: header parsing, version rules and report shape."""
2
+ import unittest
3
+
4
+ from xssmap import tech
5
+
6
+
7
+ def names(techs):
8
+ return [t.name for t in techs]
9
+
10
+
11
+ def by_name(techs, name):
12
+ return next(t for t in techs if t.name == name)
13
+
14
+
15
+ class HeaderParsingTests(unittest.TestCase):
16
+ def test_nginx_version_is_read_from_server(self):
17
+ found = tech.detect([("Server", "nginx/1.24.0")])
18
+ self.assertEqual(names(found), ["nginx"])
19
+ self.assertEqual(found[0].version, "1.24.0")
20
+ self.assertEqual(found[0].source, "Server")
21
+
22
+ def test_header_lookup_is_case_insensitive(self):
23
+ found = tech.detect([("server", "Apache/2.4.51 (Debian)")])
24
+ self.assertEqual(found[0].name, "Apache httpd")
25
+ self.assertEqual(found[0].version, "2.4.51")
26
+
27
+ def test_products_without_a_version_are_still_named(self):
28
+ found = tech.detect([("Server", "cloudflare")])
29
+ self.assertEqual(found[0].name, "Cloudflare")
30
+ self.assertEqual(found[0].version, "")
31
+
32
+ def test_several_products_in_one_header(self):
33
+ found = tech.detect([("Server", "nginx/1.18.0 (Ubuntu) OpenSSL/1.1.1f")])
34
+ self.assertEqual(names(found), ["nginx"])
35
+
36
+ def test_powered_by_and_aspnet_version(self):
37
+ found = tech.detect([("X-Powered-By", "PHP/8.1.2"),
38
+ ("X-AspNet-Version", "4.0.30319")])
39
+ self.assertEqual(set(names(found)), {"PHP", "ASP.NET"})
40
+ self.assertEqual(by_name(found, "ASP.NET").version, "4.0.30319")
41
+
42
+ def test_meta_generator_identifies_the_cms(self):
43
+ found = tech.detect([], generator="WordPress 6.2")
44
+ self.assertEqual(found[0].name, "WordPress")
45
+ self.assertEqual(found[0].version, "6.2")
46
+ self.assertEqual(found[0].source, "meta generator")
47
+
48
+ def test_cookie_names_identify_the_runtime(self):
49
+ found = tech.detect([("Set-Cookie", "PHPSESSID=abc123; path=/; HttpOnly")])
50
+ self.assertEqual(found[0].name, "PHP")
51
+ self.assertEqual(found[0].version, "")
52
+ self.assertTrue(found[0].source.startswith("cookie "))
53
+
54
+ def test_cookie_does_not_repeat_a_versioned_product(self):
55
+ found = tech.detect([("X-Powered-By", "PHP/7.4.33"),
56
+ ("Set-Cookie", "PHPSESSID=abc; path=/")])
57
+ self.assertEqual(names(found), ["PHP"])
58
+ self.assertEqual(found[0].version, "7.4.33")
59
+
60
+ def test_duplicate_evidence_is_collapsed(self):
61
+ found = tech.detect([("Server", "nginx/1.24.0"), ("Server", "nginx/1.24.0")])
62
+ self.assertEqual(len(found), 1)
63
+
64
+ def test_empty_headers_yield_nothing(self):
65
+ self.assertEqual(tech.detect([]), [])
66
+ self.assertEqual(tech.detect([], generator=""), [])
67
+
68
+
69
+ class VersionRuleTests(unittest.TestCase):
70
+ def test_apache_2449_is_flagged_critical(self):
71
+ row = tech.detect([("Server", "Apache/2.4.49")])[0]
72
+ self.assertEqual(row.status, "vulnerable")
73
+ self.assertEqual(row.severity, "critical")
74
+ self.assertIn("CVE-2021-41773", row.cves)
75
+
76
+ def test_apache_2450_is_flagged_with_its_own_cve(self):
77
+ row = tech.detect([("Server", "Apache/2.4.50")])[0]
78
+ self.assertIn("CVE-2021-42013", row.cves)
79
+
80
+ def test_patched_apache_is_not_flagged(self):
81
+ row = tech.detect([("Server", "Apache/2.4.51")])[0]
82
+ self.assertEqual(row.status, "detected")
83
+ self.assertEqual(row.cves, [])
84
+
85
+ def test_old_nginx_matches_the_resolver_cve(self):
86
+ row = tech.detect([("Server", "nginx/1.18.0")])[0]
87
+ self.assertEqual(row.status, "vulnerable")
88
+ self.assertEqual(row.severity, "high")
89
+ self.assertIn("CVE-2021-23017", row.cves)
90
+
91
+ def test_nginx_1201_is_the_fix_and_is_clean(self):
92
+ row = tech.detect([("Server", "nginx/1.20.1")])[0]
93
+ self.assertEqual(row.status, "detected")
94
+
95
+ def test_version_compare_is_numeric_not_lexicographic(self):
96
+ # 1.20.10 > 1.20.9 > 1.20.1, so none of these are "older than 1.20.1".
97
+ for version in ("1.20.9", "1.20.10", "1.21.0"):
98
+ row = tech.detect([("Server", f"nginx/{version}")])[0]
99
+ self.assertEqual(row.status, "detected", version)
100
+
101
+ def test_php_below_82_is_out_of_support_without_a_cve_claim(self):
102
+ row = tech.detect([("X-Powered-By", "PHP/7.4.33")])[0]
103
+ self.assertEqual(row.status, "outdated")
104
+ self.assertEqual(row.cves, [])
105
+ self.assertTrue(row.detail)
106
+
107
+ def test_supported_php_is_clean(self):
108
+ row = tech.detect([("X-Powered-By", "PHP/8.3.1")])[0]
109
+ self.assertEqual(row.status, "detected")
110
+
111
+ def test_iis_10_is_not_flagged(self):
112
+ row = tech.detect([("Server", "Microsoft-IIS/10.0")])[0]
113
+ self.assertEqual(row.status, "detected")
114
+
115
+ def test_iis_85_is_flagged_but_prefix_does_not_swallow_10(self):
116
+ self.assertEqual(tech.detect([("Server", "Microsoft-IIS/8.5")])[0].status,
117
+ "outdated")
118
+ self.assertEqual(tech.detect([("Server", "Microsoft-IIS/10.0")])[0].status,
119
+ "detected")
120
+
121
+ def test_cms_versions_are_reported_without_eol_claims(self):
122
+ row = tech.detect([], generator="Drupal 9")[0]
123
+ self.assertEqual(row.status, "detected")
124
+ self.assertEqual(row.cves, [])
125
+
126
+
127
+ class ReportShapeTests(unittest.TestCase):
128
+ def test_rows_carry_every_report_field(self):
129
+ rows = tech.rows(tech.detect([("Server", "Apache/2.4.49")]))
130
+ self.assertEqual(set(rows[0]), {"name", "version", "source", "status",
131
+ "severity", "cves", "detail"})
132
+ self.assertIsInstance(rows[0]["cves"], list)
133
+
134
+ def test_summary_counts_by_status(self):
135
+ found = (tech.detect([("Server", "Apache/2.4.49")])
136
+ + tech.detect([("X-Powered-By", "PHP/7.4")])
137
+ + tech.detect([("Server", "Microsoft-IIS/10.0")]))
138
+ self.assertEqual(tech.summary(found)["vulnerable"], 1)
139
+ self.assertEqual(tech.summary(found)["outdated"], 1)
140
+ self.assertEqual(tech.summary(found)["detected"], 1)
141
+
142
+ def test_line_names_the_version_and_evidence(self):
143
+ row = tech.detect([("Server", "nginx/1.18.0")])[0]
144
+ self.assertIn("nginx 1.18.0", row.line())
145
+ self.assertIn("Server", row.line())
146
+ self.assertIn("1.20.1", row.line())
147
+
148
+
149
+ if __name__ == "__main__":
150
+ unittest.main()
@@ -1,6 +1,6 @@
1
1
  """xssmap - context-aware cross-site scripting scanner with a crawler."""
2
2
 
3
- __version__ = "2.8.1"
3
+ __version__ = "2.9.0"
4
4
 
5
5
  __all__ = ["__version__", "main"]
6
6
 
@@ -18,6 +18,7 @@ from . import report as rp
18
18
  from . import sarif
19
19
  from . import state as stateio
20
20
  from . import subdomains as subs
21
+ from . import tech as techmod
21
22
  from . import waf as wafmod
22
23
  from .browser import BrowserVerifier
23
24
  from .callback import CallbackListener
@@ -723,6 +724,7 @@ def main(argv=None) -> int:
723
724
  brute_reports: list[dict] = []
724
725
  discoveries: list[dict] = []
725
726
  libs_found: list = []
727
+ server_tech: list = []
726
728
  waf_results: dict[str, wafmod.WAFDetection] = {}
727
729
  evasion_notes: dict[str, str] = {}
728
730
  fuzz_summary = None
@@ -824,6 +826,17 @@ def main(argv=None) -> int:
824
826
  if crawl_result.errors:
825
827
  log.warn(f"{len(crawl_result.errors)} request(s) failed during crawl "
826
828
  f"(first: {crawl_result.errors[0][1][:90]})")
829
+ # Server-stack pass: it reads the seed response the crawl already
830
+ # received, so it costs no extra request. An outdated version is a
831
+ # lead for the operator, never a finding on its own.
832
+ if crawl_result.root_headers:
833
+ server_tech = techmod.detect(crawl_result.root_headers,
834
+ crawl_result.generator)
835
+ for entry in server_tech:
836
+ if entry.status == "detected":
837
+ log.info(entry.line())
838
+ else:
839
+ log.warn(entry.line())
827
840
  else:
828
841
  log.info("crawling disabled")
829
842
 
@@ -1291,6 +1304,10 @@ def main(argv=None) -> int:
1291
1304
  meta["js_libraries"] = [lib.as_dict() for lib in lib_list]
1292
1305
  meta["js_library_summary"] = {
1293
1306
  k: v for k, v in jslibmod.summary(lib_list).items() if k != "libraries"}
1307
+ if server_tech:
1308
+ meta["server_stack"] = techmod.rows(server_tech)
1309
+ meta["server_stack_summary"] = {
1310
+ k: v for k, v in techmod.summary(server_tech).items() if v}
1294
1311
  if discoveries:
1295
1312
  meta["discovered_parameters"] = discoveries
1296
1313
  if fuzz_summary is not None: