stackscan 2.6.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.
Files changed (79) hide show
  1. {stackscan-2.6.0 → stackscan-2.6.3}/PKG-INFO +1 -1
  2. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/__init__.py +1 -1
  3. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/creds.py +136 -20
  4. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/cve.py +17 -3
  5. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/osdetect.py +3 -1
  6. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/services.py +9 -0
  7. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/social.py +6 -2
  8. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/tech.py +55 -16
  9. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/cli.py +69 -15
  10. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/config/__init__.py +2 -1
  11. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/config/sigdb_loader.py +2 -1
  12. stackscan-2.6.3/src/stackscan/config/sources.py +416 -0
  13. stackscan-2.6.3/src/stackscan/data/builtin.sigdb +0 -0
  14. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/fingerprint.py +7 -1
  15. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/subdomains.py +18 -0
  16. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/render.py +3 -3
  17. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/scan.py +62 -14
  18. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/scanners/secrets.py +14 -7
  19. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/scanners/takeover.py +4 -3
  20. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/theme.py +13 -1
  21. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/types/output.py +1 -0
  22. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_brute.py +68 -2
  23. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_social.py +11 -0
  24. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_sources.py +25 -2
  25. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_vhost.py +31 -0
  26. stackscan-2.6.0/src/stackscan/config/sources.py +0 -238
  27. stackscan-2.6.0/src/stackscan/data/builtin.sigdb +0 -0
  28. {stackscan-2.6.0 → stackscan-2.6.3}/.gitignore +0 -0
  29. {stackscan-2.6.0 → stackscan-2.6.3}/LICENSE +0 -0
  30. {stackscan-2.6.0 → stackscan-2.6.3}/README.md +0 -0
  31. {stackscan-2.6.0 → stackscan-2.6.3}/pyproject.toml +0 -0
  32. {stackscan-2.6.0 → stackscan-2.6.3}/scripts/build_cve_db.py +0 -0
  33. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/__main__.py +0 -0
  34. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/__init__.py +0 -0
  35. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/exposure.py +0 -0
  36. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/generic.py +0 -0
  37. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/infra.py +0 -0
  38. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/security.py +0 -0
  39. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/analyzers/vibe.py +0 -0
  40. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/core/__init__.py +0 -0
  41. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/core/core.py +0 -0
  42. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/data/cve.json.gz +0 -0
  43. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/data/reekeer-logo.png +0 -0
  44. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/data/subdomains.txt +0 -0
  45. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/export.py +0 -0
  46. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/__init__.py +0 -0
  47. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/dns.py +0 -0
  48. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/geo.py +0 -0
  49. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/ipinfo.py +0 -0
  50. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/ports.py +0 -0
  51. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/resolver.py +0 -0
  52. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/tld.py +0 -0
  53. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/tls.py +0 -0
  54. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/net/whois.py +0 -0
  55. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/scanners/__init__.py +0 -0
  56. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/scanners/isp_blocked.py +0 -0
  57. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/types/__init__.py +0 -0
  58. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/types/models.py +0 -0
  59. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/utils/__init__.py +0 -0
  60. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/utils/paths.py +0 -0
  61. {stackscan-2.6.0 → stackscan-2.6.3}/src/stackscan/utils/urls.py +0 -0
  62. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_builtin_sigdb.py +0 -0
  63. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_cli_helpers.py +0 -0
  64. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_creds.py +0 -0
  65. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_cve.py +0 -0
  66. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_dns_records.py +0 -0
  67. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_export.py +0 -0
  68. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_extra.py +0 -0
  69. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_infra.py +0 -0
  70. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_osdetect.py +0 -0
  71. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_ports.py +0 -0
  72. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_scan.py +0 -0
  73. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_scanners.py +0 -0
  74. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_services.py +0 -0
  75. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_subdomains.py +0 -0
  76. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_tech.py +0 -0
  77. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_theme.py +0 -0
  78. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_vibe.py +0 -0
  79. {stackscan-2.6.0 → stackscan-2.6.3}/tests/test_whois.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackscan
3
- Version: 2.6.0
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
@@ -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.6.3"
@@ -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
+ )
@@ -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 NoSignaturesError, SourceError, SourceStore, build_matchers
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.")
@@ -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
- args.sigdb, use_sources=not args.no_sources, use_builtin=not args.no_builtin
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)
@@ -445,7 +450,7 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
445
450
  ) -> ScanReport:
446
451
  nonlocal completed
447
452
  if args.verbose == 1:
448
- err_console.print(f"[{theme.MUTED}][*] Starting scan of {target}...[/]")
453
+ err_console.print(f"[{theme.MUTED}]{_glyphs().run} Starting scan of {target}...[/]")
449
454
 
450
455
  stage_log: StageLog | None = None
451
456
  if staged and progress_obj is not None and task_id is not None:
@@ -485,7 +490,7 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
485
490
  summary_str = ", ".join(findings)
486
491
  if args.verbose == 1:
487
492
  err_console.print(
488
- f"[{theme.SUCCESS}][+][/] [{completed}/{total_targets}] Finished {target} in {_fmt_elapsed(report.elapsed or 0)} ({summary_str})",
493
+ f"[{theme.SUCCESS}]{_glyphs().ok}[/] [{completed}/{total_targets}] Finished {target} in {_fmt_elapsed(report.elapsed or 0)} ({summary_str})",
489
494
  highlight=False,
490
495
  )
491
496
 
@@ -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}][?][/] Parser found {_brute_subject(cameras, devices)}."
752
- " Try to brute? Y(Yes)/N(No)",
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
- try:
757
- answer = input("> ").strip().lower()
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 (http URL or git repo).")
906
- add_p.add_argument("url", help="Source URL: .sigdb, rules JSON, or a git repository.")
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
- source = store.add(args.url)
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] {args.url} ([cyan]{source.kind}[/cyan], id={source.id}) -> {source.path}"
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
- table.add_row(source.id, source.kind, source.url, added)
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
- f"No signature database found. Provide --sigdb PATH, install one at {DEFAULT_SIGDB_PATH}, or add a source with 'stackscan sigdb add <url>'."
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]