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/plugins/base.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The plugin contract.
|
|
2
|
+
|
|
3
|
+
Every external tool HuntKit drives is a :class:`ToolPlugin`. A plugin is a
|
|
4
|
+
small, declarative description of *one* binary:
|
|
5
|
+
|
|
6
|
+
- metadata name / category / install hint / description
|
|
7
|
+
- typing what it consumes and what it produces (a :class:`Capability`)
|
|
8
|
+
- ``build_args`` turn a run context into the argv that follows the binary
|
|
9
|
+
- ``parse`` turn the process output into a list of items
|
|
10
|
+
|
|
11
|
+
Everything hard — binary resolution, per-tool timeouts and extra args, proxy
|
|
12
|
+
environment, retries, caching — already lives in :class:`~huntkit.core.runner.
|
|
13
|
+
CommandRunner`. A plugin never touches subprocess; it only says *what* to run
|
|
14
|
+
and *how to read the result*. That is what makes adding a new tool a matter of
|
|
15
|
+
dropping one file into this package: the registry discovers it, the pipeline
|
|
16
|
+
schedules it by capability, and nothing else changes.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from typing import TYPE_CHECKING, Optional
|
|
25
|
+
|
|
26
|
+
from ..utils.process import ProcResult
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # avoid import cycle at runtime; only needed for typing
|
|
29
|
+
from ..core.config import Config
|
|
30
|
+
from ..core.runner import CommandRunner
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Capability(str, Enum):
|
|
34
|
+
"""The data types that flow between plugins in the recon pipeline.
|
|
35
|
+
|
|
36
|
+
A plugin ``consumes`` one type and ``produces`` another; the pipeline
|
|
37
|
+
chains plugins by matching output to input (subdomains -> hosts -> urls
|
|
38
|
+
-> findings) without any plugin knowing about the others.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
DOMAIN = "domain" # a seed root domain (example.com)
|
|
42
|
+
SUBDOMAIN = "subdomain" # a discovered name (api.example.com)
|
|
43
|
+
HOST = "host" # a name that resolves / is probed live
|
|
44
|
+
URL = "url" # a live http(s) endpoint
|
|
45
|
+
PORT = "port" # an open host:port
|
|
46
|
+
PARAM = "param" # a discovered request parameter
|
|
47
|
+
JS = "js" # a javascript asset
|
|
48
|
+
FINDING = "finding" # a vuln / signal worth a human's attention
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Category(str, Enum):
|
|
52
|
+
"""Coarse grouping for `doctor`, help output, and pipeline stages."""
|
|
53
|
+
|
|
54
|
+
DISCOVERY = "discovery" # find subdomains / assets
|
|
55
|
+
RESOLVE = "resolve" # probe which are live + fingerprint
|
|
56
|
+
PORTS = "ports" # port / service scanning
|
|
57
|
+
URLS = "urls" # gather / crawl urls
|
|
58
|
+
SCAN = "scan" # vuln / fuzz / param analysis
|
|
59
|
+
ENRICH = "enrich" # metadata (asn, cdn, waf, favicon, ...)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InputMode(str, Enum):
|
|
63
|
+
"""How the target feeds into the tool.
|
|
64
|
+
|
|
65
|
+
Only :data:`STDIN` is auto-wired by the base class; for the others the
|
|
66
|
+
plugin places the target/inputs itself inside :meth:`ToolPlugin.build_args`,
|
|
67
|
+
because flag placement differs per tool (``-d dom`` vs positional vs ``-t``).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
TARGET = "target" # a single seed placed by build_args (ctx.target)
|
|
71
|
+
STDIN = "stdin" # ctx.inputs piped to the process stdin, newline-joined
|
|
72
|
+
ARGS = "args" # ctx.inputs placed by build_args as argv
|
|
73
|
+
NONE = "none" # takes no external target
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class PluginContext:
|
|
78
|
+
"""Everything a plugin needs to build and run one invocation.
|
|
79
|
+
|
|
80
|
+
Deliberately small and decoupled: a plugin sees the config and a runner,
|
|
81
|
+
a seed ``target`` (for discovery) and/or a list of ``inputs`` (for tools
|
|
82
|
+
that consume the previous stage's output). ``extra`` is an escape hatch
|
|
83
|
+
for one-off knobs (e.g. a FUZZ url) without growing this class.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
config: Config
|
|
87
|
+
runner: CommandRunner
|
|
88
|
+
target: Optional[str] = None
|
|
89
|
+
inputs: list[str] = field(default_factory=list)
|
|
90
|
+
extra: dict = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class PluginResult:
|
|
95
|
+
"""Outcome of a single plugin run."""
|
|
96
|
+
|
|
97
|
+
plugin: str
|
|
98
|
+
produces: Capability
|
|
99
|
+
items: list[str] = field(default_factory=list)
|
|
100
|
+
raw: Optional[ProcResult] = None
|
|
101
|
+
ok: bool = False
|
|
102
|
+
skipped: bool = False # tool not installed / no input — not a failure
|
|
103
|
+
reason: str = ""
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def count(self) -> int:
|
|
107
|
+
return len(self.items)
|
|
108
|
+
|
|
109
|
+
def __bool__(self) -> bool:
|
|
110
|
+
return self.ok and not self.skipped
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ToolPlugin(ABC):
|
|
114
|
+
"""Base class for every tool integration.
|
|
115
|
+
|
|
116
|
+
Subclasses set the class attributes and implement :meth:`build_args`.
|
|
117
|
+
Override :meth:`parse` when the tool's output is not one item per line.
|
|
118
|
+
The registry instantiates each concrete subclass once (they are stateless).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# ---- metadata (override as class attributes) -------------------------
|
|
122
|
+
name: str = "" # registry key; usually the binary name
|
|
123
|
+
binary: str = "" # executable to resolve (defaults to name)
|
|
124
|
+
category: Category = Category.DISCOVERY
|
|
125
|
+
description: str = ""
|
|
126
|
+
install: str = "" # copy-pasteable install hint
|
|
127
|
+
consumes: Optional[Capability] = None
|
|
128
|
+
produces: Capability = Capability.SUBDOMAIN
|
|
129
|
+
input_mode: InputMode = InputMode.TARGET
|
|
130
|
+
needs_api_key: Optional[str] = None # api_keys entry required, if any
|
|
131
|
+
|
|
132
|
+
# A subclass is registrable only once it declares a name. The abstract
|
|
133
|
+
# base and any intermediate helpers stay out of the registry.
|
|
134
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
135
|
+
super().__init_subclass__(**kwargs)
|
|
136
|
+
if not getattr(cls, "binary", "") and getattr(cls, "name", ""): # type: ignore[arg-type]
|
|
137
|
+
cls.binary = cls.name
|
|
138
|
+
|
|
139
|
+
# ---- the two things a plugin must / may define -----------------------
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
142
|
+
"""Return the argv that follows the binary for this invocation.
|
|
143
|
+
|
|
144
|
+
For STDIN tools this is just the flags (input arrives via stdin);
|
|
145
|
+
for TARGET/ARGS tools, place ``ctx.target`` / ``ctx.inputs`` here.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def parse(self, result: ProcResult, ctx: PluginContext) -> list[str]:
|
|
149
|
+
"""Extract produced items from the output. Default: one per line."""
|
|
150
|
+
return result.lines
|
|
151
|
+
|
|
152
|
+
# ---- optional hooks --------------------------------------------------
|
|
153
|
+
def stdin_payload(self, ctx: PluginContext) -> Optional[str]:
|
|
154
|
+
"""What to pipe to stdin. Default: inputs when in STDIN mode."""
|
|
155
|
+
if self.input_mode is InputMode.STDIN and ctx.inputs:
|
|
156
|
+
return "\n".join(ctx.inputs)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def cache_key(self, ctx: PluginContext) -> Optional[str]:
|
|
160
|
+
"""Content-addressed cache key, or None to disable caching.
|
|
161
|
+
|
|
162
|
+
Active only when the run opts in (``ctx.extra["use_cache"]``). The key
|
|
163
|
+
binds the binary, its arguments, and the stdin payload, so identical
|
|
164
|
+
invocations hit the cache and any change misses it.
|
|
165
|
+
"""
|
|
166
|
+
if not ctx.extra.get("use_cache"):
|
|
167
|
+
return None
|
|
168
|
+
from ..core.cache import make_key
|
|
169
|
+
|
|
170
|
+
payload = self.stdin_payload(ctx) or ""
|
|
171
|
+
return make_key(self.binary, *self.build_args(ctx), payload)
|
|
172
|
+
|
|
173
|
+
def is_available(self, runner: CommandRunner) -> bool:
|
|
174
|
+
return runner.available(self.binary)
|
|
175
|
+
|
|
176
|
+
# ---- orchestration (do not usually override) -------------------------
|
|
177
|
+
def execute(self, ctx: PluginContext) -> PluginResult:
|
|
178
|
+
"""Resolve, run, and parse — returning a uniform :class:`PluginResult`.
|
|
179
|
+
|
|
180
|
+
Never raises for the ordinary "tool missing" / "empty output" cases;
|
|
181
|
+
those are surfaced as ``skipped``/empty results so one gap does not
|
|
182
|
+
abort a whole pipeline.
|
|
183
|
+
"""
|
|
184
|
+
from ..core.runner import ToolNotFound
|
|
185
|
+
|
|
186
|
+
if self.needs_api_key and not ctx.config.api_keys.get(self.needs_api_key):
|
|
187
|
+
return PluginResult(
|
|
188
|
+
self.name,
|
|
189
|
+
self.produces,
|
|
190
|
+
skipped=True,
|
|
191
|
+
reason=f"missing api key: {self.needs_api_key}",
|
|
192
|
+
)
|
|
193
|
+
if not self.is_available(ctx.runner):
|
|
194
|
+
return PluginResult(self.name, self.produces, skipped=True, reason="not installed")
|
|
195
|
+
if self.input_mode in (InputMode.STDIN, InputMode.ARGS) and not ctx.inputs:
|
|
196
|
+
return PluginResult(self.name, self.produces, skipped=True, reason="no input")
|
|
197
|
+
|
|
198
|
+
args = self.build_args(ctx)
|
|
199
|
+
try:
|
|
200
|
+
result = ctx.runner.run(
|
|
201
|
+
self.binary,
|
|
202
|
+
args,
|
|
203
|
+
stdin_data=self.stdin_payload(ctx),
|
|
204
|
+
cache_key=self.cache_key(ctx),
|
|
205
|
+
)
|
|
206
|
+
except ToolNotFound as exc:
|
|
207
|
+
return PluginResult(self.name, self.produces, skipped=True, reason=str(exc))
|
|
208
|
+
|
|
209
|
+
# Non-zero exit is common (empty source); still parse what came back.
|
|
210
|
+
items = self.parse(result, ctx)
|
|
211
|
+
return PluginResult(
|
|
212
|
+
self.name,
|
|
213
|
+
self.produces,
|
|
214
|
+
items=items,
|
|
215
|
+
raw=result,
|
|
216
|
+
ok=result.ok,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
220
|
+
return f"<{type(self).__name__} {self.name} {self.category.value}>"
|
huntkit/plugins/chaos.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""chaos — ProjectDiscovery's Chaos subdomain dataset (needs an API key)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Chaos(ToolPlugin):
|
|
9
|
+
name = "chaos"
|
|
10
|
+
category = Category.DISCOVERY
|
|
11
|
+
description = "Query the ProjectDiscovery Chaos dataset"
|
|
12
|
+
install = "go install github.com/projectdiscovery/chaos-client/cmd/chaos@latest"
|
|
13
|
+
consumes = Capability.DOMAIN
|
|
14
|
+
produces = Capability.SUBDOMAIN
|
|
15
|
+
input_mode = InputMode.TARGET
|
|
16
|
+
needs_api_key = "chaos"
|
|
17
|
+
|
|
18
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
19
|
+
key = ctx.config.api_keys.get("chaos", "")
|
|
20
|
+
return ["-d", str(ctx.target), "-silent", "-key", key]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""dalfox — XSS scanning / parameter analysis (hahwul)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..core.pacing import Pace, delay_arg
|
|
6
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Dalfox(ToolPlugin):
|
|
10
|
+
name = "dalfox"
|
|
11
|
+
category = Category.SCAN
|
|
12
|
+
description = "XSS scanning / parameter analysis"
|
|
13
|
+
install = "go install github.com/hahwul/dalfox/v2@latest"
|
|
14
|
+
consumes = Capability.URL
|
|
15
|
+
produces = Capability.FINDING
|
|
16
|
+
input_mode = InputMode.STDIN
|
|
17
|
+
|
|
18
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
19
|
+
# pipe mode reads candidate urls from stdin
|
|
20
|
+
pace = Pace.from_config(ctx.config)
|
|
21
|
+
args = ["pipe", "--silence", "--no-color", "--no-spinner"]
|
|
22
|
+
args += ["--worker", str(pace.concurrency)]
|
|
23
|
+
args += delay_arg("--delay", pace.delay, unit="ms") # dalfox wants ms
|
|
24
|
+
return args
|
huntkit/plugins/dnsx.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""dnsx — fast DNS resolver; filters a name list down to what resolves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..core.pacing import Pace, rate_arg
|
|
6
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Dnsx(ToolPlugin):
|
|
10
|
+
name = "dnsx"
|
|
11
|
+
category = Category.RESOLVE
|
|
12
|
+
description = "Resolve a subdomain list, keeping only live DNS names"
|
|
13
|
+
install = "go install github.com/projectdiscovery/dnsx/cmd/dnsx@latest"
|
|
14
|
+
consumes = Capability.SUBDOMAIN
|
|
15
|
+
produces = Capability.HOST
|
|
16
|
+
input_mode = InputMode.STDIN
|
|
17
|
+
|
|
18
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
19
|
+
return ["-silent", "-nc", *rate_arg("-rate-limit", Pace.from_config(ctx.config).rate)]
|
huntkit/plugins/ffuf.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""ffuf — content/vhost/parameter fuzzing.
|
|
2
|
+
|
|
3
|
+
Needs a URL containing the ``FUZZ`` marker and a wordlist. If the target has
|
|
4
|
+
no marker, ``/FUZZ`` is appended; the wordlist comes from ``ctx.extra`` or
|
|
5
|
+
``wordlists.dir_fuzz`` in config.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..core.pacing import Pace, delay_arg, rate_arg
|
|
11
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Ffuf(ToolPlugin):
|
|
15
|
+
name = "ffuf"
|
|
16
|
+
category = Category.SCAN
|
|
17
|
+
description = "Content/vhost/parameter fuzzing"
|
|
18
|
+
install = "go install github.com/ffuf/ffuf/v2@latest"
|
|
19
|
+
consumes = Capability.URL
|
|
20
|
+
produces = Capability.URL
|
|
21
|
+
input_mode = InputMode.TARGET
|
|
22
|
+
|
|
23
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
24
|
+
url = str(ctx.target or "")
|
|
25
|
+
if "FUZZ" not in url:
|
|
26
|
+
url = url.rstrip("/") + "/FUZZ"
|
|
27
|
+
args = ["-u", url, "-s", "-mc", "200,204,301,302,307,401,403"]
|
|
28
|
+
wordlist = ctx.extra.get("wordlist") or ctx.config.wordlists.dir_fuzz
|
|
29
|
+
if wordlist:
|
|
30
|
+
args += ["-w", str(wordlist)]
|
|
31
|
+
pace = Pace.from_config(ctx.config)
|
|
32
|
+
args += rate_arg("-rate", pace.rate) # ffuf: total requests/sec
|
|
33
|
+
args += delay_arg("-p", pace.delay, unit="sec") # seconds between requests
|
|
34
|
+
return args
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""findomain — fast passive subdomain enumeration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Findomain(ToolPlugin):
|
|
9
|
+
name = "findomain"
|
|
10
|
+
category = Category.DISCOVERY
|
|
11
|
+
description = "Fast passive subdomain enumeration"
|
|
12
|
+
install = "sudo apt install findomain # or grab a release binary"
|
|
13
|
+
consumes = Capability.DOMAIN
|
|
14
|
+
produces = Capability.SUBDOMAIN
|
|
15
|
+
input_mode = InputMode.TARGET
|
|
16
|
+
|
|
17
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
18
|
+
return ["-t", str(ctx.target), "-q"]
|
huntkit/plugins/gau.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""gau — fetch known URLs from wayback/otx/commoncrawl (lc/gau)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Gau(ToolPlugin):
|
|
9
|
+
name = "gau"
|
|
10
|
+
category = Category.URLS
|
|
11
|
+
description = "Fetch known URLs (wayback/otx/commoncrawl)"
|
|
12
|
+
install = "go install github.com/lc/gau/v2/cmd/gau@latest"
|
|
13
|
+
consumes = Capability.DOMAIN
|
|
14
|
+
produces = Capability.URL
|
|
15
|
+
input_mode = InputMode.TARGET
|
|
16
|
+
|
|
17
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
18
|
+
return ["--threads", str(ctx.config.general.threads), str(ctx.target)]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""hakrawler — fast endpoint crawler that reads live urls from stdin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Hakrawler(ToolPlugin):
|
|
9
|
+
name = "hakrawler"
|
|
10
|
+
category = Category.URLS
|
|
11
|
+
description = "Crawl live hosts for endpoints and assets"
|
|
12
|
+
install = "go install github.com/hakluke/hakrawler@latest"
|
|
13
|
+
consumes = Capability.URL
|
|
14
|
+
produces = Capability.URL
|
|
15
|
+
input_mode = InputMode.STDIN
|
|
16
|
+
|
|
17
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
18
|
+
# -u unique, -d crawl depth
|
|
19
|
+
return ["-u", "-d", "2"]
|
huntkit/plugins/httpx.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""httpx — probe live hosts, titles, tech, status codes (ProjectDiscovery).
|
|
2
|
+
|
|
3
|
+
Note: on a bug-bounty box this is ProjectDiscovery's ``httpx``. A Python
|
|
4
|
+
package of the same name ships a different ``httpx`` CLI; pin
|
|
5
|
+
``tools.httpx.path`` in config if both are installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..core.pacing import Pace, delay_arg, rate_arg
|
|
11
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Httpx(ToolPlugin):
|
|
15
|
+
name = "httpx"
|
|
16
|
+
category = Category.RESOLVE
|
|
17
|
+
description = "Probe live hosts (status, title, tech-detect)"
|
|
18
|
+
install = "go install github.com/projectdiscovery/httpx/cmd/httpx@latest"
|
|
19
|
+
consumes = Capability.SUBDOMAIN
|
|
20
|
+
produces = Capability.URL
|
|
21
|
+
input_mode = InputMode.STDIN
|
|
22
|
+
|
|
23
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
24
|
+
pace = Pace.from_config(ctx.config)
|
|
25
|
+
args = ["-silent", "-no-color", "-t", str(ctx.config.general.threads)]
|
|
26
|
+
args += rate_arg("-rate-limit", pace.rate)
|
|
27
|
+
args += delay_arg("-delay", pace.delay)
|
|
28
|
+
if ctx.config.http.follow_redirects:
|
|
29
|
+
args.append("-follow-redirects")
|
|
30
|
+
return args
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""katana — active crawler / URL discovery (ProjectDiscovery)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..core.pacing import Pace, rate_arg
|
|
6
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Katana(ToolPlugin):
|
|
10
|
+
name = "katana"
|
|
11
|
+
category = Category.URLS
|
|
12
|
+
description = "Active crawler / URL discovery"
|
|
13
|
+
install = "go install github.com/projectdiscovery/katana/cmd/katana@latest"
|
|
14
|
+
consumes = Capability.URL
|
|
15
|
+
produces = Capability.URL
|
|
16
|
+
input_mode = InputMode.STDIN
|
|
17
|
+
|
|
18
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
19
|
+
# urls arrive via stdin; -d = crawl depth, -c = concurrency
|
|
20
|
+
pace = Pace.from_config(ctx.config)
|
|
21
|
+
args = ["-silent", "-nc", "-d", "2", "-c", str(ctx.config.general.threads)]
|
|
22
|
+
args += rate_arg("-rate-limit", pace.rate)
|
|
23
|
+
return args
|
huntkit/plugins/naabu.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""naabu — fast SYN/CONNECT port scanner (ProjectDiscovery)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..core.pacing import Pace, rate_arg
|
|
6
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Naabu(ToolPlugin):
|
|
10
|
+
name = "naabu"
|
|
11
|
+
category = Category.PORTS
|
|
12
|
+
description = "Fast port scanner"
|
|
13
|
+
install = "go install github.com/projectdiscovery/naabu/v2/cmd/naabu@latest"
|
|
14
|
+
consumes = Capability.HOST
|
|
15
|
+
produces = Capability.PORT
|
|
16
|
+
input_mode = InputMode.STDIN
|
|
17
|
+
|
|
18
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
19
|
+
# hosts arrive via stdin; emit host:port on stdout. port_rate is a
|
|
20
|
+
# packet budget, a different knob from the HTTP request rate.
|
|
21
|
+
pace = Pace.from_config(ctx.config)
|
|
22
|
+
return ["-silent", "-no-color", *rate_arg("-rate", pace.port_rate)]
|
huntkit/plugins/nmap.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""nmap — port scan + service/version detection.
|
|
2
|
+
|
|
3
|
+
Reads targets as argv (nmap has no stdin list mode) and emits greppable
|
|
4
|
+
output on stdout, which we parse into ``host:port`` entries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..utils.process import ProcResult
|
|
10
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Nmap(ToolPlugin):
|
|
14
|
+
name = "nmap"
|
|
15
|
+
category = Category.PORTS
|
|
16
|
+
description = "Port scan + service/version detection"
|
|
17
|
+
install = "sudo apt install nmap"
|
|
18
|
+
consumes = Capability.HOST
|
|
19
|
+
produces = Capability.PORT
|
|
20
|
+
input_mode = InputMode.ARGS
|
|
21
|
+
|
|
22
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
23
|
+
return ["-T4", "-Pn", "--top-ports", "1000", "-oG", "-", *ctx.inputs]
|
|
24
|
+
|
|
25
|
+
def parse(self, result: ProcResult, ctx: PluginContext) -> list[str]:
|
|
26
|
+
out: list[str] = []
|
|
27
|
+
for line in result.lines:
|
|
28
|
+
if "Ports:" not in line:
|
|
29
|
+
continue
|
|
30
|
+
parts = line.split()
|
|
31
|
+
if len(parts) < 2 or parts[0] != "Host:":
|
|
32
|
+
continue
|
|
33
|
+
host = parts[1]
|
|
34
|
+
# e.g. "Ports: 22/open/tcp//ssh///, 80/open/tcp//http///"
|
|
35
|
+
for chunk in line.split("Ports:", 1)[1].split(","):
|
|
36
|
+
fields = chunk.strip().split("/")
|
|
37
|
+
if len(fields) >= 2 and fields[1] == "open":
|
|
38
|
+
out.append(f"{host}:{fields[0]}")
|
|
39
|
+
return out
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""nuclei — template-based vulnerability scanner (ProjectDiscovery)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..core.pacing import Pace, rate_arg
|
|
6
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Nuclei(ToolPlugin):
|
|
10
|
+
name = "nuclei"
|
|
11
|
+
category = Category.SCAN
|
|
12
|
+
description = "Template-based vulnerability scanner"
|
|
13
|
+
install = "go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest"
|
|
14
|
+
consumes = Capability.URL
|
|
15
|
+
produces = Capability.FINDING
|
|
16
|
+
input_mode = InputMode.STDIN
|
|
17
|
+
|
|
18
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
19
|
+
n = ctx.config.nuclei
|
|
20
|
+
args = ["-silent", "-nc"]
|
|
21
|
+
if n.severity:
|
|
22
|
+
args += ["-severity", n.severity]
|
|
23
|
+
if n.templates:
|
|
24
|
+
args += ["-t", n.templates]
|
|
25
|
+
args += rate_arg("-rate-limit", Pace.from_config(ctx.config).rate)
|
|
26
|
+
args += n.extra_args
|
|
27
|
+
return args
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Plugin discovery and lookup.
|
|
2
|
+
|
|
3
|
+
The registry is the reason a new tool costs one file: on first use it walks
|
|
4
|
+
this package, imports every module, and collects every concrete
|
|
5
|
+
:class:`~huntkit.plugins.base.ToolPlugin` subclass. Nothing has to be listed
|
|
6
|
+
by hand — drop ``huntkit/plugins/mytool.py`` in and it is picked up.
|
|
7
|
+
|
|
8
|
+
Consumers ask by name, category, or capability so the CLI (`doctor`, `plugins`)
|
|
9
|
+
and the recon pipeline can reason about tools generically.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import inspect
|
|
16
|
+
import pkgutil
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from typing import TYPE_CHECKING, Optional
|
|
19
|
+
|
|
20
|
+
from .base import Capability, Category, ToolPlugin
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ..core.runner import CommandRunner
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PluginRegistry:
|
|
27
|
+
"""An in-memory index of plugin singletons, keyed by name."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._by_name: dict[str, ToolPlugin] = {}
|
|
31
|
+
|
|
32
|
+
# ---- population ------------------------------------------------------
|
|
33
|
+
def register(self, plugin: ToolPlugin) -> None:
|
|
34
|
+
if not plugin.name:
|
|
35
|
+
raise ValueError(f"{type(plugin).__name__} has no name")
|
|
36
|
+
if plugin.name in self._by_name:
|
|
37
|
+
raise ValueError(f"duplicate plugin name: {plugin.name!r}")
|
|
38
|
+
self._by_name[plugin.name] = plugin
|
|
39
|
+
|
|
40
|
+
# ---- lookup ----------------------------------------------------------
|
|
41
|
+
def get(self, name: str) -> Optional[ToolPlugin]:
|
|
42
|
+
return self._by_name.get(name)
|
|
43
|
+
|
|
44
|
+
def __contains__(self, name: object) -> bool:
|
|
45
|
+
return name in self._by_name
|
|
46
|
+
|
|
47
|
+
def __len__(self) -> int:
|
|
48
|
+
return len(self._by_name)
|
|
49
|
+
|
|
50
|
+
def all(self) -> list[ToolPlugin]:
|
|
51
|
+
return sorted(self._by_name.values(), key=lambda p: (p.category.value, p.name))
|
|
52
|
+
|
|
53
|
+
def names(self) -> list[str]:
|
|
54
|
+
return sorted(self._by_name)
|
|
55
|
+
|
|
56
|
+
def by_category(self, category: Category) -> list[ToolPlugin]:
|
|
57
|
+
return [p for p in self.all() if p.category is category]
|
|
58
|
+
|
|
59
|
+
def producing(self, cap: Capability) -> list[ToolPlugin]:
|
|
60
|
+
return [p for p in self.all() if p.produces is cap]
|
|
61
|
+
|
|
62
|
+
def consuming(self, cap: Capability) -> list[ToolPlugin]:
|
|
63
|
+
return [p for p in self.all() if p.consumes is cap]
|
|
64
|
+
|
|
65
|
+
def available(self, runner: CommandRunner) -> list[ToolPlugin]:
|
|
66
|
+
"""Plugins whose binary is installed / pinned right now."""
|
|
67
|
+
return [p for p in self.all() if p.is_available(runner)]
|
|
68
|
+
|
|
69
|
+
def missing(self, runner: CommandRunner) -> list[ToolPlugin]:
|
|
70
|
+
return [p for p in self.all() if not p.is_available(runner)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
# Discovery
|
|
75
|
+
# --------------------------------------------------------------------------
|
|
76
|
+
def _iter_plugin_classes(package_name: str) -> Iterable[type[ToolPlugin]]:
|
|
77
|
+
"""Import every module in the package and yield concrete plugin classes."""
|
|
78
|
+
package = importlib.import_module(package_name)
|
|
79
|
+
for mod in pkgutil.iter_modules(package.__path__):
|
|
80
|
+
if mod.name in {"base", "registry"} or mod.name.startswith("_"):
|
|
81
|
+
continue
|
|
82
|
+
importlib.import_module(f"{package_name}.{mod.name}")
|
|
83
|
+
|
|
84
|
+
seen: set[type[ToolPlugin]] = set()
|
|
85
|
+
stack: list[type[ToolPlugin]] = list(ToolPlugin.__subclasses__())
|
|
86
|
+
while stack:
|
|
87
|
+
cls = stack.pop()
|
|
88
|
+
if cls in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(cls)
|
|
91
|
+
stack.extend(cls.__subclasses__())
|
|
92
|
+
if inspect.isabstract(cls) or not getattr(cls, "name", ""):
|
|
93
|
+
continue
|
|
94
|
+
# only plugins that actually live in this package — never a stray
|
|
95
|
+
# subclass defined by tests or downstream code
|
|
96
|
+
if not cls.__module__.startswith(package_name):
|
|
97
|
+
continue
|
|
98
|
+
yield cls
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def discover(package_name: str = __package__ or "huntkit.plugins") -> PluginRegistry:
|
|
102
|
+
"""Build a fresh registry by scanning ``package_name`` for plugins."""
|
|
103
|
+
registry = PluginRegistry()
|
|
104
|
+
for cls in _iter_plugin_classes(package_name):
|
|
105
|
+
registry.register(cls())
|
|
106
|
+
return registry
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_REGISTRY: Optional[PluginRegistry] = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_registry(*, refresh: bool = False) -> PluginRegistry:
|
|
113
|
+
"""Return the process-wide registry, discovering plugins on first use."""
|
|
114
|
+
global _REGISTRY
|
|
115
|
+
if _REGISTRY is None or refresh:
|
|
116
|
+
_REGISTRY = discover()
|
|
117
|
+
return _REGISTRY
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""subfinder — passive subdomain enumeration (ProjectDiscovery)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..core.pacing import Pace, rate_arg
|
|
6
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Subfinder(ToolPlugin):
|
|
10
|
+
name = "subfinder"
|
|
11
|
+
category = Category.DISCOVERY
|
|
12
|
+
description = "Passive subdomain enumeration"
|
|
13
|
+
install = "go install github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest"
|
|
14
|
+
consumes = Capability.DOMAIN
|
|
15
|
+
produces = Capability.SUBDOMAIN
|
|
16
|
+
input_mode = InputMode.TARGET
|
|
17
|
+
|
|
18
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
19
|
+
args = ["-silent", "-d", str(ctx.target)]
|
|
20
|
+
args += rate_arg("-rate-limit", Pace.from_config(ctx.config).rate)
|
|
21
|
+
return args
|