huntkit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. huntkit/__init__.py +4 -0
  2. huntkit/__main__.py +8 -0
  3. huntkit/ai/__init__.py +42 -0
  4. huntkit/ai/advisor.py +133 -0
  5. huntkit/ai/providers.py +200 -0
  6. huntkit/app/__init__.py +13 -0
  7. huntkit/app/config_cmd.py +65 -0
  8. huntkit/app/context.py +50 -0
  9. huntkit/app/main.py +1166 -0
  10. huntkit/app/plugins_cmd.py +79 -0
  11. huntkit/app/workspace_cmd.py +62 -0
  12. huntkit/core/__init__.py +6 -0
  13. huntkit/core/cache.py +67 -0
  14. huntkit/core/config.py +272 -0
  15. huntkit/core/logger.py +66 -0
  16. huntkit/core/pacing.py +71 -0
  17. huntkit/core/runner.py +117 -0
  18. huntkit/core/state.py +154 -0
  19. huntkit/core/workspace.py +145 -0
  20. huntkit/enrich/__init__.py +39 -0
  21. huntkit/enrich/asn.py +139 -0
  22. huntkit/enrich/engine.py +167 -0
  23. huntkit/enrich/favicon.py +91 -0
  24. huntkit/enrich/mmh3.py +69 -0
  25. huntkit/fingerprint/__init__.py +26 -0
  26. huntkit/fingerprint/engine.py +83 -0
  27. huntkit/fingerprint/rules.py +170 -0
  28. huntkit/history/__init__.py +38 -0
  29. huntkit/history/diff.py +95 -0
  30. huntkit/history/store.py +133 -0
  31. huntkit/intel/__init__.py +48 -0
  32. huntkit/intel/engine.py +233 -0
  33. huntkit/intel/signals.py +826 -0
  34. huntkit/knowledge/__init__.py +27 -0
  35. huntkit/knowledge/playbooks.py +452 -0
  36. huntkit/monitor/__init__.py +24 -0
  37. huntkit/monitor/runner.py +111 -0
  38. huntkit/notify/__init__.py +16 -0
  39. huntkit/notify/webhook.py +73 -0
  40. huntkit/pipeline.py +302 -0
  41. huntkit/plugins/__init__.py +36 -0
  42. huntkit/plugins/amass.py +25 -0
  43. huntkit/plugins/arjun.py +18 -0
  44. huntkit/plugins/assetfinder.py +18 -0
  45. huntkit/plugins/base.py +220 -0
  46. huntkit/plugins/chaos.py +20 -0
  47. huntkit/plugins/dalfox.py +24 -0
  48. huntkit/plugins/dnsx.py +19 -0
  49. huntkit/plugins/ffuf.py +34 -0
  50. huntkit/plugins/findomain.py +18 -0
  51. huntkit/plugins/gau.py +18 -0
  52. huntkit/plugins/hakrawler.py +19 -0
  53. huntkit/plugins/httpx.py +30 -0
  54. huntkit/plugins/katana.py +23 -0
  55. huntkit/plugins/naabu.py +22 -0
  56. huntkit/plugins/nmap.py +39 -0
  57. huntkit/plugins/nuclei.py +27 -0
  58. huntkit/plugins/registry.py +117 -0
  59. huntkit/plugins/subfinder.py +21 -0
  60. huntkit/plugins/waybackurls.py +29 -0
  61. huntkit/plugins/whatweb.py +19 -0
  62. huntkit/py.typed +0 -0
  63. huntkit/report/__init__.py +103 -0
  64. huntkit/report/builder.py +145 -0
  65. huntkit/report/render.py +323 -0
  66. huntkit/scanner/__init__.py +28 -0
  67. huntkit/scanner/runner.py +239 -0
  68. huntkit/secrets/__init__.py +34 -0
  69. huntkit/secrets/patterns.py +172 -0
  70. huntkit/secrets/scanner.py +133 -0
  71. huntkit/shots/__init__.py +29 -0
  72. huntkit/shots/runner.py +186 -0
  73. huntkit/tui/__init__.py +13 -0
  74. huntkit/tui/dashboard.py +160 -0
  75. huntkit/utils/__init__.py +6 -0
  76. huntkit/utils/filesystem.py +109 -0
  77. huntkit/utils/process.py +104 -0
  78. huntkit/utils/severity.py +49 -0
  79. huntkit/utils/terminal.py +88 -0
  80. huntkit/utils/validators.py +99 -0
  81. huntkit-0.3.0.dist-info/METADATA +340 -0
  82. huntkit-0.3.0.dist-info/RECORD +86 -0
  83. huntkit-0.3.0.dist-info/WHEEL +5 -0
  84. huntkit-0.3.0.dist-info/entry_points.txt +2 -0
  85. huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  86. huntkit-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,186 @@
