stackscan 2.1.0__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.
Files changed (45) hide show
  1. stackscan/__init__.py +5 -0
  2. stackscan/__main__.py +4 -0
  3. stackscan/analyzers/__init__.py +29 -0
  4. stackscan/analyzers/creds.py +218 -0
  5. stackscan/analyzers/cve.py +458 -0
  6. stackscan/analyzers/exposure.py +73 -0
  7. stackscan/analyzers/infra.py +114 -0
  8. stackscan/analyzers/security.py +26 -0
  9. stackscan/analyzers/services.py +285 -0
  10. stackscan/analyzers/tech.py +178 -0
  11. stackscan/cli.py +750 -0
  12. stackscan/config/__init__.py +19 -0
  13. stackscan/config/sigdb_loader.py +74 -0
  14. stackscan/config/sources.py +238 -0
  15. stackscan/core/__init__.py +3 -0
  16. stackscan/core/core.py +60 -0
  17. stackscan/data/builtin.sigdb +0 -0
  18. stackscan/data/cve.json.gz +0 -0
  19. stackscan/data/reekeer-logo.png +0 -0
  20. stackscan/data/subdomains.txt +522 -0
  21. stackscan/export.py +574 -0
  22. stackscan/net/__init__.py +22 -0
  23. stackscan/net/dns.py +168 -0
  24. stackscan/net/fingerprint.py +41 -0
  25. stackscan/net/geo.py +70 -0
  26. stackscan/net/ipinfo.py +94 -0
  27. stackscan/net/ports.py +219 -0
  28. stackscan/net/resolver.py +54 -0
  29. stackscan/net/subdomains.py +457 -0
  30. stackscan/net/tld.py +126 -0
  31. stackscan/net/tls.py +78 -0
  32. stackscan/render.py +541 -0
  33. stackscan/scan.py +545 -0
  34. stackscan/theme.py +23 -0
  35. stackscan/types/__init__.py +43 -0
  36. stackscan/types/models.py +18 -0
  37. stackscan/types/output.py +367 -0
  38. stackscan/utils/__init__.py +4 -0
  39. stackscan/utils/paths.py +10 -0
  40. stackscan/utils/urls.py +39 -0
  41. stackscan-2.1.0.dist-info/METADATA +341 -0
  42. stackscan-2.1.0.dist-info/RECORD +45 -0
  43. stackscan-2.1.0.dist-info/WHEEL +4 -0
  44. stackscan-2.1.0.dist-info/entry_points.txt +2 -0
  45. stackscan-2.1.0.dist-info/licenses/LICENSE +18 -0
