stackscan 2.7.2__py3-none-any.whl → 2.7.3__py3-none-any.whl

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/__init__.py CHANGED
@@ -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.7.2"
5
+ __version__ = "2.7.3"
@@ -101,6 +101,16 @@ _GENERATOR_RE = re.compile(
101
101
  "<meta[^>]+name=[\\\"']generator[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']", re.IGNORECASE
102
102
  )
103
103
  _SSH_RE = re.compile(r"openssh[\s_/:-](\d+\.\d+(?:p\d+)?)", re.IGNORECASE)
104
+ _VERSION_PREFIX_RE = re.compile(r"\s*[vV]?\d+\.\d")
105
+ _IMPRECISE_VERSION_RE = re.compile(
106
+ r"\b(?:or|and) (?:later|higher|earlier|newer|above|below|greater)\b", re.IGNORECASE
107
+ )
108
+
109
+
110
+ def _usable_port_version(version: str) -> bool:
111
+ if not _VERSION_PREFIX_RE.match(version):
112
+ return False
113
+ return not _IMPRECISE_VERSION_RE.search(version)
104
114
  _CORE_COMMIT_RE = re.compile(
105
115
  r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE
106
116
  )
@@ -276,7 +286,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
276
286
  )
277
287
  )
278
288
  continue
