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,95 @@
1
+ """Diff two snapshots — what appeared and what disappeared since last run.
2
+
3
+ The delta is the whole point of re-running a program: new subdomains to probe,
4
+ freshly-live URLs, and above all *new findings*. :func:`diff_snapshots`
5
+ compares two :class:`~huntkit.history.store.Snapshot` objects per category;
6
+ :func:`current_vs` compares a saved baseline against the workspace as it stands
7
+ right now, so ``huntkit diff`` answers "what changed since the baseline?".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ from ..core.workspace import Workspace
15
+ from .store import CATEGORIES, Snapshot, capture
16
+
17
+
18
+ @dataclass
19
+ class CategoryDiff:
20
+ name: str
21
+ added: list[str] = field(default_factory=list)
22
+ removed: list[str] = field(default_factory=list)
23
+
24
+ @property
25
+ def changed(self) -> bool:
26
+ return bool(self.added or self.removed)
27
+
28
+
29
+ @dataclass
30
+ class DiffReport:
31
+ program: str
32
+ base_id: str
33
+ base_time: str
34
+ new_id: str
35
+ new_time: str
36
+ categories: list[CategoryDiff] = field(default_factory=list)
37
+
38
+ def by_name(self, name: str) -> CategoryDiff:
39
+ for c in self.categories:
40
+ if c.name == name:
41
+ return c
42
+ return CategoryDiff(name)
43
+
44
+ @property
45
+ def changed(self) -> list[CategoryDiff]:
46
+ return [c for c in self.categories if c.changed]
47
+
48
+ @property
49
+ def has_changes(self) -> bool:
50
+ return bool(self.changed)
51
+
52
+ @property
53
+ def total_added(self) -> int:
54
+ return sum(len(c.added) for c in self.categories)
55
+
56
+ @property
57
+ def total_removed(self) -> int:
58
+ return sum(len(c.removed) for c in self.categories)
59
+
60
+ @property
61
+ def new_findings(self) -> list[str]:
62
+ return self.by_name("findings").added
63
+
64
+
65
+ def diff_snapshots(old: Snapshot, new: Snapshot, program: str) -> DiffReport:
66
+ """Per-category added/removed between two snapshots (old → new)."""
67
+ cats: list[CategoryDiff] = []
68
+ for name in CATEGORIES:
69
+ old_set = set(old.get(name))
70
+ new_set = set(new.get(name))
71
+ cats.append(
72
+ CategoryDiff(
73
+ name=name,
74
+ added=sorted(new_set - old_set),
75
+ removed=sorted(old_set - new_set),
76
+ )
77
+ )
78
+ return DiffReport(
79
+ program=program,
80
+ base_id=old.id,
81
+ base_time=old.generated,
82
+ new_id=new.id,
83
+ new_time=new.generated,
84
+ categories=cats,
85
+ )
86
+
87
+
88
+ def current_vs(ws: Workspace, baseline: Snapshot) -> tuple[DiffReport, Snapshot]:
89
+ """Diff the workspace as it stands now against ``baseline``.
90
+
91
+ Returns the report and the freshly-captured (still unsaved) snapshot, so a
92
+ caller can persist it as the next baseline if asked.
93
+ """
94
+ now = capture(ws)
95
+ return diff_snapshots(baseline, now, ws.program), now
@@ -0,0 +1,133 @@
1
+ """Snapshots — freeze a program's result set so later runs can be diffed.
2
+
3
+ Bug bounty is not one pass; it is the same scope re-run over weeks. The signal
4
+ that matters is the *delta*: a subdomain that appeared yesterday, a URL that
5
+ went live, a finding that is new since the last sweep. This layer captures the
6
+ current state of a workspace's result files into a timestamped snapshot under
7
+ ``history/`` so :mod:`huntkit.history.diff` can compare two points in time.
8
+
9
+ Pure and offline — it only reads artifacts already on disk.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from ..core.workspace import Workspace
20
+
21
+ HISTORY_DIR = "history"
22
+ _FINDINGS_JSON = "scans/findings.json"
23
+
24
+ # The result sets a snapshot freezes, in pipeline order. Each text category
25
+ # maps to a deduped result file; ``findings`` is flattened from the scan JSON.
26
+ CATEGORIES: tuple[str, ...] = ("subdomains", "resolved", "live", "ports", "urls", "findings")
27
+ _FILE_SOURCES: dict[str, str] = {
28
+ "subdomains": "recon/subdomains.txt",
29
+ "resolved": "recon/resolved.txt",
30
+ "live": "recon/live.txt",
31
+ "ports": "recon/ports.txt",
32
+ "urls": "urls/urls.txt",
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class Snapshot:
38
+ """One frozen point in time: the sorted, deduped items per category."""
39
+
40
+ id: str # sortable stamp, e.g. 20261027-163000
41
+ generated: str # ISO-8601 UTC
42
+ items: dict[str, list[str]] = field(default_factory=dict)
43
+
44
+ @property
45
+ def counts(self) -> dict[str, int]:
46
+ return {cat: len(self.items.get(cat, [])) for cat in CATEGORIES}
47
+
48
+ @property
49
+ def total(self) -> int:
50
+ return sum(len(v) for v in self.items.values())
51
+
52
+ def get(self, category: str) -> list[str]:
53
+ return self.items.get(category, [])
54
+
55
+ def to_dict(self) -> dict:
56
+ return {"id": self.id, "generated": self.generated, "items": self.items}
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: dict) -> Snapshot:
60
+ raw = data.get("items", {})
61
+ items = {cat: list(raw.get(cat, [])) for cat in CATEGORIES}
62
+ return cls(id=data.get("id", ""), generated=data.get("generated", ""), items=items)
63
+
64
+
65
+ def _findings(ws: Workspace) -> list[str]:
66
+ """Flatten the combined scan findings into a sorted, deduped list."""
67
+ path = ws.path(_FINDINGS_JSON)
68
+ if not path.exists():
69
+ return []
70
+ try:
71
+ data = json.loads(path.read_text(encoding="utf-8"))
72
+ except (ValueError, OSError):
73
+ return []
74
+ seen: set[str] = set()
75
+ for lines in data.get("tools", {}).values():
76
+ seen.update(ln for ln in lines if ln.strip())
77
+ return sorted(seen)
78
+
79
+
80
+ def capture(ws: Workspace) -> Snapshot:
81
+ """Freeze the workspace's current result sets into an unsaved snapshot."""
82
+ items: dict[str, list[str]] = {}
83
+ for cat, rel in _FILE_SOURCES.items():
84
+ items[cat] = sorted(dict.fromkeys(ws.read_lines(rel)))
85
+ items["findings"] = _findings(ws)
86
+ return Snapshot(
87
+ id=time.strftime("%Y%m%d-%H%M%S", time.gmtime()),
88
+ generated=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
89
+ items=items,
90
+ )
91
+
92
+
93
+ def save(ws: Workspace, snap: Snapshot) -> Path:
94
+ """Persist a snapshot under ``history/<id>.json`` and return its path."""
95
+ outdir = Path(ws.path(HISTORY_DIR))
96
+ outdir.mkdir(parents=True, exist_ok=True)
97
+ path = outdir / f"{snap.id}.json"
98
+ path.write_text(json.dumps(snap.to_dict(), indent=2), encoding="utf-8")
99
+ return path
100
+
101
+
102
+ def load(path: Path) -> Snapshot:
103
+ return Snapshot.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
104
+
105
+
106
+ def list_snapshots(ws: Workspace) -> list[Snapshot]:
107
+ """All saved snapshots, oldest first (sorted by id)."""
108
+ outdir = Path(ws.path(HISTORY_DIR))
109
+ if not outdir.exists():
110
+ return []
111
+ snaps = []
112
+ for p in outdir.glob("*.json"):
113
+ try:
114
+ snaps.append(load(p))
115
+ except (ValueError, OSError):
116
+ continue
117
+ return sorted(snaps, key=lambda s: s.id)
118
+
119
+
120
+ def latest(ws: Workspace) -> Snapshot | None:
121
+ snaps = list_snapshots(ws)
122
+ return snaps[-1] if snaps else None
123
+
124
+
125
+ def find(ws: Workspace, snapshot_id: str) -> Snapshot | None:
126
+ """Look up a snapshot by exact id, or None."""
127
+ path = Path(ws.path(HISTORY_DIR)) / f"{snapshot_id}.json"
128
+ if not path.exists():
129
+ return None
130
+ try:
131
+ return load(path)
132
+ except (ValueError, OSError):
133
+ return None
@@ -0,0 +1,48 @@
1
+ """Intelligence layer — turn recon output into scored, prioritised leads.
2
+
3
+ The engine reads a workspace's recon artifacts, runs them through a declarative
4
+ :mod:`~huntkit.intel.signals` catalog, scores each host, and rolls the signals
5
+ up into prioritised attack paths. It's a feature layer (like the pipeline):
6
+ it builds on ``core`` and ``utils`` but is independent of the plugin registry,
7
+ so it stays pure and easy to test.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .engine import (
13
+ AttackPath,
14
+ HostIntel,
15
+ IntelReport,
16
+ Priority,
17
+ analyze,
18
+ save_report,
19
+ )
20
+ from .signals import (
21
+ Rule,
22
+ Severity,
23
+ Signal,
24
+ scan_signals,
25
+ signals_from_findings,
26
+ signals_from_labels,
27
+ signals_from_params,
28
+ signals_from_paths,
29
+ signals_from_ports,
30
+ )
31
+
32
+ __all__ = [
33
+ "AttackPath",
34
+ "HostIntel",
35
+ "IntelReport",
36
+ "Priority",
37
+ "Rule",
38
+ "Severity",
39
+ "Signal",
40
+ "analyze",
41
+ "save_report",
42
+ "scan_signals",
43
+ "signals_from_findings",
44
+ "signals_from_labels",
45
+ "signals_from_params",
46
+ "signals_from_paths",
47
+ "signals_from_ports",
48
+ ]
@@ -0,0 +1,233 @@
1
+ """Intelligence engine — recon output -> scored, prioritised attack surface.
2
+
3
+ Reads a workspace's recon artifacts, runs them through the signal catalog,
4
+ groups the findings per host, scores each host Low -> Critical, and rolls the
5
+ signals up into prioritised **attack paths** (which bug-class playbook to run,
6
+ and where). Pure analysis over data already on disk — it never touches the
7
+ target.
8
+
9
+ The layout paths it reads mirror :data:`huntkit.pipeline.STAGE_FILES`; they are
10
+ duplicated here as small constants so the intelligence layer stays independent
11
+ of the pipeline (and trivially testable without the plugin registry).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from collections import Counter, defaultdict
19
+ from dataclasses import dataclass, field
20
+ from enum import IntEnum
21
+ from pathlib import Path
22
+
23
+ from ..core.workspace import Workspace
24
+ from ..knowledge.playbooks import titles as _playbook_titles
25
+ from ..utils import filesystem as fs
26
+ from .signals import Severity, Signal, scan_signals, url_host
27
+
28
+ # Workspace-relative recon artifacts the engine consumes.
29
+ _LIVE = "recon/live.txt"
30
+ _URLS = "urls/urls.txt"
31
+ _PORTS = "recon/ports.txt"
32
+ _SUBS = "recon/subdomains.txt"
33
+ _RESOLVED = "recon/resolved.txt"
34
+ # Active-scan output (written by the scanner layer); optional. Named here as a
35
+ # constant rather than imported, so intel stays independent of the scanner.
36
+ _FINDINGS = "scans/findings.json"
37
+
38
+ # Human names for the bug-class playbook ids. The knowledge base owns the
39
+ # playbook catalog, so names come from there — one source of truth, and an
40
+ # attack path in a report links straight to `huntkit playbook <id>`.
41
+ PLAYBOOK_NAMES: dict[str, str] = _playbook_titles()
42
+
43
+
44
+ class Priority(IntEnum):
45
+ LOW = 1
46
+ MEDIUM = 2
47
+ HIGH = 3
48
+ CRITICAL = 4
49
+
50
+ @property
51
+ def label(self) -> str:
52
+ return self.name.capitalize()
53
+
54
+ @property
55
+ def style(self) -> str: # matches the theme keys in utils.terminal
56
+ return self.name.lower()
57
+
58
+
59
+ def _priority(signals: list[Signal]) -> Priority:
60
+ """Host priority = worst single signal, nudged up by breadth of surface.
61
+
62
+ A Critical signal makes the host Critical outright; otherwise a High
63
+ signal (or enough stacked medium/low ones) makes it High, and so on. The
64
+ aggregate thresholds mean a host with many medium issues still rises.
65
+ """
66
+ if not signals:
67
+ return Priority.LOW
68
+ top = max(s.severity for s in signals)
69
+ total = sum(int(s.severity) for s in signals)
70
+ if top >= Severity.CRITICAL:
71
+ return Priority.CRITICAL
72
+ if top >= Severity.HIGH or total >= 8:
73
+ return Priority.HIGH
74
+ if top >= Severity.MEDIUM or total >= 4:
75
+ return Priority.MEDIUM
76
+ return Priority.LOW
77
+
78
+
79
+ @dataclass
80
+ class HostIntel:
81
+ host: str
82
+ signals: list[Signal]
83
+
84
+ @property
85
+ def score(self) -> int:
86
+ return sum(int(s.severity) for s in self.signals)
87
+
88
+ @property
89
+ def priority(self) -> Priority:
90
+ return _priority(self.signals)
91
+
92
+ @property
93
+ def playbooks(self) -> list[str]:
94
+ """Suggested playbooks, most-severe signal first, de-duplicated."""
95
+ ordered: list[str] = []
96
+ for s in sorted(self.signals, key=lambda x: -int(x.severity)):
97
+ for pb in s.playbooks:
98
+ if pb not in ordered:
99
+ ordered.append(pb)
100
+ return ordered
101
+
102
+ def ranked_signals(self) -> list[Signal]:
103
+ return sorted(self.signals, key=lambda s: (-int(s.severity), s.category, s.id))
104
+
105
+ def to_dict(self) -> dict:
106
+ return {
107
+ "host": self.host,
108
+ "priority": self.priority.label,
109
+ "score": self.score,
110
+ "playbooks": self.playbooks,
111
+ "signals": [s.to_dict() for s in self.ranked_signals()],
112
+ }
113
+
114
+
115
+ @dataclass
116
+ class AttackPath:
117
+ """One bug-class playbook and everywhere the recon points it."""
118
+
119
+ playbook: str
120
+ name: str
121
+ severity: Severity # worst contributing signal
122
+ hosts: list[str]
123
+ signals: list[Signal]
124
+
125
+ def to_dict(self) -> dict:
126
+ return {
127
+ "playbook": self.playbook,
128
+ "name": self.name,
129
+ "severity": self.severity.label,
130
+ "hosts": self.hosts,
131
+ "signal_count": len(self.signals),
132
+ }
133
+
134
+
135
+ @dataclass
136
+ class IntelReport:
137
+ program: str
138
+ hosts: list[HostIntel]
139
+ generated: float = field(default_factory=time.time)
140
+
141
+ @property
142
+ def signals(self) -> list[Signal]:
143
+ return [s for h in self.hosts for s in h.signals]
144
+
145
+ @property
146
+ def summary(self) -> dict[str, int]:
147
+ """Host counts per priority, always highest-first and fully populated."""
148
+ counts = Counter(h.priority for h in self.hosts)
149
+ return {p.label: counts.get(p, 0) for p in sorted(Priority, reverse=True)}
150
+
151
+ def attack_paths(self) -> list[AttackPath]:
152
+ groups: dict[str, list[Signal]] = defaultdict(list)
153
+ for s in self.signals:
154
+ for pb in s.playbooks:
155
+ groups[pb].append(s)
156
+ paths = [
157
+ AttackPath(
158
+ playbook=pb,
159
+ name=PLAYBOOK_NAMES.get(pb, pb),
160
+ severity=max(s.severity for s in sigs),
161
+ hosts=sorted({s.host for s in sigs}),
162
+ signals=sigs,
163
+ )
164
+ for pb, sigs in groups.items()
165
+ ]
166
+ paths.sort(key=lambda p: (-int(p.severity), -len(p.hosts), p.playbook))
167
+ return paths
168
+
169
+ def to_dict(self) -> dict:
170
+ return {
171
+ "program": self.program,
172
+ "generated": self.generated,
173
+ "summary": self.summary,
174
+ "hosts": [h.to_dict() for h in self.hosts],
175
+ "attack_paths": [p.to_dict() for p in self.attack_paths()],
176
+ }
177
+
178
+
179
+ def _host_pool(ws: Workspace, live: list[str]) -> list[str]:
180
+ """Every hostname worth label-analysis: subdomains, resolved, live hosts."""
181
+ pool: set[str] = set(ws.read_lines(_SUBS)) | set(ws.read_lines(_RESOLVED))
182
+ for url in live:
183
+ host = url_host(url)
184
+ if host:
185
+ pool.add(host)
186
+ return sorted(h.lower() for h in pool if h.strip())
187
+
188
+
189
+ def _read_findings(ws: Workspace) -> dict[str, list[str]]:
190
+ """Scanner findings keyed by tool, or empty if no scan has run / file is bad."""
191
+ path = ws.path(_FINDINGS)
192
+ if not path.exists():
193
+ return {}
194
+ try:
195
+ data = json.loads(fs.read_text(path))
196
+ except (ValueError, OSError):
197
+ return {}
198
+ tools = data.get("tools") if isinstance(data, dict) else None
199
+ if not isinstance(tools, dict):
200
+ return {}
201
+ return {
202
+ str(tool): [str(x) for x in lines]
203
+ for tool, lines in tools.items()
204
+ if isinstance(lines, list)
205
+ }
206
+
207
+
208
+ def analyze(ws: Workspace) -> IntelReport:
209
+ """Score a workspace's recon surface into a prioritised intel report."""
210
+ live = ws.read_lines(_LIVE)
211
+ urls = ws.read_lines(_URLS)
212
+ ports = ws.read_lines(_PORTS)
213
+ hosts = _host_pool(ws, live)
214
+ url_pool = list(dict.fromkeys(live + urls)) # dedupe, keep order
215
+ findings = _read_findings(ws)
216
+
217
+ signals = scan_signals(ports=ports, urls=url_pool, hosts=hosts, findings=findings)
218
+
219
+ by_host: dict[str, list[Signal]] = defaultdict(list)
220
+ for s in signals:
221
+ by_host[s.host].append(s)
222
+
223
+ intel_hosts = [HostIntel(host=h, signals=sigs) for h, sigs in by_host.items()]
224
+ intel_hosts.sort(key=lambda h: (-int(h.priority), -h.score, h.host))
225
+ return IntelReport(program=ws.program, hosts=intel_hosts)
226
+
227
+
228
+ def save_report(ws: Workspace, report: IntelReport) -> Path:
229
+ """Persist the report as scans/intel.json for later reporting; record count."""
230
+ path = ws.path("scans", "intel.json")
231
+ fs.write_text(path, json.dumps(report.to_dict(), indent=2))
232
+ ws.state.set_count("intel_signals", len(report.signals))
233
+ return path