1
+ r"""Screenshots — capture a visual of every live URL for triage and the report.
2
+
3
+ A wall of screenshots is how a hunter eyeballs a large surface fast: default
4
+ landing pages, login portals, stack-trace error screens, and abandoned admin
5
+ panels all jump out of a grid in a way a list of URLs never does. This layer
6
+ drives a headless screenshotter (``gowitness`` by default) over the live URLs
7
+ recon gathered and records a manifest the report turns into a gallery.
8
+
9
+ It follows the scanner's shape: targets are read from the workspace's own,
10
+ already scope-filtered result files *and* re-checked against scope here, so the
11
+ camera never points at a host recon would have refused. The actual capture is
12
+ injected (``CaptureFn``) so the orchestration — target collection, the
13
+ manifest, scope enforcement — is tested without a browser or ``gowitness`` on
14
+ the box.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import time
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Optional
25
+ from urllib.parse import urlparse
26
+
27
+ from ..core.config import Config
28
+ from ..core.runner import CommandRunner
29
+ from ..core.workspace import Workspace
30
+
31
+ # Where captures land (under reports/ so a self-contained report can embed
32
+ # them) and where the manifest is written.
33
+ SHOTS_DIR = "reports/shots"
34
+ SHOTS_JSON = "scans/screenshots.json"
35
+ SHOTS_STATE = "shots"
36
+ TARGET_FILES = ["recon/live.txt", "urls/urls.txt"]
37
+
38
+ # A capture backend: given the URLs and an output directory, take the shots and
39
+ # return a mapping of url -> produced image filename (basename within the dir).
40
+ # Any URL left out of the mapping is reported as not captured.
41
+ CaptureFn = Callable[[list[str], Path], dict[str, str]]
42
+
43
+ EventFn = Callable[[dict], None]
44
+
45
+
46
+ @dataclass
47
+ class Shot:
48
+ url: str
49
+ file: str = "" # path relative to the workspace root, or "" if not captured
50
+ error: str = ""
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ return bool(self.file)
55
+
56
+ def to_dict(self) -> dict:
57
+ return {"url": self.url, "file": self.file, "error": self.error}
58
+
59
+
60
+ @dataclass
61
+ class ShotSummary:
62
+ program: str
63
+ targets: int = 0
64
+ shots: list[Shot] = field(default_factory=list)
65
+
66
+ @property
67
+ def captured(self) -> int:
68
+ return sum(1 for s in self.shots if s.ok)
69
+
70
+
71
+ def shot_targets(ws: Workspace) -> list[str]:
72
+ """Candidate URLs from the workspace, deduped and scope-checked."""
73
+ seen: dict[str, None] = {}
74
+ for rel in TARGET_FILES:
75
+ for url in ws.read_lines(rel):
76
+ host = _host_of(url)
77
+ if host and ws.in_scope(host):
78
+ seen[url] = None
79
+ return sorted(seen)
80
+
81
+
82
+ def _host_of(url: str) -> Optional[str]:
83
+ parsed = urlparse(url if "://" in url else f"//{url}", scheme="http")
84
+ return parsed.hostname
85
+
86
+
87
+ def run_shots(
88
+ ws: Workspace,
89
+ config: Config,
90
+ *,
91
+ runner: Optional[CommandRunner] = None,
92
+ capture: Optional[CaptureFn] = None,
93
+ on_event: Optional[EventFn] = None,
94
+ ) -> ShotSummary:
95
+ """Capture the live URLs and write ``scans/screenshots.json``."""
96
+ emit: EventFn = on_event or (lambda _e: None)
97
+ runner = runner or CommandRunner(config)
98
+ capture = capture or gowitness_capture(runner)
99
+
100
+ targets = shot_targets(ws)
101
+ summary = ShotSummary(program=ws.program, targets=len(targets))
102
+ ws.state.start(SHOTS_STATE)
103
+ emit({"event": "shots_start", "targets": len(targets)})
104
+
105
+ if not targets:
106
+ ws.state.skip(SHOTS_STATE)
107
+ emit({"event": "shots_done", "captured": 0})
108
+ return summary
109
+
110
+ outdir = Path(ws.path(SHOTS_DIR))
111
+ outdir.mkdir(parents=True, exist_ok=True)
112
+ try:
113
+ produced = capture(targets, outdir)
114
+ except Exception as exc: # noqa: BLE001 — a backend crash is recorded, not raised
115
+ ws.state.skip(SHOTS_STATE)
116
+ emit({"event": "shots_error", "reason": str(exc) or type(exc).__name__})
117
+ return summary
118
+
119
+ for url in targets:
120
+ name = produced.get(url)
121
+ if name:
122
+ summary.shots.append(Shot(url=url, file=f"{SHOTS_DIR}/{name}"))
123
+ else:
124
+ summary.shots.append(Shot(url=url, error="not captured"))
125
+
126
+ _persist(ws, summary)
127
+ ws.state.done(SHOTS_STATE, targets=len(targets), captured=summary.captured)
128
+ emit({"event": "shots_done", "captured": summary.captured})
129
+ return summary
130
+
131
+
132
+ def _persist(ws: Workspace, summary: ShotSummary) -> None:
133
+ payload = {
134
+ "program": summary.program,
135
+ "generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
136
+ "dir": SHOTS_DIR,
137
+ "shots": [s.to_dict() for s in summary.shots if s.ok],
138
+ }
139
+ ws.path(SHOTS_JSON).write_text(json.dumps(payload, indent=2), encoding="utf-8")
140
+
141
+
142
+ def gowitness_capture(runner: CommandRunner) -> CaptureFn:
143
+ """A :data:`CaptureFn` backed by ``gowitness`` (best-effort, version-tolerant).
144
+
145
+ gowitness is driven over a URL list into ``outdir``; afterwards the
146
+ directory is scanned for the images it produced and each is paired with the
147
+ URL whose host appears in its filename, falling back to positional order
148
+ when the counts line up. The whole capture is skipped cleanly when the
149
+ binary is absent, so a box without gowitness still runs the rest of HuntKit.
150
+ """
151
+
152
+ def capture(urls: list[str], outdir: Path) -> dict[str, str]: # pragma: no cover
153
+ if not runner.available("gowitness"):
154
+ return {}
155
+ listing = outdir / "_targets.txt"
156
+ listing.write_text("\n".join(urls) + "\n", encoding="utf-8")
157
+ # gowitness v3: `scan file -f <list> -s <screenshot-dir> --write-none`
158
+ runner.run(
159
+ "gowitness",
160
+ ["scan", "file", "-f", str(listing), "-s", str(outdir), "--write-none"],
161
+ )
162
+ return _pair_images(urls, outdir)
163
+
164
+ return capture
165
+
166
+
167
+ def _pair_images(urls: list[str], outdir: Path) -> dict[str, str]: # pragma: no cover
168
+ """Pair produced image files to the URLs that made them (best effort)."""
169
+ images = sorted(
170
+ (p for p in outdir.iterdir() if p.suffix.lower() in (".png", ".jpeg", ".jpg")),
171
+ key=lambda p: p.stat().st_mtime,
172
+ )
173
+ names = [p.name for p in images]
174
+ out: dict[str, str] = {}
175
+ unmatched = list(names)
176
+ for url in urls:
177
+ host = _host_of(url) or ""
178
+ hit = next((n for n in unmatched if host and host.replace(".", "-") in n), None)
179
+ hit = hit or next((n for n in unmatched if host and host in n), None)
180
+ if hit:
181
+ out[url] = hit
182
+ unmatched.remove(hit)
183
+ # positional fallback only when nothing matched by name but counts align
184
+ if not out and len(names) == len(urls):
185
+ out = dict(zip(urls, names, strict=True))
186
+ return out
@@ -0,0 +1,13 @@
1
+ """Terminal UI layer — a one-screen engagement dashboard.
2
+
3
+ Built on Rich (already a runtime dependency); no TUI framework is added.
4
+ :func:`render_dashboard` is a pure function returning a Rich renderable, so the
5
+ view is unit-tested by rendering to a recording console and the live ``--watch``
6
+ loop is a thin wrapper around it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .dashboard import render_dashboard
12
+
13
+ __all__ = ["render_dashboard"]
@@ -0,0 +1,160 @@
1
+ """A one-screen engagement dashboard, composed from what a workspace holds.
2
+
3
+ `huntkit dashboard -p <prog>` paints the whole picture on a single screen —
4
+ priority breakdown, recon surface, the top scored hosts and attack paths, the
5
+ notable exposures, enrichment highlights, and when the surface last changed.
6
+ With ``--watch`` it redraws on an interval, so a long recon in one terminal can
7
+ be watched live in another.
8
+
9
+ Built entirely on Rich (already a runtime dependency) — no TUI framework is
10
+ added. :func:`render_dashboard` is a pure function returning a Rich renderable,
11
+ so it is unit-tested by rendering to a recording console, and the live loop
12
+ stays a thin wrapper around it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+
19
+ from rich import box
20
+ from rich.console import Group, RenderableType
21
+ from rich.panel import Panel
22
+ from rich.table import Table
23
+ from rich.text import Text
24
+
25
+ from ..core.workspace import Workspace
26
+ from ..history import latest
27
+ from ..intel import Priority
28
+ from ..report import build as report_build
29
+ from ..report.builder import Report
30
+ from ..utils.severity import Severity
31
+
32
+ _ENRICH_JSON = "scans/enrich.json"
33
+
34
+
35
+ def _header(report: Report) -> Panel:
36
+ body = Text()
37
+ body.append(report.program, style="bold cyan")
38
+ scope = ", ".join(report.scope_in) or "—"
39
+ body.append(f"\nin scope: {scope}", style="dim")
40
+ if report.scope_out:
41
+ body.append(f"\nout of scope: {', '.join(report.scope_out)}", style="dim")
42
+ return Panel(body, title="HuntKit", title_align="left", box=box.ROUNDED)
43
+
44
+
45
+ def _priority_bar(report: Report) -> Panel:
46
+ t = Table.grid(expand=True, padding=(0, 2))
47
+ labels = [p.label for p in sorted(Priority, reverse=True)]
48
+ for _ in labels:
49
+ t.add_column(justify="center", ratio=1)
50
+ counts = report.intel.summary
51
+ t.add_row(*[Text(str(counts.get(lbl, 0)), style=f"bold {lbl}") for lbl in labels])
52
+ t.add_row(*[Text(lbl, style="dim") for lbl in labels])
53
+ sig = len(report.intel.signals)
54
+ hosts = len(report.intel.hosts)
55
+ return Panel(t, title=f"priority · {sig} signals / {hosts} hosts", title_align="left")
56
+
57
+
58
+ def _recon_bar(report: Report) -> Panel:
59
+ t = Table.grid(expand=True, padding=(0, 2))
60
+ items = list(report.recon.items())
61
+ for _ in items:
62
+ t.add_column(justify="center", ratio=1)
63
+ t.add_row(*[Text(str(n), style="bold") for _, n in items])
64
+ t.add_row(*[Text(label, style="dim") for label, _ in items])
65
+ return Panel(t, title="recon surface", title_align="left")
66
+
67
+
68
+ def _hosts_table(report: Report, top: int) -> Table:
69
+ t = Table(title="top hosts", title_justify="left", box=box.SIMPLE, expand=True)
70
+ t.add_column("host", overflow="fold")
71
+ t.add_column("pri")
72
+ t.add_column("score", justify="right")
73
+ t.add_column("sig", justify="right")
74
+ t.add_column("playbooks", overflow="fold")
75
+ for h in report.intel.hosts[:top]:
76
+ t.add_row(
77
+ h.host,
78
+ Text(h.priority.label, style=h.priority.style),
79
+ str(h.score),
80
+ str(len(h.signals)),
81
+ ", ".join(h.playbooks[:3]) or "—",
82
+ )
83
+ return t
84
+
85
+
86
+ def _paths_table(report: Report, top: int) -> Table:
87
+ t = Table(title="attack paths", title_justify="left", box=box.SIMPLE, expand=True)
88
+ t.add_column("playbook", overflow="fold")
89
+ t.add_column("sev")
90
+ t.add_column("hosts", justify="right")
91
+ for p in report.intel.attack_paths()[:top]:
92
+ t.add_row(p.name, Text(p.severity.label, style=p.severity.style), str(len(p.hosts)))
93
+ return t
94
+
95
+
96
+ def _notable_table(report: Report, top: int) -> Table:
97
+ t = Table(title="notable exposures", title_justify="left", box=box.SIMPLE, expand=True)
98
+ t.add_column("sev")
99
+ t.add_column("host", overflow="fold")
100
+ t.add_column("finding", overflow="fold")
101
+ hot = sorted(
102
+ (s for s in report.intel.signals if int(s.severity) >= Severity.HIGH),
103
+ key=lambda s: (-int(s.severity), s.host),
104
+ )
105
+ for s in hot[:top]:
106
+ t.add_row(Text(s.severity.label, style=s.severity.style), s.host, s.title)
107
+ return t
108
+
109
+
110
+ def _enrich_panel(ws: Workspace) -> Panel | None:
111
+ path = ws.path(_ENRICH_JSON)
112
+ if not path.exists():
113
+ return None
114
+ try:
115
+ data = json.loads(path.read_text(encoding="utf-8"))
116
+ except (ValueError, OSError):
117
+ return None
118
+ favs = [f for f in data.get("favicons", []) if f.get("hash") is not None]
119
+ asns = [a for a in data.get("asns", []) if a.get("asn")]
120
+ if not favs and not asns:
121
+ return None
122
+ body = Text()
123
+ body.append(f"favicons hashed: {len(favs)}\n", style="bold")
124
+ unique_asns = sorted({a["asn"] for a in asns}, key=lambda s: int(s)) if asns else []
125
+ body.append(f"ASNs: {len(asns)} host(s) across {len(unique_asns)} network(s)", style="bold")
126
+ if unique_asns:
127
+ body.append(" " + ", ".join(f"AS{a}" for a in unique_asns[:8]), style="dim")
128
+ return Panel(body, title="enrichment", title_align="left")
129
+
130
+
131
+ def _snapshot_line(ws: Workspace) -> Text:
132
+ snap = latest(ws)
133
+ if snap is None:
134
+ return Text("no snapshot yet — `huntkit diff --save` to set a baseline", style="dim")
135
+ return Text(
136
+ f"last snapshot {snap.id} · live {snap.counts['live']} · "
137
+ f"urls {snap.counts['urls']} · findings {snap.counts['findings']}",
138
+ style="dim",
139
+ )
140
+
141
+
142
+ def render_dashboard(ws: Workspace, *, top: int = 10) -> RenderableType:
143
+ """Compose the one-screen dashboard for a workspace (pure; no I/O beyond reads)."""
144
+ report = report_build(ws)
145
+ parts: list[RenderableType] = [
146
+ _header(report),
147
+ _priority_bar(report),
148
+ _recon_bar(report),
149
+ ]
150
+ if report.intel.hosts:
151
+ parts.append(_hosts_table(report, top))
152
+ if report.intel.attack_paths():
153
+ parts.append(_paths_table(report, top))
154
+ if any(int(s.severity) >= Severity.HIGH for s in report.intel.signals):
155
+ parts.append(_notable_table(report, top))
156
+ enrich = _enrich_panel(ws)
157
+ if enrich is not None:
158
+ parts.append(enrich)
159
+ parts.append(_snapshot_line(ws))
160
+ return Group(*parts)
@@ -0,0 +1,6 @@
1
+ """Low-level, dependency-light helpers shared across HuntKit.
2
+
3
+ Nothing in `utils` may import from `huntkit.core`, `huntkit.recon`, etc. —
4
+ it is the bottom of the dependency graph so it can be reused everywhere
5
+ without cycles.
6
+ """
@@ -0,0 +1,109 @@
1
+ """Filesystem helpers: atomic writes, deduped line sets, safe joins.
2
+
3
+ All text I/O is UTF-8 with newline normalisation so results are identical on
4
+ Windows and Linux (the original tool produced cp1252 mojibake on Windows).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tempfile
11
+ import time
12
+ from collections.abc import Iterable
13
+ from pathlib import Path
14
+
15
+ ENCODING = "utf-8"
16
+
17
+
18
+ def _replace_with_retry(src: str, dst: Path, attempts: int = 5) -> None:
19
+ """os.replace, retrying transient Windows locks.
20
+
21
+ On Windows an antivirus/indexer can briefly hold a freshly written file
22
+ open, making os.replace fail with PermissionError (WinError 5) even in a
23
+ single-threaded run. A few short retries make atomic writes reliable.
24
+ """
25
+ for i in range(attempts):
26
+ try:
27
+ os.replace(src, dst)
28
+ return
29
+ except PermissionError:
30
+ if i == attempts - 1:
31
+ raise
32
+ time.sleep(0.05 * (i + 1))
33
+
34
+
35
+ def ensure_dir(path: Path) -> Path:
36
+ path.mkdir(parents=True, exist_ok=True)
37
+ return path
38
+
39
+
40
+ def read_text(path: Path, default: str = "") -> str:
41
+ if not path.exists():
42
+ return default
43
+ return path.read_text(encoding=ENCODING)
44
+
45
+
46
+ def write_text(path: Path, data: str) -> None:
47
+ """Atomically write text: write to a temp file, then replace.
48
+
49
+ Prevents half-written files if the process is interrupted mid-write —
50
+ important for state.json and long result sets.
51
+ """
52
+ ensure_dir(path.parent)
53
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
54
+ try:
55
+ with os.fdopen(fd, "w", encoding=ENCODING, newline="\n") as fh:
56
+ fh.write(data)
57
+ _replace_with_retry(tmp, path)
58
+ finally:
59
+ if os.path.exists(tmp):
60
+ try:
61
+ os.unlink(tmp)
62
+ except OSError: # pragma: no cover - best-effort cleanup
63
+ pass
64
+
65
+
66
+ def read_lines(path: Path) -> list[str]:
67
+ if not path.exists():
68
+ return []
69
+ return [ln.strip() for ln in read_text(path).splitlines() if ln.strip()]
70
+
71
+
72
+ def append_unique(path: Path, items: Iterable[str]) -> int:
73
+ """Merge items into a sorted, unique line file. Returns count of new lines.
74
+
75
+ This is the `anew`-style primitive the whole recon pipeline relies on to
76
+ never store the same asset twice.
77
+ """
78
+ incoming = {i.strip() for i in items if i and i.strip()}
79
+ if not incoming and not path.exists():
80
+ return 0
81
+ existing = set(read_lines(path))
82
+ new = incoming - existing
83
+ if not new and path.exists():
84
+ return 0
85
+ merged = sorted(existing | incoming)
86
+ write_text(path, "\n".join(merged) + ("\n" if merged else ""))
87
+ return len(new)
88
+
89
+
90
+ def count_lines(path: Path) -> int:
91
+ return len(read_lines(path))
92
+
93
+
94
+ def safe_join(base: Path, *parts: str) -> Path:
95
+ """Join under `base`, refusing any result that escapes it (traversal guard)."""
96
+ base = base.resolve()
97
+ target = base.joinpath(*parts).resolve()
98
+ if base != target and base not in target.parents:
99
+ raise ValueError(f"path escapes workspace: {target}")
100
+ return target
101
+
102
+
103
+ def human_size(num_bytes: int) -> str:
104
+ size = float(num_bytes)
105
+ for unit in ("B", "KB", "MB", "GB", "TB"):
106
+ if size < 1024 or unit == "TB":
107
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
108
+ size /= 1024
109
+ return f"{size:.1f}TB"
@@ -0,0 +1,104 @@
1
+ """Low-level subprocess execution.
2
+
3
+ A single, safe choke-point for running external binaries. Everything is
4
+ passed as an argv list (never a shell string) so there is no shell-injection
5
+ surface, even though HuntKit forwards user-controlled domains to tools.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+
17
+ @dataclass
18
+ class ProcResult:
19
+ """Outcome of a single command execution."""
20
+
21
+ code: int
22
+ stdout: str
23
+ stderr: str
24
+ argv: list[str]
25
+ duration: float = 0.0
26
+ timed_out: bool = False
27
+
28
+ @property
29
+ def ok(self) -> bool:
30
+ return self.code == 0
31
+
32
+ @property
33
+ def lines(self) -> list[str]:
34
+ return [ln for ln in self.stdout.splitlines() if ln.strip()]
35
+
36
+ def __bool__(self) -> bool: # allow `if result:`
37
+ return self.ok
38
+
39
+
40
+ # Sentinels for failures that never reached the binary.
41
+ NOT_FOUND = 127
42
+ TIMEOUT = 124
43
+
44
+
45
+ def which(binary: str) -> Optional[str]:
46
+ return shutil.which(binary)
47
+
48
+
49
+ def execute(
50
+ argv: Sequence[str],
51
+ *,
52
+ timeout: Optional[int] = None,
53
+ stdin_data: Optional[str] = None,
54
+ env: Optional[dict] = None,
55
+ cwd: Optional[str] = None,
56
+ ) -> ProcResult:
57
+ """Run `argv`, capture output, and never raise on tool failure.
58
+
59
+ Recon tooling routinely exits non-zero (empty source, no results); that
60
+ is data, not an error, so we surface the code and let callers decide.
61
+ """
62
+ import time
63
+
64
+ argv = [str(a) for a in argv]
65
+ started = time.monotonic()
66
+ try:
67
+ proc = subprocess.run(
68
+ argv,
69
+ input=stdin_data,
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=timeout,
73
+ env=env,
74
+ cwd=cwd,
75
+ )
76
+ except FileNotFoundError:
77
+ return ProcResult(
78
+ NOT_FOUND, "", f"binary not found: {argv[0]}", argv, duration=time.monotonic() - started
79
+ )
80
+ except subprocess.TimeoutExpired as exc:
81
+ partial = _decode(exc.stdout)
82
+ return ProcResult(
83
+ TIMEOUT,
84
+ partial,
85
+ f"timeout after {timeout}s",
86
+ argv,
87
+ duration=time.monotonic() - started,
88
+ timed_out=True,
89
+ )
90
+ return ProcResult(
91
+ proc.returncode,
92
+ proc.stdout,
93
+ proc.stderr,
94
+ argv,
95
+ duration=time.monotonic() - started,
96
+ )
97
+
98
+
99
+ def _decode(data) -> str:
100
+ if data is None:
101
+ return ""
102
+ if isinstance(data, bytes):
103
+ return data.decode(errors="replace")
104
+ return str(data)
@@ -0,0 +1,49 @@
1
+ """Shared severity vocabulary.
2
+
3
+ One ordered :class:`Severity` enum for the whole framework: the intelligence
4
+ engine scores with it, the knowledge base tags playbooks with it, and the
5
+ reporter (phase 7) renders with it. It lives in ``utils`` — the bottom of the
6
+ dependency graph — so every feature layer shares one definition instead of
7
+ each redefining severity and drifting apart.
8
+
9
+ ``.label`` / ``.style`` map straight onto the Rich theme keys in
10
+ :mod:`huntkit.utils.terminal`, so a severity colours itself consistently
11
+ wherever it is printed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from enum import IntEnum
17
+
18
+
19
+ class Severity(IntEnum):
20
+ """Ordered so signals can be compared and aggregated numerically."""
21
+
22
+ INFO = 1
23
+ LOW = 2
24
+ MEDIUM = 3
25
+ HIGH = 4
26
+ CRITICAL = 5
27
+
28
+ @property
29
+ def label(self) -> str:
30
+ return "informational" if self is Severity.INFO else self.name.lower()
31
+
32
+ @property
33
+ def style(self) -> str: # matches the theme keys in utils.terminal
34
+ return self.label
35
+
36
+ @classmethod
37
+ def from_name(cls, name: str) -> Severity:
38
+ """Parse a label back into a Severity ('informational'/'info' -> INFO).
39
+
40
+ The inverse of :attr:`label`, so severities that were serialised to
41
+ JSON (e.g. scans/intel.json) can be read back as ordered enums.
42
+ """
43
+ key = name.strip().lower()
44
+ if key in ("info", "informational"):
45
+ return cls.INFO
46
+ try:
47
+ return cls[key.upper()]
48
+ except KeyError:
49
+ raise ValueError(f"unknown severity: {name!r}") from None