xssmap 2.8.0__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.9.0/CHANGELOG.md +91 -0
  2. xssmap-2.9.0/LICENSE +21 -0
  3. xssmap-2.9.0/MANIFEST.in +4 -0
  4. {xssmap-2.8.0/xssmap.egg-info → xssmap-2.9.0}/PKG-INFO +37 -2
  5. xssmap-2.8.0/PKG-INFO → xssmap-2.9.0/README.md +28 -17
  6. {xssmap-2.8.0 → xssmap-2.9.0}/pyproject.toml +17 -4
  7. xssmap-2.9.0/tests/test_crawlperf.py +230 -0
  8. xssmap-2.9.0/tests/test_tech.py +150 -0
  9. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/__init__.py +1 -1
  10. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/cli.py +17 -0
  11. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/crawler.py +86 -39
  12. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/net.py +85 -11
  13. xssmap-2.9.0/xssmap/py.typed +0 -0
  14. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/report.py +40 -4
  15. xssmap-2.9.0/xssmap/tech.py +261 -0
  16. xssmap-2.8.0/README.md → xssmap-2.9.0/xssmap.egg-info/PKG-INFO +52 -0
  17. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap.egg-info/SOURCES.txt +7 -0
  18. {xssmap-2.8.0 → xssmap-2.9.0}/setup.cfg +0 -0
  19. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_bench.py +0 -0
  20. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_brute.py +0 -0
  21. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_budget.py +0 -0
  22. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_charset.py +0 -0
  23. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_csp.py +0 -0
  24. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_domscan.py +0 -0
  25. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_domstate.py +0 -0
  26. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_efficiency.py +0 -0
  27. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_exploit.py +0 -0
  28. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_fuzz.py +0 -0
  29. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_honesty.py +0 -0
  30. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_inline.py +0 -0
  31. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_jslib.py +0 -0
  32. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_leads.py +0 -0
  33. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_mxss.py +0 -0
  34. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_ops.py +0 -0
  35. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_params.py +0 -0
  36. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_recon_report.py +0 -0
  37. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_session.py +0 -0
  38. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_subdomains.py +0 -0
  39. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_units.py +0 -0
  40. {xssmap-2.8.0 → xssmap-2.9.0}/tests/test_waf.py +0 -0
  41. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/__main__.py +0 -0
  42. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/browser.py +0 -0
  43. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/brute.py +0 -0
  44. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/callback.py +0 -0
  45. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/charset.py +0 -0
  46. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/contexts.py +0 -0
  47. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/csp.py +0 -0
  48. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/domscan.py +0 -0
  49. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/domstate.py +0 -0
  50. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/efficiency.py +0 -0
  51. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/exploit.py +0 -0
  52. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/filters.py +0 -0
  53. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/fuzz.py +0 -0
  54. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/htmlparse.py +0 -0
  55. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/jslib.py +0 -0
  56. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/mxss.py +0 -0
  57. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/params.py +0 -0
  58. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/payloads.py +0 -0
  59. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/repro.py +0 -0
  60. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/sarif.py +0 -0
  61. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/scanner.py +0 -0
  62. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/session.py +0 -0
  63. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/state.py +0 -0
  64. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/stored.py +0 -0
  65. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/subdomains.py +0 -0
  66. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/verify.py +0 -0
  67. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap/waf.py +0 -0
  68. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap.egg-info/dependency_links.txt +0 -0
  69. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap.egg-info/entry_points.txt +0 -0
  70. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap.egg-info/requires.txt +0 -0
  71. {xssmap-2.8.0 → xssmap-2.9.0}/xssmap.egg-info/top_level.txt +0 -0
