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.
- {xssmap-2.8.1 → xssmap-2.9.0}/CHANGELOG.md +39 -0
- {xssmap-2.8.1/xssmap.egg-info → xssmap-2.9.0}/PKG-INFO +29 -1
- {xssmap-2.8.1 → xssmap-2.9.0}/README.md +28 -0
- xssmap-2.9.0/tests/test_crawlperf.py +230 -0
- xssmap-2.9.0/tests/test_tech.py +150 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/__init__.py +1 -1
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/cli.py +17 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/crawler.py +86 -39
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/net.py +85 -11
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/report.py +40 -4
- xssmap-2.9.0/xssmap/tech.py +261 -0
- {xssmap-2.8.1 → xssmap-2.9.0/xssmap.egg-info}/PKG-INFO +29 -1
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/SOURCES.txt +3 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/LICENSE +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/MANIFEST.in +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/pyproject.toml +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/setup.cfg +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_bench.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_brute.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_budget.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_charset.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_csp.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_domscan.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_domstate.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_efficiency.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_exploit.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_fuzz.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_honesty.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_inline.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_jslib.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_leads.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_mxss.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_ops.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_params.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_recon_report.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_session.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_subdomains.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_units.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/tests/test_waf.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/__main__.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/browser.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/brute.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/callback.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/charset.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/contexts.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/csp.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/domscan.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/domstate.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/efficiency.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/exploit.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/filters.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/fuzz.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/htmlparse.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/jslib.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/mxss.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/params.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/payloads.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/py.typed +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/repro.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/sarif.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/scanner.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/session.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/state.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/stored.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/subdomains.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/verify.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap/waf.py +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/dependency_links.txt +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/entry_points.txt +0 -0
- {xssmap-2.8.1 → xssmap-2.9.0}/xssmap.egg-info/requires.txt +0 -0
- {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.
|
|
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()
|
|
@@ -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:
|