huntkit 0.3.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.
- huntkit/__init__.py +4 -0
- huntkit/__main__.py +8 -0
- huntkit/ai/__init__.py +42 -0
- huntkit/ai/advisor.py +133 -0
- huntkit/ai/providers.py +200 -0
- huntkit/app/__init__.py +13 -0
- huntkit/app/config_cmd.py +65 -0
- huntkit/app/context.py +50 -0
- huntkit/app/main.py +1166 -0
- huntkit/app/plugins_cmd.py +79 -0
- huntkit/app/workspace_cmd.py +62 -0
- huntkit/core/__init__.py +6 -0
- huntkit/core/cache.py +67 -0
- huntkit/core/config.py +272 -0
- huntkit/core/logger.py +66 -0
- huntkit/core/pacing.py +71 -0
- huntkit/core/runner.py +117 -0
- huntkit/core/state.py +154 -0
- huntkit/core/workspace.py +145 -0
- huntkit/enrich/__init__.py +39 -0
- huntkit/enrich/asn.py +139 -0
- huntkit/enrich/engine.py +167 -0
- huntkit/enrich/favicon.py +91 -0
- huntkit/enrich/mmh3.py +69 -0
- huntkit/fingerprint/__init__.py +26 -0
- huntkit/fingerprint/engine.py +83 -0
- huntkit/fingerprint/rules.py +170 -0
- huntkit/history/__init__.py +38 -0
- huntkit/history/diff.py +95 -0
- huntkit/history/store.py +133 -0
- huntkit/intel/__init__.py +48 -0
- huntkit/intel/engine.py +233 -0
- huntkit/intel/signals.py +826 -0
- huntkit/knowledge/__init__.py +27 -0
- huntkit/knowledge/playbooks.py +452 -0
- huntkit/monitor/__init__.py +24 -0
- huntkit/monitor/runner.py +111 -0
- huntkit/notify/__init__.py +16 -0
- huntkit/notify/webhook.py +73 -0
- huntkit/pipeline.py +302 -0
- huntkit/plugins/__init__.py +36 -0
- huntkit/plugins/amass.py +25 -0
- huntkit/plugins/arjun.py +18 -0
- huntkit/plugins/assetfinder.py +18 -0
- huntkit/plugins/base.py +220 -0
- huntkit/plugins/chaos.py +20 -0
- huntkit/plugins/dalfox.py +24 -0
- huntkit/plugins/dnsx.py +19 -0
- huntkit/plugins/ffuf.py +34 -0
- huntkit/plugins/findomain.py +18 -0
- huntkit/plugins/gau.py +18 -0
- huntkit/plugins/hakrawler.py +19 -0
- huntkit/plugins/httpx.py +30 -0
- huntkit/plugins/katana.py +23 -0
- huntkit/plugins/naabu.py +22 -0
- huntkit/plugins/nmap.py +39 -0
- huntkit/plugins/nuclei.py +27 -0
- huntkit/plugins/registry.py +117 -0
- huntkit/plugins/subfinder.py +21 -0
- huntkit/plugins/waybackurls.py +29 -0
- huntkit/plugins/whatweb.py +19 -0
- huntkit/py.typed +0 -0
- huntkit/report/__init__.py +103 -0
- huntkit/report/builder.py +145 -0
- huntkit/report/render.py +323 -0
- huntkit/scanner/__init__.py +28 -0
- huntkit/scanner/runner.py +239 -0
- huntkit/secrets/__init__.py +34 -0
- huntkit/secrets/patterns.py +172 -0
- huntkit/secrets/scanner.py +133 -0
- huntkit/shots/__init__.py +29 -0
- huntkit/shots/runner.py +186 -0
- huntkit/tui/__init__.py +13 -0
- huntkit/tui/dashboard.py +160 -0
- huntkit/utils/__init__.py +6 -0
- huntkit/utils/filesystem.py +109 -0
- huntkit/utils/process.py +104 -0
- huntkit/utils/severity.py +49 -0
- huntkit/utils/terminal.py +88 -0
- huntkit/utils/validators.py +99 -0
- huntkit-0.3.0.dist-info/METADATA +340 -0
- huntkit-0.3.0.dist-info/RECORD +86 -0
- huntkit-0.3.0.dist-info/WHEEL +5 -0
- huntkit-0.3.0.dist-info/entry_points.txt +2 -0
- huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- huntkit-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
r"""Active scanning — drive the vuln/fuzz toolchain over gathered URLs.
|
|
2
|
+
|
|
3
|
+
The recon pipeline *discovers* assets; :func:`run_scan` is the active phase
|
|
4
|
+
that runs the ``SCAN``-category plugins (nuclei, dalfox, …) over the live URLs
|
|
5
|
+
recon found and records their findings.
|
|
6
|
+
|
|
7
|
+
It deliberately reuses the recon pipeline's machinery rather than inventing a
|
|
8
|
+
second one:
|
|
9
|
+
|
|
10
|
+
- **same plugin contract** — a scanner is just a ``ToolPlugin`` whose
|
|
11
|
+
category is :data:`Category.SCAN`; a new one is one file, picked up with
|
|
12
|
+
no change here;
|
|
13
|
+
- **same concurrency model** — every available scanner runs on a thread
|
|
14
|
+
pool, results merged once on the main thread;
|
|
15
|
+
- **same safety model** — targets are read from the workspace's already
|
|
16
|
+
scope-filtered result files *and* re-checked against the program scope
|
|
17
|
+
here, so the active phase can never reach a host recon would have refused.
|
|
18
|
+
|
|
19
|
+
Only STDIN scanners (those that take the whole URL list at once, e.g. nuclei,
|
|
20
|
+
dalfox) run in this batch. Per-URL fuzzers (ffuf, arjun) have a different
|
|
21
|
+
shape and are driven separately; they are reported as such, not silently
|
|
22
|
+
dropped.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Optional
|
|
33
|
+
from urllib.parse import urlparse
|
|
34
|
+
|
|
35
|
+
from ..core.config import Config
|
|
36
|
+
from ..core.runner import CommandRunner
|
|
37
|
+
from ..core.workspace import Workspace
|
|
38
|
+
from ..plugins import (
|
|
39
|
+
Capability,
|
|
40
|
+
Category,
|
|
41
|
+
InputMode,
|
|
42
|
+
PluginContext,
|
|
43
|
+
PluginResult,
|
|
44
|
+
ToolPlugin,
|
|
45
|
+
get_registry,
|
|
46
|
+
)
|
|
47
|
+
from ..plugins.registry import PluginRegistry
|
|
48
|
+
|
|
49
|
+
# Files the scanner reads its candidate URLs from, in order. Both are written
|
|
50
|
+
# by recon and are already scope-filtered at discovery; we re-filter anyway.
|
|
51
|
+
TARGET_FILES = ["recon/live.txt", "urls/urls.txt"]
|
|
52
|
+
|
|
53
|
+
# Where a scan writes. Per-tool raw output plus one combined JSON.
|
|
54
|
+
FINDINGS_JSON = "scans/findings.json"
|
|
55
|
+
SCAN_STATE = "scan"
|
|
56
|
+
|
|
57
|
+
EventFn = Callable[[dict], None]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ScanOutcome:
|
|
62
|
+
tool: str
|
|
63
|
+
findings: list[str] = field(default_factory=list)
|
|
64
|
+
ran: bool = False
|
|
65
|
+
skipped: bool = False
|
|
66
|
+
reason: str = ""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def count(self) -> int:
|
|
70
|
+
return len(self.findings)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ScanSummary:
|
|
75
|
+
program: str
|
|
76
|
+
targets: int = 0
|
|
77
|
+
outcomes: list[ScanOutcome] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def total_findings(self) -> int:
|
|
81
|
+
return sum(o.count for o in self.outcomes if o.ran)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def ran(self) -> list[str]:
|
|
85
|
+
return [o.tool for o in self.outcomes if o.ran]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scan_plugins(registry: PluginRegistry) -> list[ToolPlugin]:
|
|
89
|
+
"""The batch scanners: SCAN-category plugins that consume a URL list via stdin."""
|
|
90
|
+
return [p for p in registry.by_category(Category.SCAN) if p.input_mode is InputMode.STDIN]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def scan_targets(ws: Workspace) -> list[str]:
|
|
94
|
+
"""Collect candidate URLs from the workspace, deduped and scope-checked.
|
|
95
|
+
|
|
96
|
+
A URL survives only if its host is in scope for the program. With no scope
|
|
97
|
+
set the workspace treats everything as in scope, but recon always sets at
|
|
98
|
+
least ``*.<seed>`` — so the active phase stays bounded either way.
|
|
99
|
+
"""
|
|
100
|
+
seen: dict[str, None] = {}
|
|
101
|
+
for rel in TARGET_FILES:
|
|
102
|
+
for url in ws.read_lines(rel):
|
|
103
|
+
host = _host_of(url)
|
|
104
|
+
if host and ws.in_scope(host):
|
|
105
|
+
seen[url] = None
|
|
106
|
+
return sorted(seen)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _host_of(url: str) -> Optional[str]:
|
|
110
|
+
"""Hostname of a URL, tolerating bare ``host`` / ``host:port`` entries."""
|
|
111
|
+
parsed = urlparse(url if "://" in url else f"//{url}", scheme="http")
|
|
112
|
+
return parsed.hostname
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Scanner:
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
ws: Workspace,
|
|
119
|
+
config: Config,
|
|
120
|
+
*,
|
|
121
|
+
runner: Optional[CommandRunner] = None,
|
|
122
|
+
registry: Optional[PluginRegistry] = None,
|
|
123
|
+
on_event: Optional[EventFn] = None,
|
|
124
|
+
threads: Optional[int] = None,
|
|
125
|
+
use_cache: bool = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
self.ws = ws
|
|
128
|
+
self.config = config
|
|
129
|
+
self.runner = runner or CommandRunner(config)
|
|
130
|
+
self.registry = registry if registry is not None else get_registry()
|
|
131
|
+
self._emit: EventFn = on_event or (lambda _e: None)
|
|
132
|
+
self.threads = threads or config.general.threads
|
|
133
|
+
self.use_cache = use_cache
|
|
134
|
+
|
|
135
|
+
def run(self, *, tools: Optional[list[str]] = None) -> ScanSummary:
|
|
136
|
+
targets = scan_targets(self.ws)
|
|
137
|
+
summary = ScanSummary(program=self.ws.program, targets=len(targets))
|
|
138
|
+
|
|
139
|
+
chosen = scan_plugins(self.registry)
|
|
140
|
+
if tools:
|
|
141
|
+
wanted = {t.lower() for t in tools}
|
|
142
|
+
chosen = [p for p in chosen if p.name in wanted]
|
|
143
|
+
|
|
144
|
+
self.ws.state.start(SCAN_STATE)
|
|
145
|
+
self._emit(
|
|
146
|
+
{"event": "scan_start", "targets": len(targets), "tools": [p.name for p in chosen]}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# no targets: record and stop — nothing to feed the scanners
|
|
150
|
+
if not targets:
|
|
151
|
+
self.ws.state.skip(SCAN_STATE)
|
|
152
|
+
self._emit({"event": "scan_done", "targets": 0, "findings": 0, "ran": []})
|
|
153
|
+
return summary
|
|
154
|
+
|
|
155
|
+
ctx = PluginContext(
|
|
156
|
+
config=self.config,
|
|
157
|
+
runner=self.runner,
|
|
158
|
+
inputs=targets,
|
|
159
|
+
extra={"use_cache": self.use_cache},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
results: dict[str, PluginResult] = {}
|
|
163
|
+
with ThreadPoolExecutor(max_workers=max(1, min(self.threads, len(chosen) or 1))) as pool:
|
|
164
|
+
futures = {pool.submit(p.execute, ctx): p.name for p in chosen}
|
|
165
|
+
for fut in as_completed(futures):
|
|
166
|
+
name = futures[fut]
|
|
167
|
+
try:
|
|
168
|
+
results[name] = fut.result()
|
|
169
|
+
except Exception as exc: # a crashing scanner must not sink the run
|
|
170
|
+
results[name] = PluginResult(
|
|
171
|
+
name,
|
|
172
|
+
Capability.FINDING,
|
|
173
|
+
skipped=True,
|
|
174
|
+
reason=f"error: {exc}",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# merge + persist on the main thread (no write races)
|
|
178
|
+
for plugin in chosen:
|
|
179
|
+
summary.outcomes.append(self._finalize(plugin.name, results.get(plugin.name)))
|
|
180
|
+
|
|
181
|
+
self._write_combined(summary)
|
|
182
|
+
self.ws.state.set_count("findings", summary.total_findings)
|
|
183
|
+
self.ws.state.done(
|
|
184
|
+
SCAN_STATE, targets=len(targets), findings=summary.total_findings, ran=summary.ran
|
|
185
|
+
)
|
|
186
|
+
self._emit(
|
|
187
|
+
{
|
|
188
|
+
"event": "scan_done",
|
|
189
|
+
"targets": len(targets),
|
|
190
|
+
"findings": summary.total_findings,
|
|
191
|
+
"ran": summary.ran,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
return summary
|
|
195
|
+
|
|
196
|
+
def _finalize(self, name: str, result: Optional[PluginResult]) -> ScanOutcome:
|
|
197
|
+
if result is None or result.skipped:
|
|
198
|
+
reason = result.reason if result is not None else "not run"
|
|
199
|
+
self._emit({"event": "plugin_skip", "plugin": name, "reason": reason})
|
|
200
|
+
return ScanOutcome(tool=name, skipped=True, reason=reason)
|
|
201
|
+
|
|
202
|
+
findings = [f for f in result.items if f.strip()]
|
|
203
|
+
self.ws.path("scans", f"{name}.txt").write_text(
|
|
204
|
+
"\n".join(findings) + ("\n" if findings else ""), encoding="utf-8"
|
|
205
|
+
)
|
|
206
|
+
self._emit({"event": "plugin_done", "plugin": name, "found": len(findings)})
|
|
207
|
+
return ScanOutcome(tool=name, findings=findings, ran=True)
|
|
208
|
+
|
|
209
|
+
def _write_combined(self, summary: ScanSummary) -> None:
|
|
210
|
+
payload = {
|
|
211
|
+
"program": summary.program,
|
|
212
|
+
"generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
213
|
+
"targets": summary.targets,
|
|
214
|
+
"tools": {o.tool: o.findings for o in summary.outcomes if o.ran},
|
|
215
|
+
}
|
|
216
|
+
self.ws.path(FINDINGS_JSON).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def run_scan(
|
|
220
|
+
ws: Workspace,
|
|
221
|
+
config: Config,
|
|
222
|
+
*,
|
|
223
|
+
tools: Optional[list[str]] = None,
|
|
224
|
+
runner: Optional[CommandRunner] = None,
|
|
225
|
+
registry: Optional[PluginRegistry] = None,
|
|
226
|
+
on_event: Optional[EventFn] = None,
|
|
227
|
+
threads: Optional[int] = None,
|
|
228
|
+
use_cache: bool = False,
|
|
229
|
+
) -> ScanSummary:
|
|
230
|
+
"""Convenience wrapper: build a :class:`Scanner` and run it once."""
|
|
231
|
+
return Scanner(
|
|
232
|
+
ws,
|
|
233
|
+
config,
|
|
234
|
+
runner=runner,
|
|
235
|
+
registry=registry,
|
|
236
|
+
on_event=on_event,
|
|
237
|
+
threads=threads,
|
|
238
|
+
use_cache=use_cache,
|
|
239
|
+
).run(tools=tools)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Secret & JS-endpoint scanner — mine gathered content for leaks.
|
|
2
|
+
|
|
3
|
+
A pure feature layer (depends only on ``utils``): it matches a declarative
|
|
4
|
+
pattern catalog against text the operator already collected and returns
|
|
5
|
+
redacted findings. Offline; never touches a target.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .patterns import (
|
|
11
|
+
SECRET_PATTERNS,
|
|
12
|
+
SecretPattern,
|
|
13
|
+
extract_endpoints,
|
|
14
|
+
shannon_entropy,
|
|
15
|
+
)
|
|
16
|
+
from .scanner import (
|
|
17
|
+
SecretHit,
|
|
18
|
+
endpoints_from_paths,
|
|
19
|
+
redact,
|
|
20
|
+
scan_paths,
|
|
21
|
+
scan_text,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SECRET_PATTERNS",
|
|
26
|
+
"SecretHit",
|
|
27
|
+
"SecretPattern",
|
|
28
|
+
"endpoints_from_paths",
|
|
29
|
+
"extract_endpoints",
|
|
30
|
+
"redact",
|
|
31
|
+
"scan_paths",
|
|
32
|
+
"scan_text",
|
|
33
|
+
"shannon_entropy",
|
|
34
|
+
]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Secret-pattern catalog + JS endpoint extraction.
|
|
2
|
+
|
|
3
|
+
A declarative set of regexes for high-signal credential leaks — the same
|
|
4
|
+
"add a row, not a branch" ethos as the signal and playbook catalogs. Each
|
|
5
|
+
pattern names the capture group holding the secret so the scanner can redact
|
|
6
|
+
exactly that span, and an optional Shannon-entropy floor to drop low-entropy
|
|
7
|
+
false positives on the generic patterns.
|
|
8
|
+
|
|
9
|
+
Pure and offline: it matches text the operator already gathered (JS bundles,
|
|
10
|
+
config dumps, responses) and never touches a target.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from ..utils.severity import Severity
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SecretPattern:
|
|
25
|
+
id: str
|
|
26
|
+
title: str
|
|
27
|
+
severity: Severity
|
|
28
|
+
regex: re.Pattern
|
|
29
|
+
group: int = 0 # capture group holding the secret value
|
|
30
|
+
min_entropy: float = 0.0 # skip matches below this Shannon entropy (0 = off)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def shannon_entropy(s: str) -> float:
|
|
34
|
+
"""Bits-per-character Shannon entropy — high for random keys, low for words."""
|
|
35
|
+
if not s:
|
|
36
|
+
return 0.0
|
|
37
|
+
n = len(s)
|
|
38
|
+
return -sum((c / n) * math.log2(c / n) for c in Counter(s).values())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _c(pattern: str, flags: int = 0) -> re.Pattern:
|
|
42
|
+
return re.compile(pattern, flags)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Ordered most-specific (unique prefixes) → most-generic (keyword + entropy).
|
|
46
|
+
SECRET_PATTERNS: tuple[SecretPattern, ...] = (
|
|
47
|
+
SecretPattern(
|
|
48
|
+
"aws-access-key", "AWS access key id", Severity.CRITICAL, _c(r"\b(AKIA[0-9A-Z]{16})\b"), 1
|
|
49
|
+
),
|
|
50
|
+
SecretPattern(
|
|
51
|
+
"aws-secret-key",
|
|
52
|
+
"AWS secret access key",
|
|
53
|
+
Severity.CRITICAL,
|
|
54
|
+
_c(r"(?i)aws.{0,24}?['\"]([A-Za-z0-9/+=]{40})['\"]"),
|
|
55
|
+
1,
|
|
56
|
+
4.0,
|
|
57
|
+
),
|
|
58
|
+
SecretPattern(
|
|
59
|
+
"gcp-api-key", "Google API key", Severity.HIGH, _c(r"\b(AIza[0-9A-Za-z\-_]{35})\b"), 1
|
|
60
|
+
),
|
|
61
|
+
SecretPattern(
|
|
62
|
+
"gcp-oauth",
|
|
63
|
+
"Google OAuth access token",
|
|
64
|
+
Severity.HIGH,
|
|
65
|
+
_c(r"\b(ya29\.[0-9A-Za-z\-_]{20,})"),
|
|
66
|
+
),
|
|
67
|
+
SecretPattern(
|
|
68
|
+
"github-token", "GitHub token", Severity.CRITICAL, _c(r"\b(gh[opsu]_[0-9A-Za-z]{36})\b"), 1
|
|
69
|
+
),
|
|
70
|
+
SecretPattern(
|
|
71
|
+
"github-pat",
|
|
72
|
+
"GitHub fine-grained PAT",
|
|
73
|
+
Severity.CRITICAL,
|
|
74
|
+
_c(r"\b(github_pat_[0-9A-Za-z_]{82})\b"),
|
|
75
|
+
1,
|
|
76
|
+
),
|
|
77
|
+
SecretPattern(
|
|
78
|
+
"slack-token", "Slack token", Severity.HIGH, _c(r"\b(xox[baprs]-[0-9A-Za-z-]{10,48})\b"), 1
|
|
79
|
+
),
|
|
80
|
+
SecretPattern(
|
|
81
|
+
"slack-webhook",
|
|
82
|
+
"Slack incoming webhook",
|
|
83
|
+
Severity.MEDIUM,
|
|
84
|
+
_c(r"(https://hooks\.slack\.com/services/T[0-9A-Za-z_]+/B[0-9A-Za-z_]+/[0-9A-Za-z_]+)"),
|
|
85
|
+
1,
|
|
86
|
+
),
|
|
87
|
+
SecretPattern(
|
|
88
|
+
"stripe-live",
|
|
89
|
+
"Stripe live secret key",
|
|
90
|
+
Severity.CRITICAL,
|
|
91
|
+
_c(r"\b(sk_live_[0-9A-Za-z]{24,})\b"),
|
|
92
|
+
1,
|
|
93
|
+
),
|
|
94
|
+
SecretPattern(
|
|
95
|
+
"stripe-test",
|
|
96
|
+
"Stripe test secret key",
|
|
97
|
+
Severity.LOW,
|
|
98
|
+
_c(r"\b(sk_test_[0-9A-Za-z]{24,})\b"),
|
|
99
|
+
1,
|
|
100
|
+
),
|
|
101
|
+
SecretPattern(
|
|
102
|
+
"openai-key",
|
|
103
|
+
"OpenAI API key",
|
|
104
|
+
Severity.HIGH,
|
|
105
|
+
_c(r"\b(sk-(?:proj-)?[A-Za-z0-9_\-]{32,})\b"),
|
|
106
|
+
1,
|
|
107
|
+
3.5,
|
|
108
|
+
),
|
|
109
|
+
SecretPattern("twilio-key", "Twilio API key", Severity.HIGH, _c(r"\b(SK[0-9a-fA-F]{32})\b"), 1),
|
|
110
|
+
SecretPattern(
|
|
111
|
+
"mailgun-key", "Mailgun API key", Severity.HIGH, _c(r"\b(key-[0-9a-zA-Z]{32})\b"), 1
|
|
112
|
+
),
|
|
113
|
+
SecretPattern(
|
|
114
|
+
"npm-token", "npm access token", Severity.HIGH, _c(r"\b(npm_[0-9A-Za-z]{36})\b"), 1
|
|
115
|
+
),
|
|
116
|
+
SecretPattern(
|
|
117
|
+
"private-key",
|
|
118
|
+
"Private key block",
|
|
119
|
+
Severity.CRITICAL,
|
|
120
|
+
_c(r"(-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----)"),
|
|
121
|
+
1,
|
|
122
|
+
),
|
|
123
|
+
SecretPattern(
|
|
124
|
+
"jwt",
|
|
125
|
+
"JSON Web Token",
|
|
126
|
+
Severity.MEDIUM,
|
|
127
|
+
_c(r"\b(eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})\b"),
|
|
128
|
+
1,
|
|
129
|
+
),
|
|
130
|
+
SecretPattern(
|
|
131
|
+
"generic-secret",
|
|
132
|
+
"Hardcoded secret / key",
|
|
133
|
+
Severity.MEDIUM,
|
|
134
|
+
_c(
|
|
135
|
+
r"(?i)(?:api[_-]?key|secret|password|passwd|access[_-]?token|auth[_-]?token)"
|
|
136
|
+
r"['\"]?\s*[:=]\s*['\"]([0-9A-Za-z\-_./+=]{12,})['\"]"
|
|
137
|
+
),
|
|
138
|
+
1,
|
|
139
|
+
3.2,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# JS endpoint / path extraction — quoted paths and absolute URLs in script text.
|
|
145
|
+
ENDPOINT_RE = re.compile(r"""['"`](/[\w.\-/?=&%:@~]{2,}|https?://[\w.\-/?=&%:@~]+)['"`]""")
|
|
146
|
+
|
|
147
|
+
# Static-asset extensions that are noise when hunting for API endpoints.
|
|
148
|
+
_NOISE_EXT = (
|
|
149
|
+
".css",
|
|
150
|
+
".png",
|
|
151
|
+
".jpg",
|
|
152
|
+
".jpeg",
|
|
153
|
+
".gif",
|
|
154
|
+
".svg",
|
|
155
|
+
".webp",
|
|
156
|
+
".woff",
|
|
157
|
+
".woff2",
|
|
158
|
+
".ttf",
|
|
159
|
+
".ico",
|
|
160
|
+
".map",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract_endpoints(text: str) -> list[str]:
|
|
165
|
+
"""Pull API-ish paths and URLs out of JS text, de-duped, asset noise dropped."""
|
|
166
|
+
found: list[str] = []
|
|
167
|
+
for m in ENDPOINT_RE.finditer(text):
|
|
168
|
+
ep = m.group(1)
|
|
169
|
+
if ep.lower().rsplit("?", 1)[0].endswith(_NOISE_EXT):
|
|
170
|
+
continue
|
|
171
|
+
found.append(ep)
|
|
172
|
+
return list(dict.fromkeys(found))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Scan gathered text for leaked secrets and JS endpoints.
|
|
2
|
+
|
|
3
|
+
The scanner matches the pattern catalog against text or files and returns typed
|
|
4
|
+
:class:`SecretHit` findings. Every finding's value is **redacted** on the way
|
|
5
|
+
out — enough to locate it in the source, never enough to re-leak it — so the
|
|
6
|
+
scanner's output (tables, JSON) is safe to store and share.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..utils.severity import Severity
|
|
15
|
+
from .patterns import SECRET_PATTERNS, extract_endpoints, shannon_entropy
|
|
16
|
+
|
|
17
|
+
# Binary / asset extensions never worth scanning as text.
|
|
18
|
+
_SKIP_EXT = {
|
|
19
|
+
".png",
|
|
20
|
+
".jpg",
|
|
21
|
+
".jpeg",
|
|
22
|
+
".gif",
|
|
23
|
+
".webp",
|
|
24
|
+
".ico",
|
|
25
|
+
".bmp",
|
|
26
|
+
".svg",
|
|
27
|
+
".pdf",
|
|
28
|
+
".zip",
|
|
29
|
+
".gz",
|
|
30
|
+
".tar",
|
|
31
|
+
".bz2",
|
|
32
|
+
".7z",
|
|
33
|
+
".rar",
|
|
34
|
+
".woff",
|
|
35
|
+
".woff2",
|
|
36
|
+
".ttf",
|
|
37
|
+
".eot",
|
|
38
|
+
".mp4",
|
|
39
|
+
".mp3",
|
|
40
|
+
".wasm",
|
|
41
|
+
".exe",
|
|
42
|
+
".dll",
|
|
43
|
+
".so",
|
|
44
|
+
".dylib",
|
|
45
|
+
".class",
|
|
46
|
+
".pyc",
|
|
47
|
+
}
|
|
48
|
+
_MAX_BYTES = 8 * 1024 * 1024 # skip files larger than 8 MB
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class SecretHit:
|
|
53
|
+
id: str
|
|
54
|
+
title: str
|
|
55
|
+
severity: Severity
|
|
56
|
+
source: str
|
|
57
|
+
line: int
|
|
58
|
+
match: str # redacted — safe to print and store
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"id": self.id,
|
|
63
|
+
"title": self.title,
|
|
64
|
+
"severity": self.severity.label,
|
|
65
|
+
"source": self.source,
|
|
66
|
+
"line": self.line,
|
|
67
|
+
"match": self.match,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def redact(value: str) -> str:
|
|
72
|
+
"""Mask the middle of a secret: keep a few edge chars to locate it."""
|
|
73
|
+
if len(value) <= 8:
|
|
74
|
+
return "*" * len(value)
|
|
75
|
+
return f"{value[:4]}…{value[-4:]} ({len(value)} chars)"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scan_text(text: str, *, source: str = "-") -> list[SecretHit]:
|
|
79
|
+
"""Find secrets in ``text``, de-duped per (pattern, source, line, value)."""
|
|
80
|
+
hits: list[SecretHit] = []
|
|
81
|
+
seen: set[tuple] = set()
|
|
82
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
83
|
+
for pat in SECRET_PATTERNS:
|
|
84
|
+
for m in pat.regex.finditer(line):
|
|
85
|
+
value = m.group(pat.group)
|
|
86
|
+
if pat.min_entropy and shannon_entropy(value) < pat.min_entropy:
|
|
87
|
+
continue
|
|
88
|
+
key = (pat.id, source, lineno, value)
|
|
89
|
+
if key in seen:
|
|
90
|
+
continue
|
|
91
|
+
seen.add(key)
|
|
92
|
+
hits.append(
|
|
93
|
+
SecretHit(pat.id, pat.title, pat.severity, source, lineno, redact(value))
|
|
94
|
+
)
|
|
95
|
+
return hits
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _readable(path: Path) -> str | None:
|
|
99
|
+
if path.suffix.lower() in _SKIP_EXT:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
if path.stat().st_size > _MAX_BYTES:
|
|
103
|
+
return None
|
|
104
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
105
|
+
except OSError:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _walk(paths: list[Path]):
|
|
110
|
+
"""Yield (path, text) for every readable file under the given paths."""
|
|
111
|
+
for raw in paths:
|
|
112
|
+
p = Path(raw)
|
|
113
|
+
files = sorted(f for f in p.rglob("*") if f.is_file()) if p.is_dir() else [p]
|
|
114
|
+
for f in files:
|
|
115
|
+
text = _readable(f)
|
|
116
|
+
if text is not None:
|
|
117
|
+
yield f, text
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def scan_paths(paths: list[Path]) -> list[SecretHit]:
|
|
121
|
+
"""Scan files and directories (recursively) for secrets."""
|
|
122
|
+
hits: list[SecretHit] = []
|
|
123
|
+
for path, text in _walk([Path(p) for p in paths]):
|
|
124
|
+
hits.extend(scan_text(text, source=str(path)))
|
|
125
|
+
return hits
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def endpoints_from_paths(paths: list[Path]) -> list[str]:
|
|
129
|
+
"""Extract JS endpoints across files and directories, de-duped in order."""
|
|
130
|
+
found: list[str] = []
|
|
131
|
+
for _path, text in _walk([Path(p) for p in paths]):
|
|
132
|
+
found.extend(extract_endpoints(text))
|
|
133
|
+
return list(dict.fromkeys(found))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Screenshots layer — a visual of every live URL, for triage and the report.
|
|
2
|
+
|
|
3
|
+
Drives a headless screenshotter (``gowitness``) over the scope-filtered live
|
|
4
|
+
URLs and records a manifest (``scans/screenshots.json``) that the reporting
|
|
5
|
+
layer turns into an embedded gallery. Depends only on ``core``; the capture
|
|
6
|
+
backend is injectable, so the orchestration is tested without a browser.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .runner import (
|
|
12
|
+
SHOTS_DIR,
|
|
13
|
+
SHOTS_JSON,
|
|
14
|
+
Shot,
|
|
15
|
+
ShotSummary,
|
|
16
|
+
gowitness_capture,
|
|
17
|
+
run_shots,
|
|
18
|
+
shot_targets,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"SHOTS_DIR",
|
|
23
|
+
"SHOTS_JSON",
|
|
24
|
+
"Shot",
|
|
25
|
+
"ShotSummary",
|
|
26
|
+
"gowitness_capture",
|
|
27
|
+
"run_shots",
|
|
28
|
+
"shot_targets",
|
|
29
|
+
]
|