@@ -0,0 +1,91 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ Releases before 2.8.0 predate this file; their history is in the git log.
8
+
9
+ ## [Unreleased]
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
+
50
+ ## [2.8.1] - 2026-11-13
51
+
52
+ Packaging release. No scanner behaviour changed.
53
+
54
+ ### Added
55
+
56
+ - `LICENSE` (MIT).
57
+ - `CHANGELOG.md`.
58
+ - `xssmap/py.typed` so downstream type checkers use the project's inline
59
+ annotations (PEP 561), shipped inside the wheel as package data.
60
+
61
+ ### Changed
62
+
63
+ - `pyproject.toml` now declares the license with PEP 639 metadata
64
+ (`license = "MIT"`, `license-files`) instead of carrying a comment saying no
65
+ license was stated, so the wheel and sdist advertise the terms and PyPI
66
+ renders them.
67
+ - Build backend floor raised to `setuptools>=77`, which is the first release
68
+ that understands `license-files` and an SPDX license expression.
69
+ - Author metadata now names the maintainer instead of repeating the project
70
+ name.
71
+
72
+ ## [2.8.0] - 2026-11-13
73
+
74
+ ### Added
75
+
76
+ - DOM state-store and hydration flows. A value written to
77
+ `localStorage`/`sessionStorage` on one page and rendered as HTML on another,
78
+ and a server-rendered state blob (`__INITIAL_STATE__`, `__NEXT_DATA__`,
79
+ `data-*`) that a bundle re-reads after hydration, are now replayed step by
80
+ step in the browser instead of being left as unprovable static guesses. The
81
+ new `xssmap/domstate.py` engine drives both, and the bundled lab serves
82
+ `/dom-state-read` and `/dom-hydrate` endpoints to exercise them.
83
+ - Splash banner in the sqlmap idiom, drawn on every interactive run and
84
+ suppressed with `--no-color`.
85
+ - 21 tests covering the DOM state-store and hydration engine.
86
+
87
+ ### Fixed
88
+
89
+ - The remaining DOM-analysis gap: storage-mediated and hydration-mediated
90
+ source-to-sink flows that the static tracer could see but not confirm are now
91
+ confirmed in a real browser.
xssmap-2.9.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ian Mattas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ # Everything a reader of the sdist needs to audit what changed between releases.
2
+ # LICENSE is already pulled in by PEP 639 `license-files`; README and pyproject
3
+ # are added by setuptools itself.
4
+ include CHANGELOG.md
@@ -1,8 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xssmap
3
- Version: 2.8.0
3
+ Version: 2.9.0
4
4
  Summary: Context-aware XSS scanner: crawler, browser verification, mXSS, charset and stored-XSS stages
5
- Author: xssmap
5
+ Author: Ian Mattas
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/imattas/xssmap
8
+ Project-URL: Repository, https://github.com/imattas/xssmap
9
+ Project-URL: Changelog, https://github.com/imattas/xssmap/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/imattas/xssmap/issues
6
11
  Keywords: security,xss,scanner,pentest,sarif
7
12
  Classifier: Environment :: Console
8
13
  Classifier: Intended Audience :: Information Technology
@@ -11,9 +16,11 @@ Classifier: Programming Language :: Python :: 3 :: Only
11
16
  Classifier: Topic :: Security
12
17
  Requires-Python: >=3.9
13
18
  Description-Content-Type: text/markdown
19
+ License-File: LICENSE
14
20
  Provides-Extra: dev
15
21
  Requires-Dist: ruff>=0.6; extra == "dev"
16
22
  Requires-Dist: mypy>=1.10; extra == "dev"
23
+ Dynamic: license-file
17
24
 
18
25
  # xssmap
19
26
 
@@ -51,6 +58,12 @@ actually execute in a real browser.
51
58
  friends from body or filename, compares versions against each advisory's affected range
52
59
  (so 2.3.2 is not blamed for a 3.4.1 fix) and can merge fresh advisories from osv.dev
53
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.
54
67
  - **Leads, not silence.** A parameter that reflects but swallows every payload is recorded
55
68
  as an unconfirmed lead with the measured filter and how much of the probe survived