@@ -0,0 +1,19 @@
1
+ from stackscan.config.sigdb_loader import (
2
+ DEFAULT_SIGDB_PATH,
3
+ NoSignaturesError,
4
+ build_matchers,
5
+ builtin_sigdb_path,
6
+ resolve_sigdb_paths,
7
+ )
8
+ from stackscan.config.sources import Source, SourceError, SourceStore
9
+
10
+ __all__ = [
11
+ "DEFAULT_SIGDB_PATH",
12
+ "NoSignaturesError",
13
+ "Source",
14
+ "SourceError",
15
+ "SourceStore",
16
+ "build_matchers",
17
+ "builtin_sigdb_path",
18
+ "resolve_sigdb_paths",
19
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from stackscan.config.sources import SourceStore
8
+
9
+ if TYPE_CHECKING:
10
+ from sigdb.core import SigDBMatcher
11
+ DEFAULT_SIGDB_PATH = Path.home() / "reekeer" / "sigdb" / "sigdb.sigdb"
12
+
13
+
14
+ class NoSignaturesError(RuntimeError):
15
+ pass
16
+
17
+
18
+ def _default_sigdb_path() -> Path | None:
19
+ return DEFAULT_SIGDB_PATH if DEFAULT_SIGDB_PATH.is_file() else None
20
+
21
+
22
+ def builtin_sigdb_path() -> Path | None:
23
+ try:
24
+ resource = resources.files("stackscan.data").joinpath("builtin.sigdb")
25
+ except (ModuleNotFoundError, FileNotFoundError):
26
+ return None
27
+ path = Path(str(resource))
28
+ return path if path.is_file() else None
29
+
30
+
31
+ def _load_matcher(path: Path) -> SigDBMatcher:
32
+ from sigdb.core import SigDBMatcher, load_sigdb
33
+
34
+ return SigDBMatcher(load_sigdb(path))
35
+
36
+
37
+ def resolve_sigdb_paths(
38
+ explicit: str | Path | None, *, use_sources: bool = True, use_builtin: bool = True
39
+ ) -> list[Path]:
40
+ paths: list[Path] = []
41
+ if use_builtin:
42
+ builtin = builtin_sigdb_path()
43
+ if builtin is not None:
44
+ paths.append(builtin)
45
+ if explicit is not None:
46
+ path = Path(explicit)
47
+ if not path.is_file():
48
+ raise FileNotFoundError(f"SigDB file not found: {explicit}")
49
+ paths.append(path)
50
+ else:
51
+ default = _default_sigdb_path()
52
+ if default is not None:
53
+ paths.append(default)
54
+ if use_sources:
55
+ paths.extend(SourceStore().resolve_paths())
56
+ seen: set[Path] = set()
57
+ ordered: list[Path] = []
58
+ for path in paths:
59
+ resolved = path.resolve()
60
+ if resolved not in seen:
61
+ seen.add(resolved)
62
+ ordered.append(path)
63
+ return ordered
64
+
65
+
66
+ def build_matchers(
67
+ explicit: str | Path | None, *, use_sources: bool = True, use_builtin: bool = True
68
+ ) -> list[SigDBMatcher]:
69
+ paths = resolve_sigdb_paths(explicit, use_sources=use_sources, use_builtin=use_builtin)
70
+ if not paths:
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>'."
73
+ )
74
+ return [_load_matcher(path) for path in paths]
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import time
8
+ import urllib.request
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ SIGDB_MAGIC = b"SIGT"
14
+ _RULES_FILENAMES = ("sigdb.json", "rules.json", "signatures.json")
15
+ _DOWNLOAD_UA = "stackscan-source-manager/1.0"
16
+ _DOWNLOAD_TIMEOUT = 30
17
+ _MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
18
+ _DOWNLOAD_CHUNK_SIZE = 8192
19
+
20
+
21
+ class SourceError(RuntimeError):
22
+ pass
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Source:
27
+ id: str
28
+ url: str
29
+ kind: str
30
+ path: str
31
+ added: int
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return asdict(self)
35
+
36
+
37
+ def _base_dir(env: str, default: Path) -> Path:
38
+ override = os.environ.get("STACKSCAN_HOME")
39
+ if override:
40
+ return Path(override)
41
+ raw = os.environ.get(env)
42
+ return Path(raw) if raw else default
43
+
44
+
45
+ def config_home() -> Path:
46
+ return _base_dir("XDG_CONFIG_HOME", Path.home() / ".config") / "stackscan"
47
+
48
+
49
+ def cache_home() -> Path:
50
+ return _base_dir("XDG_CACHE_HOME", Path.home() / ".cache") / "stackscan"
51
+
52
+
53
+ def registry_path() -> Path:
54
+ return config_home() / "sources.json"
55
+
56
+
57
+ def sources_cache_dir() -> Path:
58
+ return cache_home() / "sources"
59
+
60
+
61
+ def _source_id(url: str) -> str:
62
+ return hashlib.sha256(url.encode("utf-8")).hexdigest()[:12]
63
+
64
+
65
+ def _looks_like_git(url: str) -> bool:
66
+ return url.endswith(".git") or url.startswith(("git@", "git+", "ssh://"))
67
+
68
+
69
+ def _normalize_git_url(url: str) -> str:
70
+ return url[4:] if url.startswith("git+") else url
71
+
72
+
73
+ class SourceStore:
74
+ def __init__(self) -> None:
75
+ self._registry = registry_path()
76
+ self._cache = sources_cache_dir()
77
+
78
+ def _load_raw(self) -> list[dict[str, Any]]:
79
+ if not self._registry.is_file():
80
+ return []
81
+ try:
82
+ data = json.loads(self._registry.read_text(encoding="utf-8"))
83
+ except (OSError, json.JSONDecodeError):
84
+ return []
85
+ if not isinstance(data, list):
86
+ return []
87
+ rows = cast("list[object]", data)
88
+ return [cast("dict[str, Any]", row) for row in rows if isinstance(row, dict)]
89
+
90
+ def list(self) -> list[Source]:
91
+ sources: list[Source] = []
92
+ for row in self._load_raw():
93
+ try:
94
+ sources.append(
95
+ Source(
96
+ id=str(row["id"]),
97
+ url=str(row["url"]),
98
+ kind=str(row["kind"]),
99
+ path=str(row["path"]),
100
+ added=int(row["added"]),
101
+ )
102
+ )
103
+ except (KeyError, ValueError, TypeError):
104
+ continue
105
+ return sources
106
+
107
+ def _persist(self, sources: list[Source]) -> None:
108
+ self._registry.parent.mkdir(parents=True, exist_ok=True)
109
+ payload = [source.to_dict() for source in sources]
110
+ self._registry.write_text(
111
+ json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
112
+ )
113
+
114
+ def add(self, url: str) -> Source:
115
+ url = url.strip()
116
+ if not url:
117
+ raise SourceError("empty source url")
118
+ kind = "git" if _looks_like_git(url) else "http"
119
+ source_id = _source_id(url)
120
+ dest = self._cache / source_id
121
+ dest.mkdir(parents=True, exist_ok=True)
122
+ compiled = dest / "signatures.sigdb"
123
+ if kind == "git":
124
+ _materialize_git(url, dest, compiled)
125
+ else:
126
+ _materialize_http(url, compiled)
127
+ source = Source(
128
+ id=source_id, url=url, kind=kind, path=str(compiled), added=int(time.time())
129
+ )
130
+ sources = [s for s in self.list() if s.id != source_id]
131
+ sources.append(source)
132
+ self._persist(sources)
133
+ return source
134
+
135
+ def remove(self, key: str) -> bool:
136
+ sources = self.list()
137
+ kept = [s for s in sources if s.id != key and s.url != key]
138
+ if len(kept) == len(sources):
139
+ return False
140
+ self._persist(kept)
141
+ removed = [s for s in sources if s not in kept]
142
+ for source in removed:
143
+ _remove_tree(self._cache / source.id)
144
+ return True
145
+
146
+ def update(self, key: str | None = None) -> list[Source]:
147
+ targets = [s for s in self.list() if key in (None, s.id, s.url)]
148
+ refreshed: list[Source] = []
149
+ for source in targets:
150
+ refreshed.append(self.add(source.url))
151
+ return refreshed
152
+
153
+ def resolve_paths(self) -> list[Path]:
154
+ paths: list[Path] = []
155
+ for source in self.list():
156
+ path = Path(source.path)
157
+ if path.is_file():
158
+ paths.append(path)
159
+ return paths
160
+
161
+
162
+ def _http_get(url: str) -> bytes:
163
+ request = urllib.request.Request(url, headers={"User-Agent": _DOWNLOAD_UA})
164
+ try:
165
+ with urllib.request.urlopen(request, timeout=_DOWNLOAD_TIMEOUT) as response:
166
+ chunks: list[bytes] = []
167
+ total = 0
168
+ while True:
169
+ chunk = response.read(_DOWNLOAD_CHUNK_SIZE)
170
+ if not chunk:
171
+ break
172
+ total += len(chunk)
173
+ if total > _MAX_DOWNLOAD_BYTES:
174
+ raise SourceError(
175
+ f"downloaded source from {url} exceeds {_MAX_DOWNLOAD_BYTES} bytes"
176
+ )
177
+ chunks.append(chunk)
178
+ return b"".join(chunks)
179
+ except OSError as exc:
180
+ raise SourceError(f"failed to fetch {url}: {exc}") from exc
181
+
182
+
183
+ def _compile_rules_bytes(raw: bytes, output: Path) -> None:
184
+ from sigdb.core import build_sigdb
185
+
186
+ try:
187
+ rules = json.loads(raw)
188
+ except json.JSONDecodeError as exc:
189
+ raise SourceError("source is neither a .sigdb file nor valid rules JSON") from exc
190
+ if not isinstance(rules, dict):
191
+ raise SourceError("rules JSON must be an object of {name: definition}")
192
+ output.parent.mkdir(parents=True, exist_ok=True)
193
+ build_sigdb(rules=cast("dict[str, Any]", rules), output_path=output)
194
+
195
+
196
+ def _materialize_http(url: str, output: Path) -> None:
197
+ raw = _http_get(url)
198
+ if raw[:4] == SIGDB_MAGIC:
199
+ output.parent.mkdir(parents=True, exist_ok=True)
200
+ output.write_bytes(raw)
201
+ return
202
+ _compile_rules_bytes(raw, output)
203
+
204
+
205
+ def _materialize_git(url: str, dest: Path, output: Path) -> None:
206
+ checkout = dest / "repo"
207
+ _remove_tree(checkout)
208
+ clone_url = _normalize_git_url(url)
209
+ try:
210
+ subprocess.run(
211
+ ["git", "clone", "--depth", "1", clone_url, str(checkout)],
212
+ check=True,
213
+ capture_output=True,
214
+ text=True,
215
+ )
216
+ except FileNotFoundError as exc:
217
+ raise SourceError("git is not installed") from exc
218
+ except subprocess.CalledProcessError as exc:
219
+ raise SourceError(f"git clone failed: {exc.stderr.strip()}") from exc
220
+ prebuilt = sorted(checkout.rglob("*.sigdb"))
221
+ if prebuilt:
222
+ output.parent.mkdir(parents=True, exist_ok=True)
223
+ output.write_bytes(prebuilt[0].read_bytes())
224
+ return
225
+ for name in _RULES_FILENAMES:
226
+ candidate = checkout / name
227
+ if candidate.is_file():
228
+ _compile_rules_bytes(candidate.read_bytes(), output)
229
+ return
230
+ raise SourceError("repository has no .sigdb or rules JSON (sigdb.json/rules.json)")
231
+
232
+
233
+ def _remove_tree(path: Path) -> None:
234
+ if not path.exists():
235
+ return
236
+ import shutil
237
+
238
+ shutil.rmtree(path, ignore_errors=True)
@@ -0,0 +1,3 @@
1
+ from .core import StackscanSession
2
+
3
+ __all__ = ["StackscanSession"]
stackscan/core/core.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from aiohttp import ClientSession, ClientTimeout
6
+
7
+ from stackscan.net.resolver import build_connector
8
+ from stackscan.types import FetchResult
9
+
10
+
11
+ def _lower_headers(items: Iterable[tuple[str, str]]) -> dict[str, str]:
12
+ return {key.lower(): value for key, value in items}
13
+
14
+
15
+ class StackscanSession:
16
+ def __init__(self) -> None:
17
+ self._session: ClientSession | None = None
18
+
19
+ async def __aenter__(self) -> StackscanSession:
20
+ self._session = ClientSession(connector=build_connector())
21
+ return self
22
+
23
+ async def __aexit__(self, *exc: object) -> None:
24
+ if self._session is not None:
25
+ await self._session.close()
26
+ self._session = None
27
+
28
+ async def fetch(
29
+ self, url: str, *, timeout: float, user_agent: str, insecure: bool, max_bytes: int
30
+ ) -> FetchResult:
31
+ session = self._session
32
+ if session is None:
33
+ raise RuntimeError("StackscanSession is not entered")
34
+ async with session.get(
35
+ url,
36
+ headers={"User-Agent": user_agent},
37
+ ssl=False if insecure else True,
38
+ timeout=ClientTimeout(total=timeout),
39
+ ) as resp:
40
+ status = resp.status
41
+ header_items = list(resp.headers.items())
42
+ headers = _lower_headers(header_items)
43
+ raw_headers = [f"{key.lower()}: {value}" for key, value in header_items]
44
+ cookies = resp.headers.getall("Set-Cookie", [])
45
+ charset = resp.charset or "utf-8"
46
+ body_bytes = await resp.content.read(max_bytes)
47
+ while await resp.content.read(8192):
48
+ pass
49
+ body = body_bytes.decode(charset, errors="replace")
50
+ url_final = str(resp.url)
51
+ version = resp.version
52
+ http_version = f"{version.major}.{version.minor}" if version else None
53
+ return FetchResult(
54
+ url=url_final,
55
+ status=status,
56
+ headers={"_raw": "\n".join(raw_headers), **headers},
57
+ body=body,
58
+ cookies=tuple(cookies),
59
+ http_version=http_version,
60
+ )
Binary file
Binary file
Binary file