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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""waybackurls — fetch URLs from the Wayback Machine (tomnomnom).
|
|
2
|
+
|
|
3
|
+
Reads a single domain from stdin, so the seed target is piped rather than
|
|
4
|
+
placed on the argv.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Waybackurls(ToolPlugin):
|
|
15
|
+
name = "waybackurls"
|
|
16
|
+
category = Category.URLS
|
|
17
|
+
description = "Fetch URLs from the Wayback Machine"
|
|
18
|
+
install = "go install github.com/tomnomnom/waybackurls@latest"
|
|
19
|
+
consumes = Capability.DOMAIN
|
|
20
|
+
produces = Capability.URL
|
|
21
|
+
input_mode = InputMode.TARGET # single seed, fed via stdin below
|
|
22
|
+
|
|
23
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
def stdin_payload(self, ctx: PluginContext) -> Optional[str]:
|
|
27
|
+
if ctx.target:
|
|
28
|
+
return ctx.target
|
|
29
|
+
return "\n".join(ctx.inputs) if ctx.inputs else None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""whatweb — technology fingerprinting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WhatWeb(ToolPlugin):
|
|
9
|
+
name = "whatweb"
|
|
10
|
+
category = Category.RESOLVE
|
|
11
|
+
description = "Technology fingerprinting"
|
|
12
|
+
install = "sudo apt install whatweb"
|
|
13
|
+
consumes = Capability.URL
|
|
14
|
+
produces = Capability.FINDING
|
|
15
|
+
input_mode = InputMode.ARGS
|
|
16
|
+
|
|
17
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
18
|
+
# one fingerprint line per target; targets given as argv
|
|
19
|
+
return ["--no-errors", "--colour=never", "-q", *ctx.inputs]
|
huntkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Reporting layer — turn a scored workspace into a shareable report.
|
|
2
|
+
|
|
3
|
+
A composition feature layer over ``intel`` and ``knowledge``: :func:`build`
|
|
4
|
+
assembles the :class:`~huntkit.report.builder.Report`, :mod:`render` turns it
|
|
5
|
+
into Markdown / HTML / JSON, and :func:`write` persists it under the
|
|
6
|
+
workspace's ``reports/`` directory. PDF is best-effort — with the optional
|
|
7
|
+
``weasyprint`` backend it renders a PDF, otherwise it writes the print-ready
|
|
8
|
+
HTML and says so, keeping the core dependency-light.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..core.workspace import Workspace
|
|
18
|
+
from ..utils import filesystem as fs
|
|
19
|
+
from ..utils.validators import sanitize_filename
|
|
20
|
+
from . import render
|
|
21
|
+
from .builder import Report, Screenshot, build
|
|
22
|
+
|
|
23
|
+
# Canonical output formats.
|
|
24
|
+
FORMATS = ("md", "html", "json", "pdf")
|
|
25
|
+
_ALIASES = {
|
|
26
|
+
"md": "md",
|
|
27
|
+
"markdown": "md",
|
|
28
|
+
"html": "html",
|
|
29
|
+
"htm": "html",
|
|
30
|
+
"json": "json",
|
|
31
|
+
"pdf": "pdf",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalise_format(fmt: str) -> str | None:
|
|
36
|
+
"""Map a user-supplied format onto a canonical one, or None if unknown."""
|
|
37
|
+
return _ALIASES.get(fmt.strip().lower())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Written:
|
|
42
|
+
"""Where a report landed, plus an optional note (e.g. PDF degraded to HTML)."""
|
|
43
|
+
|
|
44
|
+
path: Path
|
|
45
|
+
note: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _default_path(ws: Workspace, ext: str) -> Path:
|
|
49
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
50
|
+
return ws.path("reports", f"{sanitize_filename(ws.program)}-{stamp}.{ext}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_RENDERERS = {
|
|
54
|
+
"md": render.to_markdown,
|
|
55
|
+
"html": render.to_html,
|
|
56
|
+
"json": render.to_json,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write(ws: Workspace, report: Report, fmt: str, out: Path | None = None) -> Written:
|
|
61
|
+
"""Render ``report`` as ``fmt`` (canonical) and write it to disk."""
|
|
62
|
+
if fmt == "pdf":
|
|
63
|
+
return _write_pdf(ws, report, out)
|
|
64
|
+
target = out or _default_path(ws, fmt)
|
|
65
|
+
fs.write_text(target, _RENDERERS[fmt](report))
|
|
66
|
+
return Written(target)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_pdf(ws: Workspace, report: Report, out: Path | None) -> Written:
|
|
70
|
+
html = render.to_html(report)
|
|
71
|
+
target = out or _default_path(ws, "pdf")
|
|
72
|
+
try:
|
|
73
|
+
import weasyprint
|
|
74
|
+
except Exception:
|
|
75
|
+
# No PDF backend: write the print-ready HTML and tell the user.
|
|
76
|
+
fallback = target.with_suffix(".html")
|
|
77
|
+
fs.write_text(fallback, html)
|
|
78
|
+
return Written(
|
|
79
|
+
fallback,
|
|
80
|
+
"no PDF backend (install `weasyprint`) — wrote print-ready HTML "
|
|
81
|
+
"instead; open it and use Print → Save as PDF.",
|
|
82
|
+
)
|
|
83
|
+
fs.ensure_dir(target.parent) # pragma: no cover
|
|
84
|
+
weasyprint.HTML(string=html).write_pdf(str(target)) # pragma: no cover
|
|
85
|
+
return Written(target) # pragma: no cover
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate(ws: Workspace, fmt: str = "md", out: Path | None = None) -> Written:
|
|
89
|
+
"""Build the report and write it in one call."""
|
|
90
|
+
return write(ws, build(ws), fmt, out)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"FORMATS",
|
|
95
|
+
"Report",
|
|
96
|
+
"Screenshot",
|
|
97
|
+
"Written",
|
|
98
|
+
"build",
|
|
99
|
+
"generate",
|
|
100
|
+
"normalise_format",
|
|
101
|
+
"render",
|
|
102
|
+
"write",
|
|
103
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Assemble an engagement report from everything a workspace holds.
|
|
2
|
+
|
|
3
|
+
A :class:`Report` is the composition layer: it runs the intelligence engine
|
|
4
|
+
over the recon surface, pulls the referenced playbooks out of the knowledge
|
|
5
|
+
base, and pairs them with scope and recon counts. The renderers
|
|
6
|
+
(:mod:`huntkit.report.render`) turn this one model into Markdown, HTML, or
|
|
7
|
+
JSON, so every format shows exactly the same facts.
|
|
8
|
+
|
|
9
|
+
Pure and offline — it only reads artifacts already on disk.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from ..core.workspace import Workspace
|
|
20
|
+
from ..intel import IntelReport, analyze
|
|
21
|
+
from ..knowledge import Playbook, get_playbook
|
|
22
|
+
|
|
23
|
+
# Recon artifacts summarised in the report, in pipeline order.
|
|
24
|
+
_RECON_FILES: tuple[tuple[str, str], ...] = (
|
|
25
|
+
("subdomains", "recon/subdomains.txt"),
|
|
26
|
+
("resolved", "recon/resolved.txt"),
|
|
27
|
+
("live", "recon/live.txt"),
|
|
28
|
+
("ports", "recon/ports.txt"),
|
|
29
|
+
("urls", "urls/urls.txt"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_SHOTS_MANIFEST = "scans/screenshots.json"
|
|
33
|
+
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Screenshot:
|
|
38
|
+
"""One captured URL and, for the HTML renderer, its inlined image.
|
|
39
|
+
|
|
40
|
+
``data_uri`` is populated by the builder (which can read the file) so the
|
|
41
|
+
HTML report stays a single self-contained page; it is deliberately left
|
|
42
|
+
out of :meth:`to_dict` so JSON/Markdown reference the file by path.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
url: str
|
|
46
|
+
file: str # relative to the workspace root
|
|
47
|
+
data_uri: str = ""
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return {"url": self.url, "file": self.file}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Report:
|
|
55
|
+
program: str
|
|
56
|
+
scope_in: list[str]
|
|
57
|
+
scope_out: list[str]
|
|
58
|
+
recon: dict[str, int] # stage -> asset count
|
|
59
|
+
intel: IntelReport
|
|
60
|
+
playbooks: list[Playbook] # referenced by attack paths, severity order
|
|
61
|
+
screenshots: list[Screenshot] = field(default_factory=list)
|
|
62
|
+
generated: float = field(default_factory=time.time)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def has_findings(self) -> bool:
|
|
66
|
+
return bool(self.intel.signals)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_data(self) -> bool:
|
|
70
|
+
"""True if there is anything worth writing — findings or recon assets."""
|
|
71
|
+
return self.has_findings or any(self.recon.values())
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"program": self.program,
|
|
76
|
+
"generated": self.generated,
|
|
77
|
+
"scope": {"in": self.scope_in, "out": self.scope_out},
|
|
78
|
+
"recon": self.recon,
|
|
79
|
+
"intel": self.intel.to_dict(),
|
|
80
|
+
"playbooks": [p.to_dict() for p in self.playbooks],
|
|
81
|
+
"screenshots": [s.to_dict() for s in self.screenshots],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _referenced_playbooks(intel: IntelReport) -> list[Playbook]:
|
|
86
|
+
"""The playbooks the attack paths point at, most-severe first, de-duped."""
|
|
87
|
+
out: list[Playbook] = []
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
for path in intel.attack_paths(): # already severity-ranked
|
|
90
|
+
if path.playbook in seen:
|
|
91
|
+
continue
|
|
92
|
+
pb = get_playbook(path.playbook)
|
|
93
|
+
if pb is not None:
|
|
94
|
+
out.append(pb)
|
|
95
|
+
seen.add(path.playbook)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_screenshots(ws: Workspace) -> list[Screenshot]:
|
|
100
|
+
"""Read the screenshot manifest and inline each image for the HTML report.
|
|
101
|
+
|
|
102
|
+
Missing or malformed manifests yield no screenshots; an image file that
|
|
103
|
+
has gone missing is dropped rather than erroring the whole report.
|
|
104
|
+
"""
|
|
105
|
+
manifest = ws.path(_SHOTS_MANIFEST)
|
|
106
|
+
if not manifest.exists():
|
|
107
|
+
return []
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
110
|
+
entries = data.get("shots", [])
|
|
111
|
+
except (ValueError, OSError):
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
out: list[Screenshot] = []
|
|
115
|
+
for entry in entries:
|
|
116
|
+
rel = entry.get("file", "")
|
|
117
|
+
url = entry.get("url", "")
|
|
118
|
+
if not rel or not url:
|
|
119
|
+
continue
|
|
120
|
+
shot = Screenshot(url=url, file=rel)
|
|
121
|
+
img = ws.path(rel)
|
|
122
|
+
mime = _MIME.get(img.suffix.lower())
|
|
123
|
+
if mime and img.exists():
|
|
124
|
+
try:
|
|
125
|
+
raw = base64.b64encode(img.read_bytes()).decode("ascii")
|
|
126
|
+
shot.data_uri = f"data:{mime};base64,{raw}"
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
out.append(shot)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build(ws: Workspace) -> Report:
|
|
134
|
+
"""Score the workspace and gather everything a report needs."""
|
|
135
|
+
intel = analyze(ws)
|
|
136
|
+
recon = {label: ws.count(rel) for label, rel in _RECON_FILES}
|
|
137
|
+
return Report(
|
|
138
|
+
program=ws.program,
|
|
139
|
+
scope_in=ws.scope_in,
|
|
140
|
+
scope_out=ws.scope_out,
|
|
141
|
+
recon=recon,
|
|
142
|
+
intel=intel,
|
|
143
|
+
playbooks=_referenced_playbooks(intel),
|
|
144
|
+
screenshots=_load_screenshots(ws),
|
|
145
|
+
)
|
huntkit/report/render.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Render a :class:`~huntkit.report.builder.Report` to Markdown, HTML, or JSON.
|
|
2
|
+
|
|
3
|
+
Each renderer walks the same model, so the three formats never disagree.
|
|
4
|
+
Markdown is the canonical, diff-friendly format; HTML is a self-contained,
|
|
5
|
+
print-ready page (inline CSS, no external assets); JSON is the machine form.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html as _html
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from ..intel import Signal
|
|
16
|
+
from ..knowledge import Playbook
|
|
17
|
+
from .builder import Report
|
|
18
|
+
|
|
19
|
+
_GEN_BY = "Generated by HuntKit — for authorised testing only."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ts(epoch: float) -> str:
|
|
23
|
+
return time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(epoch))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _notable(report: Report) -> list[Signal]:
|
|
27
|
+
"""High/Critical single signals — the infra exposures worth calling out."""
|
|
28
|
+
return sorted(
|
|
29
|
+
(s for s in report.intel.signals if int(s.severity) >= 4),
|
|
30
|
+
key=lambda s: (-int(s.severity), s.host),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# JSON
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
def to_json(report: Report) -> str:
|
|
38
|
+
return json.dumps(report.to_dict(), indent=2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Markdown
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
def _md_table(headers: list[str], rows: Sequence[tuple[str, ...]]) -> list[str]:
|
|
45
|
+
out = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
|
|
46
|
+
out += ["| " + " | ".join(row) + " |" for row in rows]
|
|
47
|
+
out.append("")
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_markdown(report: Report) -> str:
|
|
52
|
+
intel = report.intel
|
|
53
|
+
out: list[str] = [
|
|
54
|
+
f"# HuntKit Report — {report.program}",
|
|
55
|
+
"",
|
|
56
|
+
f"_Generated {_ts(report.generated)}_",
|
|
57
|
+
"",
|
|
58
|
+
]
|
|
59
|
+
if report.scope_in:
|
|
60
|
+
out.append(f"**In scope:** {', '.join(report.scope_in)}")
|
|
61
|
+
if report.scope_out:
|
|
62
|
+
out.append(f"**Out of scope:** {', '.join(report.scope_out)}")
|
|
63
|
+
out.append("")
|
|
64
|
+
|
|
65
|
+
out += ["## Summary", ""]
|
|
66
|
+
out += _md_table(["Priority", "Hosts"], [(label, str(n)) for label, n in intel.summary.items()])
|
|
67
|
+
out += [f"{len(intel.signals)} signals across {len(intel.hosts)} hosts.", ""]
|
|
68
|
+
|
|
69
|
+
if intel.hosts:
|
|
70
|
+
out += ["## Prioritised hosts", ""]
|
|
71
|
+
out += _md_table(
|
|
72
|
+
["Host", "Priority", "Score", "Signals", "Playbooks"],
|
|
73
|
+
[
|
|
74
|
+
(
|
|
75
|
+
h.host,
|
|
76
|
+
h.priority.label,
|
|
77
|
+
str(h.score),
|
|
78
|
+
str(len(h.signals)),
|
|
79
|
+
", ".join(h.playbooks[:4]) or "—",
|
|
80
|
+
)
|
|
81
|
+
for h in intel.hosts
|
|
82
|
+
],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
paths = intel.attack_paths()
|
|
86
|
+
if paths:
|
|
87
|
+
out += ["## Attack paths", ""]
|
|
88
|
+
rows = []
|
|
89
|
+
for p in paths:
|
|
90
|
+
where = ", ".join(p.hosts[:4]) + (" …" if len(p.hosts) > 4 else "")
|
|
91
|
+
rows.append((p.name, f"`{p.playbook}`", p.severity.label, str(len(p.hosts)), where))
|
|
92
|
+
out += _md_table(["Playbook", "id", "Severity", "Hosts", "Where"], rows)
|
|
93
|
+
|
|
94
|
+
hot = _notable(report)
|
|
95
|
+
if hot:
|
|
96
|
+
out += ["## Notable exposures", ""]
|
|
97
|
+
out += _md_table(
|
|
98
|
+
["Severity", "Host", "Finding", "Evidence"],
|
|
99
|
+
[(s.severity.label, s.host, s.title, s.evidence) for s in hot],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if report.screenshots:
|
|
103
|
+
out += ["## Screenshots", ""]
|
|
104
|
+
out += [f"- [{s.url}]({s.file})" for s in report.screenshots]
|
|
105
|
+
out += [""]
|
|
106
|
+
|
|
107
|
+
out += ["## Recon surface", ""]
|
|
108
|
+
out += _md_table(["Stage", "Count"], [(label, str(n)) for label, n in report.recon.items()])
|
|
109
|
+
|
|
110
|
+
if report.playbooks:
|
|
111
|
+
out += [
|
|
112
|
+
"## Playbooks",
|
|
113
|
+
"",
|
|
114
|
+
"The bug classes this surface points at, in priority order.",
|
|
115
|
+
"",
|
|
116
|
+
]
|
|
117
|
+
for pb in report.playbooks:
|
|
118
|
+
out.append(pb.to_markdown(level=3))
|
|
119
|
+
out.append("")
|
|
120
|
+
|
|
121
|
+
out += ["---", "", _GEN_BY]
|
|
122
|
+
return "\n".join(out).rstrip() + "\n"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# HTML
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
_CSS = """
|
|
129
|
+
:root { --bg:#ffffff; --fg:#1f2933; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
|
|
130
|
+
* { box-sizing: border-box; }
|
|
131
|
+
body { margin:0; background:var(--bg); color:var(--fg);
|
|
132
|
+
font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
|
|
133
|
+
.wrap { max-width: 900px; margin: 0 auto; padding: 40px 24px 80px; }
|
|
134
|
+
h1 { font-size: 1.9rem; margin: 0 0 4px; }
|
|
135
|
+
h1 .muted { font-weight: 400; }
|
|
136
|
+
h2 { font-size: 1.25rem; margin: 2.2rem 0 .8rem; padding-bottom:.3rem; border-bottom:2px solid var(--line); }
|
|
137
|
+
h3 { font-size: 1.05rem; margin: 1.6rem 0 .4rem; }
|
|
138
|
+
h4 { font-size: .8rem; text-transform: uppercase; letter-spacing:.04em; color: var(--muted); margin: 1rem 0 .3rem; }
|
|
139
|
+
p { margin: .5rem 0; }
|
|
140
|
+
.meta, .muted { color: var(--muted); }
|
|
141
|
+
.meta { margin: 2px 0; font-size: .9rem; }
|
|
142
|
+
code { background:#f3f4f6; padding:1px 5px; border-radius:4px; font-size:.85em; }
|
|
143
|
+
a { color: var(--accent); }
|
|
144
|
+
table { border-collapse: collapse; width:100%; margin:.4rem 0 1rem; font-size:.9rem; }
|
|
145
|
+
th, td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
146
|
+
th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); }
|
|
147
|
+
tbody tr:hover { background:#fafafa; }
|
|
148
|
+
pre { background:#0f172a; color:#e2e8f0; padding:12px 14px; border-radius:8px; overflow-x:auto;
|
|
149
|
+
font:13px/1.5 "SF Mono",ui-monospace,Menlo,Consolas,monospace; }
|
|
150
|
+
ul { margin:.3rem 0; padding-left:1.2rem; }
|
|
151
|
+
li { margin:.15rem 0; }
|
|
152
|
+
.cards { display:flex; gap:12px; flex-wrap:wrap; margin:.6rem 0 1rem; }
|
|
153
|
+
.card { flex:1; min-width:110px; border:1px solid var(--line); border-radius:10px; padding:14px 16px; }
|
|
154
|
+
.card-n { font-size:1.8rem; font-weight:700; line-height:1; }
|
|
155
|
+
.card-l { font-size:.8rem; color:var(--muted); margin-top:4px; }
|
|
156
|
+
.card-critical { border-color:#7f1d1d; } .card-critical .card-n { color:#7f1d1d; }
|
|
157
|
+
.card-high { border-color:#dc2626; } .card-high .card-n { color:#dc2626; }
|
|
158
|
+
.card-medium { border-color:#d97706; } .card-medium .card-n { color:#b45309; }
|
|
159
|
+
.card-low { border-color:#16a34a; } .card-low .card-n { color:#15803d; }
|
|
160
|
+
.sev { display:inline-block; padding:1px 8px; border-radius:999px; font-size:.75rem; font-weight:600; }
|
|
161
|
+
.sev-critical { background:#7f1d1d; color:#fff; }
|
|
162
|
+
.sev-high { background:#fee2e2; color:#991b1b; }
|
|
163
|
+
.sev-medium { background:#ffedd5; color:#9a3412; }
|
|
164
|
+
.sev-low { background:#dcfce7; color:#166534; }
|
|
165
|
+
.sev-informational { background:#e0f2fe; color:#075985; }
|
|
166
|
+
.pb { border:1px solid var(--line); border-radius:12px; padding:4px 20px 16px; margin:1rem 0; }
|
|
167
|
+
.gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:14px; margin:.6rem 0 1rem; }
|
|
168
|
+
.gallery figure { margin:0; border:1px solid var(--line); border-radius:10px; overflow:hidden; }
|
|
169
|
+
.gallery img { display:block; width:100%; height:160px; object-fit:cover; object-position:top; background:#f3f4f6; }
|
|
170
|
+
.gallery figcaption { padding:7px 10px; font-size:.78rem; color:var(--muted); word-break:break-all; border-top:1px solid var(--line); }
|
|
171
|
+
footer { margin-top:3rem; padding-top:1rem; border-top:1px solid var(--line); color:var(--muted); font-size:.85rem; }
|
|
172
|
+
@media (prefers-color-scheme: dark) {
|
|
173
|
+
:root { --bg:#0b1220; --fg:#e5e7eb; --muted:#9ca3af; --line:#1f2937; --accent:#5eead4; }
|
|
174
|
+
code { background:#1f2937; } tbody tr:hover { background:#111827; }
|
|
175
|
+
.sev-high { background:#450a0a; color:#fca5a5; } .sev-medium { background:#451a03; color:#fdba74; }
|
|
176
|
+
.sev-low { background:#052e16; color:#86efac; } .sev-informational { background:#082f49; color:#7dd3fc; }
|
|
177
|
+
}
|
|
178
|
+
@media print { .pb, .card { break-inside: avoid; } body { background:#fff; color:#000; } }
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _chip(sev) -> str:
|
|
183
|
+
return f"<span class='sev sev-{sev.style}'>{_html.escape(sev.label)}</span>"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _html_table(headers: list[str], rows: Sequence[tuple[str, ...]]) -> str:
|
|
187
|
+
head = "".join(f"<th>{_html.escape(h)}</th>" for h in headers)
|
|
188
|
+
body = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>" for row in rows)
|
|
189
|
+
return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _playbook_html(pb: Playbook) -> str:
|
|
193
|
+
p: list[str] = [
|
|
194
|
+
"<section class='pb'>",
|
|
195
|
+
f"<h3>{_html.escape(pb.title)} {_chip(pb.severity)} "
|
|
196
|
+
f"<code>{_html.escape(pb.id)}</code></h3>",
|
|
197
|
+
f"<p>{_html.escape(pb.summary)}</p>",
|
|
198
|
+
f"<p class='muted'><strong>When:</strong> {_html.escape(pb.when)}</p>",
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def block(head: str, items: tuple[str, ...]) -> None:
|
|
202
|
+
if not items:
|
|
203
|
+
return
|
|
204
|
+
p.append(f"<h4>{head}</h4><ul>")
|
|
205
|
+
p.extend(f"<li>{_html.escape(it)}</li>" for it in items)
|
|
206
|
+
p.append("</ul>")
|
|
207
|
+
|
|
208
|
+
block("Detection", pb.detection)
|
|
209
|
+
if pb.payloads:
|
|
210
|
+
p.append("<h4>Payloads</h4><pre>" + _html.escape("\n".join(pb.payloads)) + "</pre>")
|
|
211
|
+
block("Bypasses", pb.bypasses)
|
|
212
|
+
if pb.tools:
|
|
213
|
+
p.append("<h4>Go-to tools</h4><p>" + _html.escape(", ".join(pb.tools)) + "</p>")
|
|
214
|
+
if pb.references:
|
|
215
|
+
p.append("<h4>References</h4><ul>")
|
|
216
|
+
p += [
|
|
217
|
+
f"<li><a href='{_html.escape(r.url)}'>{_html.escape(r.name)}</a></li>"
|
|
218
|
+
for r in pb.references
|
|
219
|
+
]
|
|
220
|
+
p.append("</ul>")
|
|
221
|
+
p.append("</section>")
|
|
222
|
+
return "".join(p)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def to_html(report: Report) -> str:
|
|
226
|
+
intel = report.intel
|
|
227
|
+
esc = _html.escape
|
|
228
|
+
h: list[str] = [
|
|
229
|
+
"<!doctype html>",
|
|
230
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
231
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
232
|
+
f"<title>HuntKit Report — {esc(report.program)}</title>",
|
|
233
|
+
f"<style>{_CSS}</style>",
|
|
234
|
+
"</head><body><main class='wrap'>",
|
|
235
|
+
f"<h1>HuntKit Report <span class='muted'>— {esc(report.program)}</span></h1>",
|
|
236
|
+
f"<p class='meta'>Generated {_ts(report.generated)}</p>",
|
|
237
|
+
]
|
|
238
|
+
if report.scope_in:
|
|
239
|
+
h.append(
|
|
240
|
+
f"<p class='meta'><strong>In scope:</strong> {esc(', '.join(report.scope_in))}</p>"
|
|
241
|
+
)
|
|
242
|
+
if report.scope_out:
|
|
243
|
+
h.append(
|
|
244
|
+
f"<p class='meta'><strong>Out of scope:</strong> {esc(', '.join(report.scope_out))}</p>"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
h.append("<h2>Summary</h2><div class='cards'>")
|
|
248
|
+
for label, n in intel.summary.items():
|
|
249
|
+
h.append(
|
|
250
|
+
f"<div class='card card-{label.lower()}'>"
|
|
251
|
+
f"<div class='card-n'>{n}</div><div class='card-l'>{label}</div></div>"
|
|
252
|
+
)
|
|
253
|
+
h.append("</div>")
|
|
254
|
+
h.append(f"<p>{len(intel.signals)} signals across {len(intel.hosts)} hosts.</p>")
|
|
255
|
+
|
|
256
|
+
if intel.hosts:
|
|
257
|
+
h.append("<h2>Prioritised hosts</h2>")
|
|
258
|
+
h.append(
|
|
259
|
+
_html_table(
|
|
260
|
+
["Host", "Priority", "Score", "Signals", "Playbooks"],
|
|
261
|
+
[
|
|
262
|
+
(
|
|
263
|
+
esc(x.host),
|
|
264
|
+
_chip(x.priority),
|
|
265
|
+
str(x.score),
|
|
266
|
+
str(len(x.signals)),
|
|
267
|
+
esc(", ".join(x.playbooks[:4]) or "—"),
|
|
268
|
+
)
|
|
269
|
+
for x in intel.hosts
|
|
270
|
+
],
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
paths = intel.attack_paths()
|
|
275
|
+
if paths:
|
|
276
|
+
h.append("<h2>Attack paths</h2>")
|
|
277
|
+
rows = []
|
|
278
|
+
for p in paths:
|
|
279
|
+
where = esc(", ".join(p.hosts[:4])) + (" …" if len(p.hosts) > 4 else "")
|
|
280
|
+
rows.append(
|
|
281
|
+
(
|
|
282
|
+
esc(p.name),
|
|
283
|
+
f"<code>{esc(p.playbook)}</code>",
|
|
284
|
+
_chip(p.severity),
|
|
285
|
+
str(len(p.hosts)),
|
|
286
|
+
where,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
h.append(_html_table(["Playbook", "id", "Severity", "Hosts", "Where"], rows))
|
|
290
|
+
|
|
291
|
+
hot = _notable(report)
|
|
292
|
+
if hot:
|
|
293
|
+
h.append("<h2>Notable exposures</h2>")
|
|
294
|
+
h.append(
|
|
295
|
+
_html_table(
|
|
296
|
+
["Severity", "Host", "Finding", "Evidence"],
|
|
297
|
+
[(_chip(s.severity), esc(s.host), esc(s.title), esc(s.evidence)) for s in hot],
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
shots = [s for s in report.screenshots if s.data_uri]
|
|
302
|
+
if shots:
|
|
303
|
+
h.append("<h2>Screenshots</h2><div class='gallery'>")
|
|
304
|
+
for s in shots:
|
|
305
|
+
h.append(
|
|
306
|
+
f"<figure><img loading='lazy' src='{s.data_uri}' alt='{esc(s.url)}'>"
|
|
307
|
+
f"<figcaption>{esc(s.url)}</figcaption></figure>"
|
|
308
|
+
)
|
|
309
|
+
h.append("</div>")
|
|
310
|
+
|
|
311
|
+
h.append("<h2>Recon surface</h2>")
|
|
312
|
+
h.append(
|
|
313
|
+
_html_table(["Stage", "Count"], [(esc(label), str(n)) for label, n in report.recon.items()])
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if report.playbooks:
|
|
317
|
+
h.append("<h2>Playbooks</h2>")
|
|
318
|
+
h.append("<p class='muted'>The bug classes this surface points at, in priority order.</p>")
|
|
319
|
+
h += [_playbook_html(pb) for pb in report.playbooks]
|
|
320
|
+
|
|
321
|
+
h.append(f"<footer>{esc(_GEN_BY)}</footer>")
|
|
322
|
+
h.append("</main></body></html>")
|
|
323
|
+
return "\n".join(h) + "\n"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Active scanning layer.
|
|
2
|
+
|
|
3
|
+
Drives the ``SCAN``-category plugins over the URLs recon gathered, writing
|
|
4
|
+
findings to the workspace's ``scans/`` directory. See :mod:`huntkit.scanner.
|
|
5
|
+
runner` for the contract; the public surface is :func:`run_scan`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .runner import (
|
|
11
|
+
FINDINGS_JSON,
|
|
12
|
+
Scanner,
|
|
13
|
+
ScanOutcome,
|
|
14
|
+
ScanSummary,
|
|
15
|
+
run_scan,
|
|
16
|
+
scan_plugins,
|
|
17
|
+
scan_targets,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"FINDINGS_JSON",
|
|
22
|
+
"ScanOutcome",
|
|
23
|
+
"ScanSummary",
|
|
24
|
+
"Scanner",
|
|
25
|
+
"run_scan",
|
|
26
|
+
"scan_plugins",
|
|
27
|
+
"scan_targets",
|
|
28
|
+
]
|