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.
- stackscan/__init__.py +5 -0
- stackscan/__main__.py +4 -0
- stackscan/analyzers/__init__.py +29 -0
- stackscan/analyzers/creds.py +218 -0
- stackscan/analyzers/cve.py +458 -0
- stackscan/analyzers/exposure.py +73 -0
- stackscan/analyzers/infra.py +114 -0
- stackscan/analyzers/security.py +26 -0
- stackscan/analyzers/services.py +285 -0
- stackscan/analyzers/tech.py +178 -0
- stackscan/cli.py +750 -0
- stackscan/config/__init__.py +19 -0
- stackscan/config/sigdb_loader.py +74 -0
- stackscan/config/sources.py +238 -0
- stackscan/core/__init__.py +3 -0
- stackscan/core/core.py +60 -0
- stackscan/data/builtin.sigdb +0 -0
- stackscan/data/cve.json.gz +0 -0
- stackscan/data/reekeer-logo.png +0 -0
- stackscan/data/subdomains.txt +522 -0
- stackscan/export.py +574 -0
- stackscan/net/__init__.py +22 -0
- stackscan/net/dns.py +168 -0
- stackscan/net/fingerprint.py +41 -0
- stackscan/net/geo.py +70 -0
- stackscan/net/ipinfo.py +94 -0
- stackscan/net/ports.py +219 -0
- stackscan/net/resolver.py +54 -0
- stackscan/net/subdomains.py +457 -0
- stackscan/net/tld.py +126 -0
- stackscan/net/tls.py +78 -0
- stackscan/render.py +541 -0
- stackscan/scan.py +545 -0
- stackscan/theme.py +23 -0
- stackscan/types/__init__.py +43 -0
- stackscan/types/models.py +18 -0
- stackscan/types/output.py +367 -0
- stackscan/utils/__init__.py +4 -0
- stackscan/utils/paths.py +10 -0
- stackscan/utils/urls.py +39 -0
- stackscan-2.1.0.dist-info/METADATA +341 -0
- stackscan-2.1.0.dist-info/RECORD +45 -0
- stackscan-2.1.0.dist-info/WHEEL +4 -0
- stackscan-2.1.0.dist-info/entry_points.txt +2 -0
- 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)
|
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
|