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,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
+ )
@@ -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
+ ]