backhoe-osint 0.5.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.
backhoe/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,198 @@
1
+ """
2
+ Censys backend — a second richer-port-data source for infra-check,
3
+ alongside Shodan (backends/shodan.py), whose shape this module
4
+ deliberately mirrors: same raw dict field names (port/service/product/
5
+ version/vulns), same source-key-first exception hierarchy, same
6
+ KeyProvider registration pattern. That's what lets scoring.py and
7
+ report.py handle Censys findings with zero changes — they already
8
+ handle this shape generically, built for and validated against Shodan.
9
+
10
+ Needs an API key: backhoe.keys.get_api_key(CENSYS_PROVIDER) resolves one
11
+ (CENSYS_API_KEY env var, then a locally stored key, then an interactive
12
+ prompt — see keys.py). The "key" here is a Censys Personal Access Token
13
+ (PAT), used as a Bearer token, not the older API-ID+secret pair Censys's
14
+ now-deprecated v2 Search API used — confirmed live against Censys's
15
+ current docs (docs.censys.com) during development: their own docs say
16
+ "all scripted access should use" the newer Platform API (v3) this module
17
+ targets, not the legacy one. This is a real API generation Shodan-style
18
+ memory-only development couldn't have caught.
19
+
20
+ validate_key() hits /v3/accounts/users/credits, confirmed live in
21
+ Censys's docs — quoted twice — to cost no credits, letting keys.py
22
+ re-validate a stored token on every call without burning quota (same
23
+ role as Shodan's /api-info).
24
+
25
+ What's NOT confirmed live (docs summarization truncated the raw OpenAPI
26
+ schema before the exact response shape came through): the precise
27
+ 404/no-data behavior for the host-lookup endpoint (treated as an empty
28
+ result, matching Shodan's precedent and general REST convention), and
29
+ the exact shape of a service's `vulns` field. The clearest signal found
30
+ says it's a list of {"id": "CVE-...", ...} objects — different from
31
+ Shodan's plain-string-or-dict shape — so _extract_vuln_ids() below
32
+ handles THREE possible shapes rather than assuming one.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import requests
37
+
38
+ from ..keys import KeyProvider, KeyValidationError
39
+ from ..schema import Finding, FindingType
40
+
41
+ BASE_URL = "https://api.platform.censys.io/v3"
42
+ DEFAULT_TIMEOUT = 10
43
+
44
+
45
+ class CensysError(Exception):
46
+ """A real failure calling Censys — never raised for "no data for this
47
+ host", which is a legitimate empty result (see lookup_host)."""
48
+
49
+
50
+ class CensysAPIError(CensysError):
51
+ """lookup_host() failed for a reason other than "not indexed": bad
52
+ token, rate limited, Censys's own server error, network failure, or
53
+ an unparseable response."""
54
+
55
+
56
+ class CensysValidationError(CensysError, KeyValidationError):
57
+ """validate_key() itself failed inconclusively (network error, 5xx,
58
+ unparseable response) — NOT the same as the key being confirmed
59
+ wrong. keys.py treats this as "can't confirm, use the key anyway",
60
+ not as a reason to reprompt."""
61
+
62
+
63
+ def _redact_key(text: str, key: str) -> str:
64
+ """Defensive belt-and-suspenders: Censys's Bearer-token auth means the
65
+ key never appears in a request URL/query string the way Shodan's did,
66
+ but some requests error paths (e.g. InvalidHeader on a key containing
67
+ a stray control character) embed repr(key) in their message instead
68
+ of the raw string — the escaped form (\\n, \\r, etc.) never matches a
69
+ literal .replace() against the raw key, so redact both forms."""
70
+ if not key:
71
+ return text
72
+ text = text.replace(key, "<redacted>")
73
+ escaped = repr(key)[1:-1]
74
+ if escaped and escaped != key:
75
+ text = text.replace(escaped, "<redacted>")
76
+ return text
77
+
78
+
79
+ def validate_key(key: str) -> bool:
80
+ try:
81
+ resp = requests.get(
82
+ f"{BASE_URL}/accounts/users/credits",
83
+ headers={"Authorization": f"Bearer {key}"},
84
+ timeout=DEFAULT_TIMEOUT,
85
+ )
86
+ except requests.RequestException as exc:
87
+ raise CensysValidationError(f"network error contacting Censys: {_redact_key(str(exc), key)}") from exc
88
+
89
+ if resp.status_code == 200:
90
+ return True
91
+ if resp.status_code == 401:
92
+ return False
93
+ raise CensysValidationError(f"unexpected Censys /accounts/users/credits status {resp.status_code}")
94
+
95
+
96
+ def lookup_host(ip: str, key: str) -> list[Finding]:
97
+ try:
98
+ resp = requests.get(
99
+ f"{BASE_URL}/global/asset/host/{ip}",
100
+ headers={"Authorization": f"Bearer {key}"},
101
+ timeout=DEFAULT_TIMEOUT,
102
+ )
103
+ except requests.RequestException as exc:
104
+ raise CensysAPIError(f"network error contacting Censys: {_redact_key(str(exc), key)}") from exc
105
+
106
+ if resp.status_code == 404:
107
+ return []
108
+ if resp.status_code != 200:
109
+ raise CensysAPIError(f"Censys host lookup returned status {resp.status_code}")
110
+
111
+ try:
112
+ data = resp.json()
113
+ except ValueError as exc:
114
+ raise CensysAPIError(f"unparseable JSON from Censys: {exc}") from exc
115
+
116
+ return _to_findings(ip, data)
117
+
118
+
119
+ def _extract_vuln_ids(entry: dict) -> list[str]:
120
+ """CVE ids for one service entry. Shape not confirmed live (see module
121
+ docstring) — handle a dict keyed by CVE id, a plain list of CVE-id
122
+ strings, and a list of {"id": ...} objects, rather than assuming one."""
123
+ vulns = entry.get("vulns")
124
+ if not vulns:
125
+ return []
126
+ if isinstance(vulns, dict):
127
+ return sorted(vulns.keys())
128
+ if isinstance(vulns, list):
129
+ ids: list[str] = []
130
+ for v in vulns:
131
+ if isinstance(v, dict):
132
+ vid = v.get("id")
133
+ if vid:
134
+ ids.append(str(vid))
135
+ elif v:
136
+ ids.append(str(v))
137
+ return sorted(ids)
138
+ return []
139
+
140
+
141
+ def _to_findings(ip: str, data: dict) -> list[Finding]:
142
+ # Group by port before building Finding objects, same reason as
143
+ # shodan.py's _to_findings: two service entries for the same port
144
+ # would otherwise share a dedup key AND source ("censys"), letting
145
+ # merge_findings() silently let the second overwrite the first's raw
146
+ # payload (the exact Critical bug Shodan's final review caught).
147
+ result = data.get("result")
148
+ if not isinstance(result, dict):
149
+ raise CensysAPIError("unexpected Censys response shape: no 'result' object in body")
150
+ resource = result.get("resource")
151
+ if not isinstance(resource, dict):
152
+ raise CensysAPIError("unexpected Censys response shape: no 'resource' object in body")
153
+
154
+ by_port: dict[int, dict] = {}
155
+ try:
156
+ for svc in resource.get("services", []):
157
+ port = svc.get("port")
158
+ if port is None:
159
+ continue
160
+ try:
161
+ port = int(port)
162
+ except (TypeError, ValueError):
163
+ continue
164
+ merged = by_port.setdefault(
165
+ port, {"service": None, "product": None, "version": None, "vulns": []}
166
+ )
167
+ protocol = svc.get("protocol")
168
+ if merged["service"] is None and protocol is not None:
169
+ merged["service"] = protocol
170
+ for sw in svc.get("software") or []:
171
+ if merged["product"] is None and sw.get("product") is not None:
172
+ merged["product"] = sw.get("product")
173
+ merged["version"] = sw.get("version")
174
+ for v in _extract_vuln_ids(svc):
175
+ if v not in merged["vulns"]:
176
+ merged["vulns"].append(v)
177
+ except (AttributeError, TypeError) as exc:
178
+ raise CensysAPIError(f"unexpected Censys response shape: {exc}") from exc
179
+
180
+ findings: list[Finding] = []
181
+ for port, payload in by_port.items():
182
+ findings.append(
183
+ Finding(
184
+ type=FindingType.OPEN_PORT,
185
+ value=f"{ip}:{port}",
186
+ source="censys",
187
+ raw={"port": port, **payload},
188
+ )
189
+ )
190
+ return findings
191
+
192
+
193
+ CENSYS_PROVIDER = KeyProvider(
194
+ name="censys",
195
+ env_var="CENSYS_API_KEY",
196
+ prompt_label="Censys API key (Personal Access Token)",
197
+ validate=validate_key,
198
+ )
@@ -0,0 +1,87 @@
1
+ """
2
+ crt.sh backend — certificate transparency log lookups.
3
+
4
+ Chosen as BACKHOE's first backend because it needs zero API keys and
5
+ zero rate-limit headaches, so `backhoe domain-audit <domain>` works
6
+ out of the box with no setup wizard required first.
7
+ """
8
+
9
+ import requests
10
+ from datetime import datetime
11
+
12
+ from ..schema import Finding, FindingType
13
+
14
+ CRTSH_URL = "https://crt.sh/"
15
+
16
+
17
+ class CrtShError(Exception):
18
+ """Raised when crt.sh can't be queried or returns something unusable.
19
+
20
+ This is a hard failure, not a Finding — a lookup that failed has zero
21
+ subdomains in it, and rendering that as a fake "finding" with
22
+ confidence 0.0 (the old behavior) buries a real error inside the
23
+ results table instead of telling the user their scan didn't run.
24
+ """
25
+
26
+
27
+ def run(domain: str) -> list[Finding]:
28
+ """
29
+ Query crt.sh for every certificate issued for *.{domain} and pull
30
+ out the unique subdomains mentioned in them.
31
+
32
+ Raises CrtShError on any failure — callers decide how to surface that,
33
+ but it must never be silently swallowed into an empty-looking result.
34
+ """
35
+ try:
36
+ resp = requests.get(
37
+ CRTSH_URL,
38
+ params={"q": f"%.{domain}", "output": "json"},
39
+ timeout=30,
40
+ headers={"User-Agent": "backhoe-osint/0.1"},
41
+ )
42
+ resp.raise_for_status()
43
+ records = resp.json()
44
+ except requests.RequestException as e:
45
+ raise CrtShError(f"crt.sh request failed: {e}") from e
46
+ except ValueError as e:
47
+ raise CrtShError(f"crt.sh returned unparseable data: {e}") from e
48
+
49
+ if not isinstance(records, list):
50
+ raise CrtShError(f"crt.sh returned an unexpected response shape: {type(records).__name__}")
51
+
52
+ findings: list[Finding] = []
53
+ seen_subdomains: dict[str, datetime | None] = {}
54
+
55
+ for rec in records:
56
+ name_value = rec.get("name_value", "")
57
+ not_before = rec.get("not_before")
58
+ parsed_date = None
59
+ if not_before:
60
+ try:
61
+ parsed_date = datetime.strptime(not_before, "%Y-%m-%dT%H:%M:%S")
62
+ except ValueError:
63
+ pass
64
+
65
+ # a single cert can list multiple names (SANs) separated by newlines
66
+ for name in name_value.split("\n"):
67
+ name = name.strip().lower()
68
+ if not name or "*" in name:
69
+ continue
70
+ # keep the most recent issue date we've seen for this name
71
+ if name not in seen_subdomains or (
72
+ parsed_date and (seen_subdomains[name] is None or parsed_date > seen_subdomains[name])
73
+ ):
74
+ seen_subdomains[name] = parsed_date
75
+
76
+ for name, issued in seen_subdomains.items():
77
+ findings.append(
78
+ Finding(
79
+ type=FindingType.SUBDOMAIN,
80
+ value=name,
81
+ source="crt.sh",
82
+ first_seen=issued,
83
+ raw={"domain": domain},
84
+ )
85
+ )
86
+
87
+ return findings
@@ -0,0 +1,97 @@
1
+ """
2
+ DNS-based recon checks — MX/SPF/DMARC (email security posture) and PTR
3
+ (reverse DNS). All genuinely verifiable with no API key: DNS just answers
4
+ with what's actually published. The only real engineering problem is
5
+ telling "this record doesn't exist" (a legitimate finding) apart from
6
+ "we couldn't ask" (a real failure) — conflating them is exactly the kind
7
+ of silent-failure behavior that makes OSINT tools' results untrustworthy.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import socket
12
+
13
+ import dns.exception
14
+ import dns.resolver
15
+
16
+
17
+ class DnsCheckError(Exception):
18
+ """A real DNS infrastructure failure (timeout, no nameservers, the
19
+ queried domain doesn't exist at all). Never raised for an authoritative
20
+ 'this specific record doesn't exist' answer — that's a finding, not
21
+ an error.
22
+ """
23
+
24
+
25
+ def resolve_a_records(domain: str) -> list[str]:
26
+ try:
27
+ _, _, ips = socket.gethostbyname_ex(domain)
28
+ return ips
29
+ except socket.gaierror as e:
30
+ raise DnsCheckError(f"could not resolve {domain}: {e}") from e
31
+
32
+
33
+ def reverse_dns(ip: str) -> str | None:
34
+ try:
35
+ hostname, _, _ = socket.gethostbyaddr(ip)
36
+ return hostname
37
+ except socket.herror:
38
+ return None # no PTR record — a legitimate, common outcome
39
+ except socket.gaierror as e:
40
+ raise DnsCheckError(f"reverse DNS lookup for {ip} failed: {e}") from e
41
+
42
+
43
+ def check_mx(domain: str) -> list[str]:
44
+ try:
45
+ answers = dns.resolver.resolve(domain, "MX")
46
+ return sorted(str(r.exchange).rstrip(".") for r in answers)
47
+ except dns.resolver.NoAnswer:
48
+ return []
49
+ except dns.resolver.NXDOMAIN as e:
50
+ raise DnsCheckError(f"{domain} does not exist (NXDOMAIN)") from e
51
+ except (dns.exception.Timeout, dns.resolver.NoNameservers) as e:
52
+ raise DnsCheckError(f"MX lookup for {domain} failed: {e}") from e
53
+
54
+
55
+ def check_spf(domain: str) -> str | None:
56
+ try:
57
+ answers = dns.resolver.resolve(domain, "TXT")
58
+ except dns.resolver.NoAnswer:
59
+ return None
60
+ except dns.resolver.NXDOMAIN as e:
61
+ raise DnsCheckError(f"{domain} does not exist (NXDOMAIN)") from e
62
+ except (dns.exception.Timeout, dns.resolver.NoNameservers) as e:
63
+ raise DnsCheckError(f"TXT lookup for {domain} failed: {e}") from e
64
+
65
+ for record in answers:
66
+ txt = _join_txt(record)
67
+ if txt.lower().startswith("v=spf1"):
68
+ return txt
69
+ return None
70
+
71
+
72
+ def check_dmarc(domain: str) -> tuple[bool, str | None]:
73
+ """Returns (present, policy). policy is the p= value (none/quarantine/reject)."""
74
+ try:
75
+ answers = dns.resolver.resolve(f"_dmarc.{domain}", "TXT")
76
+ except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
77
+ return False, None
78
+ except (dns.exception.Timeout, dns.resolver.NoNameservers) as e:
79
+ raise DnsCheckError(f"DMARC lookup for {domain} failed: {e}") from e
80
+
81
+ for record in answers:
82
+ txt = _join_txt(record)
83
+ if txt.lower().startswith("v=dmarc1"):
84
+ policy = None
85
+ for part in txt.split(";"):
86
+ part = part.strip()
87
+ if part.lower().startswith("p="):
88
+ policy = part.split("=", 1)[1].strip().lower()
89
+ return True, policy
90
+ return False, None
91
+
92
+
93
+ def _join_txt(record) -> str:
94
+ # A TXT record's value can be split across multiple quoted strings.
95
+ return "".join(
96
+ part.decode() if isinstance(part, bytes) else part for part in record.strings
97
+ )
@@ -0,0 +1,33 @@
1
+ """
2
+ Gravatar existence check — a long-standing, well-known OSINT technique:
3
+ Gravatar exposes whether a public profile exists for an email's hash with
4
+ no authentication required. This is only ever a positive signal (a live,
5
+ human-managed inbox with a public profile), never a breach or exposure
6
+ by itself.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+
12
+ import requests
13
+
14
+ GRAVATAR_URL = "https://www.gravatar.com/avatar/{h}?d=404"
15
+
16
+
17
+ class GravatarError(Exception):
18
+ """Raised on a network failure — never for a legitimate 404 (no profile)."""
19
+
20
+
21
+ def check_gravatar(email: str, *, timeout: float = 10.0) -> bool:
22
+ digest = hashlib.sha256(email.strip().lower().encode()).hexdigest()
23
+ url = GRAVATAR_URL.format(h=digest)
24
+ try:
25
+ resp = requests.get(url, timeout=timeout, headers={"User-Agent": "backhoe/0.1"})
26
+ except requests.RequestException as e:
27
+ raise GravatarError(f"Gravatar check failed: {e}") from e
28
+
29
+ if resp.status_code == 200:
30
+ return True
31
+ if resp.status_code == 404:
32
+ return False
33
+ raise GravatarError(f"Gravatar returned unexpected status {resp.status_code}")
@@ -0,0 +1,33 @@
1
+ """
2
+ Detects whether outbound TCP/TLS traffic is being transparently
3
+ intercepted (a corporate proxy, security sandbox, some VPNs). Port scans
4
+ and raw TLS-certificate fetches are worthless under interception: every
5
+ port looks "open" and every certificate is the interceptor's re-signed
6
+ stand-in, not the real target's — not a hypothetical, this is exactly
7
+ what happened testing against a real domain in a sandboxed environment
8
+ during development (the "certificate" came back issued by the sandbox's
9
+ own egress gateway, not any real CA).
10
+
11
+ Detected by handshaking against a guaranteed-unassigned IP (TEST-NET-3,
12
+ RFC 5737) — a real network can never route there, so a completed
13
+ handshake proves something is answering on its behalf.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import socket
18
+ import ssl
19
+
20
+ _CANARY_IP = "203.0.113.1" # RFC 5737 TEST-NET-3 — reserved, never routed
21
+ _CANARY_PORT = 443
22
+
23
+
24
+ def tcp_tls_is_intercepted(*, timeout: float = 5.0) -> bool:
25
+ ctx = ssl.create_default_context()
26
+ ctx.check_hostname = False
27
+ ctx.verify_mode = ssl.CERT_NONE
28
+ try:
29
+ with socket.create_connection((_CANARY_IP, _CANARY_PORT), timeout=timeout) as sock:
30
+ with ctx.wrap_socket(sock):
31
+ return True
32
+ except OSError:
33
+ return False
@@ -0,0 +1,52 @@
1
+ """
2
+ Lightweight TCP connect scan — a small, fixed set of commonly-exposed
3
+ ports, not a full sweep. This is active scanning, not passive OSINT:
4
+ only ever run it against infrastructure you own or are explicitly
5
+ authorized to test.
6
+
7
+ Note: from behind a network that transparently intercepts outbound
8
+ connections (many corporate networks, CI sandboxes, some VPNs), every
9
+ port will falsely appear "open" because the connection succeeds against
10
+ the interceptor, not the real host. Trust these results only when run
11
+ from a direct, unproxied network.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import socket
16
+ from concurrent.futures import ThreadPoolExecutor, as_completed
17
+
18
+ COMMON_PORTS: dict[int, str] = {
19
+ 21: "ftp",
20
+ 22: "ssh",
21
+ 25: "smtp",
22
+ 80: "http",
23
+ 443: "https",
24
+ 3306: "mysql",
25
+ 3389: "rdp",
26
+ 8080: "http-alt",
27
+ 8443: "https-alt",
28
+ }
29
+
30
+
31
+ def scan_ports(
32
+ host: str,
33
+ ports: dict[int, str] | None = None,
34
+ *,
35
+ timeout: float = 3.0,
36
+ workers: int = 10,
37
+ ) -> dict[int, bool]:
38
+ ports = ports if ports is not None else COMMON_PORTS
39
+ results: dict[int, bool] = {}
40
+ with ThreadPoolExecutor(max_workers=workers) as pool:
41
+ futures = {pool.submit(_is_open, host, port, timeout): port for port in ports}
42
+ for future in as_completed(futures):
43
+ results[futures[future]] = future.result()
44
+ return results
45
+
46
+
47
+ def _is_open(host: str, port: int, timeout: float) -> bool:
48
+ try:
49
+ with socket.create_connection((host, port), timeout=timeout):
50
+ return True
51
+ except OSError:
52
+ return False
@@ -0,0 +1,150 @@
1
+ """
2
+ Shodan backend — richer per-port service/banner data than BACKHOE's own
3
+ bounded TCP connect scan (backends/portscan.py), without doing any more
4
+ raw TCP ourselves. Plain requests calls against Shodan's REST API, no
5
+ `shodan` SDK dependency — same pattern as crtsh.py/gravatar.py.
6
+
7
+ Needs an API key: backhoe.keys.get_api_key(SHODAN_PROVIDER) resolves one
8
+ (SHODAN_API_KEY env var, then a locally stored key, then an interactive
9
+ prompt — see keys.py). Shodan's free tier includes host lookups; which
10
+ optional fields (product, version, vulns) actually come back is
11
+ plan-dependent and NOT verified live in this environment — no Shodan key
12
+ or live network access was available during development (see the design
13
+ spec's "Verification honesty note"). Every optional field is read with
14
+ .get(), never assumed present. The `vulns` field's shape in particular
15
+ (list of CVE strings vs. a dict keyed by CVE ID) isn't confirmed from
16
+ Shodan's own docs either — _extract_vuln_ids() below handles both rather
17
+ than guessing one.
18
+
19
+ validate_key() hits /api-info, confirmed via Shodan's own docs to cost no
20
+ query credit — this is what lets keys.py live-validate a stored key on
21
+ every single call without burning the operator's quota.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import requests
26
+
27
+ from ..keys import KeyProvider, KeyValidationError
28
+ from ..schema import Finding, FindingType
29
+
30
+ BASE_URL = "https://api.shodan.io"
31
+ DEFAULT_TIMEOUT = 10
32
+
33
+
34
+ class ShodanError(Exception):
35
+ """A real failure calling Shodan — never raised for "no data for this
36
+ host", which is a legitimate empty result (see lookup_host)."""
37
+
38
+
39
+ class ShodanAPIError(ShodanError):
40
+ """lookup_host() failed for a reason other than "not indexed": bad key,
41
+ rate limited, Shodan's own server error, network failure, or an
42
+ unparseable response."""
43
+
44
+
45
+ class ShodanValidationError(ShodanError, KeyValidationError):
46
+ """validate_key() itself failed inconclusively (network error, 5xx,
47
+ unparseable response) — NOT the same as the key being confirmed wrong.
48
+ keys.py treats this as "can't confirm, use the key anyway", not as a
49
+ reason to reprompt."""
50
+
51
+
52
+ def _redact_key(text: str, key: str) -> str:
53
+ """Never let the raw API key reach a message a caller might print
54
+ (click.secho, terminal scrollback, logs) — `requests` embeds the
55
+ full request URL, key querystring included, in a ConnectionError's
56
+ own message."""
57
+ return text.replace(key, "<redacted>") if key else text
58
+
59
+
60
+ def validate_key(key: str) -> bool:
61
+ try:
62
+ resp = requests.get(f"{BASE_URL}/api-info", params={"key": key}, timeout=DEFAULT_TIMEOUT)
63
+ except requests.RequestException as exc:
64
+ raise ShodanValidationError(f"network error contacting Shodan: {_redact_key(str(exc), key)}") from exc
65
+
66
+ if resp.status_code == 200:
67
+ return True
68
+ if resp.status_code == 401:
69
+ return False
70
+ raise ShodanValidationError(f"unexpected Shodan /api-info status {resp.status_code}")
71
+
72
+
73
+ def lookup_host(ip: str, key: str) -> list[Finding]:
74
+ try:
75
+ resp = requests.get(f"{BASE_URL}/shodan/host/{ip}", params={"key": key}, timeout=DEFAULT_TIMEOUT)
76
+ except requests.RequestException as exc:
77
+ raise ShodanAPIError(f"network error contacting Shodan: {_redact_key(str(exc), key)}") from exc
78
+
79
+ if resp.status_code == 404:
80
+ return []
81
+ if resp.status_code != 200:
82
+ raise ShodanAPIError(f"Shodan host lookup returned status {resp.status_code}")
83
+
84
+ try:
85
+ data = resp.json()
86
+ except ValueError as exc:
87
+ raise ShodanAPIError(f"unparseable JSON from Shodan: {exc}") from exc
88
+
89
+ return _to_findings(ip, data)
90
+
91
+
92
+ def _extract_vuln_ids(entry: dict) -> list[str]:
93
+ """CVE ids for one data[] entry. Shape not verified live (see module
94
+ docstring) — handle both a dict keyed by CVE id and a plain list of
95
+ CVE id strings rather than assuming one."""
96
+ vulns = entry.get("vulns")
97
+ if not vulns:
98
+ return []
99
+ if isinstance(vulns, dict):
100
+ return sorted(vulns.keys())
101
+ if isinstance(vulns, list):
102
+ return sorted(str(v) for v in vulns)
103
+ return []
104
+
105
+
106
+ def _to_findings(ip: str, data: dict) -> list[Finding]:
107
+ # Shodan can return multiple data[] entries for the same port (different
108
+ # banners/modules/vhosts observed on it). Group by port BEFORE building
109
+ # Finding objects so each port produces exactly one Finding — otherwise
110
+ # two same-port entries would share the same dedup key AND the same
111
+ # source string ("shodan"), and merge_findings() would let the second
112
+ # entry's raw payload silently overwrite the first's (e.g. losing a CVE).
113
+ by_port: dict[int, dict] = {}
114
+ for entry in data.get("data", []):
115
+ port = entry.get("port")
116
+ if port is None:
117
+ continue
118
+ merged = by_port.setdefault(
119
+ port, {"service": None, "product": None, "version": None, "vulns": []}
120
+ )
121
+ service = (entry.get("_shodan") or {}).get("module")
122
+ if merged["service"] is None and service is not None:
123
+ merged["service"] = service
124
+ if merged["product"] is None and entry.get("product") is not None:
125
+ merged["product"] = entry.get("product")
126
+ if merged["version"] is None and entry.get("version") is not None:
127
+ merged["version"] = entry.get("version")
128
+ for v in _extract_vuln_ids(entry):
129
+ if v not in merged["vulns"]:
130
+ merged["vulns"].append(v)
131
+
132
+ findings: list[Finding] = []
133
+ for port, payload in by_port.items():
134
+ findings.append(
135
+ Finding(
136
+ type=FindingType.OPEN_PORT,
137
+ value=f"{ip}:{port}",
138
+ source="shodan",
139
+ raw={"port": port, **payload},
140
+ )
141
+ )
142
+ return findings
143
+
144
+
145
+ SHODAN_PROVIDER = KeyProvider(
146
+ name="shodan",
147
+ env_var="SHODAN_API_KEY",
148
+ prompt_label="Shodan API key",
149
+ validate=validate_key,
150
+ )