279
- if port.product and port.version:
289
+ if port.product and port.version and _usable_port_version(port.version):
280
290
  out.append(
281
291
  Software(
282
292
  name=port.product.lower().split()[0],
@@ -294,6 +304,11 @@ _SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
294
304
 
295
305
 
296
306
  def _confidence(version: str, rng: dict[str, str], source: str, backported: bool = False) -> int:
307
+ if source.startswith("body-text:"):
308
+ # a bare "name version" scraped from page prose (e.g. a blog post),
309
+ # not a live banner — kept below the default threshold so it is hidden
310
+ # unless the user asks for low-confidence matches.
311
+ return 30
297
312
  if backported:
298
313
  if "start_incl" in rng or "start_excl" in rng:
299
314
  return 35
@@ -337,8 +352,16 @@ def _match_entries(
337
352
  return
338
353
  if is_commit_hash(version):
339
354
  return
355
+ if len([c for c in re.split("[.\\-_]", version) if c[:1].isdigit()]) < 2:
356
+ return
357
+ from_prose = item.source.startswith("body-text:")
340
358
  backported = bool(item.os)
341
- caveat = "distro backport likely — patchlevel not in banner" if backported else ""
359
+ if from_prose:
360
+ caveat = "version mentioned in page text — may not be the running software"
361
+ elif backported:
362
+ caveat = "distro backport likely — patchlevel not in banner"
363
+ else:
364
+ caveat = ""
342
365
  for entry in entries:
343
366
  ranges = cast("list[dict[str, str]]", entry.get("ranges") or [])
344
367
  hit_rng = next((r for r in ranges if _in_range(version, r)), None)
@@ -357,7 +380,7 @@ def _match_entries(
357
380
  confidence=confidence,
358
381
  summary=str(entry.get("summary", "")),
359
382
  url=entry.get("url"),
360
- unconfirmed=backported,
383
+ unconfirmed=backported or from_prose,
361
384
  caveat=caveat,
362
385
  )
363
386
  agg[cve_id] = record
@@ -18,6 +18,26 @@ class ExposureProbe:
18
18
  insecure: bool
19
19
 
20
20
 
21
+ def _looks_like_robots(body: str) -> bool:
22
+ lowered = body.lower()
23
+ return any(
24
+ directive in lowered
25
+ for directive in ("user-agent:", "disallow:", "allow:", "sitemap:", "crawl-delay:")
26
+ )
27
+
28
+
29
+ def _looks_like_sitemap(body: str) -> bool:
30
+ lowered = body.lstrip().lower()
31
+ return lowered.startswith("<?xml") or "<urlset" in lowered or "<sitemapindex" in lowered
32
+
33
+
34
+ def _looks_like_security_txt(body: str) -> bool:
35
+ lowered = body.lower()
36
+ return any(
37
+ field in lowered for field in ("contact:", "expires:", "encryption:", "acknowledgments:")
38
+ )
39
+
40
+
21
41
  async def _get(session: StackscanSession, url: str, probe: ExposureProbe) -> tuple[int | None, str]:
22
42
  try:
23
43
  result = await session.fetch(
@@ -39,20 +59,20 @@ async def analyze_exposure(
39
59
  urls: dict[str, str] = {}
40
60
 
41
61
  robots_url = urljoin(base_url, "/robots.txt")
42
- robots_status, _ = await _get(session, robots_url, probe)
43
- robots = robots_status == 200
62
+ robots_status, robots_body = await _get(session, robots_url, probe)
63
+ robots = robots_status == 200 and _looks_like_robots(robots_body)
44
64
  if robots:
45
65
  urls["robots.txt"] = robots_url
46
66
 
47
67
  sitemap_url = urljoin(base_url, "/sitemap.xml")
48
- sitemap_status, _ = await _get(session, sitemap_url, probe)
49
- sitemap = sitemap_status == 200
68
+ sitemap_status, sitemap_body = await _get(session, sitemap_url, probe)
69
+ sitemap = sitemap_status == 200 and _looks_like_sitemap(sitemap_body)
50
70
  if sitemap:
51
71
  urls["sitemap.xml"] = sitemap_url
52
72
 
53
73
  sec_url = urljoin(base_url, "/.well-known/security.txt")
54
- sec_status, _ = await _get(session, sec_url, probe)
55
- security_txt = sec_status == 200
74
+ sec_status, sec_body = await _get(session, sec_url, probe)
75
+ security_txt = sec_status == 200 and _looks_like_security_txt(sec_body)
56
76
  if security_txt:
57
77
  urls["security.txt"] = sec_url
58
78
 
@@ -93,7 +93,7 @@ _POWERED_BY_PLAIN_RE = re.compile(
93
93
  _SERVER_VERSION_RE = re.compile(
94
94
  r"\b("
95
95
  + "|".join(re.escape(name) for name in sorted(SERVER_NAMES, key=len, reverse=True))
96
- + r")[/ ]v?(\d+\.\d+(?:\.\d+){0,2})",
96
+ + r")([/ ])v?(\d+\.\d+(?:\.\d+){0,2})",
97
97
  re.IGNORECASE,
98
98
  )
99
99
 
@@ -155,6 +155,11 @@ def _software_name(name: str) -> str:
155
155
  return _normalize_name(name).lower().replace(" ", "")
156
156
 
157
157
 
158
+ def _name_detached_from_version(name: str) -> bool:
159
+ """True when the version sits behind a stopword/other product, not this name."""
160
+ return _software_name(_clean_product_name(name)) != _software_name(name)
161
+
162
+
158
163
  def _category(name: str) -> str:
159
164
  if _software_name(name) in {n.replace(" ", "") for n in SERVER_NAMES}:
160
165
  return "infrastructure"
@@ -195,16 +200,18 @@ def extract_generic_tech(body: str) -> list[Technology]:
195
200
 
196
201
  for match in _SERVER_VERSION_RE.finditer(body):
197
202
  name = _normalize_name(match.group(1))
198
- version = match.group(2)
199
- remember(name, f"body:{name}/{version}", version)
203
+ version = match.group(3)
204
+ source = f"body:{name}/{version}" if match.group(2) == "/" else f"body-text:{name} {version}"
205
+ remember(name, source, version)
200
206
 
201
207
  powered_starts: set[int] = set()
202
208
  for match in _POWERED_BY_VERSION_RE.finditer(body):
203
209
  name = _normalize_name(match.group(1))
204
- version = match.group(2)
210
+ version = match.group(2) if not _name_detached_from_version(name) else None
205
211
  powered_starts.add(match.start())
206
212
  if name and not _is_noise(name):
207
- remember(name, f"body:powered-by {name} {version}", version)
213
+ evidence = f"body:powered-by {name} {version}" if version else f"body:powered-by {name}"
214
+ remember(name, evidence, version)
208
215
 
209
216
  for match in _POWERED_BY_PLAIN_RE.finditer(body):
210
217
  if match.start() in powered_starts:
@@ -275,16 +282,18 @@ def extract_generic_software(body: str, location: str = "") -> list[Software]:
275
282
 
276
283
  for match in _SERVER_VERSION_RE.finditer(body):
277
284
  name = _normalize_name(match.group(1))
278
- version = match.group(2)
279
- add(name, version, f"body:{name}/{version}")
285
+ version = match.group(3)
286
+ source = f"body:{name}/{version}" if match.group(2) == "/" else f"body-text:{name} {version}"
287
+ add(name, version, source)
280
288
 
281
289
  powered_starts: set[int] = set()
282
290
  for match in _POWERED_BY_VERSION_RE.finditer(body):
283
291
  name = _normalize_name(match.group(1))
284
- version = match.group(2)
292
+ version = match.group(2) if not _name_detached_from_version(name) else None
285
293
  powered_starts.add(match.start())
286
294
  if name:
287
- add(name, version, f"body:powered-by {name} {version}")
295
+ evidence = f"body:powered-by {name} {version}" if version else f"body:powered-by {name}"
296
+ add(name, version, evidence)
288
297
 
289
298
  for match in _POWERED_BY_PLAIN_RE.finditer(body):
290
299
  if match.start() in powered_starts:
stackscan/cli.py CHANGED
@@ -10,9 +10,10 @@ from collections.abc import Iterable
10
10
  from datetime import UTC, datetime
11
11
  from functools import lru_cache
12
12
  from pathlib import Path
13
- from typing import Any
13
+ from typing import Any, cast
14
14
 
15
15
  from rich.console import Console
16
+ from rich.markup import escape
16
17
  from rich.progress import Progress
17
18
  from rich.table import Table
18
19
 
@@ -362,7 +363,7 @@ def _bar(label: str, total: int | None = None) -> Any:
362
363
  if _reekeer_progress is None:
363
364
  return None
364
365
  try:
365
- return _reekeer_progress.bar(label, total=total)
366
+ return cast("Any", _reekeer_progress).bar(label, total=total)
366
367
  except Exception:
367
368
  # Reporting progress is not what stackscan is for. It must never be the reason a scan fails.
368
369
  return None
@@ -710,13 +711,13 @@ def _render_table(reports: list[ScanReport], show_empty: bool) -> None:
710
711
  ips.extend(report.network.ipv4)
711
712
  ips.extend(report.network.ipv6)
712
713
  table.add_row(
713
- report.final_url or report.url,
714
- ", ".join(ips) if ips else "-",
714
+ escape(report.final_url or report.url),
715
+ escape(", ".join(ips)) if ips else "-",
715
716
  str(report.status) if report.status is not None else "-",
716
- _infra_summary(report),
717
- _format_detected(report.by_category()),
717
+ escape(_infra_summary(report)),
718
+ escape(_format_detected(report.by_category())),
718
719
  _exposure_summary(report),
719
- report.error or "",
720
+ escape(report.error or ""),
720
721
  )
721
722
  console.print(table)
722
723
 
@@ -817,11 +818,11 @@ def _scan_summary(reports: list[ScanReport], elapsed: float) -> str:
817
818
 
818
819
 
819
820
  def _warn(console: Console, message: str) -> None:
820
- console.print(f"[{theme.WARN}]{_glyphs().warn}[/] {message}", highlight=False)
821
+ console.print(f"[{theme.WARN}]{_glyphs().warn}[/] {escape(message)}", highlight=False)
821
822
 
822
823
 
823
824
  def _error(console: Console, message: str) -> None:
824
- console.print(f"[{theme.DANGER}]{_glyphs().err}[/] {message}", highlight=False)
825
+ console.print(f"[{theme.DANGER}]{_glyphs().err}[/] {escape(message)}", highlight=False)
825
826
 
826
827
 
827
828
  def _set_title(title: str) -> None:
@@ -83,8 +83,18 @@ def _looks_like_git(url: str) -> bool:
83
83
  return url.endswith(".git") or url.startswith(("git@", "git+", "ssh://"))
84
84
 
85
85
 
86
+ _GIT_SCHEMES = ("https://", "http://", "ssh://", "git://", "file://")
87
+
88
+
86
89
  def _normalize_git_url(url: str) -> str:
87
- return url[4:] if url.startswith("git+") else url
90
+ normalized = url[4:] if url.startswith("git+") else url
91
+ if normalized.startswith("git@") or normalized.startswith(_GIT_SCHEMES):
92
+ return normalized
93
+ if "://" not in normalized and "::" not in normalized:
94
+ expanded = Path(normalized).expanduser()
95
+ if expanded.exists():
96
+ return str(expanded)
97
+ raise SourceError(f"unsupported git url: {url}")
88
98
 
89
99
 
90
100
  def _detect_kind(url: str) -> str:
@@ -385,7 +395,7 @@ def _materialize_git(url: str, dest: Path) -> _Materialized:
385
395
  clone_url = _normalize_git_url(url)
386
396
  try:
387
397
  subprocess.run(
388
- ["git", "clone", "--depth", "1", clone_url, str(checkout)],
398
+ ["git", "clone", "--depth", "1", "--", clone_url, str(checkout)],
389
399
  check=True,
390
400
  capture_output=True,
391
401
  text=True,
stackscan/core/core.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import codecs
3
4
  from collections.abc import Iterable
4
5
 
5
6
  from aiohttp import ClientSession, ClientTimeout
@@ -7,11 +8,23 @@ from aiohttp import ClientSession, ClientTimeout
7
8
  from stackscan.net.resolver import build_connector
8
9
  from stackscan.types import FetchResult
9
10
 
11
+ _MAX_DRAIN_BYTES = 4 * 1024 * 1024
12
+
10
13
 
11
14
  def _lower_headers(items: Iterable[tuple[str, str]]) -> dict[str, str]:
12
15
  return {key.lower(): value for key, value in items}
13
16
 
14
17
 
18
+ def _charset(declared: str | None) -> str:
19
+ if not declared:
20
+ return "utf-8"
21
+ try:
22
+ codecs.lookup(declared)
23
+ except LookupError:
24
+ return "utf-8"
25
+ return declared
26
+
27
+
15
28
  class StackscanSession:
16
29
  def __init__(self) -> None:
17
30
  self._session: ClientSession | None = None
@@ -54,10 +67,14 @@ class StackscanSession:
54
67
  headers = _lower_headers(header_items)
55
68
  raw_headers = [f"{key.lower()}: {value}" for key, value in header_items]
56
69
  cookies = resp.headers.getall("Set-Cookie", [])
57
- charset = resp.charset or "utf-8"
70
+ charset = _charset(resp.charset)
58
71
  body_bytes = await resp.content.read(max_bytes)
59
- while await resp.content.read(8192):
60
- pass
72
+ drained = 0
73
+ while drained < _MAX_DRAIN_BYTES:
74
+ chunk = await resp.content.read(8192)
75
+ if not chunk:
76
+ break
77
+ drained += len(chunk)
61
78
  body = body_bytes.decode(charset, errors="replace")
62
79
  url_final = str(resp.url)
63
80
  version = resp.version
stackscan/export.py CHANGED
@@ -27,6 +27,15 @@ def to_json(payload: Payload) -> str:
27
27
  return json.dumps(payload, ensure_ascii=False, indent=2)
28
28
 
29
29
 
30
+ def _script_json(value: Any) -> str:
31
+ return (
32
+ json.dumps(value, ensure_ascii=False)
33
+ .replace("<", "\\u003c")
34
+ .replace(">", "\\u003e")
35
+ .replace("&", "\\u0026")
36
+ )
37
+
38
+
30
39
  def build_graph(reports: list[dict[str, Any]]) -> dict[str, Any]:
31
40
  nodes: dict[str, dict[str, Any]] = {}
32
41
  edges: set[tuple[str, str, str]] = set()
@@ -407,7 +416,7 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
407
416
  if len(present_types) < 2:
408
417
  return ""
409
418
 
410
- graph_json = json.dumps(
419
+ graph_json = _script_json(
411
420
  {
412
421
  "nodes": [
413
422
  {
@@ -420,8 +429,7 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
420
429
  for n in graph["nodes"]
421
430
  ],
422
431
  "edges": graph["edges"],
423
- },
424
- ensure_ascii=False,
432
+ }
425
433
  )
426
434
 
427
435
  legend_items = "".join(
@@ -438,7 +446,7 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
438
446
  </div>
439
447
  <script>
440
448
  (function(){{
441
- const colors = {json.dumps(type_color)};
449
+ const colors = {_script_json(type_color)};
442
450
  const data = {graph_json};
443
451
  const svg = document.getElementById('netgraph');
444
452
  const viewport = svg.querySelector('.graph-viewport');
@@ -493,8 +501,14 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
493
501
  }}
494
502
 
495
503
  function showTip(ev, n) {{
496
- const meta = Object.entries(n.meta || {{}}).map(([k,v]) => `${{k}}: ${{v}}`).join('<br>');
497
- tooltip.innerHTML = `<strong>${{n.label}}</strong> (${{n.type}})${{meta ? '<br>' + meta : ''}}`;
504
+ const strong = document.createElement('strong');
505
+ strong.textContent = n.label;
506
+ const parts = [strong, document.createTextNode(` (${{n.type}})`)];
507
+ Object.entries(n.meta || {{}}).forEach(([k, v]) => {{
508
+ parts.push(document.createElement('br'));
509
+ parts.push(document.createTextNode(`${{k}}: ${{v}}`));
510
+ }});
511
+ tooltip.replaceChildren(...parts);
498
512
  tooltip.style.opacity = 1;
499
513
  moveTip(ev);
500
514
  }}
@@ -92,6 +92,16 @@ def fingerprint_http(raw_response: str) -> tuple[str | None, str | None]:
92
92
  return (server, None)
93
93
 
94
94
 
95
+ def normalize_mysql_version(product: str | None, version: str) -> tuple[str, str, str]:
96
+ distro = extract_distro(version) or ""
97
+ if version.startswith("5.5.5-"):
98
+ version = version[6:]
99
+ product = "MariaDB"
100
+ elif "mariadb" in version.lower():
101
+ product = "MariaDB"
102
+ return (product or "MySQL", version.split("-", 1)[0] or version, distro)
103
+
104
+
95
105
  def fingerprint_mysql(data: bytes) -> tuple[str | None, str | None, str | None, bool]:
96
106
  if not data or len(data) < 5:
97
107
  return (None, None, None, False)
@@ -108,13 +118,5 @@ def fingerprint_mysql(data: bytes) -> tuple[str | None, str | None, str | None,
108
118
  version = version.replace("\x00", "").strip()
109
119
  if not version:
110
120
  return (None, None, None, False)
111
- if version.startswith("5.5.5-"):
112
- version = version[6:]
113
- product = "MariaDB"
114
- elif "mariadb" in version.lower():
115
- product = "MariaDB"
116
- else:
117
- product = "MySQL"
118
- clean_version = version.split("-", 1)[0]
119
- distro = extract_distro(version)
120
- return (product, clean_version, distro, False)
121
+ product, clean_version, distro = normalize_mysql_version(None, version)
122
+ return (product, clean_version, distro or None, False)
stackscan/net/ports.py CHANGED
@@ -8,6 +8,7 @@ from stackscan.net.fingerprint import (
8
8
  fingerprint_banner,
9
9
  fingerprint_http,
10
10
  fingerprint_mysql,
11
+ normalize_mysql_version,
11
12
  sanitize_banner,
12
13
  )
13
14
  from stackscan.types import Port, PortScan
@@ -108,9 +109,9 @@ def _run_nmap(host: str, ports: tuple[int, ...]) -> PortScan | None:
108
109
  continue
109
110
  product = info.get("product") or None
110
111
  version = info.get("version") or None
111
- extra = info.get("extrainfo") or None
112
- if extra and version:
113
- version = f"{version} ({extra})"
112
+ os_tag = ""
113
+ if version and (product or "").lower() in ("mysql", "mariadb"):
114
+ product, version, os_tag = normalize_mysql_version(product, version)
114
115
  found.append(
115
116
  Port(
116
117
  port=int(number),
@@ -120,6 +121,7 @@ def _run_nmap(host: str, ports: tuple[int, ...]) -> PortScan | None:
120
121
  product=product,
121
122
  version=version,
122
123
  host=host,
124
+ os=os_tag,
123
125
  )
124
126
  )
125
127
  found.sort(key=lambda p: p.port)
@@ -227,9 +229,10 @@ async def _http_probe(
227
229
  await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
228
230
  except (TimeoutError, OSError):
229
231
  return (None, None, "")
230
- raw = await _read(reader, timeout)
231
- if not raw:
232
+ data = await _read_bytes(reader, timeout)
233
+ if not data:
232
234
  return (None, None, "")
235
+ raw = data.decode("utf-8", "replace")
233
236
  product, version = fingerprint_http(raw)
234
237
  os = _os_from_banner(raw)
235
238
  return (product, version, os)
@@ -244,10 +247,10 @@ async def _rtsp_probe(
244
247
  await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
245
248
  except (TimeoutError, OSError):
246
249
  return (None, None)
247
- raw = await _read(reader, timeout)
248
- if not raw:
250
+ data = await _read_bytes(reader, timeout)
251
+ if not data:
249
252
  return (None, None)
250
- server = fingerprint_http(raw)
253
+ server = fingerprint_http(data.decode("utf-8", "replace"))
251
254
  if server != (None, None):
252
255
  return server
253
256
  return ("RTSP", None)
stackscan/render.py CHANGED
@@ -8,6 +8,7 @@ from functools import lru_cache
8
8
  from urllib.parse import urljoin
9
9
 
10
10
  from rich.console import Console, Group, RenderableType
11
+ from rich.markup import escape
11
12
  from rich.panel import Panel
12
13
  from rich.table import Table
13
14
  from rich.text import Text
@@ -144,7 +145,7 @@ def _network_section(report: ScanReport) -> RenderableType | None:
144
145
  ttl = ""
145
146
  if rrtype in dns_ttl:
146
147
  ttl = str(dns_ttl[rrtype])
147
- table.add_row(rrtype, target, value, ttl)
148
+ table.add_row(rrtype, escape(target), escape(value), ttl)
148
149
  return table
149
150
 
150
151
 
@@ -159,7 +160,7 @@ def _whois_section(report: ScanReport) -> RenderableType | None:
159
160
  if registrar and whois.registrar_url:
160
161
  registrar += f" · {whois.registrar_url}"
161
162
  if registrar:
162
- grid.add_row("Registrar", registrar)
163
+ grid.add_row("Registrar", escape(registrar))
163
164
  if whois.registrant_public and whois.registrant:
164
165
  grid.add_row("Registrant", Text(whois.registrant, style=theme.WARN))
165
166
  elif whois.privacy:
@@ -172,9 +173,9 @@ def _whois_section(report: ScanReport) -> RenderableType | None:
172
173
  if whois.expires:
173
174
  dates.append(f"expires {whois.expires[:10]}")
174
175
  if dates:
175
- grid.add_row("Dates", " · ".join(dates))
176
+ grid.add_row("Dates", escape(" · ".join(dates)))
176
177
  if whois.nameservers:
177
- grid.add_row("Nameservers", ", ".join(whois.nameservers))
178
+ grid.add_row("Nameservers", escape(", ".join(whois.nameservers)))
178
179
  if whois.dnssec:
179
180
  style = theme.SUCCESS if whois.dnssec == "signed" else theme.MUTED
180
181
  grid.add_row("DNSSEC", Text(whois.dnssec, style=style))
@@ -270,7 +271,7 @@ def _tls_section(report: ScanReport) -> RenderableType | None:
270
271
  issued_by = _issuer_org(tls.issuer)
271
272
  if ca_kind:
272
273
  issued_by += f" · {ca_kind}"
273
- grid.add_row("Issued by", issued_by)
274
+ grid.add_row("Issued by", escape(issued_by))
274
275
  expiry = _cert_expiry(tls.not_after)
275
276
  if expiry is not None:
276
277
  grid.add_row("Expires", expiry)
@@ -278,14 +279,14 @@ def _tls_section(report: ScanReport) -> RenderableType | None:
278
279
  grid.add_row("Issued", Text(tls.not_before, style=theme.MUTED))
279
280
  proto = " ".join(filter(None, (tls.protocol, tls.cipher)))
280
281
  if proto:
281
- grid.add_row("Cipher", proto)
282
+ grid.add_row("Cipher", escape(proto))
282
283
  if tls.alpn:
283
- grid.add_row("ALPN", tls.alpn)
284
+ grid.add_row("ALPN", escape(tls.alpn))
284
285
  if tls.subject_alt_names:
285
286
  sans = ", ".join(tls.subject_alt_names[:8])
286
287
  if len(tls.subject_alt_names) > 8:
287
288
  sans += f" (+{len(tls.subject_alt_names) - 8} more)"
288
- grid.add_row("SANs", sans)
289
+ grid.add_row("SANs", escape(sans))
289
290
  if report.protocols:
290
291
  grid.add_row("HTTP", " · ".join(report.protocols))
291
292
  if not grid.row_count:
@@ -348,10 +349,10 @@ def _tech_section(report: ScanReport) -> RenderableType | None:
348
349
  for key in order:
349
350
  group = groups[key]
350
351
  table.add_row(
351
- group.name,
352
- group.version or "-",
353
- group.category,
354
- group.hosts_cell(primary),
352
+ escape(group.name),
353
+ escape(group.version or "-"),
354
+ escape(group.category),
355
+ escape(group.hosts_cell(primary)),
355
356
  group.conf_cell(),
356
357
  )
357
358
  return table
@@ -425,12 +426,12 @@ def _services_section(report: ScanReport) -> RenderableType | None:
425
426
  product = " ".join(filter(None, (port.product, port.version)))
426
427
  table.add_row(
427
428
  f"{port.port}/{port.protocol}",
428
- port.host or "-",
429
- _service_cell(product, port.service),
429
+ escape(port.host or "-"),
430
+ escape(_service_cell(product, port.service)),
430
431
  _sev_text(severity),
431
432
  )
432
433
  for svc in tech_services:
433
- table.add_row("-", "-", _service_cell(svc.name, svc.kind), _sev_text(svc.severity))
434
+ table.add_row("-", "-", escape(_service_cell(svc.name, svc.kind)), _sev_text(svc.severity))
434
435
 
435
436
  if report.ports is not None:
436
437
  return Group(table, Text(f"via {report.ports.scanner}", style="dim"))
@@ -465,10 +466,10 @@ def _cve_section(report: ScanReport) -> RenderableType | None:
465
466
  summary = f"{cve.caveat} — {summary}"
466
467
  table.add_row(
467
468
  _severity_text(cve),
468
- cve.id,
469
- affects,
470
- where,
471
- summary,
469
+ escape(cve.id),
470
+ escape(affects),
471
+ escape(where),
472
+ escape(summary),
472
473
  )
473
474
  return table
474
475
 
@@ -490,10 +491,16 @@ def _ipinfo_section(report: ScanReport) -> RenderableType | None:
490
491
  table.add_column("Source", overflow="fold")
491
492
  for info in ordered:
492
493
  location = ", ".join(v for v in (info.city, info.country) if v) or "-"
493
- org = info.org or info.isp or "-"
494
+ org = escape(info.org or info.isp or "-")
494
495
  if info.is_cdn:
495
496
  org = f"{org} [dim](CDN/proxy)[/dim]"
496
- table.add_row(info.ip, location, org, info.asn or "-", info.source or "-")
497
+ table.add_row(
498
+ escape(info.ip),
499
+ escape(location),
500
+ org,
501
+ escape(info.asn or "-"),
502
+ escape(info.source or "-"),
503
+ )
497
504
  return table
498
505
 
499
506
 
@@ -513,7 +520,9 @@ def _creds_section(report: ScanReport) -> RenderableType | None:
513
520
  label = Text(" OPEN / NO AUTH ", style="bold white on red")
514
521
  else:
515
522
  label = Text("auth required", style="dim")
516
- table.add_row(finding.target, label, f"{finding.service} — {finding.detail}")
523
+ table.add_row(
524
+ escape(finding.target), label, escape(f"{finding.service} — {finding.detail}")
525
+ )
517
526
  return table
518
527
 
519
528
 
@@ -528,10 +537,10 @@ def _secrets_section(report: ScanReport) -> RenderableType | None:
528
537
  for secret in sorted(report.secrets, key=lambda s: s.severity):
529
538
  color = theme.SEVERITY.get(secret.severity.upper(), theme.MUTED)
530
539
  table.add_row(
531
- secret.name,
532
- secret.value,
540
+ escape(secret.name),
541
+ escape(secret.value),
533
542
  Text(f" {secret.severity.upper()} ", style=f"bold {color}"),
534
- secret.location or "-",
543
+ escape(secret.location or "-"),
535
544
  )
536
545
  return table
537
546
 
@@ -549,11 +558,11 @@ def _takeovers_section(report: ScanReport) -> RenderableType | None:
549
558
  color = theme.SEVERITY.get(takeover.severity.upper(), theme.MUTED)
550
559
  verified = "verified" if takeover.verified else "potential"
551
560
  table.add_row(
552
- takeover.subdomain,
553
- takeover.service,
554
- takeover.cname,
561
+ escape(takeover.subdomain),
562
+ escape(takeover.service),
563
+ escape(takeover.cname),
555
564
  Text(f" {takeover.severity.upper()} ", style=f"bold {color}"),
556
- f"{takeover.evidence} ({verified})",
565
+ escape(f"{takeover.evidence} ({verified})"),
557
566
  )
558
567
  return table
559
568
 
@@ -575,9 +584,9 @@ def _subdomains_section(report: ScanReport) -> RenderableType | None:
575
584
  real = [ip for ip in sub.addresses if ip in report.real_ips]
576
585
  real_cell = ", ".join(real) if real else "-"
577
586
  if show_real:
578
- table.add_row(sub.name, addrs, sub.source, real_cell)
587
+ table.add_row(escape(sub.name), escape(addrs), escape(sub.source), escape(real_cell))
579
588
  else:
580
- table.add_row(sub.name, addrs, sub.source)
589
+ table.add_row(escape(sub.name), escape(addrs), escape(sub.source))
581
590
  return table if table.row_count else None
582
591
 
583
592
 
@@ -655,9 +664,9 @@ def _report_panel(report: ScanReport) -> Panel:
655
664
  if report.network is not None:
656
665
  ips.extend(report.network.ipv4)
657
666
  ips.extend(report.network.ipv6)
658
- left = f"[bold]{report.final_url or report.url}[/bold]"
667
+ left = f"[bold]{escape(report.final_url or report.url)}[/bold]"
659
668
  if ips:
660
- left += f"\n[dim]{', '.join(ips)}[/dim]"
669
+ left += f"\n[dim]{escape(', '.join(ips))}[/dim]"
661
670
  header.add_row(left, right)
662
671
  blocks: list[RenderableType] = [header]
663
672
  if report.error:
stackscan/runner.py CHANGED
@@ -31,7 +31,7 @@ from stackscan.analyzers import TechAnalyzer
31
31
  from stackscan.config import build_matchers
32
32
  from stackscan.core import StackscanSession
33
33
  from stackscan.net import GeoProvider
34
- from stackscan.scan import ScanOptions, scan_target
34
+ from stackscan.scan import ScanOptions, exc_text, scan_target
35
35
  from stackscan.types import ScanReport, Technology
36
36
 
37
37
  DEFAULT_BACKEND_URL = "http://localhost:8787"
@@ -220,6 +220,7 @@ async def _run(cfg: RunnerConfig) -> None:
220
220
 
221
221
  async def scan_one(job: dict[str, str]) -> dict[str, Any]:
222
222
  url = job["url"]
223
+ job_id = job["id"]
223
224
  try:
224
225
  report = await scan_target(
225
226
  url,
@@ -231,10 +232,20 @@ async def _run(cfg: RunnerConfig) -> None:
231
232
  log=None,
232
233
  )
233
234
  except Exception as exc: # noqa: BLE001 - one bad target must not stop the runner.
234
- report = ScanReport(url=url, error=str(exc))
235
- return report_to_result(report, job["id"], cfg.runner_id)
235
+ report = ScanReport(url=url, error=exc_text(exc))
236
+ return report_to_result(report, job_id, cfg.runner_id)
237
+
238
+ usable = [job for job in jobs if job.get("url") and job.get("id")]
239
+ skipped = len(jobs) - len(usable)
240
+ if skipped:
241
+ print(f"[!] skipped {skipped} malformed job(s)", flush=True)
242
+ if not usable:
243
+ if cfg.once:
244
+ return
245
+ await asyncio.sleep(cfg.idle_seconds)
246
+ continue
236
247
 
237
- results = await asyncio.gather(*(scan_one(job) for job in jobs))
248
+ results = await asyncio.gather(*(scan_one(job) for job in usable))
238
249
  try:
239
250
  accepted = await client.report(list(results))
240
251
  except Exception as exc: # noqa: BLE001
stackscan/scan.py CHANGED
@@ -55,7 +55,7 @@ from stackscan.types import (
55
55
  Technology,
56
56
  TlsInfo,
57
57
  )
58
- from stackscan.utils import host_of, is_https, is_ip, port_of
58
+ from stackscan.utils import host_of, is_https, is_ip, netloc_of, port_of
59
59
 
60
60
  if TYPE_CHECKING:
61
61
  from stackscan.core import StackscanSession
@@ -72,6 +72,15 @@ def _http_url(host: str, port: int, tls: bool) -> str:
72
72
  return f"{scheme}://{host}:{port}"
73
73
 
74
74
 
75
+ def exc_text(exc: BaseException) -> str:
76
+ message = str(exc).strip()
77
+ if message:
78
+ return message
79
+ if isinstance(exc, TimeoutError):
80
+ return "timed out"
81
+ return exc.__class__.__name__
82
+
83
+
75
84
  async def _cdn_ips(ips: set[str], *, timeout: float = 8.0, workers: int = 5) -> set[str]:
76
85
  import aiohttp
77
86
 
@@ -128,7 +137,7 @@ async def _fetch_with_fallback(
128
137
  )
129
138
  except Exception as exc:
130
139
  if first_error is None:
131
- first_error = str(exc)
140
+ first_error = exc_text(exc)
132
141
  continue
133
142
  return (fetched, attempt)
134
143
  report.error = first_error
@@ -372,7 +381,7 @@ async def _scan_site(
372
381
  max_bytes=options.max_bytes,
373
382
  )
374
383
  except Exception as exc:
375
- return SiteFinding(url=url, error=str(exc))
384
+ return SiteFinding(url=url, error=exc_text(exc))
376
385
  block_msg = detect_isp_block(url, fetched)
377
386
  if block_msg:
378
387
  return SiteFinding(url=url, final_url=fetched.url, status=fetched.status, error=block_msg)
@@ -448,7 +457,7 @@ def _collect_site_candidates(report: ScanReport, limit: int) -> list[str]:
448
457
  for port in port_scan.ports:
449
458
  if port.port not in _HTTP_PORTS:
450
459
  continue
451
- host = port.host or report.network.host if report.network else None
460
+ host = port.host or (report.network.host if report.network else None)
452
461
  if not host:
453
462
  continue
454
463
  tls = port.port in (443, 8443, 2083) or "https" in (port.service or "").lower()
@@ -989,7 +998,7 @@ async def scan_target(
989
998
  report.protocols = _http_protocols(fetched, report.tls)
990
999
  stage("scanning page for secrets")
991
1000
  report.secrets = scan_secrets(
992
- fetched.body, location=host_of(fetched.url) if fetched else host or ""
1001
+ fetched.body, location=netloc_of(fetched.url) if fetched else host or ""
993
1002
  )
994
1003
  if options.parse_social:
995
1004
  stage("extracting social links")
@@ -81,6 +81,29 @@ def _redact(value: str, keep: int = 6) -> str:
81
81
  return f"{value[:keep]}...{value[-keep:]}"
82
82
 
83
83
 
84
+ _PLACEHOLDER_MARKERS: tuple[str, ...] = (
85
+ "example",
86
+ "your_",
87
+ "_here",
88
+ "changeme",
89
+ "placeholder",
90
+ "redacted",
91
+ "dummy",
92
+ "sample",
93
+ "xxxx",
94
+ "<",
95
+ )
96
+
97
+
98
+ def _is_placeholder(value: str) -> bool:
99
+ """Filter well-known documentation placeholders (e.g. AWS's AKIA...EXAMPLE)."""
100
+ lowered = value.lower()
101
+ if any(marker in lowered for marker in _PLACEHOLDER_MARKERS):
102
+ return True
103
+ stripped = value.strip("*")
104
+ return len(set(stripped)) <= 1
105
+
106
+
84
107
  def scan_secrets(body: str, location: str = "") -> list[SecretFinding]:
85
108
  """Return probable secret leaks found in the response body."""
86
109
  seen: set[tuple[str, str]] = set()
@@ -89,7 +112,7 @@ def scan_secrets(body: str, location: str = "") -> list[SecretFinding]:
89
112
  for match in pattern.regex.finditer(body):
90
113
  value = match.group(1) if pattern.regex.groups else match.group(0)
91
114
  value = value.strip()
92
- if len(value) < 4:
115
+ if len(value) < 4 or _is_placeholder(value):
93
116
  continue
94
117
  key = (pattern.name, value)
95
118
  if key in seen:
@@ -1,5 +1,5 @@
1
1
  from .paths import db_dir
2
- from .urls import expand_cidr, host_of, is_cidr, is_https, is_ip, normalize_url, port_of
2
+ from .urls import expand_cidr, host_of, is_cidr, is_https, is_ip, netloc_of, normalize_url, port_of
3
3
 
4
4
  __all__ = [
5
5
  "db_dir",
@@ -8,6 +8,7 @@ __all__ = [
8
8
  "is_cidr",
9
9
  "is_ip",
10
10
  "is_https",
11
+ "netloc_of",
11
12
  "normalize_url",
12
13
  "port_of",
13
14
  ]
stackscan/utils/urls.py CHANGED
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import ipaddress
4
4
  from urllib.parse import urlparse
5
5
 
6
+ MAX_CIDR_HOSTS = 65536
7
+
6
8
 
7
9
  def is_cidr(raw: str) -> bool:
8
10
  try:
@@ -14,6 +16,11 @@ def is_cidr(raw: str) -> bool:
14
16
 
15
17
  def expand_cidr(raw: str) -> list[str]:
16
18
  network = ipaddress.ip_network(raw, strict=False)
19
+ if network.num_addresses > MAX_CIDR_HOSTS:
20
+ raise ValueError(
21
+ f"{raw} expands to {network.num_addresses} addresses"
22
+ f" (limit {MAX_CIDR_HOSTS}); use a smaller prefix"
23
+ )
17
24
  return [str(host) for host in network]
18
25
 
19
26
 
@@ -36,6 +43,14 @@ def host_of(url: str) -> str:
36
43
  return (parsed.hostname or "").lower()
37
44
 
38
45
 
46
+ def netloc_of(url: str) -> str:
47
+ parsed = urlparse(url if "://" in url else "https://" + url)
48
+ host = (parsed.hostname or "").lower()
49
+ if parsed.port is not None:
50
+ return f"{host}:{parsed.port}"
51
+ return host
52
+
53
+
39
54
  def port_of(url: str) -> int:
40
55
  parsed = urlparse(url)
41
56
  if parsed.port is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stackscan
3
- Version: 2.7.2
3
+ Version: 2.7.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
@@ -26,7 +26,6 @@ Requires-Dist: sigdb>=1.0.2
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: black>=24.0; extra == 'dev'
28
28
  Requires-Dist: build>=1.0; extra == 'dev'
29
- Requires-Dist: cibuildwheel>=2.16; extra == 'dev'
30
29
  Requires-Dist: geoip2>=4.8; extra == 'dev'
31
30
  Requires-Dist: pyright>=1.1; extra == 'dev'
32
31
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -1,17 +1,17 @@
1
- stackscan/__init__.py,sha256=iLyDhPQ-CtYpyB0Cj1Eb-EUM9I0wrNWGLsuXA62wQys,154
1
+ stackscan/__init__.py,sha256=MUquHzamgIKyOfbGzsCOwhtMxgqZHGVuW5QOFjpAj8o,154
2
2
  stackscan/__main__.py,sha256=M0ngbLh7EFeMvN_A2pxLpnwpk1xudaIQotUxoguvk_k,88
3
- stackscan/cli.py,sha256=CBfIDgTYgaNJ57Ru_qeMCiDTmQwF78UOjyT2cKxKEds,47092
3
+ stackscan/cli.py,sha256=Icy5T9_OrcrVlE77l9M3V4hpalDEvPGCYLI7rKf9kX4,47198
4
4
  stackscan/embed.py,sha256=S-yT0jJJfK3R6U1FodFnVMTXuvdoRAIhNjvOLiWQoAo,15655
5
- stackscan/export.py,sha256=TCc9ph6OIju-vhiYd3rLUqRPETrrP0_J2GGXjvXhJvQ,27603
6
- stackscan/render.py,sha256=4yJKQB4s9n7HmmX_uBZz0dqQh0DUPZJuOOD24mdrik4,26037
7
- stackscan/runner.py,sha256=2dX7-tS4R_Hy6vtuxpnyoUdf3WwZuCIctNh92a-cS4U,10154
8
- stackscan/scan.py,sha256=woXuDiGfX9197UZeDts8O7zBv4WMY92zn9eHCP6MyF4,39397
5
+ stackscan/export.py,sha256=RZhRcjzpDN2a9xC8lHIJFmQbx_bYFsvSWANyg7n5ECQ,27958
6
+ stackscan/render.py,sha256=MzF_9FXwoeJ9JhuYEDVYuDBrMNFGSKbaPaKr207cL08,26505
7
+ stackscan/runner.py,sha256=teP15NF4qApWJn7BXGR0q7YHB9bZWyVftqKf-7VyDCU,10592
8
+ stackscan/scan.py,sha256=MPkuGzvNhaAth31Lk_CvKUOy-gjjbRLTY4DTupt6HGs,39634
9
9
  stackscan/theme.py,sha256=aykB2Yjjjgekh89Rb872SIRMMWIFP6ANSwLTrwEZ9Dw,900
10
10
  stackscan/analyzers/__init__.py,sha256=2DhG7FCOvSJ2OSo_FU16iQ9M74UfiRZLzudSCorPTAE,1051
11
11
  stackscan/analyzers/creds.py,sha256=U054T4-njg0Eqpgkq7QSBk_nEZqsgmMqW4KGi0rdePU,12925
12
- stackscan/analyzers/cve.py,sha256=F0_hl5ePBGtBKh_WCSsovauLSFpUKnnHgkRLdr0Y5HI,19089
13
- stackscan/analyzers/exposure.py,sha256=wL1VJVxhLhZanwOHG3pockw9pUgb4wejNKAMZCKHnJc,2132
14
- stackscan/analyzers/generic.py,sha256=ed5-m3lFqzR4QYLpevh_6Fok3jIjDSBhEIWyp7pFpX4,8700
12
+ stackscan/analyzers/cve.py,sha256=F3fJAV8gOZVv8Big-og8R7dUrhApmVhrtwhZO5jLbeA,20049
13
+ stackscan/analyzers/exposure.py,sha256=2f0wY3LoynlrlzzE_tU3drXokLPFVfyKz3MfAGnZ56w,2861
14
+ stackscan/analyzers/generic.py,sha256=aBCBdDNaAMftvWX2B1JRwjgcRVi0_Sk12eMRhi37yyo,9336
15
15
  stackscan/analyzers/infra.py,sha256=O6b_oV18ikK86fJsSjXriFKf6jWMVxkEBeO2ZhQM3uY,5922
16
16
  stackscan/analyzers/osdetect.py,sha256=E5VN-ajjFAdaRb6Q2gZZvwEMX4YlzJyrNB_JQSS2Ezw,3830
17
17
  stackscan/analyzers/security.py,sha256=2Bp1owKVHYcRN3kU_2jX40f4QA5vbB_jg9-88hp7cvs,743
@@ -21,19 +21,19 @@ stackscan/analyzers/tech.py,sha256=aLwsSss2od3Cinl-anfncEm2dpUBUFQLKI6JC0JT1mc,1
21
21
  stackscan/analyzers/vibe.py,sha256=K-pR3ysu_2Ho9WTSIUmN0DzQIBaiTaPZ97ozWmv6dKw,6142
22
22
  stackscan/config/__init__.py,sha256=Q17mdAjUBdBxciadsZ6_apuGa03w0VdDR9p8ADKS-XM,596
23
23
  stackscan/config/sigdb_loader.py,sha256=Honi_wSFDNCgYP3A2o3RsRWZFX_mcCg9Y1B8hiU0ymQ,2261
24
- stackscan/config/sources.py,sha256=-3CHpu34YvherTLo9KfnfTSINh2Rtu5PcJ9E_FXB49s,14035
24
+ stackscan/config/sources.py,sha256=_kPcT175pYUbFagsGHo88JSrEH8gAiCwStawQJQhI7M,14446
25
25
  stackscan/core/__init__.py,sha256=dpYyqXnO3tFU4M5z0mjTAGovO4px6IRwDVtOAl7JKBE,67
26
- stackscan/core/core.py,sha256=kWSTxTLN131s8RnXkjpwAcNXDyei9BJ6rZdDjQ1C-js,2466
26
+ stackscan/core/core.py,sha256=_rTOyK2eBwWORROfh7gK6usZXwcNsgFnapHkjT9pZr4,2860
27
27
  stackscan/data/builtin.sigdb,sha256=DM_pdoRzhUArxl5PDzytCHgP2ia7FaaQ6_9WbE16H7g,1579340
28
28
  stackscan/data/cve.json.gz,sha256=LkohNP0Gk66OrgHCuCbNNRESkPXHg150CP_wibszmnI,635760
29
29
  stackscan/data/reekeer-logo.png,sha256=usimpkXaxOgnKbGIY6uLpD1Y9uefQYBLJKzBbOI-EV8,8788
30
30
  stackscan/data/subdomains.txt,sha256=91RWQUO44BRLWI6eAvo0cSy9MmBOh0EL4ddXYcv7DDg,3894
31
31
  stackscan/net/__init__.py,sha256=3LK09_4faQ9NeBjp_wbi7jlBROyZf1aENCmIFslJRWI,781
32
32
  stackscan/net/dns.py,sha256=4tPmWexu73e_ZiKme7w_fRsFUEgsKGGogm33jzZM7L4,8034
33
- stackscan/net/fingerprint.py,sha256=QgzESrldnfJidmDp-ZVrrlN2FyMbdGE65xb7OYue-qo,5069
33
+ stackscan/net/fingerprint.py,sha256=gq_0dsP8d1evhy-cRbwgA8CQgGdcgB7GsJv1o4W3ef4,5245
34
34
  stackscan/net/geo.py,sha256=W2Ze5_hpko5R-hULAFdi_SDlSFHC0Ub0KoAA34ffB5o,2225
35
35
  stackscan/net/ipinfo.py,sha256=2p0wdBrhhWM1ixScNmWG5cZjLy18NAZb2Na5JARUY0o,2796
36
- stackscan/net/ports.py,sha256=JVJrfFDCh0R3IuixA1LsNYpWJduFAOXcXWrkLQxrgqA,8134
36
+ stackscan/net/ports.py,sha256=Bz6YCMOjukx9H5zu5PMADiuubH4qELrT3Ulr9igXeSQ,8332
37
37
  stackscan/net/resolver.py,sha256=j4gNmxU0KB3ww1p-OycJxu3SY5y__x0Nqu1ODgViLCk,1905
38
38
  stackscan/net/subdomains.py,sha256=tiPbpK3dqNhoKKjkl8Gy68puqZt_Es_jxK-tk-drUpM,17120
39
39
  stackscan/net/tld.py,sha256=O5RtZggsYB6olQfd5JtEeOtbxGs7DonOSBxKFCWsans,4239
@@ -41,16 +41,16 @@ stackscan/net/tls.py,sha256=aAkWHzyst2fgbdS7ElSvPFF5085Bu5Bke441bsH3gbw,3488
41
41
  stackscan/net/whois.py,sha256=mCpozxa92kZBf-f7aLpgzSzVBbCBSoYQNnczpOe9Seg,5947
42
42
  stackscan/scanners/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
43
  stackscan/scanners/isp_blocked.py,sha256=yS_qlrozWSH-NdwDjOFdMURXj-cADb4xFJXh4FfKkNA,4313
44
- stackscan/scanners/secrets.py,sha256=Qr_3cE6rhGrDFWly5JyuCGv0y4RCx4wN00wnuxuaMDc,3458
44
+ stackscan/scanners/secrets.py,sha256=h0RuBrfZ2Cxwc-p6Tkau4DWCP1_amhyTsV0WpQNQo0I,3978
45
45
  stackscan/scanners/takeover.py,sha256=P3rnBCkakwuTG00fD0I32lfcfaKS1t48mQtbcaKA1EA,6533
46
46
  stackscan/types/__init__.py,sha256=RGFZNxPzHxUuQdwazXgDfXn8mTBveeHlEWxsqSxVLIA,954
47
47
  stackscan/types/models.py,sha256=YYVzn8j79Jermhn62CRIRL0OyprDm21ritchffaIuu4,386
48
48
  stackscan/types/output.py,sha256=eytLhJhlX4Ah5ALaY8ViCBd8QvMFh-GBeAxHBWUiYIQ,15681
49
- stackscan/utils/__init__.py,sha256=TBe8TpTO2uipX3YEr9G4nDpqE-bZjcv3iSutwY2r4A4,258
49
+ stackscan/utils/__init__.py,sha256=EW0zbCynB3qxOo2P1C6-hgTXbwCZHXuaFOxRe4-a1vg,286
50
50
  stackscan/utils/paths.py,sha256=R1vT7tCwZk1x6yS-R9pETD0qhlsflJR9_MjHDTxdKwg,234
51
- stackscan/utils/urls.py,sha256=LowDAAZlfxlgZPU-T8BnNJYKSG5V9impeWXyZs2Y3TE,1028
52
- stackscan-2.7.2.dist-info/METADATA,sha256=ItvtLZHlu-VV9RrCmoW77_a_vW-o_0nE_q_Z4AJTYqo,16544
53
- stackscan-2.7.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
54
- stackscan-2.7.2.dist-info/entry_points.txt,sha256=-wD-04qhZHxoi6M7j1T6Pmgq3cKNoVkWebLvAucSfHY,49
55
- stackscan-2.7.2.dist-info/licenses/LICENSE,sha256=5Ud3RkJNdrhzBthwtmOMDLx2cGd7wjaGLpi8iHYN2p0,1065
56
- stackscan-2.7.2.dist-info/RECORD,,
51
+ stackscan/utils/urls.py,sha256=owUr2aoPZqaVnJmUIUZ3SDPVQ1jJlxHg2Mo8UHoNgRQ,1493
52
+ stackscan-2.7.3.dist-info/METADATA,sha256=D3y7PJm5bNeZcsiprQkii2Vf01UiUkhgOSxbvaFMa0c,16494
53
+ stackscan-2.7.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
54
+ stackscan-2.7.3.dist-info/entry_points.txt,sha256=-wD-04qhZHxoi6M7j1T6Pmgq3cKNoVkWebLvAucSfHY,49
55
+ stackscan-2.7.3.dist-info/licenses/LICENSE,sha256=5Ud3RkJNdrhzBthwtmOMDLx2cGd7wjaGLpi8iHYN2p0,1065
56
+ stackscan-2.7.3.dist-info/RECORD,,