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/core/runner.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""CommandRunner — the configured, logged execution layer.
|
|
2
|
+
|
|
3
|
+
Sits on top of `utils.process.execute` and adds everything a plugin should
|
|
4
|
+
not have to think about: binary resolution, per-tool timeouts and extra
|
|
5
|
+
args, proxy/UA environment, retries with backoff, structured logging, and
|
|
6
|
+
optional result caching.
|
|
7
|
+
|
|
8
|
+
Plugins build an argv and hand it here; they never call subprocess directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..utils import process
|
|
18
|
+
from ..utils.process import ProcResult
|
|
19
|
+
from .config import Config
|
|
20
|
+
from .logger import get_logger
|
|
21
|
+
|
|
22
|
+
log = get_logger("runner")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolNotFound(RuntimeError):
|
|
26
|
+
"""Raised when a required binary is not on PATH and not pinned in config."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CommandRunner:
|
|
30
|
+
def __init__(self, config: Optional[Config] = None, cache=None) -> None:
|
|
31
|
+
self.config = config or Config()
|
|
32
|
+
self.cache = cache # optional core.cache.Cache; injected by the app
|
|
33
|
+
|
|
34
|
+
# ---- binary resolution ----------------------------------------------
|
|
35
|
+
def resolve(self, tool: str) -> Optional[str]:
|
|
36
|
+
"""Return the binary path for `tool`, honouring a pinned config path."""
|
|
37
|
+
pinned = self.config.tool(tool).path
|
|
38
|
+
if pinned:
|
|
39
|
+
return pinned
|
|
40
|
+
return process.which(tool)
|
|
41
|
+
|
|
42
|
+
def available(self, tool: str) -> bool:
|
|
43
|
+
return self.resolve(tool) is not None
|
|
44
|
+
|
|
45
|
+
# ---- execution -------------------------------------------------------
|
|
46
|
+
def run(
|
|
47
|
+
self,
|
|
48
|
+
tool: str,
|
|
49
|
+
args: Sequence[str],
|
|
50
|
+
*,
|
|
51
|
+
stdin_data: Optional[str] = None,
|
|
52
|
+
timeout: Optional[int] = None,
|
|
53
|
+
retry: Optional[int] = None,
|
|
54
|
+
cache_key: Optional[str] = None,
|
|
55
|
+
) -> ProcResult:
|
|
56
|
+
"""Run a registered tool with `args`.
|
|
57
|
+
|
|
58
|
+
`tool` is the binary name (resolved via config/PATH); `args` are the
|
|
59
|
+
arguments after it. Per-tool `extra_args` from config are appended.
|
|
60
|
+
"""
|
|
61
|
+
binary = self.resolve(tool)
|
|
62
|
+
if binary is None:
|
|
63
|
+
raise ToolNotFound(f"'{tool}' not found on PATH — install it or set tools.{tool}.path")
|
|
64
|
+
|
|
65
|
+
tool_cfg = self.config.tool(tool)
|
|
66
|
+
argv = [binary, *[str(a) for a in args], *tool_cfg.extra_args]
|
|
67
|
+
eff_timeout = timeout or tool_cfg.timeout or self.config.general.timeout
|
|
68
|
+
eff_retry = self.config.general.retry if retry is None else retry
|
|
69
|
+
|
|
70
|
+
if cache_key and self.cache is not None:
|
|
71
|
+
cached = self.cache.get(cache_key)
|
|
72
|
+
if cached is not None:
|
|
73
|
+
log.debug("cache hit: %s", cache_key)
|
|
74
|
+
return ProcResult(0, cached, "", argv, timed_out=False)
|
|
75
|
+
|
|
76
|
+
result = self._run_with_retry(argv, stdin_data, eff_timeout, eff_retry, tool)
|
|
77
|
+
|
|
78
|
+
if cache_key and self.cache is not None and result.ok:
|
|
79
|
+
self.cache.set(cache_key, result.stdout)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def _run_with_retry(
|
|
83
|
+
self,
|
|
84
|
+
argv: list[str],
|
|
85
|
+
stdin_data: Optional[str],
|
|
86
|
+
timeout: int,
|
|
87
|
+
retry: int,
|
|
88
|
+
tool: str,
|
|
89
|
+
) -> ProcResult:
|
|
90
|
+
env = self._build_env()
|
|
91
|
+
last: Optional[ProcResult] = None
|
|
92
|
+
for attempt in range(retry + 1):
|
|
93
|
+
log.debug("exec (try %d/%d): %s", attempt + 1, retry + 1, " ".join(argv))
|
|
94
|
+
result = process.execute(argv, timeout=timeout, stdin_data=stdin_data, env=env)
|
|
95
|
+
log.debug(
|
|
96
|
+
"→ code=%s dur=%.1fs lines=%d", result.code, result.duration, len(result.lines)
|
|
97
|
+
)
|
|
98
|
+
# retry only transient failures (timeout), not clean non-zero exits
|
|
99
|
+
if result.ok or not result.timed_out or attempt == retry:
|
|
100
|
+
return result
|
|
101
|
+
last = result
|
|
102
|
+
backoff = 1.5 * (attempt + 1)
|
|
103
|
+
log.warning("%s timed out; retrying in %.1fs", tool, backoff)
|
|
104
|
+
time.sleep(backoff)
|
|
105
|
+
return last or process.execute(argv, timeout=timeout, stdin_data=stdin_data, env=env)
|
|
106
|
+
|
|
107
|
+
def _build_env(self) -> Optional[dict]:
|
|
108
|
+
"""Inject proxy/UA into the child environment when configured."""
|
|
109
|
+
import os
|
|
110
|
+
|
|
111
|
+
http = self.config.http
|
|
112
|
+
if not http.proxy:
|
|
113
|
+
return None
|
|
114
|
+
env = dict(os.environ)
|
|
115
|
+
env["HTTP_PROXY"] = http.proxy
|
|
116
|
+
env["HTTPS_PROXY"] = http.proxy
|
|
117
|
+
return env
|
huntkit/core/state.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Scan state, checkpoints, and resume support.
|
|
2
|
+
|
|
3
|
+
Every pipeline stage records its status so an interrupted run can resume
|
|
4
|
+
where it left off and re-run only failed steps. Persisted atomically as JSON
|
|
5
|
+
so a crash mid-write can't corrupt it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from ..utils import filesystem as fs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StageStatus(str, Enum):
|
|
20
|
+
PENDING = "pending"
|
|
21
|
+
RUNNING = "running"
|
|
22
|
+
DONE = "done"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
SKIPPED = "skipped"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class StageRecord:
|
|
29
|
+
name: str
|
|
30
|
+
status: StageStatus = StageStatus.PENDING
|
|
31
|
+
started: Optional[float] = None
|
|
32
|
+
finished: Optional[float] = None
|
|
33
|
+
error: Optional[str] = None
|
|
34
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def duration(self) -> Optional[float]:
|
|
38
|
+
if self.started and self.finished:
|
|
39
|
+
return self.finished - self.started
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"name": self.name,
|
|
45
|
+
"status": self.status.value,
|
|
46
|
+
"started": self.started,
|
|
47
|
+
"finished": self.finished,
|
|
48
|
+
"error": self.error,
|
|
49
|
+
"meta": self.meta,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: dict[str, Any]) -> StageRecord:
|
|
54
|
+
return cls(
|
|
55
|
+
name=data["name"],
|
|
56
|
+
status=StageStatus(data.get("status", "pending")),
|
|
57
|
+
started=data.get("started"),
|
|
58
|
+
finished=data.get("finished"),
|
|
59
|
+
error=data.get("error"),
|
|
60
|
+
meta=data.get("meta", {}),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StateStore:
|
|
65
|
+
"""Persistent stage tracker for one workspace."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, path: Path) -> None:
|
|
68
|
+
self.path = path
|
|
69
|
+
self.stages: dict[str, StageRecord] = {}
|
|
70
|
+
self.counts: dict[str, int] = {}
|
|
71
|
+
self.created = time.time()
|
|
72
|
+
self.load()
|
|
73
|
+
|
|
74
|
+
# ---- persistence -----------------------------------------------------
|
|
75
|
+
def load(self) -> None:
|
|
76
|
+
import json
|
|
77
|
+
|
|
78
|
+
if not self.path.exists():
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(fs.read_text(self.path))
|
|
82
|
+
except (ValueError, OSError):
|
|
83
|
+
return
|
|
84
|
+
self.created = data.get("created", self.created)
|
|
85
|
+
self.counts = data.get("counts", {})
|
|
86
|
+
self.stages = {
|
|
87
|
+
name: StageRecord.from_dict(rec) for name, rec in data.get("stages", {}).items()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def save(self) -> None:
|
|
91
|
+
import json
|
|
92
|
+
|
|
93
|
+
payload = {
|
|
94
|
+
"created": self.created,
|
|
95
|
+
"updated": time.time(),
|
|
96
|
+
"counts": self.counts,
|
|
97
|
+
"stages": {name: rec.to_dict() for name, rec in self.stages.items()},
|
|
98
|
+
}
|
|
99
|
+
fs.write_text(self.path, json.dumps(payload, indent=2))
|
|
100
|
+
|
|
101
|
+
# ---- stage lifecycle -------------------------------------------------
|
|
102
|
+
def _get(self, stage: str) -> StageRecord:
|
|
103
|
+
if stage not in self.stages:
|
|
104
|
+
self.stages[stage] = StageRecord(name=stage)
|
|
105
|
+
return self.stages[stage]
|
|
106
|
+
|
|
107
|
+
def status(self, stage: str) -> StageStatus:
|
|
108
|
+
return self._get(stage).status
|
|
109
|
+
|
|
110
|
+
def is_done(self, stage: str) -> bool:
|
|
111
|
+
return self.status(stage) == StageStatus.DONE
|
|
112
|
+
|
|
113
|
+
def start(self, stage: str) -> None:
|
|
114
|
+
rec = self._get(stage)
|
|
115
|
+
rec.status = StageStatus.RUNNING
|
|
116
|
+
rec.started = time.time()
|
|
117
|
+
rec.error = None
|
|
118
|
+
self.save()
|
|
119
|
+
|
|
120
|
+
def done(self, stage: str, **meta: Any) -> None:
|
|
121
|
+
rec = self._get(stage)
|
|
122
|
+
rec.status = StageStatus.DONE
|
|
123
|
+
rec.finished = time.time()
|
|
124
|
+
rec.meta.update(meta)
|
|
125
|
+
self.save()
|
|
126
|
+
|
|
127
|
+
def fail(self, stage: str, error: str) -> None:
|
|
128
|
+
rec = self._get(stage)
|
|
129
|
+
rec.status = StageStatus.FAILED
|
|
130
|
+
rec.finished = time.time()
|
|
131
|
+
rec.error = error
|
|
132
|
+
self.save()
|
|
133
|
+
|
|
134
|
+
def skip(self, stage: str) -> None:
|
|
135
|
+
rec = self._get(stage)
|
|
136
|
+
rec.status = StageStatus.SKIPPED
|
|
137
|
+
self.save()
|
|
138
|
+
|
|
139
|
+
# ---- resume queries --------------------------------------------------
|
|
140
|
+
def pending(self, all_stages: list[str]) -> list[str]:
|
|
141
|
+
"""Stages not yet completed — the resume set."""
|
|
142
|
+
return [s for s in all_stages if not self.is_done(s)]
|
|
143
|
+
|
|
144
|
+
def failed(self) -> list[str]:
|
|
145
|
+
return [s for s, r in self.stages.items() if r.status == StageStatus.FAILED]
|
|
146
|
+
|
|
147
|
+
def set_count(self, key: str, value: int) -> None:
|
|
148
|
+
self.counts[key] = value
|
|
149
|
+
self.save()
|
|
150
|
+
|
|
151
|
+
def reset(self) -> None:
|
|
152
|
+
"""Clear all stage state (fresh scan)."""
|
|
153
|
+
self.stages.clear()
|
|
154
|
+
self.save()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Per-engagement workspace: layout, scope, deduped results, and state.
|
|
2
|
+
|
|
3
|
+
A workspace is one folder per program under the configured workspace root
|
|
4
|
+
(``~/.huntkit`` by default, or ``$HUNTKIT_HOME``). It owns three things:
|
|
5
|
+
|
|
6
|
+
- **layout** a stable set of subdirectories (recon/urls/scans/…)
|
|
7
|
+
- **scope** in/out patterns kept as plain, greppable text files, plus
|
|
8
|
+
an :meth:`in_scope` guard so nothing is scanned off-scope
|
|
9
|
+
- **state** a :class:`~huntkit.core.state.StateStore` for stage tracking,
|
|
10
|
+
counts, and resume
|
|
11
|
+
|
|
12
|
+
It sits in ``core`` because it depends only on the foundation (state, config,
|
|
13
|
+
utils) — never on plugins or features — so anything above can build on it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ..utils import filesystem as fs
|
|
22
|
+
from ..utils.validators import sanitize_filename
|
|
23
|
+
from .config import Config
|
|
24
|
+
from .state import StateStore
|
|
25
|
+
|
|
26
|
+
# Subdirectories created inside every workspace.
|
|
27
|
+
LAYOUT = ["scope", "recon", "urls", "scans", "notes", "reports"]
|
|
28
|
+
|
|
29
|
+
_SCOPE_IN = "scope/in_scope.txt"
|
|
30
|
+
_SCOPE_OUT = "scope/out_scope.txt"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_base(config: Config | None = None) -> Path:
|
|
34
|
+
"""Where workspaces live: $HUNTKIT_HOME, else config root, else ~/.huntkit."""
|
|
35
|
+
env = os.environ.get("HUNTKIT_HOME")
|
|
36
|
+
if env:
|
|
37
|
+
return Path(env).expanduser()
|
|
38
|
+
if config is not None:
|
|
39
|
+
return config.workspace_root
|
|
40
|
+
return Path.home() / ".huntkit"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Workspace:
|
|
44
|
+
def __init__(self, program: str, root: Path) -> None:
|
|
45
|
+
self.program = program
|
|
46
|
+
self.root = root
|
|
47
|
+
self.state = StateStore(root / "state.json")
|
|
48
|
+
|
|
49
|
+
# ---- construction ----------------------------------------------------
|
|
50
|
+
@classmethod
|
|
51
|
+
def open(
|
|
52
|
+
cls,
|
|
53
|
+
program: str,
|
|
54
|
+
*,
|
|
55
|
+
config: Config | None = None,
|
|
56
|
+
base: Path | None = None,
|
|
57
|
+
) -> Workspace:
|
|
58
|
+
base = base or default_base(config)
|
|
59
|
+
root = base / sanitize_filename(program)
|
|
60
|
+
ws = cls(program=program, root=root)
|
|
61
|
+
ws._ensure_layout()
|
|
62
|
+
return ws
|
|
63
|
+
|
|
64
|
+
def _ensure_layout(self) -> None:
|
|
65
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
for sub in LAYOUT:
|
|
67
|
+
(self.root / sub).mkdir(exist_ok=True)
|
|
68
|
+
# persist an initial state file so the workspace is discoverable by
|
|
69
|
+
# `workspace list` immediately after creation
|
|
70
|
+
if not self.state.path.exists():
|
|
71
|
+
self.state.save()
|
|
72
|
+
|
|
73
|
+
# ---- paths -----------------------------------------------------------
|
|
74
|
+
def path(self, *parts: str) -> Path:
|
|
75
|
+
"""A path inside the workspace, guarded against traversal."""
|
|
76
|
+
return fs.safe_join(self.root, *parts)
|
|
77
|
+
|
|
78
|
+
# ---- scope -----------------------------------------------------------
|
|
79
|
+
def set_scope(self, in_scope: list[str], out_scope: list[str]) -> None:
|
|
80
|
+
fs.write_text(self.path(_SCOPE_IN), "\n".join(in_scope) + ("\n" if in_scope else ""))
|
|
81
|
+
fs.write_text(self.path(_SCOPE_OUT), "\n".join(out_scope) + ("\n" if out_scope else ""))
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def scope_in(self) -> list[str]:
|
|
85
|
+
return fs.read_lines(self.path(_SCOPE_IN))
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def scope_out(self) -> list[str]:
|
|
89
|
+
return fs.read_lines(self.path(_SCOPE_OUT))
|
|
90
|
+
|
|
91
|
+
def has_scope(self) -> bool:
|
|
92
|
+
return bool(self.scope_in or self.scope_out)
|
|
93
|
+
|
|
94
|
+
def in_scope(self, host: str) -> bool:
|
|
95
|
+
"""True if ``host`` matches an in-scope pattern and no out-of-scope one.
|
|
96
|
+
|
|
97
|
+
Patterns accept a leading ``*.`` wildcard (``*.example.com`` matches
|
|
98
|
+
the apex and any subdomain). With no in-scope patterns defined,
|
|
99
|
+
everything not explicitly excluded is considered in scope.
|
|
100
|
+
"""
|
|
101
|
+
if any(_match(host, p) for p in self.scope_out):
|
|
102
|
+
return False
|
|
103
|
+
ins = self.scope_in
|
|
104
|
+
if not ins:
|
|
105
|
+
return True
|
|
106
|
+
return any(_match(host, p) for p in ins)
|
|
107
|
+
|
|
108
|
+
def filter_scope(self, hosts: list[str]) -> list[str]:
|
|
109
|
+
return [h for h in hosts if self.in_scope(h)]
|
|
110
|
+
|
|
111
|
+
# ---- deduped result sets --------------------------------------------
|
|
112
|
+
def append_unique(self, relpath: str, items: list[str]) -> int:
|
|
113
|
+
"""Merge items into a result file, sorted & unique; return new count."""
|
|
114
|
+
return fs.append_unique(self.path(relpath), items)
|
|
115
|
+
|
|
116
|
+
def read_lines(self, relpath: str) -> list[str]:
|
|
117
|
+
return fs.read_lines(self.path(relpath))
|
|
118
|
+
|
|
119
|
+
def count(self, relpath: str) -> int:
|
|
120
|
+
return fs.count_lines(self.path(relpath))
|
|
121
|
+
|
|
122
|
+
def exists(self, relpath: str) -> bool:
|
|
123
|
+
return self.path(relpath).exists()
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
126
|
+
return f"<Workspace {self.program} at {self.root}>"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------
|
|
130
|
+
# Helpers
|
|
131
|
+
# --------------------------------------------------------------------------
|
|
132
|
+
def _match(host: str, pattern: str) -> bool:
|
|
133
|
+
host = host.lower().strip()
|
|
134
|
+
pattern = pattern.lower().strip()
|
|
135
|
+
if pattern.startswith("*."):
|
|
136
|
+
suffix = pattern[1:] # ".example.com"
|
|
137
|
+
return host == pattern[2:] or host.endswith(suffix)
|
|
138
|
+
return host == pattern
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def list_workspaces(base: Path | None = None, config: Config | None = None) -> list[str]:
|
|
142
|
+
base = base or default_base(config)
|
|
143
|
+
if not base.exists():
|
|
144
|
+
return []
|
|
145
|
+
return sorted(p.name for p in base.iterdir() if p.is_dir() and (p / "state.json").exists())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Enrichment layer — asset-correlation signals over the recon surface.
|
|
2
|
+
|
|
3
|
+
Two enrichers that turn a gathered surface into leads *elsewhere*:
|
|
4
|
+
|
|
5
|
+
- **favicon** — the Shodan-convention favicon hash (``http.favicon.hash:``),
|
|
6
|
+
the pivot from one icon to every host serving it;
|
|
7
|
+
- **asn** — the ASN / BGP prefix a host is announced from, the network block
|
|
8
|
+
to sweep for sibling assets.
|
|
9
|
+
|
|
10
|
+
Depends only on ``core`` (workspace/scope) and ``utils``; both the HTTP fetch
|
|
11
|
+
and the ASN resolve/lookup are injectable, so the layer is offline in tests.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .asn import AsnResult, asn_for, parse_cymru
|
|
17
|
+
from .engine import (
|
|
18
|
+
ENRICH_JSON,
|
|
19
|
+
EnrichReport,
|
|
20
|
+
enrich_hosts,
|
|
21
|
+
enrich_origins,
|
|
22
|
+
run_enrich,
|
|
23
|
+
)
|
|
24
|
+
from .favicon import FaviconResult, favicon_hash, favicon_url, hash_favicon
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ENRICH_JSON",
|
|
28
|
+
"AsnResult",
|
|
29
|
+
"EnrichReport",
|
|
30
|
+
"FaviconResult",
|
|
31
|
+
"asn_for",
|
|
32
|
+
"enrich_hosts",
|
|
33
|
+
"enrich_origins",
|
|
34
|
+
"favicon_hash",
|
|
35
|
+
"favicon_url",
|
|
36
|
+
"hash_favicon",
|
|
37
|
+
"parse_cymru",
|
|
38
|
+
"run_enrich",
|
|
39
|
+
]
|
huntkit/enrich/asn.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""ASN enrichment — map an in-scope host to the network that announces it.
|
|
2
|
+
|
|
3
|
+
Knowing the ASN and BGP prefix behind a host turns a single name into a block
|
|
4
|
+
of IP space to sweep: sibling origins, admin panels, and dev boxes frequently
|
|
5
|
+
live in the same prefix as the flagship site. HuntKit resolves the host to an
|
|
6
|
+
IP locally, then asks Team Cymru's IP-to-ASN WHOIS service for the mapping.
|
|
7
|
+
|
|
8
|
+
> That WHOIS call is the one place this layer sends data off the box — the
|
|
9
|
+
> *resolved IP* of an in-scope host is sent to ``whois.cymru.com``. It is a
|
|
10
|
+
> public, well-known lookup service, but it is third-party egress, so the CLI
|
|
11
|
+
> names it before running and both the resolver and the lookup are injectable
|
|
12
|
+
> (the tests never touch the network).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import socket
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
ResolveFn = Callable[[str], str] # host -> IP, raises on failure
|
|
22
|
+
LookupFn = Callable[[str], str] # IP -> raw Cymru WHOIS text, raises on failure
|
|
23
|
+
|
|
24
|
+
_CYMRU_HOST = "whois.cymru.com"
|
|
25
|
+
_CYMRU_PORT = 43
|
|
26
|
+
_TIMEOUT = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AsnResult:
|
|
31
|
+
"""One host's network origin, as announced in BGP."""
|
|
32
|
+
|
|
33
|
+
host: str
|
|
34
|
+
ip: str = ""
|
|
35
|
+
asn: str = ""
|
|
36
|
+
prefix: str = ""
|
|
37
|
+
cc: str = ""
|
|
38
|
+
registry: str = ""
|
|
39
|
+
as_name: str = ""
|
|
40
|
+
error: str = ""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self) -> bool:
|
|
44
|
+
return bool(self.asn)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"host": self.host,
|
|
49
|
+
"ip": self.ip,
|
|
50
|
+
"asn": self.asn,
|
|
51
|
+
"prefix": self.prefix,
|
|
52
|
+
"cc": self.cc,
|
|
53
|
+
"registry": self.registry,
|
|
54
|
+
"as_name": self.as_name,
|
|
55
|
+
"error": self.error,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def default_resolve(host: str) -> str:
|
|
60
|
+
"""Resolve a hostname to an IPv4 address (stdlib; no third party)."""
|
|
61
|
+
return socket.gethostbyname(host)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_lookup(ip: str) -> str:
|
|
65
|
+
"""Query Team Cymru's WHOIS IP-to-ASN service for ``ip``.
|
|
66
|
+
|
|
67
|
+
Opens a short-lived TCP connection to port 43 and sends a single verbose
|
|
68
|
+
query. This is the layer's only outbound call to a non-target host.
|
|
69
|
+
"""
|
|
70
|
+
query = f" -v {ip}\r\n".encode()
|
|
71
|
+
with socket.create_connection((_CYMRU_HOST, _CYMRU_PORT), timeout=_TIMEOUT) as sock:
|
|
72
|
+
sock.sendall(query)
|
|
73
|
+
chunks = []
|
|
74
|
+
while True:
|
|
75
|
+
chunk = sock.recv(4096)
|
|
76
|
+
if not chunk:
|
|
77
|
+
break
|
|
78
|
+
chunks.append(chunk)
|
|
79
|
+
return b"".join(chunks).decode("utf-8", "replace")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_cymru(text: str) -> dict[str, str]:
|
|
83
|
+
"""Parse the first data row of a Team Cymru verbose WHOIS response.
|
|
84
|
+
|
|
85
|
+
The verbose format is a header row then pipe-delimited data:
|
|
86
|
+
``AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name``.
|
|
87
|
+
Returns an empty dict when no data row is present.
|
|
88
|
+
"""
|
|
89
|
+
for line in text.splitlines():
|
|
90
|
+
if "|" not in line:
|
|
91
|
+
continue
|
|
92
|
+
cols = [c.strip() for c in line.split("|")]
|
|
93
|
+
# a data row leads with the numeric AS; the header row ("AS | IP | …")
|
|
94
|
+
# and any noise fail that test and are skipped
|
|
95
|
+
if len(cols) < 7 or not cols[0].isdigit():
|
|
96
|
+
continue
|
|
97
|
+
return {
|
|
98
|
+
"asn": cols[0],
|
|
99
|
+
"ip": cols[1],
|
|
100
|
+
"prefix": cols[2],
|
|
101
|
+
"cc": cols[3],
|
|
102
|
+
"registry": cols[4],
|
|
103
|
+
"as_name": cols[6],
|
|
104
|
+
}
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def asn_for(
|
|
109
|
+
host: str,
|
|
110
|
+
*,
|
|
111
|
+
resolve: ResolveFn = default_resolve,
|
|
112
|
+
lookup: LookupFn = default_lookup,
|
|
113
|
+
) -> AsnResult:
|
|
114
|
+
"""Resolve ``host`` and look up its ASN, never raising.
|
|
115
|
+
|
|
116
|
+
Any failure (unresolvable name, WHOIS timeout, unparsable reply) is
|
|
117
|
+
recorded on the result so one bad host cannot sink a batch.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
ip = resolve(host)
|
|
121
|
+
except Exception as exc: # noqa: BLE001 — a resolution failure is data
|
|
122
|
+
return AsnResult(host=host, error=f"resolve: {exc or type(exc).__name__}")
|
|
123
|
+
try:
|
|
124
|
+
raw = lookup(ip)
|
|
125
|
+
except Exception as exc: # noqa: BLE001 — a lookup failure is data
|
|
126
|
+
return AsnResult(host=host, ip=ip, error=f"lookup: {exc or type(exc).__name__}")
|
|
127
|
+
|
|
128
|
+
row = parse_cymru(raw)
|
|
129
|
+
if not row:
|
|
130
|
+
return AsnResult(host=host, ip=ip, error="no ASN in response")
|
|
131
|
+
return AsnResult(
|
|
132
|
+
host=host,
|
|
133
|
+
ip=row.get("ip", ip),
|
|
134
|
+
asn=row["asn"],
|
|
135
|
+
prefix=row.get("prefix", ""),
|
|
136
|
+
cc=row.get("cc", ""),
|
|
137
|
+
registry=row.get("registry", ""),
|
|
138
|
+
as_name=row.get("as_name", ""),
|
|
139
|
+
)
|