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.
Files changed (87) hide show
  1. {stackscan-2.6.0 → stackscan-2.7.1}/PKG-INFO +64 -2
  2. {stackscan-2.6.0 → stackscan-2.7.1}/README.md +62 -0
  3. {stackscan-2.6.0 → stackscan-2.7.1}/pyproject.toml +14 -0
  4. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/__init__.py +1 -1
  5. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/creds.py +136 -20
  6. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/cve.py +17 -3
  7. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/osdetect.py +3 -1
  8. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/services.py +9 -0
  9. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/social.py +6 -2
  10. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/tech.py +55 -16
  11. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/cli.py +349 -61
  12. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/config/__init__.py +11 -1
  13. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/config/sigdb_loader.py +2 -1
  14. stackscan-2.7.1/src/stackscan/config/sources.py +418 -0
  15. stackscan-2.7.1/src/stackscan/data/builtin.sigdb +0 -0
  16. stackscan-2.7.1/src/stackscan/embed.py +387 -0
  17. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/fingerprint.py +7 -1
  18. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/subdomains.py +18 -0
  19. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/render.py +62 -15
  20. stackscan-2.7.1/src/stackscan/runner.py +286 -0
  21. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scan.py +89 -21
  22. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/secrets.py +14 -7
  23. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/takeover.py +4 -3
  24. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/theme.py +2 -18
  25. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/types/output.py +1 -0
  26. stackscan-2.7.1/src/stackscan/utils/__init__.py +13 -0
  27. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/utils/urls.py +8 -0
  28. stackscan-2.7.1/tests/test_brute.py +198 -0
  29. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_cli_helpers.py +9 -1
  30. stackscan-2.7.1/tests/test_embed.py +162 -0
  31. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_ports.py +3 -1
  32. stackscan-2.7.1/tests/test_reekeer_host.py +148 -0
  33. stackscan-2.7.1/tests/test_render.py +46 -0
  34. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_scan.py +50 -0
  35. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_services.py +6 -2
  36. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_social.py +11 -0
  37. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_sources.py +25 -2
  38. stackscan-2.7.1/tests/test_theme.py +20 -0
  39. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_vhost.py +67 -0
  40. stackscan-2.6.0/src/stackscan/config/sources.py +0 -238
  41. stackscan-2.6.0/src/stackscan/data/builtin.sigdb +0 -0
  42. stackscan-2.6.0/src/stackscan/utils/__init__.py +0 -4
  43. stackscan-2.6.0/tests/test_brute.py +0 -86
  44. stackscan-2.6.0/tests/test_theme.py +0 -26
  45. {stackscan-2.6.0 → stackscan-2.7.1}/.gitignore +0 -0
  46. {stackscan-2.6.0 → stackscan-2.7.1}/LICENSE +0 -0
  47. {stackscan-2.6.0 → stackscan-2.7.1}/scripts/build_cve_db.py +0 -0
  48. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/__main__.py +0 -0
  49. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/__init__.py +0 -0
  50. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/exposure.py +0 -0
  51. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/generic.py +0 -0
  52. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/infra.py +0 -0
  53. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/security.py +0 -0
  54. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/analyzers/vibe.py +0 -0
  55. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/core/__init__.py +0 -0
  56. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/core/core.py +0 -0
  57. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/data/cve.json.gz +0 -0
  58. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/data/reekeer-logo.png +0 -0
  59. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/data/subdomains.txt +0 -0
  60. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/export.py +0 -0
  61. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/__init__.py +0 -0
  62. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/dns.py +0 -0
  63. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/geo.py +0 -0
  64. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/ipinfo.py +0 -0
  65. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/ports.py +0 -0
  66. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/resolver.py +0 -0
  67. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/tld.py +0 -0
  68. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/tls.py +0 -0
  69. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/net/whois.py +0 -0
  70. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/__init__.py +0 -0
  71. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/scanners/isp_blocked.py +0 -0
  72. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/types/__init__.py +0 -0
  73. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/types/models.py +0 -0
  74. {stackscan-2.6.0 → stackscan-2.7.1}/src/stackscan/utils/paths.py +0 -0
  75. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_builtin_sigdb.py +0 -0
  76. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_creds.py +0 -0
  77. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_cve.py +0 -0
  78. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_dns_records.py +0 -0
  79. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_export.py +0 -0
  80. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_extra.py +0 -0
  81. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_infra.py +0 -0
  82. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_osdetect.py +0 -0
  83. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_scanners.py +0 -0
  84. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_subdomains.py +0 -0
  85. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_tech.py +0 -0
  86. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_vibe.py +0 -0
  87. {stackscan-2.6.0 → stackscan-2.7.1}/tests/test_whois.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: stackscan
3
- Version: 2.6.0
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
 
@@ -2,4 +2,4 @@ from .core import StackscanSession
2
2
  from .utils import normalize_url
3
3
 
4
4
  __all__ = ["StackscanSession", "__version__", "normalize_url"]
5
- __version__ = "2.6.0"
5
+ __version__ = "2.7.1"
@@ -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=False) as resp:
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
- device = _looks_like_device(realm, server) or port in _RTSP_PORTS
209
- if status != 401:
210
- if device or _looks_like_device("", server):
211
- return CredFinding(
212
- target=target,
213
- service=f"{scheme} ({server or 'device'})",
214
- kind="open-no-auth",
215
- detail=f"HTTP {status} without authentication",
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
- if not device:
219
- return None
220
- return BruteTarget(
221
- host=host,
222
- port=port,
223
- tls=tls,
224
- service=f"{scheme} ({server or realm or 'device'})",
225
- is_camera=_is_camera(realm, server) or port in _RTSP_PORTS,
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 _try_defaults(session, target.url, creds)
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 via HTTP Basic auth",
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
- try:
113
- raw = resources.files("stackscan.data").joinpath("cve.json.gz").read_bytes()
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(("header:", "meta:", "script")):
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
- return SocialLink("Email", "mailto:" + addr, addr) if addr else None
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
- return SocialLink("Phone", value, num) if num else None
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
- "container", "flex", "grid", "block", "inline", "inline-block", "hidden",
45
- "table", "table-cell", "table-row", "flow-root", "contents",
46
- "float-left", "float-right", "float-none", "clear-left", "clear-right",
47
- "clear-both", "clear-none", "isolate", "isolation-auto",
48
- "object-contain", "object-cover", "object-fill", "object-none", "object-scale-down",
49
- "overflow-auto", "overflow-hidden", "overflow-visible", "overflow-scroll",
50
- "overscroll-auto", "overscroll-contain", "overscroll-none",
51
- "visible", "invisible", "collapse",
52
- "static", "fixed", "absolute", "relative", "sticky",
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(r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE)
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(tech.name, tech.categories[0] if tech.categories else "service", tech.evidence[0], tech.version)
373
+ add(
374
+ tech.name,
375
+ tech.categories[0] if tech.categories else "service",
376
+ tech.evidence[0],
377
+ tech.version,
378
+ )