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
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()