56
69
  (verbatim, recased, escaped, truncated), in its own report section - never presented as a
@@ -476,6 +489,28 @@ OSV enrichment is verified against the live service, not only against fixtures.
476
489
  and Lodash 4.17.4 (9) into the local verdicts, then stopped: the fourth library was never
477
490
  queried and stayed unchanged, so the cap is visibly enforced.
478
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
+
479
514
  ### Browser cost
480
515
 
481
516
  `--browser` reuses one browser and one page per run. The report's `browser_verification`
@@ -1,20 +1,3 @@
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
1
  # xssmap
19
2
 
20
3
  A context-aware cross-site scripting scanner with a crawler, built in the spirit of
@@ -51,6 +34,12 @@ actually execute in a real browser.
51
34
  friends from body or filename, compares versions against each advisory's affected range
52
35
  (so 2.3.2 is not blamed for a 3.4.1 fix) and can merge fresh advisories from osv.dev
53
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.
54
43
  - **Leads, not silence.** A parameter that reflects but swallows every payload is recorded
55
44
  as an unconfirmed lead with the measured filter and how much of the probe survived
56
45
  (verbatim, recased, escaped, truncated), in its own report section - never presented as a
@@ -476,6 +465,28 @@ OSV enrichment is verified against the live service, not only against fixtures.
476
465
  and Lodash 4.17.4 (9) into the local verdicts, then stopped: the fourth library was never
477
466
  queried and stayed unchanged, so the cap is visibly enforced.
478
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
+
479
490
  ### Browser cost
480
491
 
481
492
  `--browser` reuses one browser and one page per run. The report's `browser_verification`
@@ -1,5 +1,7 @@
1
1
  [build-system]
2
- requires = ["setuptools>=68"]
2
+ # 77 is the first setuptools that understands PEP 639 license expressions and
3
+ # `license-files`, which is how the metadata below states the MIT terms.
4
+ requires = ["setuptools>=77"]
3
5
  build-backend = "setuptools.build_meta"
4
6
 
5
7
  [project]
@@ -8,9 +10,9 @@ dynamic = ["version"]
8
10
  description = "Context-aware XSS scanner: crawler, browser verification, mXSS, charset and stored-XSS stages"
9
11
  readme = "README.md"
10
12
  requires-python = ">=3.9"
11
- # No license field: the tree does not ship a LICENSE file, so the metadata makes
12
- # no claim about terms the author has not stated.
13
- authors = [{ name = "xssmap" }]
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ authors = [{ name = "Ian Mattas" }]
14
16
  keywords = ["security", "xss", "scanner", "pentest", "sarif"]
15
17
  classifiers = [
16
18
  "Environment :: Console",
@@ -23,6 +25,12 @@ classifiers = [
23
25
  # an assessment host without a package install.
24
26
  dependencies = []
25
27
 
28
+ [project.urls]
29
+ Homepage = "https://github.com/imattas/xssmap"
30
+ Repository = "https://github.com/imattas/xssmap"
31
+ Changelog = "https://github.com/imattas/xssmap/blob/main/CHANGELOG.md"
32
+ Issues = "https://github.com/imattas/xssmap/issues"
33
+
26
34
  [project.optional-dependencies]
27
35
  dev = ["ruff>=0.6", "mypy>=1.10"]
28
36
 
@@ -32,6 +40,11 @@ xssmap = "xssmap.cli:main"
32
40
  [tool.setuptools]
33
41
  packages = ["xssmap"]
34
42
 
43
+ # PEP 561 marker: tells type checkers the inline annotations are usable and must
44
+ # travel with the installed package, not just the source tree.
45
+ [tool.setuptools.package-data]
46
+ xssmap = ["py.typed"]
47
+
35
48
  [tool.setuptools.dynamic]
36
49
  version = { attr = "xssmap.__version__" }
37
50
 
@@ -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()