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
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
+ )