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 +0 -0
- backhoe/backends/__init__.py +0 -0
- backhoe/backends/censys.py +198 -0
- backhoe/backends/crtsh.py +87 -0
- backhoe/backends/dns_checks.py +97 -0
- backhoe/backends/gravatar.py +33 -0
- backhoe/backends/netcheck.py +33 -0
- backhoe/backends/portscan.py +52 -0
- backhoe/backends/shodan.py +150 -0
- backhoe/backends/spiderfoot.py +152 -0
- backhoe/backends/theharvester.py +104 -0
- backhoe/backends/tls.py +59 -0
- backhoe/cli.py +311 -0
- backhoe/keys.py +154 -0
- backhoe/report.py +137 -0
- backhoe/resolve.py +37 -0
- backhoe/schema.py +115 -0
- backhoe/scoring.py +246 -0
- backhoe_osint-0.5.0.dist-info/METADATA +287 -0
- backhoe_osint-0.5.0.dist-info/RECORD +24 -0
- backhoe_osint-0.5.0.dist-info/WHEEL +5 -0
- backhoe_osint-0.5.0.dist-info/entry_points.txt +2 -0
- backhoe_osint-0.5.0.dist-info/licenses/LICENSE +686 -0
- backhoe_osint-0.5.0.dist-info/top_level.txt +1 -0
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
|
+
)
|