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/shots/runner.py
ADDED
|
@@ -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
|
huntkit/tui/__init__.py
ADDED
|
@@ -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"]
|
huntkit/tui/dashboard.py
ADDED
|
@@ -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,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"
|
huntkit/utils/process.py
ADDED
|
@@ -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
|