stackscan 2.5.0__tar.gz → 2.6.3__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.5.0 → stackscan-2.6.3}/PKG-INFO +1 -1
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/__init__.py +1 -1
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/creds.py +136 -20
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/cve.py +17 -3
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/services.py +9 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/social.py +6 -2
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/cli.py +77 -23
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/config/__init__.py +2 -1
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/config/sigdb_loader.py +2 -1
- stackscan-2.6.3/src/stackscan/config/sources.py +416 -0
- stackscan-2.6.3/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/export.py +13 -44
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/dns.py +5 -1
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/subdomains.py +18 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/whois.py +39 -1
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/render.py +26 -77
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/scan.py +108 -51
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/theme.py +15 -2
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/types/output.py +9 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_brute.py +65 -1
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_cli_helpers.py +3 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_dns_records.py +17 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_export.py +49 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_ports.py +1 -3
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_scan.py +19 -2
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_services.py +2 -6
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_social.py +11 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_sources.py +25 -2
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_vhost.py +31 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_whois.py +16 -0
- stackscan-2.5.0/src/stackscan/config/sources.py +0 -238
- stackscan-2.5.0/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/.gitignore +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/LICENSE +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/README.md +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/pyproject.toml +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/scripts/build_cve_db.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/__main__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/exposure.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/generic.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/infra.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/osdetect.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/tech.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/analyzers/vibe.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/core/core.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/data/subdomains.txt +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/fingerprint.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/ipinfo.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/ports.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/resolver.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/tld.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/net/tls.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/scanners/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/scanners/isp_blocked.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/scanners/secrets.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/scanners/takeover.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/types/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/types/models.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/utils/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/src/stackscan/utils/urls.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_creds.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_cve.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_extra.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_infra.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_osdetect.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_scanners.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_subdomains.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_tech.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_theme.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.3}/tests/test_vibe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackscan
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.3
|
|
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
|
|
@@ -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)))
|
|
@@ -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://")):
|
|
@@ -17,7 +17,13 @@ from rich.table import Table
|
|
|
17
17
|
|
|
18
18
|
from stackscan import __version__, theme
|
|
19
19
|
from stackscan.analyzers import TechAnalyzer, brute_devices, summarize_edge
|
|
20
|
-
from stackscan.config import
|
|
20
|
+
from stackscan.config import (
|
|
21
|
+
DEFAULT_SOURCE_URL,
|
|
22
|
+
NoSignaturesError,
|
|
23
|
+
SourceError,
|
|
24
|
+
SourceStore,
|
|
25
|
+
build_matchers,
|
|
26
|
+
)
|
|
21
27
|
from stackscan.net import GeoProvider, nmap_available
|
|
22
28
|
from stackscan.render import render_banner, render_reports
|
|
23
29
|
from stackscan.scan import ScanOptions, StageLog, scan_target, stage_total
|
|
@@ -61,7 +67,6 @@ def _build_scan_parser() -> argparse.ArgumentParser:
|
|
|
61
67
|
|
|
62
68
|
parser.add_argument("targets", nargs="*", help="Target URLs or hostnames.")
|
|
63
69
|
parser.add_argument("-f", "--file", dest="file", type=Path, help="File with targets, one/line.")
|
|
64
|
-
parser.add_argument("--sigdb", dest="sigdb", help="Explicit .sigdb path (overrides default).")
|
|
65
70
|
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="Request timeout.")
|
|
66
71
|
parser.add_argument("--user-agent", dest="user_agent", default=DEFAULT_USER_AGENT)
|
|
67
72
|
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification.")
|
|
@@ -341,14 +346,14 @@ class _StageTracker:
|
|
|
341
346
|
self._progress.update(
|
|
342
347
|
self._task_id,
|
|
343
348
|
advance=1,
|
|
344
|
-
description=f"
|
|
349
|
+
description=f"{_glyphs().run} {self._target} {_glyphs().bullet} {message}",
|
|
345
350
|
)
|
|
346
351
|
self._title(message)
|
|
347
352
|
|
|
348
353
|
def info(self, message: str) -> None:
|
|
349
354
|
self._progress.update(
|
|
350
355
|
self._task_id,
|
|
351
|
-
description=f"
|
|
356
|
+
description=f"{_glyphs().run} {self._target} {_glyphs().bullet} {message}",
|
|
352
357
|
)
|
|
353
358
|
self._title(message)
|
|
354
359
|
|
|
@@ -363,7 +368,7 @@ class _StageTracker:
|
|
|
363
368
|
self._progress.update(
|
|
364
369
|
self._task_id,
|
|
365
370
|
advance=steps,
|
|
366
|
-
description=f"
|
|
371
|
+
description=f"{_glyphs().run} {self._target} {_glyphs().bullet} {message}",
|
|
367
372
|
)
|
|
368
373
|
self._title(message)
|
|
369
374
|
|
|
@@ -386,7 +391,7 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
386
391
|
return []
|
|
387
392
|
targets = _dedupe([normalize_url(target) for target in raw_targets])
|
|
388
393
|
matchers = build_matchers(
|
|
389
|
-
|
|
394
|
+
None, use_sources=not args.no_sources, use_builtin=not args.no_builtin
|
|
390
395
|
)
|
|
391
396
|
analyzer = TechAnalyzer(matchers)
|
|
392
397
|
geo = GeoProvider(args.geoip_db)
|
|
@@ -427,7 +432,6 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
427
432
|
from rich.progress import (
|
|
428
433
|
BarColumn,
|
|
429
434
|
MofNCompleteColumn,
|
|
430
|
-
SpinnerColumn,
|
|
431
435
|
TextColumn,
|
|
432
436
|
TimeElapsedColumn,
|
|
433
437
|
)
|
|
@@ -435,7 +439,7 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
435
439
|
err_console = Console(stderr=True)
|
|
436
440
|
total_targets = len(targets)
|
|
437
441
|
completed = 0
|
|
438
|
-
staged = args.verbose >=
|
|
442
|
+
staged = args.verbose >= 1 or (args.verbose == 0 and total_targets == 1)
|
|
439
443
|
per_target_total = stage_total(options)
|
|
440
444
|
|
|
441
445
|
async def scan_one(
|
|
@@ -446,7 +450,7 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
446
450
|
) -> ScanReport:
|
|
447
451
|
nonlocal completed
|
|
448
452
|
if args.verbose == 1:
|
|
449
|
-
err_console.print(f"[{theme.MUTED}]
|
|
453
|
+
err_console.print(f"[{theme.MUTED}]{_glyphs().run} Starting scan of {target}...[/]")
|
|
450
454
|
|
|
451
455
|
stage_log: StageLog | None = None
|
|
452
456
|
if staged and progress_obj is not None and task_id is not None:
|
|
@@ -486,14 +490,16 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
486
490
|
summary_str = ", ".join(findings)
|
|
487
491
|
if args.verbose == 1:
|
|
488
492
|
err_console.print(
|
|
489
|
-
f"[{theme.SUCCESS}][
|
|
493
|
+
f"[{theme.SUCCESS}]{_glyphs().ok}[/] [{completed}/{total_targets}] Finished {target} in {_fmt_elapsed(report.elapsed or 0)} ({summary_str})",
|
|
490
494
|
highlight=False,
|
|
491
495
|
)
|
|
492
496
|
|
|
493
497
|
_set_title(f"stackscan {completed}/{total_targets} · {target.split('://', 1)[-1]}")
|
|
494
498
|
if progress_obj is not None and task_id is not None:
|
|
495
499
|
if staged:
|
|
496
|
-
progress_obj.update(
|
|
500
|
+
progress_obj.update(
|
|
501
|
+
task_id, completed=target_total, description=f"{_glyphs().ok} {target}"
|
|
502
|
+
)
|
|
497
503
|
else:
|
|
498
504
|
progress_obj.update(task_id, advance=1, description=f"Scanning: {target}")
|
|
499
505
|
|
|
@@ -509,9 +515,8 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
509
515
|
_set_title(f"stackscan · scanning {total_targets} target(s)")
|
|
510
516
|
async with StackscanSession() as session:
|
|
511
517
|
if not is_json_terminal():
|
|
512
|
-
transient = args.verbose
|
|
518
|
+
transient = args.verbose == 0
|
|
513
519
|
with Progress(
|
|
514
|
-
SpinnerColumn(),
|
|
515
520
|
TextColumn("[progress.description]{task.description}"),
|
|
516
521
|
BarColumn(),
|
|
517
522
|
MofNCompleteColumn(),
|
|
@@ -746,17 +751,39 @@ def _brute_subject(cameras: int, devices: int) -> str:
|
|
|
746
751
|
return " and ".join(parts) if parts else "device(s)"
|
|
747
752
|
|
|
748
753
|
|
|
754
|
+
def _prompt_line(console: Console) -> str | None:
|
|
755
|
+
console.print("> ", end="")
|
|
756
|
+
try:
|
|
757
|
+
console.file.flush()
|
|
758
|
+
except Exception:
|
|
759
|
+
pass
|
|
760
|
+
tty = None
|
|
761
|
+
try:
|
|
762
|
+
tty = open("/dev/tty", encoding="utf-8", errors="replace")
|
|
763
|
+
except OSError:
|
|
764
|
+
tty = None
|
|
765
|
+
stream = tty if tty is not None else sys.stdin
|
|
766
|
+
try:
|
|
767
|
+
line = stream.readline()
|
|
768
|
+
except (OSError, EOFError, KeyboardInterrupt):
|
|
769
|
+
line = ""
|
|
770
|
+
finally:
|
|
771
|
+
if tty is not None:
|
|
772
|
+
tty.close()
|
|
773
|
+
return line if line else None
|
|
774
|
+
|
|
775
|
+
|
|
749
776
|
def _prompt_brute(cameras: int, devices: int, console: Console) -> bool:
|
|
750
777
|
console.print(
|
|
751
|
-
f"[{theme.WARN}][
|
|
752
|
-
" Try to brute? Y
|
|
778
|
+
f"[{theme.WARN}]{_glyphs().ask}[/] Parser found {_brute_subject(cameras, devices)}."
|
|
779
|
+
" Try to brute? [Y]es / [N]o",
|
|
753
780
|
highlight=False,
|
|
754
781
|
)
|
|
755
782
|
while True:
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
except (EOFError, KeyboardInterrupt):
|
|
783
|
+
answer = _prompt_line(console)
|
|
784
|
+
if answer is None:
|
|
759
785
|
return False
|
|
786
|
+
answer = answer.strip().lower()
|
|
760
787
|
if answer in ("y", "yes"):
|
|
761
788
|
return True
|
|
762
789
|
if answer in ("n", "no", ""):
|
|
@@ -902,11 +929,21 @@ def _sigdb_command(argv: list[str]) -> int:
|
|
|
902
929
|
"-h", "--help", action=_HelpAction, nargs=0, help="Show this help message and exit."
|
|
903
930
|
)
|
|
904
931
|
sub = parser.add_subparsers(dest="action", required=True)
|
|
905
|
-
add_p = sub.add_parser("add", help="Add a signature source (
|
|
906
|
-
add_p.add_argument(
|
|
932
|
+
add_p = sub.add_parser("add", help="Add a signature source (local path, web URL, or git repo).")
|
|
933
|
+
add_p.add_argument(
|
|
934
|
+
"url",
|
|
935
|
+
nargs="?",
|
|
936
|
+
help="Source: a database base URL/dir/git repo (with sigdb/ and stackscan/),"
|
|
937
|
+
" or a direct .sigdb/rules path or manifest.json."
|
|
938
|
+
f" Defaults to {DEFAULT_SOURCE_URL}.",
|
|
939
|
+
)
|
|
907
940
|
sub.add_parser("list", help="List configured sources.")
|
|
908
941
|
remove_p = sub.add_parser("remove", help="Remove a source by id or url.")
|
|
909
942
|
remove_p.add_argument("key", help="Source id or url.")
|
|
943
|
+
enable_p = sub.add_parser("enable", help="Enable a source by id or url.")
|
|
944
|
+
enable_p.add_argument("key", help="Source id or url.")
|
|
945
|
+
disable_p = sub.add_parser("disable", help="Disable a source without removing it.")
|
|
946
|
+
disable_p.add_argument("key", help="Source id or url.")
|
|
910
947
|
update_p = sub.add_parser("update", help="Re-fetch and recompile a source (or all).")
|
|
911
948
|
update_p.add_argument("key", nargs="?", help="Source id or url; omit to update all.")
|
|
912
949
|
args = parser.parse_args(argv)
|
|
@@ -914,9 +951,13 @@ def _sigdb_command(argv: list[str]) -> int:
|
|
|
914
951
|
console = Console()
|
|
915
952
|
try:
|
|
916
953
|
if args.action == "add":
|
|
917
|
-
|
|
954
|
+
url = args.url or DEFAULT_SOURCE_URL
|
|
955
|
+
source = store.add(url)
|
|
956
|
+
extras = [name for name, val in (("cve", source.cve), ("subdomains", source.subdomains)) if val]
|
|
957
|
+
suffix = f" (+{', '.join(extras)})" if extras else ""
|
|
918
958
|
console.print(
|
|
919
|
-
f"[green]Added[/green] {
|
|
959
|
+
f"[green]Added[/green] {url} ([cyan]{source.kind}[/cyan], id={source.id})"
|
|
960
|
+
f" -> {source.path}{suffix}"
|
|
920
961
|
)
|
|
921
962
|
return 0
|
|
922
963
|
if args.action == "list":
|
|
@@ -927,11 +968,17 @@ def _sigdb_command(argv: list[str]) -> int:
|
|
|
927
968
|
table = Table(title="Signature Sources")
|
|
928
969
|
table.add_column("ID", style="cyan")
|
|
929
970
|
table.add_column("Kind")
|
|
971
|
+
table.add_column("State")
|
|
930
972
|
table.add_column("URL", overflow="fold")
|
|
931
973
|
table.add_column("Added")
|
|
932
974
|
for source in sources:
|
|
933
975
|
added = datetime.fromtimestamp(source.added, UTC).strftime("%Y-%m-%d")
|
|
934
|
-
|
|
976
|
+
state = (
|
|
977
|
+
f"[{theme.SUCCESS}]enabled[/]"
|
|
978
|
+
if source.enabled
|
|
979
|
+
else f"[{theme.MUTED}]disabled[/]"
|
|
980
|
+
)
|
|
981
|
+
table.add_row(source.id, source.kind, state, source.url, added)
|
|
935
982
|
console.print(table)
|
|
936
983
|
return 0
|
|
937
984
|
if args.action == "remove":
|
|
@@ -941,6 +988,13 @@ def _sigdb_command(argv: list[str]) -> int:
|
|
|
941
988
|
return 0
|
|
942
989
|
console.print(f"[yellow]No source matched[/yellow] {args.key}")
|
|
943
990
|
return 1
|
|
991
|
+
if args.action in ("enable", "disable"):
|
|
992
|
+
enabled = args.action == "enable"
|
|
993
|
+
if store.set_enabled(args.key, enabled):
|
|
994
|
+
console.print(f"[green]{args.action.title()}d[/green] {args.key}")
|
|
995
|
+
return 0
|
|
996
|
+
console.print(f"[yellow]No source matched[/yellow] {args.key}")
|
|
997
|
+
return 1
|
|
944
998
|
if args.action == "update":
|
|
945
999
|
refreshed = store.update(args.key)
|
|
946
1000
|
if not refreshed:
|
|
@@ -5,10 +5,11 @@ from stackscan.config.sigdb_loader import (
|
|
|
5
5
|
builtin_sigdb_path,
|
|
6
6
|
resolve_sigdb_paths,
|
|
7
7
|
)
|
|
8
|
-
from stackscan.config.sources import Source, SourceError, SourceStore
|
|
8
|
+
from stackscan.config.sources import DEFAULT_SOURCE_URL, Source, SourceError, SourceStore
|
|
9
9
|
|
|
10
10
|
__all__ = [
|
|
11
11
|
"DEFAULT_SIGDB_PATH",
|
|
12
|
+
"DEFAULT_SOURCE_URL",
|
|
12
13
|
"NoSignaturesError",
|
|
13
14
|
"Source",
|
|
14
15
|
"SourceError",
|
|
@@ -69,6 +69,7 @@ def build_matchers(
|
|
|
69
69
|
paths = resolve_sigdb_paths(explicit, use_sources=use_sources, use_builtin=use_builtin)
|
|
70
70
|
if not paths:
|
|
71
71
|
raise NoSignaturesError(
|
|
72
|
-
|
|
72
|
+
"No signature database found. Add one with 'stackscan sigdb add'"
|
|
73
|
+
" (defaults to https://db.imalive.lol/sigdb)."
|
|
73
74
|
)
|
|
74
75
|
return [_load_matcher(path) for path in paths]
|