stackscan 2.6.0__tar.gz → 2.7.1__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.
- {stackscan-2.6.0 → stackscan-2.7.1}/PKG-INFO +64 -2
- {stackscan-2.6.0 → stackscan-2.7.1}/README.md +62 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/pyproject.toml +14 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/__init__.py +1 -1
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/creds.py +136 -20
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/cve.py +17 -3
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/osdetect.py +3 -1
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/services.py +9 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/social.py +6 -2
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/tech.py +55 -16
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/cli.py +349 -61
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/config/__init__.py +11 -1
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/config/sigdb_loader.py +2 -1
- stackscan-2.7.1/src/stackscan/config/sources.py +418 -0
- stackscan-2.7.1/src/stackscan/data/builtin.sigdb +0 -0
- stackscan-2.7.1/src/stackscan/embed.py +387 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/fingerprint.py +7 -1
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/subdomains.py +18 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/render.py +62 -15
- stackscan-2.7.1/src/stackscan/runner.py +286 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scan.py +89 -21
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/secrets.py +14 -7
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/takeover.py +4 -3
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/theme.py +2 -18
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/types/output.py +1 -0
- stackscan-2.7.1/src/stackscan/utils/__init__.py +13 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/utils/urls.py +8 -0
- stackscan-2.7.1/tests/test_brute.py +198 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_cli_helpers.py +9 -1
- stackscan-2.7.1/tests/test_embed.py +162 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_ports.py +3 -1
- stackscan-2.7.1/tests/test_reekeer_host.py +148 -0
- stackscan-2.7.1/tests/test_render.py +46 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_scan.py +50 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_services.py +6 -2
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_social.py +11 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_sources.py +25 -2
- stackscan-2.7.1/tests/test_theme.py +20 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_vhost.py +67 -0
- stackscan-2.6.0/src/stackscan/config/sources.py +0 -238
- stackscan-2.6.0/src/stackscan/data/builtin.sigdb +0 -0
- stackscan-2.6.0/src/stackscan/utils/__init__.py +0 -4
- stackscan-2.6.0/tests/test_brute.py +0 -86
- stackscan-2.6.0/tests/test_theme.py +0 -26
- {stackscan-2.6.0 → stackscan-2.7.1}/.gitignore +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/LICENSE +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/scripts/build_cve_db.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/__main__.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/__init__.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/exposure.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/generic.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/infra.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/vibe.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/core/core.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/data/subdomains.txt +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/export.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/__init__.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/dns.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/ipinfo.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/ports.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/resolver.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/tld.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/tls.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/whois.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/__init__.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/isp_blocked.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/types/__init__.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/types/models.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_creds.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_cve.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_dns_records.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_export.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_extra.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_infra.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_osdetect.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_scanners.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_subdomains.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_tech.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_vibe.py +0 -0
- {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_whois.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: stackscan
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.1
|
|
4
4
|
Summary: Full web stack, infrastructure, DNS, port, subdomain and CVE analyzer.
|
|
5
5
|
Project-URL: Source, https://github.com/reekeer/stackscan
|
|
6
6
|
Project-URL: Issues, https://github.com/reekeer/stackscan/issues
|
|
@@ -77,6 +77,26 @@ pip install "stackscan[geo]" # offline IP geolocation via geoip2 + a MaxMind .
|
|
|
77
77
|
`nmap` is used for port scans when present; without it a pure-Python scanner is
|
|
78
78
|
used automatically.
|
|
79
79
|
|
|
80
|
+
### Inside the reekeer shell
|
|
81
|
+
|
|
82
|
+
stackscan is also a [reekeer](https://github.com/reekeer/reekeer) plugin, mounted
|
|
83
|
+
at `/tools/recon/stackscan` (alias `ss`):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
reekeer exec /plugins install reekeer/stackscan
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Hosted there it drops the banner and the window title and hands the scan back as
|
|
90
|
+
data for reekeer to render in the shell's own tables, so it reads as a command of
|
|
91
|
+
the shell rather than a script being shelled out to. Every flag behaves the same,
|
|
92
|
+
and standalone (`pip`, `uvx stackscan`) is unchanged.
|
|
93
|
+
|
|
94
|
+
In reekeer's window the scan also gets a form. stackscan says which of its flags
|
|
95
|
+
are worth a control — the targets, the four switches that decide how deep a scan
|
|
96
|
+
goes, and the bounds worth changing when one is too slow — and reekeer draws
|
|
97
|
+
those. The rest are not hidden: the argument line under the form still takes
|
|
98
|
+
anything argparse takes, `--help` included.
|
|
99
|
+
|
|
80
100
|
---
|
|
81
101
|
|
|
82
102
|
## ✨ Features
|
|
@@ -194,6 +214,46 @@ Below are performance benchmarks conducted on two production targets using diffe
|
|
|
194
214
|
|
|
195
215
|
---
|
|
196
216
|
|
|
217
|
+
## 🛰 Runner mode
|
|
218
|
+
|
|
219
|
+
`stackscan --runner` turns the scanner into a worker for a StackScan panel: it
|
|
220
|
+
claims jobs (`POST /api/jobs/claim`), fingerprints each target with the normal
|
|
221
|
+
engine, and posts the results back (`POST /api/results`). It needs nothing but
|
|
222
|
+
network access to the panel — no database, no Redis, no shared filesystem.
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
STACKSCAN_BACKEND_URL=https://panel.example STACKSCAN_WORKER_TOKEN=… \
|
|
226
|
+
stackscan --runner
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| Variable | Default | What it is |
|
|
230
|
+
| --- | --- | --- |
|
|
231
|
+
| `STACKSCAN_BACKEND_URL` | `http://localhost:8787` | Panel base URL |
|
|
232
|
+
| `STACKSCAN_WORKER_TOKEN` | — | Shared worker token, sent as `X-Worker-Token` |
|
|
233
|
+
| `STACKSCAN_RUNNER_ID` | `runner-<host>-<pid>` | Stable id for this runner |
|
|
234
|
+
| `STACKSCAN_RUNNER_BATCH` | `5` | Jobs claimed per cycle |
|
|
235
|
+
| `STACKSCAN_RUNNER_IDLE` | `5` | Seconds to wait when the queue is empty |
|
|
236
|
+
| `STACKSCAN_RUNNER` | — | Truthy value selects runner mode without the flag |
|
|
237
|
+
|
|
238
|
+
Every one has a flag (`--backend`, `--worker-token`, `--runner-id`, `--batch`,
|
|
239
|
+
`--sigdb`, `--user-agent`), and `--once` runs a single claim/scan/report cycle,
|
|
240
|
+
which is what you want in a cron job or a smoke test. The runner profile is
|
|
241
|
+
deliberately lean — DNS, TLS, geo and IP info, no ports, CVEs or brute passes —
|
|
242
|
+
because it is meant for volume rather than depth.
|
|
243
|
+
|
|
244
|
+
Packaged for it:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
docker build -t stackscan:latest .
|
|
248
|
+
docker run --rm -e STACKSCAN_BACKEND_URL=http://panel:8787 \
|
|
249
|
+
-e STACKSCAN_WORKER_TOKEN=… stackscan:latest
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Runner mode is refused inside the reekeer shell: it is a loop that runs until it
|
|
253
|
+
is killed, and a shell command that never answers is not a command.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
197
257
|
## 🚀 Smart Scan Pipeline
|
|
198
258
|
|
|
199
259
|
The Smart Scan engine performs multiple analysis stages to collect infrastructure, technology, and security information about the target before generating the final report.
|
|
@@ -325,6 +385,8 @@ stackscan/
|
|
|
325
385
|
│ ├── config/ ← bundled + sourced sigdb loading
|
|
326
386
|
│ ├── data/ ← builtin.sigdb, cve.json.gz, subdomains.txt
|
|
327
387
|
│ ├── render.py ← the colorized panel report
|
|
388
|
+
│ ├── embed.py ← findings as data, and the form, for reekeer to draw
|
|
389
|
+
│ ├── runner.py ← panel worker: claim jobs, scan, report back
|
|
328
390
|
│ ├── scan.py ← per-target orchestration
|
|
329
391
|
│ └── cli.py ← argument parsing and entry point
|
|
330
392
|
├── scripts/ ← build_cve_db.py (NVD → offline dataset)
|
|
@@ -39,6 +39,26 @@ pip install "stackscan[geo]" # offline IP geolocation via geoip2 + a MaxMind .
|
|
|
39
39
|
`nmap` is used for port scans when present; without it a pure-Python scanner is
|
|
40
40
|
used automatically.
|
|
41
41
|
|
|
42
|
+
### Inside the reekeer shell
|
|
43
|
+
|
|
44
|
+
stackscan is also a [reekeer](https://github.com/reekeer/reekeer) plugin, mounted
|
|
45
|
+
at `/tools/recon/stackscan` (alias `ss`):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
reekeer exec /plugins install reekeer/stackscan
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Hosted there it drops the banner and the window title and hands the scan back as
|
|
52
|
+
data for reekeer to render in the shell's own tables, so it reads as a command of
|
|
53
|
+
the shell rather than a script being shelled out to. Every flag behaves the same,
|
|
54
|
+
and standalone (`pip`, `uvx stackscan`) is unchanged.
|
|
55
|
+
|
|
56
|
+
In reekeer's window the scan also gets a form. stackscan says which of its flags
|
|
57
|
+
are worth a control — the targets, the four switches that decide how deep a scan
|
|
58
|
+
goes, and the bounds worth changing when one is too slow — and reekeer draws
|
|
59
|
+
those. The rest are not hidden: the argument line under the form still takes
|
|
60
|
+
anything argparse takes, `--help` included.
|
|
61
|
+
|
|
42
62
|
---
|
|
43
63
|
|
|
44
64
|
## ✨ Features
|
|
@@ -156,6 +176,46 @@ Below are performance benchmarks conducted on two production targets using diffe
|
|
|
156
176
|
|
|
157
177
|
---
|
|
158
178
|
|
|
179
|
+
## 🛰 Runner mode
|
|
180
|
+
|
|
181
|
+
`stackscan --runner` turns the scanner into a worker for a StackScan panel: it
|
|
182
|
+
claims jobs (`POST /api/jobs/claim`), fingerprints each target with the normal
|
|
183
|
+
engine, and posts the results back (`POST /api/results`). It needs nothing but
|
|
184
|
+
network access to the panel — no database, no Redis, no shared filesystem.
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
STACKSCAN_BACKEND_URL=https://panel.example STACKSCAN_WORKER_TOKEN=… \
|
|
188
|
+
stackscan --runner
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Variable | Default | What it is |
|
|
192
|
+
| --- | --- | --- |
|
|
193
|
+
| `STACKSCAN_BACKEND_URL` | `http://localhost:8787` | Panel base URL |
|
|
194
|
+
| `STACKSCAN_WORKER_TOKEN` | — | Shared worker token, sent as `X-Worker-Token` |
|
|
195
|
+
| `STACKSCAN_RUNNER_ID` | `runner-<host>-<pid>` | Stable id for this runner |
|
|
196
|
+
| `STACKSCAN_RUNNER_BATCH` | `5` | Jobs claimed per cycle |
|
|
197
|
+
| `STACKSCAN_RUNNER_IDLE` | `5` | Seconds to wait when the queue is empty |
|
|
198
|
+
| `STACKSCAN_RUNNER` | — | Truthy value selects runner mode without the flag |
|
|
199
|
+
|
|
200
|
+
Every one has a flag (`--backend`, `--worker-token`, `--runner-id`, `--batch`,
|
|
201
|
+
`--sigdb`, `--user-agent`), and `--once` runs a single claim/scan/report cycle,
|
|
202
|
+
which is what you want in a cron job or a smoke test. The runner profile is
|
|
203
|
+
deliberately lean — DNS, TLS, geo and IP info, no ports, CVEs or brute passes —
|
|
204
|
+
because it is meant for volume rather than depth.
|
|
205
|
+
|
|
206
|
+
Packaged for it:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
docker build -t stackscan:latest .
|
|
210
|
+
docker run --rm -e STACKSCAN_BACKEND_URL=http://panel:8787 \
|
|
211
|
+
-e STACKSCAN_WORKER_TOKEN=… stackscan:latest
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Runner mode is refused inside the reekeer shell: it is a loop that runs until it
|
|
215
|
+
is killed, and a shell command that never answers is not a command.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
159
219
|
## 🚀 Smart Scan Pipeline
|
|
160
220
|
|
|
161
221
|
The Smart Scan engine performs multiple analysis stages to collect infrastructure, technology, and security information about the target before generating the final report.
|
|
@@ -287,6 +347,8 @@ stackscan/
|
|
|
287
347
|
│ ├── config/ ← bundled + sourced sigdb loading
|
|
288
348
|
│ ├── data/ ← builtin.sigdb, cve.json.gz, subdomains.txt
|
|
289
349
|
│ ├── render.py ← the colorized panel report
|
|
350
|
+
│ ├── embed.py ← findings as data, and the form, for reekeer to draw
|
|
351
|
+
│ ├── runner.py ← panel worker: claim jobs, scan, report back
|
|
290
352
|
│ ├── scan.py ← per-target orchestration
|
|
291
353
|
│ └── cli.py ← argument parsing and entry point
|
|
292
354
|
├── scripts/ ← build_cve_db.py (NVD → offline dataset)
|
|
@@ -67,6 +67,20 @@ Issues = "https://github.com/reekeer/stackscan/issues"
|
|
|
67
67
|
[project.scripts]
|
|
68
68
|
stackscan = "stackscan.cli:main"
|
|
69
69
|
|
|
70
|
+
# Mounts stackscan inside the reekeer shell as /recon/stackscan.
|
|
71
|
+
#
|
|
72
|
+
# Not `/tools/recon`: a root that holds every plugin distinguishes none of them, so the category is
|
|
73
|
+
# the top-level group.
|
|
74
|
+
#
|
|
75
|
+
# Name, version, description, authors and dependencies are read from [project] above, so nothing is
|
|
76
|
+
# repeated here. No arguments are declared on purpose: stackscan's own argparse keeps ownership of all
|
|
77
|
+
# of them, including `--help` and the `sigdb` subcommand, and reekeer hands it the raw tokens.
|
|
78
|
+
[tool.reekeer]
|
|
79
|
+
group = "/recon"
|
|
80
|
+
entrypoint = "stackscan.cli:main"
|
|
81
|
+
python-root = "src"
|
|
82
|
+
aliases = ["ss"]
|
|
83
|
+
|
|
70
84
|
[tool.hatch.version]
|
|
71
85
|
path = "src/stackscan/__init__.py"
|
|
72
86
|
|
|
@@ -42,6 +42,29 @@ _DEVICE_KEYWORDS = (
|
|
|
42
42
|
"uc-httpd",
|
|
43
43
|
"go2rtc",
|
|
44
44
|
"rtsp",
|
|
45
|
+
"mikrotik",
|
|
46
|
+
"routeros",
|
|
47
|
+
"webfig",
|
|
48
|
+
"openwrt",
|
|
49
|
+
"dd-wrt",
|
|
50
|
+
"pfsense",
|
|
51
|
+
"opnsense",
|
|
52
|
+
"ubnt",
|
|
53
|
+
"ubiquiti",
|
|
54
|
+
"unifi",
|
|
55
|
+
"tp-link",
|
|
56
|
+
"tplink",
|
|
57
|
+
"d-link",
|
|
58
|
+
"dlink",
|
|
59
|
+
"netgear",
|
|
60
|
+
"zyxel",
|
|
61
|
+
"huawei",
|
|
62
|
+
"tenda",
|
|
63
|
+
"asuswrt",
|
|
64
|
+
"idrac",
|
|
65
|
+
"ilo",
|
|
66
|
+
"ipmi",
|
|
67
|
+
"supermicro",
|
|
45
68
|
)
|
|
46
69
|
_CAMERA_KEYWORDS = (
|
|
47
70
|
"camera",
|
|
@@ -199,31 +222,45 @@ async def _probe_endpoint(
|
|
|
199
222
|
url = f"{scheme}://{host}:{port}/"
|
|
200
223
|
target = f"{host}:{port}"
|
|
201
224
|
try:
|
|
202
|
-
async with session.get(url, allow_redirects=
|
|
225
|
+
async with session.get(url, allow_redirects=True) as resp:
|
|
203
226
|
status = resp.status
|
|
204
227
|
server = resp.headers.get("Server", "")
|
|
205
228
|
realm = resp.headers.get("WWW-Authenticate", "")
|
|
229
|
+
body = ""
|
|
230
|
+
if status < 400:
|
|
231
|
+
body = (await resp.content.read(20000)).decode("utf-8", "replace")
|
|
206
232
|
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
207
233
|
return None
|
|
208
|
-
|
|
209
|
-
if status
|
|
210
|
-
if
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
234
|
+
|
|
235
|
+
if status == 401:
|
|
236
|
+
if _looks_like_device(realm, server) or port in _RTSP_PORTS:
|
|
237
|
+
return BruteTarget(
|
|
238
|
+
host=host,
|
|
239
|
+
port=port,
|
|
240
|
+
tls=tls,
|
|
241
|
+
service=f"{scheme} ({server or realm or 'device'})",
|
|
242
|
+
is_camera=_is_camera(realm, server) or port in _RTSP_PORTS,
|
|
243
|
+
login="basic",
|
|
216
244
|
)
|
|
217
245
|
return None
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
host=host,
|
|
222
|
-
|
|
223
|
-
tls=tls,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
246
|
+
|
|
247
|
+
blob = f"{server} {realm} {body}".lower()
|
|
248
|
+
if "mikrotik" in blob or "routeros" in blob:
|
|
249
|
+
return BruteTarget(host=host, port=port, tls=tls, service="RouterOS", login="mikrotik")
|
|
250
|
+
if "unifi" in server.lower() or ("unifi" in blob and "login" in blob):
|
|
251
|
+
return BruteTarget(host=host, port=port, tls=tls, service="UniFi", login="unifi")
|
|
252
|
+
if status == 200 and 'type="password"' in blob and "<form" in blob:
|
|
253
|
+
return BruteTarget(
|
|
254
|
+
host=host, port=port, tls=tls, service=f"{scheme} login form", login="form"
|
|
255
|
+
)
|
|
256
|
+
if _looks_like_device("", server):
|
|
257
|
+
return CredFinding(
|
|
258
|
+
target=target,
|
|
259
|
+
service=f"{scheme} ({server or 'device'})",
|
|
260
|
+
kind="open-no-auth",
|
|
261
|
+
detail=f"HTTP {status} without authentication",
|
|
262
|
+
)
|
|
263
|
+
return None
|
|
227
264
|
|
|
228
265
|
|
|
229
266
|
async def brute_devices(
|
|
@@ -247,7 +284,7 @@ async def brute_devices(
|
|
|
247
284
|
|
|
248
285
|
async def run(target: BruteTarget) -> None:
|
|
249
286
|
async with semaphore:
|
|
250
|
-
hit = await
|
|
287
|
+
hit = await _brute_login(session, target, creds)
|
|
251
288
|
if hit is not None:
|
|
252
289
|
username, password = hit
|
|
253
290
|
findings.append(
|
|
@@ -255,7 +292,7 @@ async def brute_devices(
|
|
|
255
292
|
target=target.target,
|
|
256
293
|
service=target.service,
|
|
257
294
|
kind="default-creds",
|
|
258
|
-
detail="default credentials accepted
|
|
295
|
+
detail=f"default credentials accepted ({target.login} login)",
|
|
259
296
|
username=username,
|
|
260
297
|
password=password,
|
|
261
298
|
)
|
|
@@ -275,6 +312,18 @@ async def brute_devices(
|
|
|
275
312
|
return findings
|
|
276
313
|
|
|
277
314
|
|
|
315
|
+
async def _brute_login(
|
|
316
|
+
session: aiohttp.ClientSession, target: BruteTarget, creds: tuple[tuple[str, str], ...]
|
|
317
|
+
) -> tuple[str, str] | None:
|
|
318
|
+
if target.login == "mikrotik":
|
|
319
|
+
return await _try_mikrotik(session, target.url, creds)
|
|
320
|
+
if target.login == "unifi":
|
|
321
|
+
return await _try_unifi(session, target.url, creds)
|
|
322
|
+
if target.login == "form":
|
|
323
|
+
return await _try_form(session, target.url, creds)
|
|
324
|
+
return await _try_defaults(session, target.url, creds)
|
|
325
|
+
|
|
326
|
+
|
|
278
327
|
async def _try_defaults(
|
|
279
328
|
session: aiohttp.ClientSession, url: str, creds: tuple[tuple[str, str], ...]
|
|
280
329
|
) -> tuple[str, str] | None:
|
|
@@ -288,3 +337,70 @@ async def _try_defaults(
|
|
|
288
337
|
return None
|
|
289
338
|
await asyncio.sleep(0.05)
|
|
290
339
|
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def _try_mikrotik(
|
|
343
|
+
session: aiohttp.ClientSession, base_url: str, creds: tuple[tuple[str, str], ...]
|
|
344
|
+
) -> tuple[str, str] | None:
|
|
345
|
+
url = base_url.rstrip("/") + "/rest/system/resource"
|
|
346
|
+
for username, password in creds:
|
|
347
|
+
try:
|
|
348
|
+
async with session.get(
|
|
349
|
+
url, auth=aiohttp.BasicAuth(username, password), allow_redirects=False
|
|
350
|
+
) as resp:
|
|
351
|
+
if resp.status == 200:
|
|
352
|
+
return (username, password)
|
|
353
|
+
if resp.status == 404:
|
|
354
|
+
return None
|
|
355
|
+
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
356
|
+
return None
|
|
357
|
+
await asyncio.sleep(0.05)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def _try_unifi(
|
|
362
|
+
session: aiohttp.ClientSession, base_url: str, creds: tuple[tuple[str, str], ...]
|
|
363
|
+
) -> tuple[str, str] | None:
|
|
364
|
+
endpoints = ("/api/login", "/api/auth/login")
|
|
365
|
+
for username, password in creds:
|
|
366
|
+
payload = {"username": username, "password": password}
|
|
367
|
+
for endpoint in endpoints:
|
|
368
|
+
url = base_url.rstrip("/") + endpoint
|
|
369
|
+
try:
|
|
370
|
+
async with session.post(url, json=payload, allow_redirects=False) as resp:
|
|
371
|
+
if resp.status == 200:
|
|
372
|
+
return (username, password)
|
|
373
|
+
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
374
|
+
continue
|
|
375
|
+
await asyncio.sleep(0.05)
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
_FORM_ENDPOINTS = ("/login", "/", "/admin/login", "/api/login", "/j_security_check")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def _try_form(
|
|
383
|
+
session: aiohttp.ClientSession, base_url: str, creds: tuple[tuple[str, str], ...]
|
|
384
|
+
) -> tuple[str, str] | None:
|
|
385
|
+
for username, password in creds:
|
|
386
|
+
data = {
|
|
387
|
+
"username": username,
|
|
388
|
+
"password": password,
|
|
389
|
+
"user": username,
|
|
390
|
+
"pass": password,
|
|
391
|
+
"j_username": username,
|
|
392
|
+
"j_password": password,
|
|
393
|
+
}
|
|
394
|
+
for endpoint in _FORM_ENDPOINTS:
|
|
395
|
+
url = base_url.rstrip("/") + endpoint
|
|
396
|
+
try:
|
|
397
|
+
async with session.post(url, data=data, allow_redirects=False) as resp:
|
|
398
|
+
location = resp.headers.get("Location", "").lower()
|
|
399
|
+
if resp.status in (301, 302, 303) and not any(
|
|
400
|
+
marker in location for marker in ("login", "error", "denied", "auth")
|
|
401
|
+
):
|
|
402
|
+
return (username, password)
|
|
403
|
+
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
404
|
+
continue
|
|
405
|
+
await asyncio.sleep(0.05)
|
|
406
|
+
return None
|
|
@@ -106,12 +106,26 @@ _CORE_COMMIT_RE = re.compile(
|
|
|
106
106
|
)
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def _cve_bytes() -> bytes | None:
|
|
110
|
+
from stackscan.config.sources import SourceStore
|
|
111
|
+
|
|
112
|
+
override = SourceStore().resolve_cve()
|
|
113
|
+
if override is not None:
|
|
114
|
+
try:
|
|
115
|
+
return override.read_bytes()
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
try:
|
|
119
|
+
return resources.files("stackscan.data").joinpath("cve.json.gz").read_bytes()
|
|
120
|
+
except (FileNotFoundError, ModuleNotFoundError, OSError):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
109
124
|
@lru_cache(maxsize=1)
|
|
110
125
|
def load_cve_db() -> CveDb:
|
|
111
126
|
empty: CveDb = {}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
except (FileNotFoundError, ModuleNotFoundError, OSError):
|
|
127
|
+
raw = _cve_bytes()
|
|
128
|
+
if raw is None:
|
|
115
129
|
return empty
|
|
116
130
|
try:
|
|
117
131
|
data = cast("dict[str, Any]", json.loads(gzip.decompress(raw)))
|
|
@@ -106,7 +106,9 @@ def detect_os(report: ScanReport) -> list[OsFinding]:
|
|
|
106
106
|
for os, src in pairs:
|
|
107
107
|
by_source.setdefault(src, []).append(os)
|
|
108
108
|
dominant_source = max(by_source, key=lambda s: len(by_source[s]))
|
|
109
|
-
if dominant_source == "port-banner" or dominant_source.startswith(
|
|
109
|
+
if dominant_source == "port-banner" or dominant_source.startswith(
|
|
110
|
+
("header:", "meta:", "script")
|
|
111
|
+
):
|
|
110
112
|
category = "banner"
|
|
111
113
|
elif dominant_source.startswith("port "):
|
|
112
114
|
category = "network"
|
|
@@ -163,6 +163,15 @@ def _service_from_tech(tech: Technology) -> ServiceFinding | None:
|
|
|
163
163
|
evidence=" ".join(tech.evidence) or f"tech: {tech.name}",
|
|
164
164
|
severity=_SEVERITY["database"],
|
|
165
165
|
)
|
|
166
|
+
categories = {category.lower() for category in tech.categories}
|
|
167
|
+
for kind in ("admin-panel", "database"):
|
|
168
|
+
if kind in categories:
|
|
169
|
+
return ServiceFinding(
|
|
170
|
+
name=tech.name,
|
|
171
|
+
kind=kind,
|
|
172
|
+
evidence=" ".join(tech.evidence) or f"tech: {tech.name}",
|
|
173
|
+
severity=_SEVERITY[kind],
|
|
174
|
+
)
|
|
166
175
|
return None
|
|
167
176
|
|
|
168
177
|
|
|
@@ -37,10 +37,14 @@ def _social_link(raw: str, base_url: str) -> SocialLink | None:
|
|
|
37
37
|
low = value.lower()
|
|
38
38
|
if low.startswith("mailto:"):
|
|
39
39
|
addr = value[7:].split("?", 1)[0].strip()
|
|
40
|
-
|
|
40
|
+
if addr and "@" in addr and "." in addr.split("@", 1)[-1]:
|
|
41
|
+
return SocialLink("Email", "mailto:" + addr, addr)
|
|
42
|
+
return None
|
|
41
43
|
if low.startswith("tel:"):
|
|
42
44
|
num = value[4:].strip()
|
|
43
|
-
|
|
45
|
+
if sum(char.isdigit() for char in num) >= 6:
|
|
46
|
+
return SocialLink("Phone", value, num)
|
|
47
|
+
return None
|
|
44
48
|
if low.startswith("//"):
|
|
45
49
|
value = "https:" + value
|
|
46
50
|
elif not low.startswith(("http://", "https://")):
|
|
@@ -40,17 +40,51 @@ def _script_srcs(html: str) -> list[str]:
|
|
|
40
40
|
_CLASS_ATTR_RE = re.compile(r'\bclass\s*=\s*"([^"]*)"|\bclass\s*=\s*\'([^\']*)\'', re.I)
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
_CSS_UTILITY_EXACT: frozenset[str] = frozenset(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
_CSS_UTILITY_EXACT: frozenset[str] = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"container",
|
|
46
|
+
"flex",
|
|
47
|
+
"grid",
|
|
48
|
+
"block",
|
|
49
|
+
"inline",
|
|
50
|
+
"inline-block",
|
|
51
|
+
"hidden",
|
|
52
|
+
"table",
|
|
53
|
+
"table-cell",
|
|
54
|
+
"table-row",
|
|
55
|
+
"flow-root",
|
|
56
|
+
"contents",
|
|
57
|
+
"float-left",
|
|
58
|
+
"float-right",
|
|
59
|
+
"float-none",
|
|
60
|
+
"clear-left",
|
|
61
|
+
"clear-right",
|
|
62
|
+
"clear-both",
|
|
63
|
+
"clear-none",
|
|
64
|
+
"isolate",
|
|
65
|
+
"isolation-auto",
|
|
66
|
+
"object-contain",
|
|
67
|
+
"object-cover",
|
|
68
|
+
"object-fill",
|
|
69
|
+
"object-none",
|
|
70
|
+
"object-scale-down",
|
|
71
|
+
"overflow-auto",
|
|
72
|
+
"overflow-hidden",
|
|
73
|
+
"overflow-visible",
|
|
74
|
+
"overflow-scroll",
|
|
75
|
+
"overscroll-auto",
|
|
76
|
+
"overscroll-contain",
|
|
77
|
+
"overscroll-none",
|
|
78
|
+
"visible",
|
|
79
|
+
"invisible",
|
|
80
|
+
"collapse",
|
|
81
|
+
"static",
|
|
82
|
+
"fixed",
|
|
83
|
+
"absolute",
|
|
84
|
+
"relative",
|
|
85
|
+
"sticky",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
54
88
|
# Tailwind-style utilities: px-4, w-full, bg-red-500, my-auto, backdrop-blur, etc.
|
|
55
89
|
_CSS_UTILITY_RE = re.compile(
|
|
56
90
|
r"^[a-z]+(-[a-z]+)?-(\d+|auto|full|screen|px|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|none|hidden|visible|inherit|current|transparent|black|white|blur|opacity|saturate|sepia|grayscale|contrast|brightness|invert|drop-shadow|hue-rotate|shadow|sm|md|lg|xl)$",
|
|
@@ -118,7 +152,9 @@ def _confidence(evidence: list[str]) -> int:
|
|
|
118
152
|
|
|
119
153
|
|
|
120
154
|
_VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?(?:[-+.]?[a-zA-Z0-9]+)?)")
|
|
121
|
-
_CORE_COMMIT_RE = re.compile(
|
|
155
|
+
_CORE_COMMIT_RE = re.compile(
|
|
156
|
+
r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE
|
|
157
|
+
)
|
|
122
158
|
|
|
123
159
|
|
|
124
160
|
def _version_key(version: str) -> tuple[int, int, int, int]:
|
|
@@ -274,9 +310,7 @@ class TechAnalyzer:
|
|
|
274
310
|
self._add(acc, matcher.match(result.body), "body")
|
|
275
311
|
self._add(acc, matcher.match(result.url), "url")
|
|
276
312
|
for token in _framework_tokens(result.body):
|
|
277
|
-
self._add(
|
|
278
|
-
acc, matcher.match_search({"framework": token}), f"framework:{token}"
|
|
279
|
-
)
|
|
313
|
+
self._add(acc, matcher.match_search({"framework": token}), f"framework:{token}")
|
|
280
314
|
self._curated(acc, result)
|
|
281
315
|
by_name: dict[str, _Hit] = {}
|
|
282
316
|
for hit in acc.values():
|
|
@@ -336,4 +370,9 @@ class TechAnalyzer:
|
|
|
336
370
|
add(name, "service", f"body:{name} Core ({commit})", version=commit)
|
|
337
371
|
|
|
338
372
|
for tech in extract_generic_tech(result.body):
|
|
339
|
-
add(
|
|
373
|
+
add(
|
|
374
|
+
tech.name,
|
|
375
|
+
tech.categories[0] if tech.categories else "service",
|
|
376
|
+
tech.evidence[0],
|
|
377
|
+
tech.version,
|
|
378
|
+
)
|