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
huntkit/app/main.py
ADDED
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
"""The HuntKit Typer application.
|
|
2
|
+
|
|
3
|
+
Defines the root command group, its global options, and the top-level
|
|
4
|
+
commands. Grouped commands (config/plugins/workspace) live in their own
|
|
5
|
+
modules and are mounted here. Run it with the ``huntkit`` console script or
|
|
6
|
+
``python -m huntkit`` — both resolve to :func:`run`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import urllib.parse
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich.markup import escape
|
|
19
|
+
|
|
20
|
+
from .. import __version__
|
|
21
|
+
from ..ai import Advisor, ApprovalDenied, ProviderError, get_provider, provider_names
|
|
22
|
+
from ..core.workspace import Workspace, list_workspaces
|
|
23
|
+
from ..enrich import EnrichReport, run_enrich
|
|
24
|
+
from ..fingerprint import fingerprint_headers, has_waf
|
|
25
|
+
from ..history import DiffReport, capture, current_vs, find, latest, list_snapshots, save
|
|
26
|
+
from ..intel import IntelReport, Priority, save_report
|
|
27
|
+
from ..intel import analyze as run_analyze
|
|
28
|
+
from ..knowledge import Playbook, all_playbooks, get_playbook
|
|
29
|
+
from ..monitor import NOTIFY_CHANGES, NOTIFY_FINDINGS, MonitorResult, run_monitor
|
|
30
|
+
from ..pipeline import RECON_STAGES, ReconSummary, run_recon
|
|
31
|
+
from ..report import FORMATS, normalise_format
|
|
32
|
+
from ..report import build as report_build
|
|
33
|
+
from ..report import write as report_write
|
|
34
|
+
from ..scanner import ScanSummary, run_scan
|
|
35
|
+
from ..secrets import endpoints_from_paths, scan_paths
|
|
36
|
+
from ..shots import ShotSummary, run_shots
|
|
37
|
+
from ..tui import render_dashboard
|
|
38
|
+
from ..utils import filesystem as fs
|
|
39
|
+
from ..utils import terminal as term
|
|
40
|
+
from ..utils import validators as v
|
|
41
|
+
from .config_cmd import config_app
|
|
42
|
+
from .context import AppContext
|
|
43
|
+
from .plugins_cmd import plugins_app
|
|
44
|
+
from .workspace_cmd import workspace_app
|
|
45
|
+
|
|
46
|
+
app = typer.Typer(
|
|
47
|
+
name="huntkit",
|
|
48
|
+
help="Local-first bug bounty recon, methodology & workflow framework.",
|
|
49
|
+
no_args_is_help=True,
|
|
50
|
+
add_completion=True,
|
|
51
|
+
rich_markup_mode="rich",
|
|
52
|
+
)
|
|
53
|
+
app.add_typer(config_app, name="config")
|
|
54
|
+
app.add_typer(plugins_app, name="plugins")
|
|
55
|
+
app.add_typer(workspace_app, name="workspace")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _version_cb(value: bool) -> None:
|
|
59
|
+
if value:
|
|
60
|
+
term.console.print(f"HuntKit {__version__}")
|
|
61
|
+
raise typer.Exit()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.callback()
|
|
65
|
+
def _root(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
config: Optional[Path] = typer.Option(
|
|
68
|
+
None, "--config", "-c", help="Path to a huntkit.yaml.", metavar="PATH"
|
|
69
|
+
),
|
|
70
|
+
workspace: Optional[Path] = typer.Option(
|
|
71
|
+
None,
|
|
72
|
+
"--workspace",
|
|
73
|
+
"-w",
|
|
74
|
+
help="Workspace base dir (default ~/.huntkit).",
|
|
75
|
+
),
|
|
76
|
+
verbose: bool = typer.Option(False, "--verbose", help="Verbose (debug) logging."),
|
|
77
|
+
quiet: bool = typer.Option(False, "--quiet", help="Errors only."),
|
|
78
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
|
|
79
|
+
_version: bool = typer.Option(
|
|
80
|
+
False, "--version", callback=_version_cb, is_eager=True, help="Show version and exit."
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Build the shared context every command reads from."""
|
|
84
|
+
if no_color:
|
|
85
|
+
term.console.no_color = True
|
|
86
|
+
term.err_console.no_color = True
|
|
87
|
+
ctx.obj = AppContext.build(
|
|
88
|
+
config_path=config, workspace=workspace, verbose=verbose, quiet=quiet
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
# version
|
|
94
|
+
# --------------------------------------------------------------------------
|
|
95
|
+
@app.command()
|
|
96
|
+
def version() -> None:
|
|
97
|
+
"""Print the HuntKit version."""
|
|
98
|
+
term.console.print(f"HuntKit {__version__}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------
|
|
102
|
+
# doctor
|
|
103
|
+
# --------------------------------------------------------------------------
|
|
104
|
+
@app.command()
|
|
105
|
+
def doctor(ctx: typer.Context) -> None:
|
|
106
|
+
"""Check which tools are installed and where HuntKit stores data."""
|
|
107
|
+
app_ctx = ctx.obj
|
|
108
|
+
registry, runner = app_ctx.registry, app_ctx.runner
|
|
109
|
+
|
|
110
|
+
term.banner("HuntKit doctor — toolchain check")
|
|
111
|
+
table = term.make_table("", ["tool", "category", "status", "path / install"])
|
|
112
|
+
missing = 0
|
|
113
|
+
for p in registry.all():
|
|
114
|
+
path = runner.resolve(p.name)
|
|
115
|
+
if path:
|
|
116
|
+
table.add_row(p.name, p.category.value, "[ok]installed[/ok]", path)
|
|
117
|
+
else:
|
|
118
|
+
missing += 1
|
|
119
|
+
table.add_row(p.name, p.category.value, "[warn]missing[/warn]", p.install)
|
|
120
|
+
term.console.print(table)
|
|
121
|
+
|
|
122
|
+
if missing:
|
|
123
|
+
term.warn(
|
|
124
|
+
f"{missing}/{len(registry)} tools missing — those stages are skipped, "
|
|
125
|
+
"HuntKit still runs."
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
term.ok("full toolchain present.")
|
|
129
|
+
term.info(f"workspace root: {app_ctx.config.workspace_root}")
|
|
130
|
+
if app_ctx.config._sources:
|
|
131
|
+
term.info("config: " + ", ".join(app_ctx.config._sources))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------
|
|
135
|
+
# init
|
|
136
|
+
# --------------------------------------------------------------------------
|
|
137
|
+
@app.command()
|
|
138
|
+
def init(
|
|
139
|
+
ctx: typer.Context,
|
|
140
|
+
program: str = typer.Argument(..., help="A name for this engagement/workspace."),
|
|
141
|
+
scope: Optional[list[str]] = typer.Option(
|
|
142
|
+
None, "--scope", "-s", help="In-scope domain, IP, CIDR, or *.wildcard (repeatable)."
|
|
143
|
+
),
|
|
144
|
+
out: Optional[list[str]] = typer.Option(
|
|
145
|
+
None, "--out", "-x", help="Out-of-scope pattern (repeatable)."
|
|
146
|
+
),
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Create a workspace and set its scope."""
|
|
149
|
+
app_ctx = ctx.obj
|
|
150
|
+
scope = scope or []
|
|
151
|
+
out = out or []
|
|
152
|
+
|
|
153
|
+
bad = [s for s in (*scope, *out) if not v.is_scope_entry(s)]
|
|
154
|
+
if bad:
|
|
155
|
+
term.error("invalid scope entries: " + ", ".join(bad))
|
|
156
|
+
term.info("use a domain, IP, CIDR, or a *.wildcard.")
|
|
157
|
+
raise typer.Exit(2)
|
|
158
|
+
|
|
159
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
160
|
+
if scope or out:
|
|
161
|
+
ws.set_scope(scope, out)
|
|
162
|
+
|
|
163
|
+
term.ok(f"workspace ready: {ws.root}")
|
|
164
|
+
if scope:
|
|
165
|
+
term.info("in-scope: " + ", ".join(scope))
|
|
166
|
+
if v.is_wildcard_scope_risky(scope):
|
|
167
|
+
term.warn("broad wildcard scope — double-check the program allows it before scanning.")
|
|
168
|
+
seed = next((s.lstrip("*.") for s in scope if v.is_domain(s.lstrip("*."))), "<domain>")
|
|
169
|
+
term.info(f"next: huntkit recon {seed} -p {program}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------
|
|
173
|
+
# recon
|
|
174
|
+
# --------------------------------------------------------------------------
|
|
175
|
+
def _render_event(event: dict) -> None:
|
|
176
|
+
kind = event.get("event")
|
|
177
|
+
if kind == "stage_start":
|
|
178
|
+
term.step(f"stage: {event['stage']}")
|
|
179
|
+
elif kind == "stage_resumed":
|
|
180
|
+
term.info(f" {event['stage']}: already done — resumed (use --fresh to re-run)")
|
|
181
|
+
elif kind == "plugin_done":
|
|
182
|
+
term.bullet(f"{event['plugin']}: {event['found']} found", "ok")
|
|
183
|
+
elif kind == "plugin_skip":
|
|
184
|
+
term.bullet(f"{event['plugin']}: skipped ({event['reason']})", "muted")
|
|
185
|
+
elif kind == "stage_done":
|
|
186
|
+
if not event["ran"]:
|
|
187
|
+
term.warn(f" {event['stage']}: no tool ran — install one to enable this stage")
|
|
188
|
+
else:
|
|
189
|
+
term.info(f" {event['stage']}: +{event['new']} new, {event['total']} total")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def recon(
|
|
194
|
+
ctx: typer.Context,
|
|
195
|
+
domain: str = typer.Argument(..., help="Seed domain, e.g. example.com."),
|
|
196
|
+
program: Optional[str] = typer.Option(
|
|
197
|
+
None, "--program", "-p", help="Workspace to use (default: the domain)."
|
|
198
|
+
),
|
|
199
|
+
stage: str = typer.Option(
|
|
200
|
+
"all", "--stage", "-s", help="all | subs | resolve | live | ports | urls."
|
|
201
|
+
),
|
|
202
|
+
threads: Optional[int] = typer.Option(
|
|
203
|
+
None, "--threads", "-t", help="Concurrency (default: config general.threads)."
|
|
204
|
+
),
|
|
205
|
+
fresh: bool = typer.Option(
|
|
206
|
+
False, "--fresh", help="Ignore saved progress and re-run every stage."
|
|
207
|
+
),
|
|
208
|
+
no_cache: bool = typer.Option(
|
|
209
|
+
False, "--no-cache", help="Bypass the tool-output cache for this run."
|
|
210
|
+
),
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Run the recon pipeline: subdomains -> resolve -> live -> ports -> urls."""
|
|
213
|
+
app_ctx = ctx.obj
|
|
214
|
+
try:
|
|
215
|
+
domain = v.normalise_domain(domain)
|
|
216
|
+
except v.ValidationError:
|
|
217
|
+
term.error(f"not a valid domain: {domain}")
|
|
218
|
+
raise typer.Exit(2) from None
|
|
219
|
+
|
|
220
|
+
if stage != "all" and stage not in RECON_STAGES:
|
|
221
|
+
term.error(f"unknown stage: {stage} (choose all/{'/'.join(RECON_STAGES)})")
|
|
222
|
+
raise typer.Exit(2)
|
|
223
|
+
|
|
224
|
+
program = program or _pick_program(app_ctx, default=domain)
|
|
225
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
226
|
+
|
|
227
|
+
if ws.has_scope():
|
|
228
|
+
if not ws.in_scope(domain):
|
|
229
|
+
term.error(f"{domain} is out of scope for '{program}'.")
|
|
230
|
+
term.info(f"add it: huntkit init {program} -s {domain}")
|
|
231
|
+
raise typer.Exit(2)
|
|
232
|
+
else:
|
|
233
|
+
# bound an un-scoped run to the target's own tree, and say so
|
|
234
|
+
ws.set_scope([f"*.{domain}"], [])
|
|
235
|
+
term.warn(f"no scope set — limiting to *.{domain}. Refine with `huntkit init`.")
|
|
236
|
+
|
|
237
|
+
if fresh:
|
|
238
|
+
ws.state.reset()
|
|
239
|
+
|
|
240
|
+
term.banner(f"recon {domain} -> {program}")
|
|
241
|
+
stages = None if stage == "all" else [stage]
|
|
242
|
+
summary = run_recon(
|
|
243
|
+
ws,
|
|
244
|
+
app_ctx.config,
|
|
245
|
+
domain,
|
|
246
|
+
stages=stages,
|
|
247
|
+
resume=not fresh,
|
|
248
|
+
runner=app_ctx.runner,
|
|
249
|
+
registry=app_ctx.registry,
|
|
250
|
+
on_event=_render_event,
|
|
251
|
+
threads=threads,
|
|
252
|
+
use_cache=not no_cache,
|
|
253
|
+
)
|
|
254
|
+
_print_summary(summary, ws)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _pick_program(app_ctx: AppContext, default: str) -> str:
|
|
258
|
+
"""Reuse the only existing workspace if there is exactly one, else `default`."""
|
|
259
|
+
existing = list_workspaces(base=app_ctx.base, config=app_ctx.config)
|
|
260
|
+
return existing[0] if len(existing) == 1 else default
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _print_summary(summary: ReconSummary, ws: Workspace) -> None:
|
|
264
|
+
rows = []
|
|
265
|
+
for s in summary.stages:
|
|
266
|
+
tools = "resumed" if s.resumed else (", ".join(s.ran) or "-")
|
|
267
|
+
rows.append((s.stage, str(s.new), str(s.total), tools))
|
|
268
|
+
term.print_table("Recon summary", ["stage", "new", "total", "tools"], rows)
|
|
269
|
+
term.ok(f"{summary.total_new} new assets. Files under {ws.root}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --------------------------------------------------------------------------
|
|
273
|
+
# scan
|
|
274
|
+
# --------------------------------------------------------------------------
|
|
275
|
+
def _render_scan_event(event: dict) -> None:
|
|
276
|
+
kind = event.get("event")
|
|
277
|
+
if kind == "scan_start":
|
|
278
|
+
tools = ", ".join(event["tools"]) or "none installed"
|
|
279
|
+
term.step(f"scanning {event['targets']} target(s) with: {tools}")
|
|
280
|
+
elif kind == "plugin_done":
|
|
281
|
+
term.bullet(f"{event['plugin']}: {event['found']} finding(s)", "ok")
|
|
282
|
+
elif kind == "plugin_skip":
|
|
283
|
+
term.bullet(f"{event['plugin']}: skipped ({event['reason']})", "muted")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.command()
|
|
287
|
+
def scan(
|
|
288
|
+
ctx: typer.Context,
|
|
289
|
+
program: Optional[str] = typer.Option(
|
|
290
|
+
None, "--program", "-p", help="Workspace to scan (default: the only one)."
|
|
291
|
+
),
|
|
292
|
+
tool: Optional[list[str]] = typer.Option(
|
|
293
|
+
None,
|
|
294
|
+
"--tool",
|
|
295
|
+
"-T",
|
|
296
|
+
help="Restrict to these scanners (repeatable; default: all available).",
|
|
297
|
+
),
|
|
298
|
+
threads: Optional[int] = typer.Option(
|
|
299
|
+
None, "--threads", "-t", help="Concurrency (default: config general.threads)."
|
|
300
|
+
),
|
|
301
|
+
no_cache: bool = typer.Option(
|
|
302
|
+
False, "--no-cache", help="Bypass the tool-output cache for this run."
|
|
303
|
+
),
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Run the vuln/fuzz scanners (nuclei, dalfox, …) over the gathered URLs."""
|
|
306
|
+
app_ctx = ctx.obj
|
|
307
|
+
program = program or _pick_program(app_ctx, default="")
|
|
308
|
+
if not program:
|
|
309
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
310
|
+
raise typer.Exit(2)
|
|
311
|
+
|
|
312
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
313
|
+
term.banner(f"scan — {program}")
|
|
314
|
+
summary = run_scan(
|
|
315
|
+
ws,
|
|
316
|
+
app_ctx.config,
|
|
317
|
+
tools=tool,
|
|
318
|
+
runner=app_ctx.runner,
|
|
319
|
+
registry=app_ctx.registry,
|
|
320
|
+
on_event=_render_scan_event,
|
|
321
|
+
threads=threads,
|
|
322
|
+
use_cache=not no_cache,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if summary.targets == 0:
|
|
326
|
+
term.warn(f"no in-scope URLs to scan for '{program}'.")
|
|
327
|
+
term.info(
|
|
328
|
+
f"gather some first: huntkit recon <domain> -p {program} (the live & urls stages)"
|
|
329
|
+
)
|
|
330
|
+
raise typer.Exit(0)
|
|
331
|
+
|
|
332
|
+
if not summary.ran:
|
|
333
|
+
term.warn("no scanner ran — install one (e.g. nuclei) to enable this phase.")
|
|
334
|
+
term.info("see what's available: huntkit doctor")
|
|
335
|
+
raise typer.Exit(0)
|
|
336
|
+
|
|
337
|
+
_print_scan_summary(summary, ws)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _print_scan_summary(summary: ScanSummary, ws: Workspace) -> None:
|
|
341
|
+
rows = []
|
|
342
|
+
for o in summary.outcomes:
|
|
343
|
+
status = f"{o.count} finding(s)" if o.ran else f"[muted]skipped: {o.reason}[/muted]"
|
|
344
|
+
rows.append((o.tool, status))
|
|
345
|
+
term.print_table("Scan summary", ["scanner", "result"], rows)
|
|
346
|
+
term.ok(
|
|
347
|
+
f"{summary.total_findings} finding(s) across {summary.targets} target(s). "
|
|
348
|
+
f"Details under {ws.path('scans')}"
|
|
349
|
+
)
|
|
350
|
+
if summary.total_findings:
|
|
351
|
+
term.info(f"fold findings into host scoring: huntkit analyze -p {summary.program}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --------------------------------------------------------------------------
|
|
355
|
+
# analyze
|
|
356
|
+
# --------------------------------------------------------------------------
|
|
357
|
+
@app.command()
|
|
358
|
+
def analyze(
|
|
359
|
+
ctx: typer.Context,
|
|
360
|
+
program: Optional[str] = typer.Option(
|
|
361
|
+
None, "--program", "-p", help="Workspace to analyse (default: the only one)."
|
|
362
|
+
),
|
|
363
|
+
top: int = typer.Option(15, "--top", "-n", help="How many hosts / paths to show."),
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Score the recon surface into prioritised hosts and attack paths."""
|
|
366
|
+
app_ctx = ctx.obj
|
|
367
|
+
program = program or _pick_program(app_ctx, default="")
|
|
368
|
+
if not program:
|
|
369
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
370
|
+
raise typer.Exit(2)
|
|
371
|
+
|
|
372
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
373
|
+
report = run_analyze(ws)
|
|
374
|
+
|
|
375
|
+
if not report.signals:
|
|
376
|
+
term.warn(f"no signals for '{program}' — thin or ungathered recon surface.")
|
|
377
|
+
term.info(f"gather more: huntkit recon <domain> -p {program} (urls & ports help most)")
|
|
378
|
+
raise typer.Exit(0)
|
|
379
|
+
|
|
380
|
+
term.banner(f"intel — {program}")
|
|
381
|
+
term.info(
|
|
382
|
+
"hosts by priority: "
|
|
383
|
+
+ " ".join(
|
|
384
|
+
f"[{p.style}]{p.label}[/{p.style}] {report.summary[p.label]}"
|
|
385
|
+
for p in sorted(Priority, reverse=True)
|
|
386
|
+
if report.summary[p.label]
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
rows = []
|
|
391
|
+
for h in report.hosts[:top]:
|
|
392
|
+
pr = h.priority
|
|
393
|
+
rows.append(
|
|
394
|
+
(
|
|
395
|
+
h.host,
|
|
396
|
+
f"[{pr.style}]{pr.label}[/{pr.style}]",
|
|
397
|
+
str(h.score),
|
|
398
|
+
str(len(h.signals)),
|
|
399
|
+
", ".join(h.playbooks[:4]) or "-",
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
term.print_table(
|
|
403
|
+
"Prioritised hosts", ["host", "priority", "score", "signals", "playbooks"], rows
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
_print_notable(report, top)
|
|
407
|
+
_print_attack_paths(report, top)
|
|
408
|
+
|
|
409
|
+
path = save_report(ws, report)
|
|
410
|
+
term.ok(f"{len(report.signals)} signals across {len(report.hosts)} hosts — wrote {path}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _print_notable(report: IntelReport, top: int) -> None:
|
|
414
|
+
"""High/Critical single signals — infra exposures that drive priority."""
|
|
415
|
+
hot = sorted(
|
|
416
|
+
(s for s in report.signals if int(s.severity) >= 4),
|
|
417
|
+
key=lambda s: (-int(s.severity), s.host),
|
|
418
|
+
)
|
|
419
|
+
if not hot:
|
|
420
|
+
return
|
|
421
|
+
rows = [
|
|
422
|
+
(
|
|
423
|
+
f"[{s.severity.style}]{s.severity.label}[/{s.severity.style}]",
|
|
424
|
+
s.host,
|
|
425
|
+
s.title,
|
|
426
|
+
s.evidence,
|
|
427
|
+
)
|
|
428
|
+
for s in hot[:top]
|
|
429
|
+
]
|
|
430
|
+
term.print_table("Notable exposures", ["severity", "host", "finding", "evidence"], rows)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _print_attack_paths(report: IntelReport, top: int) -> None:
|
|
434
|
+
paths = report.attack_paths()
|
|
435
|
+
if not paths:
|
|
436
|
+
return
|
|
437
|
+
rows = []
|
|
438
|
+
for p in paths[:top]:
|
|
439
|
+
where = ", ".join(p.hosts[:3]) + (" …" if len(p.hosts) > 3 else "")
|
|
440
|
+
rows.append(
|
|
441
|
+
(
|
|
442
|
+
p.playbook,
|
|
443
|
+
p.name,
|
|
444
|
+
f"[{p.severity.style}]{p.severity.label}[/{p.severity.style}]",
|
|
445
|
+
str(len(p.hosts)),
|
|
446
|
+
where,
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
term.print_table(
|
|
450
|
+
"Attack paths (highest impact first)",
|
|
451
|
+
["id", "playbook", "severity", "hosts", "where"],
|
|
452
|
+
rows,
|
|
453
|
+
)
|
|
454
|
+
term.info("open a playbook for the how-to: huntkit playbook <id>")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# --------------------------------------------------------------------------
|
|
458
|
+
# report
|
|
459
|
+
# --------------------------------------------------------------------------
|
|
460
|
+
@app.command()
|
|
461
|
+
def report(
|
|
462
|
+
ctx: typer.Context,
|
|
463
|
+
program: Optional[str] = typer.Option(
|
|
464
|
+
None, "--program", "-p", help="Workspace to report on (default: the only one)."
|
|
465
|
+
),
|
|
466
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md | html | json | pdf."),
|
|
467
|
+
out: Optional[Path] = typer.Option(
|
|
468
|
+
None, "--out", "-o", help="Write here instead of the workspace reports/ dir."
|
|
469
|
+
),
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Write an engagement report (recon + intel + playbooks) to a file."""
|
|
472
|
+
app_ctx = ctx.obj
|
|
473
|
+
canonical = normalise_format(fmt)
|
|
474
|
+
if canonical is None:
|
|
475
|
+
term.error(f"unknown format: {fmt}")
|
|
476
|
+
term.info("choose one: " + " | ".join(FORMATS))
|
|
477
|
+
raise typer.Exit(2)
|
|
478
|
+
|
|
479
|
+
program = program or _pick_program(app_ctx, default="")
|
|
480
|
+
if not program:
|
|
481
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
482
|
+
raise typer.Exit(2)
|
|
483
|
+
|
|
484
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
485
|
+
rep = report_build(ws)
|
|
486
|
+
if not rep.has_data:
|
|
487
|
+
term.warn(f"nothing to report for '{program}' — no recon gathered yet.")
|
|
488
|
+
term.info(f"run: huntkit recon <domain> -p {program}")
|
|
489
|
+
raise typer.Exit(0)
|
|
490
|
+
|
|
491
|
+
written = report_write(ws, rep, canonical, out)
|
|
492
|
+
|
|
493
|
+
term.banner(f"report — {program}")
|
|
494
|
+
intel = rep.intel
|
|
495
|
+
if intel.signals:
|
|
496
|
+
term.info(
|
|
497
|
+
"hosts by priority: "
|
|
498
|
+
+ " ".join(
|
|
499
|
+
f"[{p.style}]{p.label}[/{p.style}] {intel.summary[p.label]}"
|
|
500
|
+
for p in sorted(Priority, reverse=True)
|
|
501
|
+
if intel.summary[p.label]
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
term.ok(f"{canonical} report → {written.path}")
|
|
505
|
+
if written.note:
|
|
506
|
+
term.warn(written.note)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# --------------------------------------------------------------------------
|
|
510
|
+
# playbook (knowledge base)
|
|
511
|
+
# --------------------------------------------------------------------------
|
|
512
|
+
@app.command()
|
|
513
|
+
def playbook(
|
|
514
|
+
name: Optional[str] = typer.Argument(
|
|
515
|
+
None, help="Bug-class id (e.g. ssrf). Omit to list every playbook."
|
|
516
|
+
),
|
|
517
|
+
md: bool = typer.Option(False, "--md", help="Print raw Markdown (for reports / copy-paste)."),
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Show a bug-class playbook: how to find it, payloads, bypasses, refs."""
|
|
520
|
+
if not name:
|
|
521
|
+
_list_playbooks()
|
|
522
|
+
return
|
|
523
|
+
pb = get_playbook(name)
|
|
524
|
+
if pb is None:
|
|
525
|
+
term.error(f"unknown playbook '{name}'.")
|
|
526
|
+
term.info("pick one: " + ", ".join(p.id for p in all_playbooks()))
|
|
527
|
+
raise typer.Exit(2)
|
|
528
|
+
if md:
|
|
529
|
+
# raw markdown straight to stdout — no Rich wrapping/markup so long
|
|
530
|
+
# payload lines stay intact and it copy-pastes clean into a report
|
|
531
|
+
typer.echo(pb.to_markdown())
|
|
532
|
+
return
|
|
533
|
+
_render_playbook(pb)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _list_playbooks() -> None:
|
|
537
|
+
term.banner("HuntKit playbooks")
|
|
538
|
+
rows = [
|
|
539
|
+
(p.id, f"[{p.severity.style}]{p.severity.label}[/{p.severity.style}]", p.title, p.summary)
|
|
540
|
+
for p in all_playbooks()
|
|
541
|
+
]
|
|
542
|
+
term.print_table("Bug-class knowledge base", ["id", "severity", "class", "what it is"], rows)
|
|
543
|
+
term.info("detail: huntkit playbook <id> e.g. huntkit playbook ssrf")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _render_playbook(pb: Playbook) -> None:
|
|
547
|
+
term.banner(pb.title)
|
|
548
|
+
term.info(
|
|
549
|
+
f"severity [{pb.severity.style}]{pb.severity.label}[/{pb.severity.style}]"
|
|
550
|
+
f" · id [bold]{pb.id}[/bold]"
|
|
551
|
+
)
|
|
552
|
+
term.console.print(pb.summary)
|
|
553
|
+
term.console.print(f"[muted]when:[/muted] {escape(pb.when)}")
|
|
554
|
+
|
|
555
|
+
if pb.detection:
|
|
556
|
+
term.step("Detection")
|
|
557
|
+
for i, item in enumerate(pb.detection, 1):
|
|
558
|
+
term.bullet(f"{i}. {escape(item)}")
|
|
559
|
+
if pb.payloads:
|
|
560
|
+
term.step("Payloads")
|
|
561
|
+
for p in pb.payloads:
|
|
562
|
+
term.console.print(f" [muted]›[/muted] {escape(p)}")
|
|
563
|
+
if pb.bypasses:
|
|
564
|
+
term.step("Bypasses")
|
|
565
|
+
for b in pb.bypasses:
|
|
566
|
+
term.bullet(escape(b), "muted")
|
|
567
|
+
if pb.tools:
|
|
568
|
+
term.step("Go-to tools")
|
|
569
|
+
term.bullet(", ".join(pb.tools), "ok")
|
|
570
|
+
if pb.references:
|
|
571
|
+
term.step("References")
|
|
572
|
+
for r in pb.references:
|
|
573
|
+
term.bullet(f"{escape(r.name)} — {r.url}", "info")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# --------------------------------------------------------------------------
|
|
577
|
+
# advise (AI)
|
|
578
|
+
# --------------------------------------------------------------------------
|
|
579
|
+
@app.command()
|
|
580
|
+
def advise(
|
|
581
|
+
ctx: typer.Context,
|
|
582
|
+
program: Optional[str] = typer.Option(
|
|
583
|
+
None, "--program", "-p", help="Workspace to advise on (default: the only one)."
|
|
584
|
+
),
|
|
585
|
+
provider: Optional[str] = typer.Option(
|
|
586
|
+
None, "--provider", help="ollama | claude | openai (default: config ai.provider)."
|
|
587
|
+
),
|
|
588
|
+
model: Optional[str] = typer.Option(
|
|
589
|
+
None, "--model", "-m", help="Override the provider's default model."
|
|
590
|
+
),
|
|
591
|
+
yes: bool = typer.Option(
|
|
592
|
+
False, "--yes", "-y", help="Skip the cloud-send confirmation (for scripting)."
|
|
593
|
+
),
|
|
594
|
+
) -> None:
|
|
595
|
+
"""Ask a local or cloud LLM to prioritise the recon findings into a plan."""
|
|
596
|
+
app_ctx = ctx.obj
|
|
597
|
+
program = program or _pick_program(app_ctx, default="")
|
|
598
|
+
if not program:
|
|
599
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
600
|
+
raise typer.Exit(2)
|
|
601
|
+
|
|
602
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
603
|
+
report = run_analyze(ws)
|
|
604
|
+
if not report.signals:
|
|
605
|
+
term.warn(f"no findings to advise on for '{program}'.")
|
|
606
|
+
term.info(
|
|
607
|
+
f"gather & score first: huntkit recon <domain> -p {program} && huntkit analyze -p {program}"
|
|
608
|
+
)
|
|
609
|
+
raise typer.Exit(0)
|
|
610
|
+
|
|
611
|
+
name = (provider or app_ctx.config.ai.provider or "ollama").strip().lower()
|
|
612
|
+
prov = get_provider(name)
|
|
613
|
+
if prov is None:
|
|
614
|
+
term.error(f"unknown AI provider: {name}")
|
|
615
|
+
term.info("choose one: " + ", ".join(provider_names()))
|
|
616
|
+
raise typer.Exit(2)
|
|
617
|
+
|
|
618
|
+
if not prov.available(app_ctx.config):
|
|
619
|
+
term.error(f"provider '{name}' is not usable right now.")
|
|
620
|
+
if prov.is_local:
|
|
621
|
+
term.info(f"start it: ollama serve (endpoint {app_ctx.config.ai.endpoint})")
|
|
622
|
+
else:
|
|
623
|
+
term.info(f"set an API key: export HUNTKIT_APIKEY_{name.upper()}=<key>")
|
|
624
|
+
raise typer.Exit(1)
|
|
625
|
+
|
|
626
|
+
advisor = Advisor(prov, app_ctx.config)
|
|
627
|
+
model_id = advisor.resolve_model(model)
|
|
628
|
+
term.banner(f"advise — {program} ({name} / {model_id})")
|
|
629
|
+
|
|
630
|
+
def _approve(payload: str) -> bool:
|
|
631
|
+
return _confirm_cloud_send(name, prov.endpoint_host(app_ctx.config), model_id, payload, yes)
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
advice = advisor.advise(report, model=model, approve=_approve)
|
|
635
|
+
except ApprovalDenied:
|
|
636
|
+
term.warn("aborted — nothing was sent off your machine.")
|
|
637
|
+
raise typer.Exit(0) from None
|
|
638
|
+
except ProviderError as e:
|
|
639
|
+
term.error(str(e))
|
|
640
|
+
raise typer.Exit(1) from None
|
|
641
|
+
|
|
642
|
+
if advice.is_local:
|
|
643
|
+
term.info(f"local provider — data stayed on {advice.endpoint}")
|
|
644
|
+
term.step("Advisor")
|
|
645
|
+
term.console.print(advice.answer, markup=False, highlight=False)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _confirm_cloud_send(
|
|
649
|
+
provider: str, host: str, model: str, payload: str, assume_yes: bool
|
|
650
|
+
) -> bool:
|
|
651
|
+
"""Show exactly what will leave the machine, then require a clear yes."""
|
|
652
|
+
term.warn(f"'{provider}' is a CLOUD model — this sends your recon data to {host}.")
|
|
653
|
+
term.info(f"model: {model} destination: {host}")
|
|
654
|
+
term.console.rule("exact text that will be sent")
|
|
655
|
+
term.console.print(payload, markup=False, highlight=False)
|
|
656
|
+
term.console.rule()
|
|
657
|
+
if assume_yes:
|
|
658
|
+
term.info("--yes supplied — sending without a prompt.")
|
|
659
|
+
return True
|
|
660
|
+
return typer.confirm("Send this recon data to the cloud provider?", default=False)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# --------------------------------------------------------------------------
|
|
664
|
+
# secrets
|
|
665
|
+
# --------------------------------------------------------------------------
|
|
666
|
+
@app.command()
|
|
667
|
+
def secrets(
|
|
668
|
+
paths: list[Path] = typer.Argument(..., help="Files or directories to scan for leaks."),
|
|
669
|
+
endpoints: bool = typer.Option(
|
|
670
|
+
False, "--endpoints", "-e", help="Also extract JS endpoints/paths."
|
|
671
|
+
),
|
|
672
|
+
out: Optional[Path] = typer.Option(
|
|
673
|
+
None, "--out", "-o", help="Write findings (redacted) as JSON here."
|
|
674
|
+
),
|
|
675
|
+
top: int = typer.Option(50, "--top", "-n", help="Max rows to print."),
|
|
676
|
+
) -> None:
|
|
677
|
+
"""Scan gathered text (JS, configs, dumps) for leaked secrets and endpoints."""
|
|
678
|
+
missing = [str(p) for p in paths if not p.exists()]
|
|
679
|
+
if missing:
|
|
680
|
+
term.error("not found: " + ", ".join(missing))
|
|
681
|
+
raise typer.Exit(2)
|
|
682
|
+
|
|
683
|
+
hits = scan_paths(paths)
|
|
684
|
+
hits.sort(key=lambda h: (-int(h.severity), h.source, h.line))
|
|
685
|
+
|
|
686
|
+
term.banner("secrets scan")
|
|
687
|
+
if hits:
|
|
688
|
+
rows = [
|
|
689
|
+
(
|
|
690
|
+
f"[{h.severity.style}]{h.severity.label}[/{h.severity.style}]",
|
|
691
|
+
h.title,
|
|
692
|
+
f"{h.source}:{h.line}",
|
|
693
|
+
h.match,
|
|
694
|
+
)
|
|
695
|
+
for h in hits[:top]
|
|
696
|
+
]
|
|
697
|
+
term.print_table(
|
|
698
|
+
"Potential secrets (redacted)", ["severity", "type", "where", "match"], rows
|
|
699
|
+
)
|
|
700
|
+
term.warn(
|
|
701
|
+
f"{len(hits)} potential secret(s) — verify before reporting; values are redacted."
|
|
702
|
+
)
|
|
703
|
+
else:
|
|
704
|
+
term.ok("no secrets matched.")
|
|
705
|
+
|
|
706
|
+
eps: list[str] = []
|
|
707
|
+
if endpoints:
|
|
708
|
+
eps = endpoints_from_paths(paths)
|
|
709
|
+
if eps:
|
|
710
|
+
term.step(f"JS endpoints ({len(eps)})")
|
|
711
|
+
for ep in eps[:top]:
|
|
712
|
+
term.bullet(escape(ep), "info")
|
|
713
|
+
else:
|
|
714
|
+
term.info("no endpoints extracted.")
|
|
715
|
+
|
|
716
|
+
if out:
|
|
717
|
+
payload = {"secrets": [h.to_dict() for h in hits], "endpoints": eps}
|
|
718
|
+
fs.write_text(out, json.dumps(payload, indent=2))
|
|
719
|
+
term.ok(f"wrote {out}")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
# --------------------------------------------------------------------------
|
|
723
|
+
# fingerprint
|
|
724
|
+
# --------------------------------------------------------------------------
|
|
725
|
+
@app.command()
|
|
726
|
+
def fingerprint(
|
|
727
|
+
source: str = typer.Argument(
|
|
728
|
+
"-", help="Header file, or - for stdin (e.g. curl -sI URL | huntkit fingerprint)."
|
|
729
|
+
),
|
|
730
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write matches as JSON here."),
|
|
731
|
+
) -> None:
|
|
732
|
+
"""Identify CDN / WAF / cloud / tech from captured HTTP response headers."""
|
|
733
|
+
if source == "-":
|
|
734
|
+
text = sys.stdin.read()
|
|
735
|
+
else:
|
|
736
|
+
path = Path(source)
|
|
737
|
+
if not path.exists():
|
|
738
|
+
term.error(f"not found: {source}")
|
|
739
|
+
raise typer.Exit(2)
|
|
740
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
741
|
+
|
|
742
|
+
matches = fingerprint_headers(text)
|
|
743
|
+
term.banner("fingerprint")
|
|
744
|
+
if not matches:
|
|
745
|
+
term.warn("no CDN/WAF/cloud/tech fingerprints matched.")
|
|
746
|
+
term.info("feed response headers, e.g.: curl -sI https://target | huntkit fingerprint")
|
|
747
|
+
raise typer.Exit(0)
|
|
748
|
+
|
|
749
|
+
rows = [
|
|
750
|
+
(f"[{m.severity.style}]{m.kind}[/{m.severity.style}]", m.name, escape(m.evidence))
|
|
751
|
+
for m in matches
|
|
752
|
+
]
|
|
753
|
+
term.print_table("Fingerprints", ["kind", "vendor", "evidence"], rows)
|
|
754
|
+
if has_waf(matches):
|
|
755
|
+
wafs = ", ".join(m.name for m in matches if m.kind == "waf")
|
|
756
|
+
term.warn(
|
|
757
|
+
f"WAF present ({wafs}) — expect filtering; tune payloads/rate and "
|
|
758
|
+
"consider origin-IP discovery."
|
|
759
|
+
)
|
|
760
|
+
if out:
|
|
761
|
+
fs.write_text(out, json.dumps([m.to_dict() for m in matches], indent=2))
|
|
762
|
+
term.ok(f"wrote {out}")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# --------------------------------------------------------------------------
|
|
766
|
+
# enrich
|
|
767
|
+
# --------------------------------------------------------------------------
|
|
768
|
+
def _render_enrich_event(event: dict) -> None:
|
|
769
|
+
kind = event.get("event")
|
|
770
|
+
if kind == "favicon_start":
|
|
771
|
+
term.step(f"favicon: hashing {event['targets']} origin(s)")
|
|
772
|
+
elif kind == "favicon_done":
|
|
773
|
+
term.bullet(f"favicon: {event['hits']} hash(es)", "ok")
|
|
774
|
+
elif kind == "asn_start":
|
|
775
|
+
term.step(f"asn: resolving {event['targets']} host(s) via Team Cymru WHOIS")
|
|
776
|
+
elif kind == "asn_done":
|
|
777
|
+
term.bullet(f"asn: {event['hits']} mapping(s)", "ok")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@app.command()
|
|
781
|
+
def enrich(
|
|
782
|
+
ctx: typer.Context,
|
|
783
|
+
program: Optional[str] = typer.Option(
|
|
784
|
+
None, "--program", "-p", help="Workspace to enrich (default: the only one)."
|
|
785
|
+
),
|
|
786
|
+
favicon: bool = typer.Option(True, "--favicon/--no-favicon", help="Hash site favicons."),
|
|
787
|
+
asn: bool = typer.Option(True, "--asn/--no-asn", help="Look up ASN / BGP prefix per host."),
|
|
788
|
+
threads: int = typer.Option(10, "--threads", "-t", help="Concurrency."),
|
|
789
|
+
out: Optional[Path] = typer.Option(
|
|
790
|
+
None, "--out", "-o", help="Also write the report JSON here."
|
|
791
|
+
),
|
|
792
|
+
) -> None:
|
|
793
|
+
"""Add favicon hashes (Shodan pivot) and ASN/BGP origins to the recon surface."""
|
|
794
|
+
app_ctx = ctx.obj
|
|
795
|
+
program = program or _pick_program(app_ctx, default="")
|
|
796
|
+
if not program:
|
|
797
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
798
|
+
raise typer.Exit(2)
|
|
799
|
+
if not (favicon or asn):
|
|
800
|
+
term.error("nothing to do — enable at least one of --favicon / --asn.")
|
|
801
|
+
raise typer.Exit(2)
|
|
802
|
+
|
|
803
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
804
|
+
term.banner(f"enrich — {program}")
|
|
805
|
+
if asn:
|
|
806
|
+
term.info(
|
|
807
|
+
"note: ASN lookup sends each in-scope host's resolved IP to "
|
|
808
|
+
"whois.cymru.com (public third-party WHOIS)."
|
|
809
|
+
)
|
|
810
|
+
report = run_enrich(
|
|
811
|
+
ws, do_favicon=favicon, do_asn=asn, threads=threads, on_event=_render_enrich_event
|
|
812
|
+
)
|
|
813
|
+
_print_enrich(report, ws, out)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _print_enrich(report: EnrichReport, ws: Workspace, out: Optional[Path]) -> None:
|
|
817
|
+
favs = [f for f in report.favicons if f.ok]
|
|
818
|
+
if favs:
|
|
819
|
+
rows = [(escape(f.url), str(f.hash), f.shodan) for f in favs]
|
|
820
|
+
term.print_table("Favicon hashes", ["origin", "mmh3", "shodan pivot"], rows)
|
|
821
|
+
asns = [a for a in report.asns if a.ok]
|
|
822
|
+
if asns:
|
|
823
|
+
arows = [(escape(a.host), a.ip, f"AS{a.asn}", a.prefix, escape(a.as_name)) for a in asns]
|
|
824
|
+
term.print_table("ASN origins", ["host", "ip", "asn", "prefix", "network"], arows)
|
|
825
|
+
if report.unique_asns:
|
|
826
|
+
term.info(
|
|
827
|
+
f"{len(report.unique_asns)} unique ASN(s) to sweep: "
|
|
828
|
+
+ ", ".join(f"AS{a}" for a in report.unique_asns)
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
if not favs and not asns:
|
|
832
|
+
term.warn("no enrichment results — thin recon surface, or hosts unreachable.")
|
|
833
|
+
term.info(f"gather some first: huntkit recon <domain> -p {report.program}")
|
|
834
|
+
|
|
835
|
+
term.ok(
|
|
836
|
+
f"favicon: {report.favicon_hits}, asn: {report.asn_hits}. "
|
|
837
|
+
f"Report: {ws.path('scans', 'enrich.json')}"
|
|
838
|
+
)
|
|
839
|
+
if out:
|
|
840
|
+
fs.write_text(out, Path(ws.path("scans", "enrich.json")).read_text(encoding="utf-8"))
|
|
841
|
+
term.ok(f"wrote {out}")
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# --------------------------------------------------------------------------
|
|
845
|
+
# shots (screenshots)
|
|
846
|
+
# --------------------------------------------------------------------------
|
|
847
|
+
def _render_shots_event(event: dict) -> None:
|
|
848
|
+
kind = event.get("event")
|
|
849
|
+
if kind == "shots_start":
|
|
850
|
+
term.step(f"capturing {event['targets']} URL(s) with gowitness")
|
|
851
|
+
elif kind == "shots_done":
|
|
852
|
+
term.bullet(f"captured {event['captured']} screenshot(s)", "ok")
|
|
853
|
+
elif kind == "shots_error":
|
|
854
|
+
term.bullet(f"capture failed: {event['reason']}", "muted")
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
@app.command()
|
|
858
|
+
def shots(
|
|
859
|
+
ctx: typer.Context,
|
|
860
|
+
program: Optional[str] = typer.Option(
|
|
861
|
+
None, "--program", "-p", help="Workspace to screenshot (default: the only one)."
|
|
862
|
+
),
|
|
863
|
+
) -> None:
|
|
864
|
+
"""Screenshot every live in-scope URL (gowitness) for the report gallery."""
|
|
865
|
+
app_ctx = ctx.obj
|
|
866
|
+
program = program or _pick_program(app_ctx, default="")
|
|
867
|
+
if not program:
|
|
868
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
869
|
+
raise typer.Exit(2)
|
|
870
|
+
|
|
871
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
872
|
+
term.banner(f"shots — {program}")
|
|
873
|
+
summary = run_shots(ws, app_ctx.config, runner=app_ctx.runner, on_event=_render_shots_event)
|
|
874
|
+
_print_shots_summary(summary, ws)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _print_shots_summary(summary: ShotSummary, ws: Workspace) -> None:
|
|
878
|
+
if summary.targets == 0:
|
|
879
|
+
term.warn(f"no in-scope URLs to screenshot for '{summary.program}'.")
|
|
880
|
+
term.info(f"gather some first: huntkit recon <domain> -p {summary.program}")
|
|
881
|
+
raise typer.Exit(0)
|
|
882
|
+
if summary.captured == 0:
|
|
883
|
+
term.warn("no screenshots captured — is gowitness installed? huntkit doctor")
|
|
884
|
+
raise typer.Exit(0)
|
|
885
|
+
term.ok(f"{summary.captured}/{summary.targets} captured under {ws.path('reports', 'shots')}.")
|
|
886
|
+
term.info(f"embed them in a gallery: huntkit report -p {summary.program} -f html")
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
# --------------------------------------------------------------------------
|
|
890
|
+
# diff (snapshots over time)
|
|
891
|
+
# --------------------------------------------------------------------------
|
|
892
|
+
@app.command()
|
|
893
|
+
def diff(
|
|
894
|
+
ctx: typer.Context,
|
|
895
|
+
program: Optional[str] = typer.Option(
|
|
896
|
+
None, "--program", "-p", help="Workspace to diff (default: the only one)."
|
|
897
|
+
),
|
|
898
|
+
save_baseline: bool = typer.Option(
|
|
899
|
+
False, "--save", help="After diffing, save the current state as a new baseline."
|
|
900
|
+
),
|
|
901
|
+
against: Optional[str] = typer.Option(
|
|
902
|
+
None, "--against", help="Baseline snapshot id (default: the most recent)."
|
|
903
|
+
),
|
|
904
|
+
show_list: bool = typer.Option(False, "--list", help="List saved snapshots and exit."),
|
|
905
|
+
top: int = typer.Option(25, "--top", "-n", help="Max items to list per category."),
|
|
906
|
+
) -> None:
|
|
907
|
+
"""Show what changed since a saved snapshot — new subdomains, URLs, findings."""
|
|
908
|
+
app_ctx = ctx.obj
|
|
909
|
+
program = program or _pick_program(app_ctx, default="")
|
|
910
|
+
if not program:
|
|
911
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
912
|
+
raise typer.Exit(2)
|
|
913
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
914
|
+
|
|
915
|
+
if show_list:
|
|
916
|
+
_print_snapshots(ws, program)
|
|
917
|
+
raise typer.Exit(0)
|
|
918
|
+
|
|
919
|
+
baseline = find(ws, against) if against else latest(ws)
|
|
920
|
+
if against and baseline is None:
|
|
921
|
+
term.error(f"no snapshot '{against}' — see them with: huntkit diff -p {program} --list")
|
|
922
|
+
raise typer.Exit(2)
|
|
923
|
+
|
|
924
|
+
term.banner(f"diff — {program}")
|
|
925
|
+
if baseline is None:
|
|
926
|
+
if save_baseline:
|
|
927
|
+
snap = save(ws, capture(ws))
|
|
928
|
+
term.ok(f"no baseline yet — saved the current state as '{snap.stem}'.")
|
|
929
|
+
else:
|
|
930
|
+
term.warn("no baseline snapshot yet.")
|
|
931
|
+
term.info(f"set one: huntkit diff -p {program} --save")
|
|
932
|
+
raise typer.Exit(0)
|
|
933
|
+
|
|
934
|
+
report, now = current_vs(ws, baseline)
|
|
935
|
+
_print_diff(report, top)
|
|
936
|
+
if save_baseline:
|
|
937
|
+
save(ws, now)
|
|
938
|
+
term.ok(f"baseline updated → '{now.id}'.")
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _print_snapshots(ws: Workspace, program: str) -> None:
|
|
942
|
+
snaps = list_snapshots(ws)
|
|
943
|
+
term.banner(f"snapshots — {program}")
|
|
944
|
+
if not snaps:
|
|
945
|
+
term.warn("no snapshots saved yet.")
|
|
946
|
+
term.info(f"save one: huntkit diff -p {program} --save")
|
|
947
|
+
return
|
|
948
|
+
rows = [
|
|
949
|
+
(s.id, s.generated, str(s.counts["live"]), str(s.counts["urls"]), str(s.counts["findings"]))
|
|
950
|
+
for s in snaps
|
|
951
|
+
]
|
|
952
|
+
term.print_table("Saved snapshots", ["id", "generated", "live", "urls", "findings"], rows)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _print_diff(report: DiffReport, top: int) -> None:
|
|
956
|
+
term.info(f"baseline '{report.base_id}' → now: +{report.total_added} -{report.total_removed}")
|
|
957
|
+
if not report.has_changes:
|
|
958
|
+
term.ok(f"no changes since '{report.base_id}'.")
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
rows = [(c.name, f"+{len(c.added)}", f"-{len(c.removed)}") for c in report.changed]
|
|
962
|
+
term.print_table("Changed", ["category", "added", "removed"], rows)
|
|
963
|
+
|
|
964
|
+
if report.new_findings:
|
|
965
|
+
term.step(f"NEW findings ({len(report.new_findings)})")
|
|
966
|
+
for item in report.new_findings[:top]:
|
|
967
|
+
term.bullet(escape(item), "ok")
|
|
968
|
+
|
|
969
|
+
for c in report.changed:
|
|
970
|
+
if c.name == "findings" or not (c.added or c.removed):
|
|
971
|
+
continue
|
|
972
|
+
term.step(c.name)
|
|
973
|
+
for item in c.added[:top]:
|
|
974
|
+
term.bullet(f"+ {escape(item)}", "ok")
|
|
975
|
+
for item in c.removed[:top]:
|
|
976
|
+
term.bullet(f"- {escape(item)}", "muted")
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
# --------------------------------------------------------------------------
|
|
980
|
+
# dashboard (TUI)
|
|
981
|
+
# --------------------------------------------------------------------------
|
|
982
|
+
@app.command()
|
|
983
|
+
def dashboard(
|
|
984
|
+
ctx: typer.Context,
|
|
985
|
+
program: Optional[str] = typer.Option(
|
|
986
|
+
None, "--program", "-p", help="Workspace to show (default: the only one)."
|
|
987
|
+
),
|
|
988
|
+
top: int = typer.Option(10, "--top", "-n", help="Rows per table."),
|
|
989
|
+
watch: Optional[float] = typer.Option(
|
|
990
|
+
None, "--watch", "-W", help="Redraw every N seconds (Ctrl-C to stop)."
|
|
991
|
+
),
|
|
992
|
+
) -> None:
|
|
993
|
+
"""Paint a one-screen engagement dashboard (optionally live with --watch)."""
|
|
994
|
+
app_ctx = ctx.obj
|
|
995
|
+
program = program or _pick_program(app_ctx, default="")
|
|
996
|
+
if not program:
|
|
997
|
+
term.error("which workspace? pass -p <program> (none, or several, found).")
|
|
998
|
+
raise typer.Exit(2)
|
|
999
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
1000
|
+
|
|
1001
|
+
if watch and watch > 0:
|
|
1002
|
+
_watch_dashboard(ws, top, watch)
|
|
1003
|
+
return
|
|
1004
|
+
term.console.print(render_dashboard(ws, top=top))
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _watch_dashboard(ws: Workspace, top: int, interval: float) -> None: # pragma: no cover
|
|
1008
|
+
import time
|
|
1009
|
+
|
|
1010
|
+
from rich.live import Live
|
|
1011
|
+
|
|
1012
|
+
try:
|
|
1013
|
+
with Live(render_dashboard(ws, top=top), console=term.console, screen=True) as live:
|
|
1014
|
+
while True:
|
|
1015
|
+
time.sleep(interval)
|
|
1016
|
+
live.update(render_dashboard(ws, top=top))
|
|
1017
|
+
except KeyboardInterrupt:
|
|
1018
|
+
term.info("stopped.")
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# --------------------------------------------------------------------------
|
|
1022
|
+
# monitor (continuous re-sweep + webhook)
|
|
1023
|
+
# --------------------------------------------------------------------------
|
|
1024
|
+
def _render_monitor_event(event: dict) -> None:
|
|
1025
|
+
kind = event.get("event")
|
|
1026
|
+
if kind == "monitor_start":
|
|
1027
|
+
base = event.get("baseline") or "none"
|
|
1028
|
+
term.step(f"monitor {event['program']} (baseline: {base})")
|
|
1029
|
+
elif kind == "recon_begin":
|
|
1030
|
+
term.bullet("re-running recon…", "muted")
|
|
1031
|
+
elif kind == "notify":
|
|
1032
|
+
host = urllib.parse.urlsplit(event["webhook"]).hostname or "?"
|
|
1033
|
+
term.warn(f"egress: sending the delta to webhook host '{host}'")
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
@app.command()
|
|
1037
|
+
def monitor(
|
|
1038
|
+
ctx: typer.Context,
|
|
1039
|
+
domain: str = typer.Argument(..., help="Seed domain to re-sweep."),
|
|
1040
|
+
program: Optional[str] = typer.Option(
|
|
1041
|
+
None, "--program", "-p", help="Workspace to use (default: the domain)."
|
|
1042
|
+
),
|
|
1043
|
+
webhook: Optional[str] = typer.Option(
|
|
1044
|
+
None,
|
|
1045
|
+
"--webhook",
|
|
1046
|
+
"-u",
|
|
1047
|
+
help="Slack/Discord incoming webhook; receives the delta (opt-in egress).",
|
|
1048
|
+
),
|
|
1049
|
+
notify_on: str = typer.Option(
|
|
1050
|
+
NOTIFY_FINDINGS, "--notify-on", help="findings | changes — what fires the webhook."
|
|
1051
|
+
),
|
|
1052
|
+
interval: Optional[float] = typer.Option(
|
|
1053
|
+
None, "--interval", "-i", help="Re-sweep every N minutes (Ctrl-C to stop)."
|
|
1054
|
+
),
|
|
1055
|
+
) -> None:
|
|
1056
|
+
"""Re-sweep a program, diff against the last snapshot, notify on new findings."""
|
|
1057
|
+
app_ctx = ctx.obj
|
|
1058
|
+
try:
|
|
1059
|
+
domain = v.normalise_domain(domain)
|
|
1060
|
+
except v.ValidationError:
|
|
1061
|
+
term.error(f"not a valid domain: {domain}")
|
|
1062
|
+
raise typer.Exit(2) from None
|
|
1063
|
+
|
|
1064
|
+
if notify_on not in (NOTIFY_FINDINGS, NOTIFY_CHANGES):
|
|
1065
|
+
term.error(f"--notify-on must be '{NOTIFY_FINDINGS}' or '{NOTIFY_CHANGES}'.")
|
|
1066
|
+
raise typer.Exit(2)
|
|
1067
|
+
|
|
1068
|
+
program = program or _pick_program(app_ctx, default=domain)
|
|
1069
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
1070
|
+
|
|
1071
|
+
# monitor re-runs recon, so honour scope exactly as `recon` does.
|
|
1072
|
+
if ws.has_scope():
|
|
1073
|
+
if not ws.in_scope(domain):
|
|
1074
|
+
term.error(f"{domain} is out of scope for '{program}'.")
|
|
1075
|
+
term.info(f"add it: huntkit init {program} -s {domain}")
|
|
1076
|
+
raise typer.Exit(2)
|
|
1077
|
+
else:
|
|
1078
|
+
ws.set_scope([f"*.{domain}"], [])
|
|
1079
|
+
term.warn(f"no scope set — limiting to *.{domain}. Refine with `huntkit init`.")
|
|
1080
|
+
|
|
1081
|
+
def _recon() -> ReconSummary:
|
|
1082
|
+
return run_recon(
|
|
1083
|
+
ws,
|
|
1084
|
+
app_ctx.config,
|
|
1085
|
+
domain,
|
|
1086
|
+
runner=app_ctx.runner,
|
|
1087
|
+
registry=app_ctx.registry,
|
|
1088
|
+
on_event=_render_event,
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
def _sweep() -> MonitorResult:
|
|
1092
|
+
return run_monitor(
|
|
1093
|
+
ws,
|
|
1094
|
+
app_ctx.config,
|
|
1095
|
+
domain,
|
|
1096
|
+
webhook=webhook,
|
|
1097
|
+
notify_on=notify_on,
|
|
1098
|
+
recon=_recon,
|
|
1099
|
+
on_event=_render_monitor_event,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
if interval and interval > 0:
|
|
1103
|
+
_watch_monitor(_sweep, interval)
|
|
1104
|
+
return
|
|
1105
|
+
term.banner(f"monitor {domain} -> {program}")
|
|
1106
|
+
_print_monitor(_sweep())
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _print_monitor(result: MonitorResult) -> None:
|
|
1110
|
+
if result.first_run or result.report is None:
|
|
1111
|
+
term.ok(
|
|
1112
|
+
f"first run — saved baseline '{result.snapshot.id}'. Re-run later to see the delta."
|
|
1113
|
+
)
|
|
1114
|
+
return
|
|
1115
|
+
_print_diff(result.report, top=25)
|
|
1116
|
+
if result.notified:
|
|
1117
|
+
term.ok("webhook notified.")
|
|
1118
|
+
elif result.report.has_changes:
|
|
1119
|
+
term.info("changes found; no webhook set (pass --webhook to get pinged).")
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _watch_monitor(sweep, interval_minutes: float) -> None: # pragma: no cover
|
|
1123
|
+
import time
|
|
1124
|
+
|
|
1125
|
+
try:
|
|
1126
|
+
while True:
|
|
1127
|
+
_print_monitor(sweep())
|
|
1128
|
+
term.info(f"next sweep in {interval_minutes} min (Ctrl-C to stop)…")
|
|
1129
|
+
time.sleep(interval_minutes * 60)
|
|
1130
|
+
except KeyboardInterrupt:
|
|
1131
|
+
term.info("stopped.")
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
# --------------------------------------------------------------------------
|
|
1135
|
+
# clean
|
|
1136
|
+
# --------------------------------------------------------------------------
|
|
1137
|
+
@app.command()
|
|
1138
|
+
def clean(
|
|
1139
|
+
ctx: typer.Context,
|
|
1140
|
+
program: str = typer.Option(..., "--program", "-p", help="Workspace to clean."),
|
|
1141
|
+
state: bool = typer.Option(False, "--state", help="Reset stage state (forces a full re-run)."),
|
|
1142
|
+
cache: bool = typer.Option(False, "--cache", help="Clear the tool-output cache."),
|
|
1143
|
+
all_: bool = typer.Option(False, "--all", help="Both of the above."),
|
|
1144
|
+
) -> None:
|
|
1145
|
+
"""Reset stage state and/or clear cached tool output."""
|
|
1146
|
+
app_ctx = ctx.obj
|
|
1147
|
+
if all_:
|
|
1148
|
+
state = cache = True
|
|
1149
|
+
if not (state or cache):
|
|
1150
|
+
state = True # sensible default
|
|
1151
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
1152
|
+
if state:
|
|
1153
|
+
ws.state.reset()
|
|
1154
|
+
term.ok(f"reset stage state for '{program}'.")
|
|
1155
|
+
if cache:
|
|
1156
|
+
removed = app_ctx.cache.clear()
|
|
1157
|
+
term.ok(f"cleared {removed} cache entries.")
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def run(argv: Optional[list[str]] = None) -> None:
|
|
1161
|
+
"""Console-script / ``python -m huntkit`` entry point."""
|
|
1162
|
+
app(args=argv)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1166
|
+
run()
|