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,79 @@
1
+ """`huntkit plugins` — browse the tool plugins HuntKit can drive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ..plugins import Category
10
+ from ..utils import terminal as term
11
+
12
+ plugins_app = typer.Typer(no_args_is_help=True, help="Browse and inspect tool plugins.")
13
+
14
+
15
+ def _status(available: bool) -> str:
16
+ return "[ok]installed[/ok]" if available else "[muted]missing[/muted]"
17
+
18
+
19
+ @plugins_app.command("list")
20
+ def plugins_list(
21
+ ctx: typer.Context,
22
+ category: Optional[str] = typer.Option(
23
+ None, "--category", "-c", help="Filter by category (discovery/resolve/ports/urls/scan)."
24
+ ),
25
+ available_only: bool = typer.Option(
26
+ False, "--available", "-a", help="Show only installed tools."
27
+ ),
28
+ ) -> None:
29
+ """List plugins, their capability chain, and install status."""
30
+ app_ctx = ctx.obj
31
+ registry, runner = app_ctx.registry, app_ctx.runner
32
+
33
+ plugins = registry.all()
34
+ if category:
35
+ try:
36
+ plugins = registry.by_category(Category(category))
37
+ except ValueError:
38
+ term.error(f"unknown category: {category}")
39
+ raise typer.Exit(2) from None
40
+ if available_only:
41
+ plugins = [p for p in plugins if p.is_available(runner)]
42
+
43
+ table = term.make_table("Plugins", ["tool", "category", "consumes", "produces", "status"])
44
+ for p in plugins:
45
+ table.add_row(
46
+ p.name,
47
+ p.category.value,
48
+ p.consumes.value if p.consumes else "-",
49
+ p.produces.value,
50
+ _status(p.is_available(runner)),
51
+ )
52
+ term.console.print(table)
53
+ installed = len(registry.available(runner))
54
+ term.info(f"{installed}/{len(registry)} tools installed.")
55
+
56
+
57
+ @plugins_app.command("show")
58
+ def plugins_show(ctx: typer.Context, name: str = typer.Argument(..., help="Plugin name.")) -> None:
59
+ """Show one plugin's metadata and how to install it."""
60
+ app_ctx = ctx.obj
61
+ plugin = app_ctx.registry.get(name)
62
+ if plugin is None:
63
+ term.error(f"no such plugin: {name}")
64
+ raise typer.Exit(2)
65
+ available = plugin.is_available(app_ctx.runner)
66
+ rows = [
67
+ ("name", plugin.name),
68
+ ("category", plugin.category.value),
69
+ ("description", plugin.description or "-"),
70
+ ("consumes", plugin.consumes.value if plugin.consumes else "-"),
71
+ ("produces", plugin.produces.value),
72
+ ("input mode", plugin.input_mode.value),
73
+ ("status", "installed" if available else "missing"),
74
+ ]
75
+ if plugin.needs_api_key:
76
+ rows.append(("api key", plugin.needs_api_key))
77
+ if not available:
78
+ rows.append(("install", plugin.install))
79
+ term.print_table(f"plugin: {plugin.name}", ["field", "value"], rows)
@@ -0,0 +1,62 @@
1
+ """`huntkit workspace` — list and inspect engagement workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ..core.workspace import Workspace, default_base, list_workspaces
8
+ from ..utils import terminal as term
9
+
10
+ workspace_app = typer.Typer(no_args_is_help=True, help="List and inspect workspaces.")
11
+
12
+
13
+ @workspace_app.command("list")
14
+ def workspace_list(ctx: typer.Context) -> None:
15
+ """List every workspace under the workspace root."""
16
+ app_ctx = ctx.obj
17
+ base = app_ctx.base or default_base(app_ctx.config)
18
+ names = list_workspaces(base=base)
19
+ if not names:
20
+ term.warn(f"no workspaces yet under {base}")
21
+ term.info("create one with: huntkit init <program> -s <domain>")
22
+ return
23
+ rows = []
24
+ for name in names:
25
+ ws = Workspace.open(name, config=app_ctx.config, base=app_ctx.base)
26
+ subs = ws.count("recon/subdomains.txt")
27
+ live = ws.count("recon/live.txt")
28
+ rows.append((name, str(subs), str(live), str(len(ws.scope_in))))
29
+ term.print_table(f"Workspaces ({base})", ["program", "subs", "live", "scope"], rows)
30
+
31
+
32
+ @workspace_app.command("show")
33
+ def workspace_show(
34
+ ctx: typer.Context,
35
+ program: str = typer.Argument(..., help="Workspace / program name."),
36
+ ) -> None:
37
+ """Show scope, counts, and stage status for one workspace."""
38
+ app_ctx = ctx.obj
39
+ ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
40
+ if not ws.has_scope() and not ws.state.counts:
41
+ term.warn(f"workspace '{program}' looks empty ({ws.root})")
42
+
43
+ term.banner(f"workspace: {program}")
44
+ term.info(f"path: {ws.root}")
45
+
46
+ scope_in = ws.scope_in or ["(none — everything in scope)"]
47
+ term.step("Scope")
48
+ for entry in scope_in:
49
+ term.bullet(entry, style="ok")
50
+ for entry in ws.scope_out:
51
+ term.bullet(f"{entry} (excluded)", style="warn")
52
+
53
+ if ws.state.counts:
54
+ term.print_table(
55
+ "Assets", ["metric", "count"], [(k, str(v)) for k, v in ws.state.counts.items()]
56
+ )
57
+ if ws.state.stages:
58
+ rows = []
59
+ for name, rec in ws.state.stages.items():
60
+ dur = f"{rec.duration:.1f}s" if rec.duration else "-"
61
+ rows.append((name, rec.status.value, dur))
62
+ term.print_table("Stages", ["stage", "status", "time"], rows)
@@ -0,0 +1,6 @@
1
+ """HuntKit core: configuration, logging, execution, cache, and state.
2
+
3
+ The core layer sits above `utils` and below the feature packages (recon,
4
+ scanners, intelligence, reporting, ai). Feature code depends on core; core
5
+ never depends on feature code.
6
+ """
huntkit/core/cache.py ADDED
@@ -0,0 +1,67 @@
1
+ """Content-addressed result cache.
2
+
3
+ Skips re-running an expensive tool when the same command + input has already
4
+ produced output within a TTL. Keys are hashes so the same passive enumeration
5
+ isn't repeated across runs of a workspace. Entirely local; lives in the
6
+ workspace `cache/` directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from ..utils import filesystem as fs
18
+
19
+
20
+ def make_key(*parts: str) -> str:
21
+ """Stable cache key from arbitrary string parts (tool, args, stdin)."""
22
+ digest = hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()
23
+ return digest[:32]
24
+
25
+
26
+ class Cache:
27
+ def __init__(self, root: Path, ttl_seconds: int = 86_400, enabled: bool = True) -> None:
28
+ self.root = root
29
+ self.ttl = ttl_seconds
30
+ self.enabled = enabled
31
+
32
+ def _path(self, key: str) -> Path:
33
+ return self.root / f"{key}.json"
34
+
35
+ def get(self, key: str) -> Optional[str]:
36
+ if not self.enabled:
37
+ return None
38
+ path = self._path(key)
39
+ if not path.exists():
40
+ return None
41
+ try:
42
+ record = json.loads(fs.read_text(path))
43
+ except (json.JSONDecodeError, OSError):
44
+ return None
45
+ if self.ttl and time.time() - record.get("ts", 0) > self.ttl:
46
+ return None
47
+ return record.get("value")
48
+
49
+ def set(self, key: str, value: str) -> None:
50
+ if not self.enabled:
51
+ return
52
+ fs.ensure_dir(self.root)
53
+ fs.write_text(self._path(key), json.dumps({"ts": time.time(), "value": value}))
54
+
55
+ def invalidate(self, key: str) -> None:
56
+ path = self._path(key)
57
+ if path.exists():
58
+ path.unlink()
59
+
60
+ def clear(self) -> int:
61
+ if not self.root.exists():
62
+ return 0
63
+ removed = 0
64
+ for f in self.root.glob("*.json"):
65
+ f.unlink()
66
+ removed += 1
67
+ return removed
huntkit/core/config.py ADDED
@@ -0,0 +1,272 @@
1
+ """Layered YAML configuration.
2
+
3
+ Resolution order (later wins):
4
+ 1. built-in defaults (this module)
5
+ 2. user config ~/.config/huntkit/huntkit.yaml (or $HUNTKIT_CONFIG_HOME)
6
+ 3. project config ./huntkit.yaml (cwd, walked upward)
7
+ 4. explicit --config path
8
+ 5. environment HUNTKIT_<SECTION>_<KEY>
9
+
10
+ Everything is typed via dataclasses so the rest of the codebase gets
11
+ autocomplete and mypy checking instead of dictionary spelunking.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import asdict, dataclass, field, fields, is_dataclass
18
+ from pathlib import Path
19
+ from typing import Any, Optional
20
+
21
+ import yaml
22
+
23
+ CONFIG_FILENAME = "huntkit.yaml"
24
+
25
+
26
+ # --------------------------------------------------------------------------
27
+ # Typed sections
28
+ # --------------------------------------------------------------------------
29
+ @dataclass
30
+ class GeneralConfig:
31
+ workspace_path: str = "~/.huntkit"
32
+ threads: int = 20
33
+ rate: int = 150 # requests/sec hint passed to rate-aware tools
34
+ timeout: int = 300 # per-command seconds
35
+ retry: int = 1 # retries on transient failure
36
+ default_report_format: str = "markdown"
37
+ color: bool = True
38
+
39
+
40
+ @dataclass
41
+ class HttpConfig:
42
+ proxy: Optional[str] = None # e.g. http://127.0.0.1:8080 (Burp)
43
+ user_agent: str = "HuntKit/0.2 (+https://github.com/Raresney/HuntKit)"
44
+ headers: dict[str, str] = field(default_factory=dict)
45
+ rate_limit: int = 150 # HTTP requests/sec hint passed to rate-aware tools
46
+ delay: float = 0.0 # seconds between requests for tools that support it
47
+ follow_redirects: bool = True
48
+
49
+
50
+ @dataclass
51
+ class ToolConfig:
52
+ """Per-tool overrides. `path` pins a binary; `extra_args` is appended."""
53
+
54
+ enabled: bool = True
55
+ path: Optional[str] = None
56
+ extra_args: list[str] = field(default_factory=list)
57
+ timeout: Optional[int] = None
58
+
59
+
60
+ @dataclass
61
+ class WordlistConfig:
62
+ dir_fuzz: Optional[str] = None
63
+ dns: Optional[str] = None
64
+ params: Optional[str] = None
65
+
66
+
67
+ @dataclass
68
+ class NucleiConfig:
69
+ templates: Optional[str] = None # custom template dir
70
+ severity: str = "low,medium,high,critical"
71
+ extra_args: list[str] = field(default_factory=list)
72
+
73
+
74
+ @dataclass
75
+ class AIConfig:
76
+ provider: str = "ollama" # ollama|openai|claude|gemini
77
+ model: str = "llama3.2"
78
+ endpoint: str = "http://127.0.0.1:11434"
79
+ require_approval: bool = True # never send engagement data silently
80
+ max_context_items: int = 40
81
+
82
+
83
+ @dataclass
84
+ class Config:
85
+ general: GeneralConfig = field(default_factory=GeneralConfig)
86
+ http: HttpConfig = field(default_factory=HttpConfig)
87
+ wordlists: WordlistConfig = field(default_factory=WordlistConfig)
88
+ nuclei: NucleiConfig = field(default_factory=NucleiConfig)
89
+ ai: AIConfig = field(default_factory=AIConfig)
90
+ tools: dict[str, ToolConfig] = field(default_factory=dict)
91
+ api_keys: dict[str, str] = field(default_factory=dict)
92
+
93
+ # provenance (not serialised into user config)
94
+ _sources: list[str] = field(default_factory=list, repr=False)
95
+
96
+ # ---- construction ----------------------------------------------------
97
+ @classmethod
98
+ def load(cls, explicit_path: Optional[Path] = None) -> Config:
99
+ cfg = cls()
100
+ for path in _candidate_paths(explicit_path):
101
+ data = _read_yaml(path)
102
+ if data:
103
+ cfg._merge(data)
104
+ cfg._sources.append(str(path))
105
+ cfg._apply_env()
106
+ return cfg
107
+
108
+ @classmethod
109
+ def from_dict(cls, data: dict[str, Any]) -> Config:
110
+ cfg = cls()
111
+ cfg._merge(data or {})
112
+ return cfg
113
+
114
+ # ---- access ----------------------------------------------------------
115
+ def tool(self, name: str) -> ToolConfig:
116
+ """Return the (possibly default) config for a named tool."""
117
+ return self.tools.get(name, ToolConfig())
118
+
119
+ @property
120
+ def workspace_root(self) -> Path:
121
+ return Path(self.general.workspace_path).expanduser()
122
+
123
+ def to_dict(self) -> dict[str, Any]:
124
+ data = _dataclass_to_dict(self)
125
+ data.pop("_sources", None)
126
+ return data
127
+
128
+ # ---- internal merge --------------------------------------------------
129
+ def _merge(self, data: dict[str, Any]) -> None:
130
+ for key in ("general", "http", "wordlists", "nuclei", "ai"):
131
+ if key in data and isinstance(data[key], dict):
132
+ _merge_into(getattr(self, key), data[key])
133
+ if "tools" in data and isinstance(data["tools"], dict):
134
+ for name, tdata in data["tools"].items():
135
+ base = self.tools.get(name, ToolConfig())
136
+ _merge_into(base, tdata or {})
137
+ self.tools[name] = base
138
+ if "api_keys" in data and isinstance(data["api_keys"], dict):
139
+ self.api_keys.update({k: str(v) for k, v in data["api_keys"].items()})
140
+
141
+ def _apply_env(self) -> None:
142
+ """Override scalars from HUNTKIT_<SECTION>_<KEY> environment vars."""
143
+ for section_name in ("general", "http", "ai", "nuclei"):
144
+ section = getattr(self, section_name)
145
+ for f in fields(section):
146
+ env_key = f"HUNTKIT_{section_name.upper()}_{f.name.upper()}"
147
+ if env_key in os.environ:
148
+ setattr(section, f.name, _coerce(os.environ[env_key], f.type))
149
+ # API keys: HUNTKIT_APIKEY_<NAME>
150
+ for env_key, val in os.environ.items():
151
+ if env_key.startswith("HUNTKIT_APIKEY_"):
152
+ self.api_keys[env_key[len("HUNTKIT_APIKEY_") :].lower()] = val
153
+
154
+
155
+ # --------------------------------------------------------------------------
156
+ # Helpers
157
+ # --------------------------------------------------------------------------
158
+ def _candidate_paths(explicit: Optional[Path]) -> list[Path]:
159
+ paths: list[Path] = []
160
+ # user-level
161
+ home = os.environ.get("HUNTKIT_CONFIG_HOME")
162
+ user_dir = Path(home).expanduser() if home else Path.home() / ".config" / "huntkit"
163
+ paths.append(user_dir / CONFIG_FILENAME)
164
+ # project-level: nearest huntkit.yaml walking up from cwd
165
+ project = _find_upwards(Path.cwd(), CONFIG_FILENAME)
166
+ if project:
167
+ paths.append(project)
168
+ if explicit:
169
+ paths.append(Path(explicit).expanduser())
170
+ return paths
171
+
172
+
173
+ def _find_upwards(start: Path, filename: str) -> Optional[Path]:
174
+ for parent in [start, *start.parents]:
175
+ candidate = parent / filename
176
+ if candidate.is_file():
177
+ return candidate
178
+ return None
179
+
180
+
181
+ def _read_yaml(path: Path) -> Optional[dict[str, Any]]:
182
+ try:
183
+ if not path.is_file():
184
+ return None
185
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
186
+ return loaded if isinstance(loaded, dict) else None
187
+ except (OSError, yaml.YAMLError):
188
+ return None
189
+
190
+
191
+ def _merge_into(obj: Any, data: dict[str, Any]) -> None:
192
+ """Shallow-merge dict values onto a dataclass instance, by field name."""
193
+ valid = {f.name for f in fields(obj)}
194
+ for key, value in data.items():
195
+ if key in valid and value is not None:
196
+ setattr(obj, key, value)
197
+
198
+
199
+ def _coerce(raw: str, target_type: Any) -> Any:
200
+ type_str = str(target_type)
201
+ if "int" in type_str:
202
+ try:
203
+ return int(raw)
204
+ except ValueError:
205
+ return raw
206
+ if "bool" in type_str:
207
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
208
+ return raw
209
+
210
+
211
+ def _dataclass_to_dict(obj: Any) -> Any:
212
+ if is_dataclass(obj) and not isinstance(obj, type):
213
+ return {k: _dataclass_to_dict(v) for k, v in asdict(obj).items()}
214
+ return obj
215
+
216
+
217
+ def sample_yaml() -> str:
218
+ """Render a documented default config for `huntkit config init`."""
219
+ return _SAMPLE_YAML
220
+
221
+
222
+ _SAMPLE_YAML = """\
223
+ # HuntKit configuration — https://github.com/Raresney/HuntKit
224
+ # Place at ~/.config/huntkit/huntkit.yaml (global) or ./huntkit.yaml (project).
225
+ # Every value below is a default; delete what you don't need to override.
226
+
227
+ general:
228
+ workspace_path: ~/.huntkit
229
+ threads: 20
230
+ rate: 150
231
+ timeout: 300
232
+ retry: 1
233
+ default_report_format: markdown # markdown | html | json
234
+ color: true
235
+
236
+ http:
237
+ proxy: null # e.g. http://127.0.0.1:8080 for Burp
238
+ user_agent: "HuntKit/0.2 (+https://github.com/Raresney/HuntKit)"
239
+ headers: {} # e.g. { X-Bug-Bounty: my-handle }
240
+ rate_limit: 150
241
+ follow_redirects: true
242
+
243
+ wordlists:
244
+ dir_fuzz: null # e.g. /usr/share/seclists/.../common.txt
245
+ dns: null
246
+ params: null
247
+
248
+ nuclei:
249
+ templates: null # custom template directory
250
+ severity: low,medium,high,critical
251
+ extra_args: []
252
+
253
+ ai:
254
+ provider: ollama # ollama | openai | claude | gemini
255
+ model: llama3.2
256
+ endpoint: http://127.0.0.1:11434
257
+ require_approval: true # never send recon data without a prompt
258
+ max_context_items: 40
259
+
260
+ # Per-tool overrides. Pin a binary path, disable a tool, or append args.
261
+ tools:
262
+ amass:
263
+ enabled: true
264
+ # path: /opt/amass/amass
265
+ # extra_args: ["-active"]
266
+
267
+ # Secrets pulled by data-source tools. Prefer env vars: HUNTKIT_APIKEY_<NAME>.
268
+ api_keys: {}
269
+ # chaos: "..."
270
+ # github: "..."
271
+ # shodan: "..."
272
+ """
huntkit/core/logger.py ADDED
@@ -0,0 +1,66 @@
1
+ """Structured logging built on Rich.
2
+
3
+ Console logging is human-friendly; an optional rotating file handler writes a
4
+ full engagement log into the workspace `logs/` directory. Feature modules
5
+ call `get_logger(__name__)` and never touch handler configuration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from rich.logging import RichHandler
15
+
16
+ from ..utils.terminal import console
17
+
18
+ _CONFIGURED = False
19
+ _ROOT = "huntkit"
20
+
21
+
22
+ def setup_logging(
23
+ level: str = "INFO",
24
+ logfile: Optional[Path] = None,
25
+ *,
26
+ verbose: bool = False,
27
+ ) -> None:
28
+ """Configure the `huntkit` logger tree once.
29
+
30
+ Safe to call repeatedly (e.g. after a workspace is opened to attach a
31
+ file handler) — handlers are reset rather than duplicated.
32
+ """
33
+ global _CONFIGURED
34
+ logger = logging.getLogger(_ROOT)
35
+ logger.setLevel(logging.DEBUG if verbose else getattr(logging, level.upper(), logging.INFO))
36
+ logger.handlers.clear()
37
+ logger.propagate = False
38
+
39
+ rich_handler = RichHandler(
40
+ console=console,
41
+ show_time=verbose,
42
+ show_path=verbose,
43
+ rich_tracebacks=True,
44
+ markup=True,
45
+ )
46
+ rich_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
47
+ logger.addHandler(rich_handler)
48
+
49
+ if logfile is not None:
50
+ logfile.parent.mkdir(parents=True, exist_ok=True)
51
+ file_handler = logging.FileHandler(logfile, encoding="utf-8")
52
+ file_handler.setLevel(logging.DEBUG)
53
+ file_handler.setFormatter(
54
+ logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
55
+ )
56
+ logger.addHandler(file_handler)
57
+
58
+ _CONFIGURED = True
59
+
60
+
61
+ def get_logger(name: str = _ROOT) -> logging.Logger:
62
+ if not _CONFIGURED:
63
+ setup_logging()
64
+ if name == _ROOT or name.startswith(_ROOT + "."):
65
+ return logging.getLogger(name)
66
+ return logging.getLogger(f"{_ROOT}.{name}")
huntkit/core/pacing.py ADDED
@@ -0,0 +1,71 @@
1
+ """Politeness — one policy that paces every rate-aware tool.
2
+
3
+ Real bug-bounty programs ban noisy scanners, and "authorised" does not mean
4
+ "hammer the target". HuntKit keeps a single rate / delay / concurrency policy
5
+ (from config) and maps it onto each tool's native flags, so dialing a program
6
+ down to a gentle crawl is one edit, not twelve scattered ones.
7
+
8
+ Pure and offline: it only turns the policy into argv fragments.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from .config import Config
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Pace:
22
+ """The politeness policy, normalised from config.
23
+
24
+ ``rate`` is HTTP requests/sec; ``port_rate`` is packets/sec for the port
25
+ scanner (a different budget); ``delay`` is seconds between requests for the
26
+ tools that support it; ``concurrency`` caps workers. A zero rate/delay
27
+ means "no limit" / "no wait".
28
+ """
29
+
30
+ rate: int
31
+ port_rate: int
32
+ delay: float
33
+ concurrency: int
34
+
35
+ @classmethod
36
+ def from_config(cls, config: Config) -> Pace:
37
+ return cls(
38
+ rate=max(0, config.http.rate_limit),
39
+ port_rate=max(0, config.general.rate),
40
+ delay=max(0.0, config.http.delay),
41
+ concurrency=max(1, config.general.threads),
42
+ )
43
+
44
+
45
+ def duration(seconds: float) -> str:
46
+ """Render seconds as a ProjectDiscovery duration string: '500ms', '2s'."""
47
+ if seconds < 1:
48
+ return f"{int(round(seconds * 1000))}ms"
49
+ whole = int(seconds)
50
+ return f"{whole}s" if seconds == whole else f"{seconds}s"
51
+
52
+
53
+ def rate_arg(flag: str, rate: int) -> list[str]:
54
+ """``[flag, rate]`` when a positive limit is set, else nothing."""
55
+ return [flag, str(rate)] if rate > 0 else []
56
+
57
+
58
+ def delay_arg(flag: str, seconds: float, *, unit: str = "pd") -> list[str]:
59
+ """``[flag, value]`` for an inter-request delay, in the tool's own unit.
60
+
61
+ ``unit`` picks the rendering: ``pd`` → a ProjectDiscovery duration string
62
+ ('500ms'), ``sec`` → plain fractional seconds (ffuf ``-p``), ``ms`` → whole
63
+ milliseconds (dalfox ``--delay``). A non-positive delay emits nothing.
64
+ """
65
+ if seconds <= 0:
66
+ return []
67
+ if unit == "sec":
68
+ return [flag, str(seconds)]
69
+ if unit == "ms":
70
+ return [flag, str(int(round(seconds * 1000)))]
71
+ return [flag, duration(seconds)]