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/__init__.py
ADDED
huntkit/__main__.py
ADDED
huntkit/ai/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""AI layer — optional, multi-provider advisor over the intel report.
|
|
2
|
+
|
|
3
|
+
A feature layer (like intel/knowledge/report) that builds on ``core`` and
|
|
4
|
+
``intel``. Local providers (Ollama) run fully offline; cloud providers are
|
|
5
|
+
gated behind explicit approval so engagement data is never sent silently.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .advisor import (
|
|
11
|
+
SYSTEM_PROMPT,
|
|
12
|
+
Advice,
|
|
13
|
+
Advisor,
|
|
14
|
+
ApprovalDenied,
|
|
15
|
+
build_context,
|
|
16
|
+
make_advisor,
|
|
17
|
+
)
|
|
18
|
+
from .providers import (
|
|
19
|
+
AnthropicProvider,
|
|
20
|
+
OllamaProvider,
|
|
21
|
+
OpenAIProvider,
|
|
22
|
+
Provider,
|
|
23
|
+
ProviderError,
|
|
24
|
+
get_provider,
|
|
25
|
+
provider_names,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"SYSTEM_PROMPT",
|
|
30
|
+
"Advice",
|
|
31
|
+
"Advisor",
|
|
32
|
+
"ApprovalDenied",
|
|
33
|
+
"AnthropicProvider",
|
|
34
|
+
"OllamaProvider",
|
|
35
|
+
"OpenAIProvider",
|
|
36
|
+
"Provider",
|
|
37
|
+
"ProviderError",
|
|
38
|
+
"build_context",
|
|
39
|
+
"get_provider",
|
|
40
|
+
"make_advisor",
|
|
41
|
+
"provider_names",
|
|
42
|
+
]
|
huntkit/ai/advisor.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""AI advisor — turn a scored intel report into a prioritised action plan.
|
|
2
|
+
|
|
3
|
+
The advisor is where the security guarantee lives: for a **cloud** provider it
|
|
4
|
+
refuses to send anything unless an ``approve`` callback (shown the exact text)
|
|
5
|
+
returns True. That check runs in this layer, not just the CLI, so no caller can
|
|
6
|
+
accidentally exfiltrate engagement data — the gate is enforced before
|
|
7
|
+
``provider.complete`` is ever reached.
|
|
8
|
+
|
|
9
|
+
It reasons only over data HuntKit already gathered (an :class:`IntelReport`);
|
|
10
|
+
it never touches the target.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from ..core.config import Config
|
|
20
|
+
from ..intel import IntelReport
|
|
21
|
+
from .providers import Provider, get_provider
|
|
22
|
+
|
|
23
|
+
SYSTEM_PROMPT = (
|
|
24
|
+
"You are HuntKit's triage advisor for AUTHORISED bug-bounty and "
|
|
25
|
+
"penetration-testing engagements only. You are given a scored summary of "
|
|
26
|
+
"reconnaissance findings the operator has ALREADY gathered against in-scope "
|
|
27
|
+
"targets. Produce a concise, prioritised action plan: which hosts and "
|
|
28
|
+
"attack paths to focus on first, and the concrete next checks for each, "
|
|
29
|
+
"mapped to the bug classes shown.\n"
|
|
30
|
+
"Rules: assume the testing is authorised and in-scope; never suggest actions "
|
|
31
|
+
"outside the listed scope, indiscriminate mass exploitation, or destructive "
|
|
32
|
+
"steps. Prefer precise, verifiable checks over noisy scanning. Be brief and "
|
|
33
|
+
"actionable — bullet points, no preamble."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApprovalDenied(RuntimeError):
|
|
38
|
+
"""Raised when a cloud send was required but not approved. Nothing was sent."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Advice:
|
|
43
|
+
provider: str
|
|
44
|
+
model: str
|
|
45
|
+
is_local: bool
|
|
46
|
+
endpoint: str
|
|
47
|
+
prompt: str # the exact context that was sent
|
|
48
|
+
answer: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_context(report: IntelReport, *, max_items: int = 40) -> str:
|
|
52
|
+
"""Render the intel report into the compact text the model reasons over.
|
|
53
|
+
|
|
54
|
+
This is exactly what leaves the machine for a cloud provider, so it is kept
|
|
55
|
+
plain, bounded, and free of anything but recon-derived facts.
|
|
56
|
+
"""
|
|
57
|
+
out: list[str] = [f"Program: {report.program}", ""]
|
|
58
|
+
|
|
59
|
+
out.append("Prioritised hosts:")
|
|
60
|
+
for h in report.hosts[:max_items]:
|
|
61
|
+
pbs = ", ".join(h.playbooks[:4]) or "-"
|
|
62
|
+
out.append(f"- {h.host} [{h.priority.label}] score={h.score} playbooks={pbs}")
|
|
63
|
+
|
|
64
|
+
paths = report.attack_paths()
|
|
65
|
+
if paths:
|
|
66
|
+
out += ["", "Attack paths:"]
|
|
67
|
+
for p in paths[:max_items]:
|
|
68
|
+
out.append(
|
|
69
|
+
f"- {p.name} ({p.playbook}) severity={p.severity.label} hosts={len(p.hosts)}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
hot = sorted(
|
|
73
|
+
(s for s in report.signals if int(s.severity) >= 4),
|
|
74
|
+
key=lambda s: (-int(s.severity), s.host),
|
|
75
|
+
)
|
|
76
|
+
if hot:
|
|
77
|
+
out += ["", "Notable exposures:"]
|
|
78
|
+
for s in hot[:max_items]:
|
|
79
|
+
out.append(f"- [{s.severity.label}] {s.host}: {s.title} ({s.evidence})")
|
|
80
|
+
|
|
81
|
+
return "\n".join(out).strip() + "\n"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Advisor:
|
|
85
|
+
"""Pairs a provider with config and enforces the cloud-approval gate."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, provider: Provider, config: Config) -> None:
|
|
88
|
+
self.provider = provider
|
|
89
|
+
self.config = config
|
|
90
|
+
|
|
91
|
+
def resolve_model(self, explicit: Optional[str] = None) -> str:
|
|
92
|
+
if explicit:
|
|
93
|
+
return explicit
|
|
94
|
+
# only honour the configured model for the configured provider —
|
|
95
|
+
# e.g. don't send ollama's 'llama3.2' default to Anthropic
|
|
96
|
+
if self.provider.name == self.config.ai.provider and self.config.ai.model:
|
|
97
|
+
return self.config.ai.model
|
|
98
|
+
return self.provider.default_model
|
|
99
|
+
|
|
100
|
+
def advise(
|
|
101
|
+
self,
|
|
102
|
+
report: IntelReport,
|
|
103
|
+
*,
|
|
104
|
+
model: Optional[str] = None,
|
|
105
|
+
approve: Optional[Callable[[str], bool]] = None,
|
|
106
|
+
) -> Advice:
|
|
107
|
+
"""Ask the provider for guidance. Cloud providers require approval first."""
|
|
108
|
+
ctx = build_context(report, max_items=self.config.ai.max_context_items)
|
|
109
|
+
model_id = self.resolve_model(model)
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
not self.provider.is_local
|
|
113
|
+
and self.config.ai.require_approval
|
|
114
|
+
and (approve is None or not approve(ctx))
|
|
115
|
+
):
|
|
116
|
+
raise ApprovalDenied(f"sending recon data to '{self.provider.name}' was not approved")
|
|
117
|
+
|
|
118
|
+
answer = self.provider.complete(SYSTEM_PROMPT, ctx, model=model_id, config=self.config)
|
|
119
|
+
return Advice(
|
|
120
|
+
provider=self.provider.name,
|
|
121
|
+
model=model_id,
|
|
122
|
+
is_local=self.provider.is_local,
|
|
123
|
+
endpoint=self.provider.endpoint_host(self.config),
|
|
124
|
+
prompt=ctx,
|
|
125
|
+
answer=answer,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_advisor(config: Config, provider_name: Optional[str] = None) -> Optional[Advisor]:
|
|
130
|
+
"""Build an advisor for the named (or configured) provider; None if unknown."""
|
|
131
|
+
name = (provider_name or config.ai.provider or "ollama").strip().lower()
|
|
132
|
+
provider = get_provider(name)
|
|
133
|
+
return Advisor(provider, config) if provider is not None else None
|
huntkit/ai/providers.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""AI providers — a thin, dependency-free abstraction over one HTTP call.
|
|
2
|
+
|
|
3
|
+
Each provider turns a (system, prompt) pair into completion text. They talk raw
|
|
4
|
+
JSON over stdlib ``urllib`` so the AI layer stays optional and adds no
|
|
5
|
+
dependencies (HuntKit is lean-dep on purpose; Ollama has no SDK anyway).
|
|
6
|
+
|
|
7
|
+
The security-relevant property is :attr:`Provider.is_local`. Local providers
|
|
8
|
+
(Ollama on 127.0.0.1) never send engagement data off the machine; cloud
|
|
9
|
+
providers do, and the advisor gates them behind explicit approval.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
from ..core.config import Config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProviderError(RuntimeError):
|
|
23
|
+
"""A provider could not produce a completion (network, auth, empty reply)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _post_json(url: str, headers: dict[str, str], payload: dict, timeout: int) -> dict:
|
|
27
|
+
"""POST JSON and parse the JSON reply. Raises ProviderError on any failure.
|
|
28
|
+
|
|
29
|
+
Kept module-level so tests can stub the whole network round-trip.
|
|
30
|
+
"""
|
|
31
|
+
body = json.dumps(payload).encode("utf-8")
|
|
32
|
+
req = urllib.request.Request(
|
|
33
|
+
url,
|
|
34
|
+
data=body,
|
|
35
|
+
method="POST",
|
|
36
|
+
headers={"content-type": "application/json", **headers},
|
|
37
|
+
)
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
40
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
41
|
+
except urllib.error.HTTPError as e:
|
|
42
|
+
detail = e.read().decode("utf-8", "replace")[:400]
|
|
43
|
+
raise ProviderError(f"{url}: HTTP {e.code} — {detail}") from None
|
|
44
|
+
except urllib.error.URLError as e:
|
|
45
|
+
raise ProviderError(f"cannot reach {url}: {e.reason}") from None
|
|
46
|
+
except (TimeoutError, OSError, ValueError) as e:
|
|
47
|
+
raise ProviderError(f"request to {url} failed: {e}") from None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _api_key(config: Config, name: str, *env_names: str) -> str:
|
|
51
|
+
"""Resolve an API key: config.api_keys first, then well-known env vars."""
|
|
52
|
+
key = config.api_keys.get(name)
|
|
53
|
+
if key:
|
|
54
|
+
return key
|
|
55
|
+
for env in env_names:
|
|
56
|
+
val = os.environ.get(env)
|
|
57
|
+
if val:
|
|
58
|
+
return val
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Provider:
|
|
63
|
+
"""Base provider. Subclasses set the class attrs and implement ``complete``."""
|
|
64
|
+
|
|
65
|
+
name: str = ""
|
|
66
|
+
default_model: str = ""
|
|
67
|
+
is_local: bool = False
|
|
68
|
+
|
|
69
|
+
def available(self, config: Config) -> bool:
|
|
70
|
+
"""Whether this provider can be used right now (key present, etc.)."""
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def endpoint_host(self, config: Config) -> str:
|
|
74
|
+
"""Human-readable destination, shown at the approval gate."""
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OllamaProvider(Provider):
|
|
82
|
+
"""Local Ollama (offline). Data never leaves the machine — no approval gate."""
|
|
83
|
+
|
|
84
|
+
name = "ollama"
|
|
85
|
+
default_model = "llama3.2"
|
|
86
|
+
is_local = True
|
|
87
|
+
|
|
88
|
+
def endpoint_host(self, config: Config) -> str:
|
|
89
|
+
return config.ai.endpoint
|
|
90
|
+
|
|
91
|
+
def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
|
|
92
|
+
url = config.ai.endpoint.rstrip("/") + "/api/chat"
|
|
93
|
+
data = _post_json(
|
|
94
|
+
url,
|
|
95
|
+
{},
|
|
96
|
+
{
|
|
97
|
+
"model": model,
|
|
98
|
+
"messages": [
|
|
99
|
+
{"role": "system", "content": system},
|
|
100
|
+
{"role": "user", "content": prompt},
|
|
101
|
+
],
|
|
102
|
+
"stream": False,
|
|
103
|
+
},
|
|
104
|
+
timeout=config.general.timeout,
|
|
105
|
+
)
|
|
106
|
+
text = (data.get("message") or {}).get("content", "").strip()
|
|
107
|
+
if not text:
|
|
108
|
+
raise ProviderError("Ollama returned no content (is the model pulled?)")
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AnthropicProvider(Provider):
|
|
113
|
+
"""Anthropic Claude via the Messages API (cloud → gated)."""
|
|
114
|
+
|
|
115
|
+
name = "claude"
|
|
116
|
+
default_model = "claude-opus-4-8"
|
|
117
|
+
is_local = False
|
|
118
|
+
|
|
119
|
+
def _key(self, config: Config) -> str:
|
|
120
|
+
return _api_key(config, "anthropic", "ANTHROPIC_API_KEY")
|
|
121
|
+
|
|
122
|
+
def available(self, config: Config) -> bool:
|
|
123
|
+
return bool(self._key(config))
|
|
124
|
+
|
|
125
|
+
def endpoint_host(self, config: Config) -> str:
|
|
126
|
+
return "api.anthropic.com"
|
|
127
|
+
|
|
128
|
+
def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
|
|
129
|
+
key = self._key(config)
|
|
130
|
+
if not key:
|
|
131
|
+
raise ProviderError("no Anthropic API key (set HUNTKIT_APIKEY_ANTHROPIC)")
|
|
132
|
+
data = _post_json(
|
|
133
|
+
"https://api.anthropic.com/v1/messages",
|
|
134
|
+
{"x-api-key": key, "anthropic-version": "2023-06-01"},
|
|
135
|
+
{
|
|
136
|
+
"model": model,
|
|
137
|
+
"max_tokens": 1024,
|
|
138
|
+
"system": system,
|
|
139
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
140
|
+
},
|
|
141
|
+
timeout=config.general.timeout,
|
|
142
|
+
)
|
|
143
|
+
text = "".join(
|
|
144
|
+
b.get("text", "") for b in data.get("content", []) if b.get("type") == "text"
|
|
145
|
+
).strip()
|
|
146
|
+
if not text:
|
|
147
|
+
raise ProviderError("Anthropic returned no text content")
|
|
148
|
+
return text
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class OpenAIProvider(Provider):
|
|
152
|
+
"""OpenAI chat completions (cloud → gated)."""
|
|
153
|
+
|
|
154
|
+
name = "openai"
|
|
155
|
+
default_model = "gpt-4o-mini"
|
|
156
|
+
is_local = False
|
|
157
|
+
|
|
158
|
+
def _key(self, config: Config) -> str:
|
|
159
|
+
return _api_key(config, "openai", "OPENAI_API_KEY")
|
|
160
|
+
|
|
161
|
+
def available(self, config: Config) -> bool:
|
|
162
|
+
return bool(self._key(config))
|
|
163
|
+
|
|
164
|
+
def endpoint_host(self, config: Config) -> str:
|
|
165
|
+
return "api.openai.com"
|
|
166
|
+
|
|
167
|
+
def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
|
|
168
|
+
key = self._key(config)
|
|
169
|
+
if not key:
|
|
170
|
+
raise ProviderError("no OpenAI API key (set HUNTKIT_APIKEY_OPENAI)")
|
|
171
|
+
data = _post_json(
|
|
172
|
+
"https://api.openai.com/v1/chat/completions",
|
|
173
|
+
{"authorization": f"Bearer {key}"},
|
|
174
|
+
{
|
|
175
|
+
"model": model,
|
|
176
|
+
"messages": [
|
|
177
|
+
{"role": "system", "content": system},
|
|
178
|
+
{"role": "user", "content": prompt},
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
timeout=config.general.timeout,
|
|
182
|
+
)
|
|
183
|
+
choices = data.get("choices") or []
|
|
184
|
+
text = (choices[0].get("message", {}).get("content", "") if choices else "").strip()
|
|
185
|
+
if not text:
|
|
186
|
+
raise ProviderError("OpenAI returned no content")
|
|
187
|
+
return text
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_PROVIDERS: dict[str, Provider] = {
|
|
191
|
+
p.name: p for p in (OllamaProvider(), AnthropicProvider(), OpenAIProvider())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_provider(name: str) -> Provider | None:
|
|
196
|
+
return _PROVIDERS.get(name.strip().lower())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def provider_names() -> list[str]:
|
|
200
|
+
return list(_PROVIDERS)
|
huntkit/app/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""HuntKit's Typer command-line interface (v0.2).
|
|
2
|
+
|
|
3
|
+
The application object and its entry point live here. During the v0.2
|
|
4
|
+
transition the legacy argparse CLI (``huntkit.cli``) remains the default
|
|
5
|
+
``huntkit`` console script; this interface is reachable via
|
|
6
|
+
``python -m huntkit`` until it reaches feature parity.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .main import app, run
|
|
12
|
+
|
|
13
|
+
__all__ = ["app", "run"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`huntkit config` — inspect and scaffold configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from ..core.config import CONFIG_FILENAME, Config, sample_yaml
|
|
13
|
+
from ..utils import filesystem as fs
|
|
14
|
+
from ..utils import terminal as term
|
|
15
|
+
|
|
16
|
+
config_app = typer.Typer(no_args_is_help=True, help="Inspect and scaffold configuration.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _user_config_path() -> Path:
|
|
20
|
+
home = os.environ.get("HUNTKIT_CONFIG_HOME")
|
|
21
|
+
base = Path(home).expanduser() if home else Path.home() / ".config" / "huntkit"
|
|
22
|
+
return base / CONFIG_FILENAME
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@config_app.command("init")
|
|
26
|
+
def config_init(
|
|
27
|
+
path: Optional[Path] = typer.Option(
|
|
28
|
+
None, "--path", "-o", help="Where to write (default: user config dir)."
|
|
29
|
+
),
|
|
30
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite if it exists."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Write a documented default huntkit.yaml."""
|
|
33
|
+
target = path or _user_config_path()
|
|
34
|
+
if target.exists() and not force:
|
|
35
|
+
term.warn(f"{target} already exists — use --force to overwrite.")
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
fs.write_text(target, sample_yaml())
|
|
38
|
+
term.ok(f"wrote config: {target}")
|
|
39
|
+
term.info("edit it, or override any value with HUNTKIT_<SECTION>_<KEY> env vars.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@config_app.command("show")
|
|
43
|
+
def config_show(ctx: typer.Context) -> None:
|
|
44
|
+
"""Print the resolved, merged configuration (secrets redacted)."""
|
|
45
|
+
config: Config = ctx.obj.config
|
|
46
|
+
data = config.to_dict()
|
|
47
|
+
if data.get("api_keys"):
|
|
48
|
+
data["api_keys"] = dict.fromkeys(data["api_keys"], "***")
|
|
49
|
+
term.console.print(yaml.safe_dump(data, sort_keys=False, default_flow_style=False).rstrip())
|
|
50
|
+
if config._sources:
|
|
51
|
+
term.info("merged from: " + ", ".join(config._sources))
|
|
52
|
+
else:
|
|
53
|
+
term.info("using built-in defaults (no config file found).")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@config_app.command("path")
|
|
57
|
+
def config_path() -> None:
|
|
58
|
+
"""Show where HuntKit looks for configuration."""
|
|
59
|
+
user = _user_config_path()
|
|
60
|
+
project = Path.cwd() / CONFIG_FILENAME
|
|
61
|
+
rows = [
|
|
62
|
+
("user", str(user), "yes" if user.exists() else "no"),
|
|
63
|
+
("project", str(project), "yes" if project.exists() else "no"),
|
|
64
|
+
]
|
|
65
|
+
term.print_table("Config search path", ["scope", "path", "exists"], rows)
|
huntkit/app/context.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The object passed down every command via Typer's context.
|
|
2
|
+
|
|
3
|
+
Built once in the root callback from the global options, so each command
|
|
4
|
+
gets a ready configuration, a runner, a cache, and the plugin registry
|
|
5
|
+
without re-parsing anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from ..core.cache import Cache
|
|
15
|
+
from ..core.config import Config
|
|
16
|
+
from ..core.logger import setup_logging
|
|
17
|
+
from ..core.runner import CommandRunner
|
|
18
|
+
from ..plugins import get_registry
|
|
19
|
+
from ..plugins.registry import PluginRegistry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AppContext:
|
|
24
|
+
config: Config
|
|
25
|
+
runner: CommandRunner
|
|
26
|
+
cache: Cache
|
|
27
|
+
registry: PluginRegistry
|
|
28
|
+
base: Optional[Path] = None # workspace base override (--workspace)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def build(
|
|
32
|
+
cls,
|
|
33
|
+
*,
|
|
34
|
+
config_path: Optional[Path] = None,
|
|
35
|
+
workspace: Optional[Path] = None,
|
|
36
|
+
verbose: bool = False,
|
|
37
|
+
quiet: bool = False,
|
|
38
|
+
) -> AppContext:
|
|
39
|
+
config = Config.load(config_path)
|
|
40
|
+
level = "DEBUG" if verbose else ("ERROR" if quiet else "INFO")
|
|
41
|
+
setup_logging(level=level, verbose=verbose)
|
|
42
|
+
cache = Cache(config.workspace_root / ".cache")
|
|
43
|
+
runner = CommandRunner(config, cache=cache)
|
|
44
|
+
return cls(
|
|
45
|
+
config=config,
|
|
46
|
+
runner=runner,
|
|
47
|
+
cache=cache,
|
|
48
|
+
registry=get_registry(),
|
|
49
|
+
base=workspace,
|
|
50
|
+
)
|