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.
Files changed (86) hide show
  1. huntkit/__init__.py +4 -0
  2. huntkit/__main__.py +8 -0
  3. huntkit/ai/__init__.py +42 -0
  4. huntkit/ai/advisor.py +133 -0
  5. huntkit/ai/providers.py +200 -0
  6. huntkit/app/__init__.py +13 -0
  7. huntkit/app/config_cmd.py +65 -0
  8. huntkit/app/context.py +50 -0
  9. huntkit/app/main.py +1166 -0
  10. huntkit/app/plugins_cmd.py +79 -0
  11. huntkit/app/workspace_cmd.py +62 -0
  12. huntkit/core/__init__.py +6 -0
  13. huntkit/core/cache.py +67 -0
  14. huntkit/core/config.py +272 -0
  15. huntkit/core/logger.py +66 -0
  16. huntkit/core/pacing.py +71 -0
  17. huntkit/core/runner.py +117 -0
  18. huntkit/core/state.py +154 -0
  19. huntkit/core/workspace.py +145 -0
  20. huntkit/enrich/__init__.py +39 -0
  21. huntkit/enrich/asn.py +139 -0
  22. huntkit/enrich/engine.py +167 -0
  23. huntkit/enrich/favicon.py +91 -0
  24. huntkit/enrich/mmh3.py +69 -0
  25. huntkit/fingerprint/__init__.py +26 -0
  26. huntkit/fingerprint/engine.py +83 -0
  27. huntkit/fingerprint/rules.py +170 -0
  28. huntkit/history/__init__.py +38 -0
  29. huntkit/history/diff.py +95 -0
  30. huntkit/history/store.py +133 -0
  31. huntkit/intel/__init__.py +48 -0
  32. huntkit/intel/engine.py +233 -0
  33. huntkit/intel/signals.py +826 -0
  34. huntkit/knowledge/__init__.py +27 -0
  35. huntkit/knowledge/playbooks.py +452 -0
  36. huntkit/monitor/__init__.py +24 -0
  37. huntkit/monitor/runner.py +111 -0
  38. huntkit/notify/__init__.py +16 -0
  39. huntkit/notify/webhook.py +73 -0
  40. huntkit/pipeline.py +302 -0
  41. huntkit/plugins/__init__.py +36 -0
  42. huntkit/plugins/amass.py +25 -0
  43. huntkit/plugins/arjun.py +18 -0
  44. huntkit/plugins/assetfinder.py +18 -0
  45. huntkit/plugins/base.py +220 -0
  46. huntkit/plugins/chaos.py +20 -0
  47. huntkit/plugins/dalfox.py +24 -0
  48. huntkit/plugins/dnsx.py +19 -0
  49. huntkit/plugins/ffuf.py +34 -0
  50. huntkit/plugins/findomain.py +18 -0
  51. huntkit/plugins/gau.py +18 -0
  52. huntkit/plugins/hakrawler.py +19 -0
  53. huntkit/plugins/httpx.py +30 -0
  54. huntkit/plugins/katana.py +23 -0
  55. huntkit/plugins/naabu.py +22 -0
  56. huntkit/plugins/nmap.py +39 -0
  57. huntkit/plugins/nuclei.py +27 -0
  58. huntkit/plugins/registry.py +117 -0
  59. huntkit/plugins/subfinder.py +21 -0
  60. huntkit/plugins/waybackurls.py +29 -0
  61. huntkit/plugins/whatweb.py +19 -0
  62. huntkit/py.typed +0 -0
  63. huntkit/report/__init__.py +103 -0
  64. huntkit/report/builder.py +145 -0
  65. huntkit/report/render.py +323 -0
  66. huntkit/scanner/__init__.py +28 -0
  67. huntkit/scanner/runner.py +239 -0
  68. huntkit/secrets/__init__.py +34 -0
  69. huntkit/secrets/patterns.py +172 -0
  70. huntkit/secrets/scanner.py +133 -0
  71. huntkit/shots/__init__.py +29 -0
  72. huntkit/shots/runner.py +186 -0
  73. huntkit/tui/__init__.py +13 -0
  74. huntkit/tui/dashboard.py +160 -0
  75. huntkit/utils/__init__.py +6 -0
  76. huntkit/utils/filesystem.py +109 -0
  77. huntkit/utils/process.py +104 -0
  78. huntkit/utils/severity.py +49 -0
  79. huntkit/utils/terminal.py +88 -0
  80. huntkit/utils/validators.py +99 -0
  81. huntkit-0.3.0.dist-info/METADATA +340 -0
  82. huntkit-0.3.0.dist-info/RECORD +86 -0
  83. huntkit-0.3.0.dist-info/WHEEL +5 -0
  84. huntkit-0.3.0.dist-info/entry_points.txt +2 -0
  85. huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  86. 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
+ ]