@murk-security/runner-darwin-arm64 0.0.0-stage → 0.7.0
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.
- package/README.md +2 -2
- package/bin/harness +0 -0
- package/package.json +15 -4
- package/runners/basic_harness_run.py +1271 -0
- package/runners/checklists/anonymous.json +24 -0
- package/runners/cve_feed.json +53 -0
- package/runners/prod_env.py +1686 -0
- package/runners/profile.py +307 -0
- package/runners/recon.py +396 -0
- package/runners/remediate.py +429 -0
- package/runners/report.py +365 -0
- package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
- package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
- package/runners/skills/exploit/business-logic/SKILL.md +53 -0
- package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
- package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
- package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
- package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
- package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
- package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
- package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
- package/runners/skills/exploit/service-probing/SKILL.md +51 -0
- package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
- package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
- package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
- package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
- package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
- package/runners/skills/web/content-discovery/SKILL.md +59 -0
- package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
- package/runners/skills.py +174 -0
package/runners/recon.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Recon / EASM ops for the Blackglass basic-harness product env (Phase 2).
|
|
3
|
+
|
|
4
|
+
WAF-INDEPENDENT external attack-surface discovery. These ops never hit the
|
|
5
|
+
target's protected origin, so a CDN/WAF in front of the app cannot block them.
|
|
6
|
+
They query a FIXED allowlist of external data sources (DoH resolvers, crt.sh,
|
|
7
|
+
cloud-storage endpoints, public registries, GitHub) about the customer's
|
|
8
|
+
VERIFIED root domain and org, and are strictly READ-ONLY.
|
|
9
|
+
|
|
10
|
+
Imported and dispatched by prod_env.py: the harness sees ONE env process whose
|
|
11
|
+
`describe` advertises these ops alongside `http`/`list_identities`. No Go-core
|
|
12
|
+
change -- recon is new vocabulary on the existing `env` tool.
|
|
13
|
+
|
|
14
|
+
Design constraints inherited from the Go env wiring (env.go):
|
|
15
|
+
* Each op MUST answer well under the harness's fixed 30s per-call timeout, or
|
|
16
|
+
the whole env process is killed and restarted. Every network call carries a
|
|
17
|
+
short timeout; an op returns a partial/timeout result instead of hanging.
|
|
18
|
+
* One op at a time per process; the prober fans out via `steps` batches and
|
|
19
|
+
across cells, not inside one call.
|
|
20
|
+
|
|
21
|
+
Scope carve-out (approved by the operator): dns/crt/tls operate ONLY on the
|
|
22
|
+
verified root domain and its subdomains. storage/registries/code_search query
|
|
23
|
+
the fixed data-source allowlist about org-derived names. code_search is OPT-IN
|
|
24
|
+
and needs BLACKGLASS_GITHUB_TOKEN; it is disabled (noop) otherwise.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import socket
|
|
32
|
+
import ssl
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from urllib.parse import quote, urlencode, urlparse
|
|
36
|
+
|
|
37
|
+
# Per-request network timeouts, sized so an op always answers inside the harness's
|
|
38
|
+
# fixed 30s guillotine (env.go step() uses a 30s Do()); a timeout kills+restarts the
|
|
39
|
+
# whole env process, so stay well under it. dns fans out over several DoH calls, so
|
|
40
|
+
# its per-call budget is small; crt.sh is a single slow source, so it gets more.
|
|
41
|
+
NET_TIMEOUT = 8 # single-call ops: tls, storage, registries, code_search, internetdb
|
|
42
|
+
DOH_TIMEOUT = 4 # dns: up to ~8 sequential calls -> worst case ~32s only if DoH is down
|
|
43
|
+
CRT_TIMEOUT = 12 # crt.sh per attempt; up to 2 attempts (crt.sh 502s a lot) -> <30s
|
|
44
|
+
ARCHIVE_TIMEOUT = 12 # Wayback CDX can be slow; stay under the 30s guillotine
|
|
45
|
+
|
|
46
|
+
# The FIXED external data sources this module may reach. A confined/prod egress
|
|
47
|
+
# jail must allowlist exactly these hosts for recon to work; nothing else here
|
|
48
|
+
# ever makes an outbound call. The prober cannot point recon at arbitrary hosts
|
|
49
|
+
# -- it supplies a subject (domain/bucket/namespace) and we query these.
|
|
50
|
+
DATA_SOURCES = (
|
|
51
|
+
"dns.google", "crt.sh", "s3.amazonaws.com", "storage.googleapis.com",
|
|
52
|
+
"hub.docker.com", "registry.npmjs.org", "api.github.com",
|
|
53
|
+
"internetdb.shodan.io", "web.archive.org",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
57
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
|
58
|
+
|
|
59
|
+
# Data-source base URLs, overridable via env for a proxied/air-gapped egress path
|
|
60
|
+
# or for a hermetic test harness that mocks them. Defaults are the public services.
|
|
61
|
+
DOH_URL = os.environ.get("RECON_DOH_URL", "https://dns.google/resolve")
|
|
62
|
+
CRT_URL = os.environ.get("RECON_CRT_URL", "https://crt.sh/")
|
|
63
|
+
S3_BASE = os.environ.get("RECON_S3_BASE", "https://s3.amazonaws.com")
|
|
64
|
+
GCS_BASE = os.environ.get("RECON_GCS_BASE", "https://storage.googleapis.com")
|
|
65
|
+
DOCKER_BASE = os.environ.get("RECON_DOCKER_BASE", "https://hub.docker.com")
|
|
66
|
+
NPM_BASE = os.environ.get("RECON_NPM_BASE", "https://registry.npmjs.org")
|
|
67
|
+
INTERNETDB_URL = os.environ.get("RECON_INTERNETDB_URL", "https://internetdb.shodan.io")
|
|
68
|
+
WAYBACK_URL = os.environ.get("RECON_WAYBACK_URL", "https://web.archive.org/cdx/search/cdx")
|
|
69
|
+
|
|
70
|
+
# CNAME targets that commonly indicate a dangling / takeover-prone record when
|
|
71
|
+
# the fronting resource no longer exists. Presence is a CANDIDATE signal only;
|
|
72
|
+
# the prober should corroborate (e.g. an http fetch showing the provider's
|
|
73
|
+
# "no such bucket/app" page) before reporting a confirmed takeover.
|
|
74
|
+
TAKEOVER_SUFFIXES = (
|
|
75
|
+
".s3.amazonaws.com", ".cloudfront.net", ".github.io", ".herokudns.com",
|
|
76
|
+
".herokuapp.com", ".azurewebsites.net", ".cloudapp.net", ".trafficmanager.net",
|
|
77
|
+
".fastly.net", ".netlify.app", ".ghost.io", ".surge.sh", ".pantheonsite.io",
|
|
78
|
+
".readthedocs.io", ".wpengine.com", ".zendesk.com", ".statuspage.io",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get(url: str, headers: dict | None = None, timeout: int = NET_TIMEOUT,
|
|
83
|
+
max_bytes: int = 1_000_000):
|
|
84
|
+
"""GET a URL from a fixed data source. Returns (status, body_text, err).
|
|
85
|
+
|
|
86
|
+
Reads at most max_bytes to bound memory; crt.sh (big CT JSON) passes a larger cap.
|
|
87
|
+
"""
|
|
88
|
+
h = {"User-Agent": _UA, "Accept": "application/json, text/plain, */*"}
|
|
89
|
+
if headers:
|
|
90
|
+
h.update(headers)
|
|
91
|
+
req = urllib.request.Request(url, headers=h, method="GET")
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
94
|
+
return resp.status, resp.read(max_bytes).decode("utf-8", "replace"), None
|
|
95
|
+
except urllib.error.HTTPError as e:
|
|
96
|
+
body = e.read(200_000).decode("utf-8", "replace") if hasattr(e, "read") else ""
|
|
97
|
+
return e.code, body, None
|
|
98
|
+
except Exception as e: # noqa: BLE001 - surface to the agent, never crash the env
|
|
99
|
+
return None, "", str(e)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_json(url: str, headers: dict | None = None, timeout: int = NET_TIMEOUT,
|
|
103
|
+
max_bytes: int = 1_000_000):
|
|
104
|
+
status, body, err = _get(url, headers, timeout, max_bytes)
|
|
105
|
+
if err:
|
|
106
|
+
return None, err
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(body), None
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
return None, f"non-JSON response (status {status})"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- scope ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def registrable(host: str) -> str:
|
|
116
|
+
"""Best-effort registrable domain: the last two labels.
|
|
117
|
+
|
|
118
|
+
NOTE: this does not consult the Public Suffix List, so multi-label TLDs
|
|
119
|
+
(co.uk, com.au) resolve to the wrong root. Acceptable for a first cut;
|
|
120
|
+
revisit with a PSL if customers on such TLDs appear.
|
|
121
|
+
"""
|
|
122
|
+
host = (host or "").strip(".").lower()
|
|
123
|
+
labels = host.split(".")
|
|
124
|
+
return ".".join(labels[-2:]) if len(labels) >= 2 else host
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def in_domain_scope(name: str, root: str) -> bool:
|
|
128
|
+
name = (name or "").strip(".").lower()
|
|
129
|
+
root = (root or "").strip(".").lower()
|
|
130
|
+
return bool(root) and (name == root or name.endswith("." + root))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- dns --------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _doh(name: str, rtype: str):
|
|
136
|
+
"""Resolve one record type via Google DoH JSON. Returns (status_code, [data])."""
|
|
137
|
+
url = DOH_URL + "?" + urlencode({"name": name, "type": rtype})
|
|
138
|
+
data, err = _get_json(url, timeout=DOH_TIMEOUT)
|
|
139
|
+
if err or not isinstance(data, dict):
|
|
140
|
+
return None, []
|
|
141
|
+
answers = [a.get("data", "").strip('"') for a in (data.get("Answer") or [])
|
|
142
|
+
if a.get("type") is not None]
|
|
143
|
+
return data.get("Status"), answers
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def op_dns(args: dict, root: str) -> dict:
|
|
147
|
+
name = str(args.get("name") or root)
|
|
148
|
+
if not in_domain_scope(name, root):
|
|
149
|
+
return {"ok": False, "error": f"dns is scoped to the verified root domain {root!r} and its subdomains; {name!r} is out of scope"}
|
|
150
|
+
records: dict = {}
|
|
151
|
+
for rtype in ("A", "AAAA", "CNAME", "MX", "TXT", "NS"):
|
|
152
|
+
status, answers = _doh(name, rtype)
|
|
153
|
+
if answers:
|
|
154
|
+
records[rtype] = answers
|
|
155
|
+
|
|
156
|
+
# Dangling / takeover heuristic: a CNAME whose target no longer resolves
|
|
157
|
+
# (NXDOMAIN) or points at a known takeover-prone provider.
|
|
158
|
+
dangling = False
|
|
159
|
+
takeover_candidate = None
|
|
160
|
+
for cname in records.get("CNAME", []):
|
|
161
|
+
target = cname.strip(".").lower()
|
|
162
|
+
a_status, a_ans = _doh(target, "A")
|
|
163
|
+
if a_status == 3 and not a_ans: # NXDOMAIN, nothing resolves
|
|
164
|
+
dangling = True
|
|
165
|
+
for suffix in TAKEOVER_SUFFIXES:
|
|
166
|
+
if target.endswith(suffix):
|
|
167
|
+
takeover_candidate = {"cname": target, "provider": suffix.lstrip(".")}
|
|
168
|
+
out = {"ok": True, "name": name, "records": records}
|
|
169
|
+
if dangling:
|
|
170
|
+
out["dangling"] = True
|
|
171
|
+
if takeover_candidate:
|
|
172
|
+
out["takeover_candidate"] = takeover_candidate
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- certificate transparency ----------------------------------------------
|
|
177
|
+
|
|
178
|
+
def op_crt(args: dict, root: str) -> dict:
|
|
179
|
+
domain = str(args.get("domain") or root)
|
|
180
|
+
if not in_domain_scope(domain, root):
|
|
181
|
+
return {"ok": False, "error": f"crt is scoped to the verified root domain {root!r}; {domain!r} is out of scope"}
|
|
182
|
+
url = CRT_URL + "?" + urlencode({"q": "%." + domain, "output": "json"})
|
|
183
|
+
# crt.sh frequently returns 502/503 or times out; retry once (budget stays <30s).
|
|
184
|
+
data = last_err = None
|
|
185
|
+
for _ in range(2):
|
|
186
|
+
data, last_err = _get_json(url, timeout=CRT_TIMEOUT, max_bytes=20_000_000)
|
|
187
|
+
if isinstance(data, list):
|
|
188
|
+
break
|
|
189
|
+
if not isinstance(data, list):
|
|
190
|
+
return {"ok": False, "error": f"crt.sh unavailable (transient; retry later): {last_err}"}
|
|
191
|
+
hosts: set[str] = set()
|
|
192
|
+
for row in data:
|
|
193
|
+
for h in str(row.get("name_value", "")).splitlines():
|
|
194
|
+
h = h.strip().lower().lstrip("*.")
|
|
195
|
+
if h and in_domain_scope(h, root):
|
|
196
|
+
hosts.add(h)
|
|
197
|
+
ordered = sorted(hosts)[:200]
|
|
198
|
+
return {"ok": True, "domain": domain, "count": len(ordered), "hostnames": ordered}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- live TLS certificate ---------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def op_tls(args: dict, root: str) -> dict:
|
|
204
|
+
host = str(args.get("host") or root)
|
|
205
|
+
if not in_domain_scope(host, root):
|
|
206
|
+
return {"ok": False, "error": f"tls is scoped to the verified root domain {root!r}; {host!r} is out of scope"}
|
|
207
|
+
port = int(args.get("port") or 443)
|
|
208
|
+
ctx = ssl.create_default_context()
|
|
209
|
+
try:
|
|
210
|
+
with socket.create_connection((host, port), timeout=NET_TIMEOUT) as sock:
|
|
211
|
+
with ctx.wrap_socket(sock, server_hostname=host) as ss:
|
|
212
|
+
cert = ss.getpeercert() or {}
|
|
213
|
+
sans = [v for (k, v) in cert.get("subjectAltName", []) if k.lower() == "dns"]
|
|
214
|
+
return {"ok": True, "host": host, "verified": True,
|
|
215
|
+
"subject": dict(x[0] for x in cert.get("subject", [])),
|
|
216
|
+
"notAfter": cert.get("notAfter"), "sans": sans}
|
|
217
|
+
except ssl.SSLCertVerificationError as e:
|
|
218
|
+
return {"ok": True, "host": host, "verified": False, "error": str(e),
|
|
219
|
+
"note": "cert did not verify against system roots; SANs unavailable without it -- use crt for historical SANs"}
|
|
220
|
+
except Exception as e: # noqa: BLE001
|
|
221
|
+
return {"ok": False, "error": f"tls connect failed: {e}"}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --- cloud storage ----------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def op_storage(args: dict, root: str) -> dict:
|
|
227
|
+
name = str(args.get("name") or "").strip().lower()
|
|
228
|
+
if not name or "/" in name or " " in name:
|
|
229
|
+
return {"ok": False, "error": "storage needs a bucket 'name' (derive candidates from the org/domain, e.g. acme, acme-backups, acme-assets)"}
|
|
230
|
+
results = []
|
|
231
|
+
# S3: path-style GET lists keys when the bucket allows public read.
|
|
232
|
+
s3_status, s3_body, s3_err = _get(f"{S3_BASE}/{quote(name)}/?max-keys=5")
|
|
233
|
+
if s3_err:
|
|
234
|
+
results.append({"provider": "s3", "error": s3_err})
|
|
235
|
+
else:
|
|
236
|
+
public = s3_status == 200 and "<ListBucketResult" in s3_body
|
|
237
|
+
exists = s3_status in (200, 403)
|
|
238
|
+
results.append({"provider": "s3", "status": s3_status, "exists": exists,
|
|
239
|
+
"public_read": public})
|
|
240
|
+
# GCS: XML API lists objects when public.
|
|
241
|
+
g_status, g_body, g_err = _get(f"{GCS_BASE}/{quote(name)}?max-keys=5")
|
|
242
|
+
if g_err:
|
|
243
|
+
results.append({"provider": "gcs", "error": g_err})
|
|
244
|
+
else:
|
|
245
|
+
public = g_status == 200 and "<ListBucketResult" in g_body
|
|
246
|
+
exists = g_status in (200, 403)
|
|
247
|
+
results.append({"provider": "gcs", "status": g_status, "exists": exists,
|
|
248
|
+
"public_read": public})
|
|
249
|
+
any_public = any(r.get("public_read") for r in results)
|
|
250
|
+
return {"ok": True, "name": name, "results": results, "public_read": any_public}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- public registries ------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def op_registries(args: dict, root: str) -> dict:
|
|
256
|
+
namespace = str(args.get("namespace") or "").strip().lower()
|
|
257
|
+
if not namespace:
|
|
258
|
+
return {"ok": False, "error": "registries needs a 'namespace' (org/user handle derived from the target, e.g. 'acme')"}
|
|
259
|
+
out: dict = {"ok": True, "namespace": namespace}
|
|
260
|
+
# Docker Hub public repositories under the namespace.
|
|
261
|
+
data, err = _get_json(f"{DOCKER_BASE}/v2/repositories/{quote(namespace)}/?page_size=25")
|
|
262
|
+
if err:
|
|
263
|
+
out["docker"] = {"error": err}
|
|
264
|
+
elif isinstance(data, dict):
|
|
265
|
+
repos = [{"name": r.get("name"), "pulls": r.get("pull_count"), "private": r.get("is_private")}
|
|
266
|
+
for r in (data.get("results") or [])]
|
|
267
|
+
out["docker"] = {"count": data.get("count", len(repos)), "repos": repos}
|
|
268
|
+
# npm search for packages tied to the org/name.
|
|
269
|
+
npm, nerr = _get_json(f"{NPM_BASE}/-/v1/search?" + urlencode({"text": namespace, "size": 20}))
|
|
270
|
+
if nerr:
|
|
271
|
+
out["npm"] = {"error": nerr}
|
|
272
|
+
elif isinstance(npm, dict):
|
|
273
|
+
pkgs = [{"name": o.get("package", {}).get("name"), "version": o.get("package", {}).get("version")}
|
|
274
|
+
for o in (npm.get("objects") or [])]
|
|
275
|
+
out["npm"] = {"total": npm.get("total", len(pkgs)), "packages": pkgs[:20]}
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --- opt-in public-code secret search (token-gated) -------------------------
|
|
280
|
+
|
|
281
|
+
def op_code_search(args: dict, root: str) -> dict:
|
|
282
|
+
token = os.environ.get("BLACKGLASS_GITHUB_TOKEN")
|
|
283
|
+
if not token:
|
|
284
|
+
return {"ok": True, "enabled": False,
|
|
285
|
+
"note": "code_search is opt-in and disabled: set BLACKGLASS_GITHUB_TOKEN to search public code for leaked org secrets."}
|
|
286
|
+
query = str(args.get("query") or root)
|
|
287
|
+
url = "https://api.github.com/search/code?" + urlencode({"q": query, "per_page": 20})
|
|
288
|
+
data, err = _get_json(url, headers={"Authorization": f"Bearer {token}",
|
|
289
|
+
"Accept": "application/vnd.github+json"})
|
|
290
|
+
if err:
|
|
291
|
+
return {"ok": False, "enabled": True, "error": f"github code search failed: {err}"}
|
|
292
|
+
items = [{"repo": i.get("repository", {}).get("full_name"), "path": i.get("path"),
|
|
293
|
+
"url": i.get("html_url")} for i in (data.get("items") or [])] if isinstance(data, dict) else []
|
|
294
|
+
return {"ok": True, "enabled": True, "query": query,
|
|
295
|
+
"total": (data or {}).get("total_count", len(items)), "matches": items}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --- passive internet-scan data (Shodan InternetDB) -------------------------
|
|
299
|
+
|
|
300
|
+
def _looks_ipv4(s: str) -> bool:
|
|
301
|
+
parts = s.split(".")
|
|
302
|
+
return len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def op_internetdb(args: dict, root: str) -> dict:
|
|
306
|
+
"""Passive lookup of Shodan's free keyless InternetDB for an IP (or a host under
|
|
307
|
+
the root domain, resolved first). Returns ports/cpes/vulns/tags/hostnames. These
|
|
308
|
+
are LEADS from third-party scan data -- never target traffic -- to be corroborated,
|
|
309
|
+
not reported as confirmed."""
|
|
310
|
+
ip = str(args.get("ip") or "").strip()
|
|
311
|
+
host = str(args.get("host") or "").strip().lower()
|
|
312
|
+
if host:
|
|
313
|
+
if not in_domain_scope(host, root):
|
|
314
|
+
return {"ok": False, "error": f"internetdb host is scoped to {root!r} and its subdomains; {host!r} is out of scope"}
|
|
315
|
+
_, answers = _doh(host, "A")
|
|
316
|
+
ips = [a for a in answers if _looks_ipv4(a)]
|
|
317
|
+
if not ips:
|
|
318
|
+
return {"ok": True, "host": host, "found": False, "note": "host has no A record to look up"}
|
|
319
|
+
ip = ips[0]
|
|
320
|
+
if not _looks_ipv4(ip):
|
|
321
|
+
return {"ok": False, "error": "internetdb needs an 'ip' (IPv4) or a 'host' under the root domain"}
|
|
322
|
+
data, err = _get_json(f"{INTERNETDB_URL}/{quote(ip)}")
|
|
323
|
+
if err:
|
|
324
|
+
return {"ok": False, "error": f"internetdb lookup failed: {err}"}
|
|
325
|
+
if not isinstance(data, dict) or "ports" not in data:
|
|
326
|
+
return {"ok": True, "ip": ip, "found": False, "note": "no InternetDB data for this IP"}
|
|
327
|
+
return {"ok": True, "ip": ip, "found": True,
|
|
328
|
+
"ports": data.get("ports", []), "hostnames": data.get("hostnames", []),
|
|
329
|
+
"cpes": data.get("cpes", []), "tags": data.get("tags", []),
|
|
330
|
+
"vulns": data.get("vulns", []),
|
|
331
|
+
"note": ("ports/cpes/vulns are LEADS from Shodan's internet-wide scan data, NOT "
|
|
332
|
+
"confirmed facts about the live target -- corroborate (e.g. fingerprint the "
|
|
333
|
+
"running version via http) before reporting; never report a vuln from this alone.")}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --- passive historical URLs (Wayback Machine CDX) --------------------------
|
|
337
|
+
|
|
338
|
+
def op_archive(args: dict, root: str) -> dict:
|
|
339
|
+
"""Passive pull of a host's historical URLs from the Wayback Machine CDX index
|
|
340
|
+
(queries the archive, not the target). Surfaces old/dead endpoints, params, and
|
|
341
|
+
admin paths to PROBE with http -- leads, not findings."""
|
|
342
|
+
host = str(args.get("host") or args.get("domain") or root).strip().lower()
|
|
343
|
+
if not in_domain_scope(host, root):
|
|
344
|
+
return {"ok": False, "error": f"archive is scoped to {root!r} and its subdomains; {host!r} is out of scope"}
|
|
345
|
+
url = WAYBACK_URL + "?" + urlencode({
|
|
346
|
+
"url": f"{host}/*", "output": "json",
|
|
347
|
+
"fl": "original,statuscode,mimetype", "collapse": "urlkey", "limit": "1000"})
|
|
348
|
+
data, err = _get_json(url, timeout=ARCHIVE_TIMEOUT)
|
|
349
|
+
if err:
|
|
350
|
+
return {"ok": False, "error": f"wayback lookup failed: {err}"}
|
|
351
|
+
rows = data[1:] if isinstance(data, list) and data else [] # row 0 is the header
|
|
352
|
+
paths: set[str] = set()
|
|
353
|
+
for r in rows:
|
|
354
|
+
orig = r[0] if isinstance(r, list) and r else ""
|
|
355
|
+
pr = urlparse(orig)
|
|
356
|
+
p = pr.path + (("?" + pr.query) if pr.query else "")
|
|
357
|
+
if p and p.startswith("/"):
|
|
358
|
+
paths.add(p)
|
|
359
|
+
ordered = sorted(paths)[:300]
|
|
360
|
+
return {"ok": True, "host": host, "count": len(ordered), "paths": ordered,
|
|
361
|
+
"note": ("historical URLs from the Wayback Machine -- LEADS to probe with http; many "
|
|
362
|
+
"may be dead. They are not findings on their own.")}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# --- dispatch + describe ----------------------------------------------------
|
|
366
|
+
|
|
367
|
+
OPS = {
|
|
368
|
+
"dns": op_dns, "crt": op_crt, "tls": op_tls,
|
|
369
|
+
"storage": op_storage, "registries": op_registries, "code_search": op_code_search,
|
|
370
|
+
"internetdb": op_internetdb, "archive": op_archive,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def handle(op: str, args: dict, root: str):
|
|
375
|
+
"""Return a result dict for a recon op, or None if op is not a recon op."""
|
|
376
|
+
fn = OPS.get(op)
|
|
377
|
+
if fn is None:
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
return fn(args, root)
|
|
381
|
+
except Exception as e: # noqa: BLE001 - never crash the env on a recon op
|
|
382
|
+
return {"ok": False, "error": f"recon op {op!r} failed: {e}"}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def describe_ops(root: str) -> dict:
|
|
386
|
+
"""The recon ops to advertise in the env's describe, keyed by name."""
|
|
387
|
+
return {
|
|
388
|
+
"dns": f'{{"name"?: "sub.{root}"}} resolve A/AAAA/CNAME/MX/TXT/NS for a host under {root}; flags dangling CNAMEs and takeover candidates. Defaults to the root.',
|
|
389
|
+
"crt": f'{{"domain"?: "{root}"}} mine certificate-transparency logs (crt.sh) for subdomains/hostnames of {root} -- surfaces staging/admin/internal hosts leaked in certs.',
|
|
390
|
+
"tls": f'{{"host": "sub.{root}", "port"?: 443}} read a live TLS cert for a host under {root}: SANs, subject, expiry.',
|
|
391
|
+
"storage": '{"name": "bucket-name"} probe S3 + GCS for a public cloud-storage bucket (read-only). Derive candidate names from the org/domain.',
|
|
392
|
+
"registries": '{"namespace": "org"} list public Docker Hub repos + npm packages for an org/handle derived from the target.',
|
|
393
|
+
"code_search": '{"query"?: "..."} OPT-IN (needs a token): search public code for the org\'s leaked secrets. Returns a disabled note when no token is set.',
|
|
394
|
+
"internetdb": f'{{"host"?: "sub.{root}", "ip"?: "1.2.3.4"}} passive Shodan InternetDB lookup for a host under {root} (resolved to its IP) or a raw IP: open ports, CPEs, tags, and candidate CVEs. LEADS to corroborate, not confirmed findings.',
|
|
395
|
+
"archive": f'{{"host"?: "sub.{root}"}} passive Wayback Machine history for a host under {root}: old/dead URLs, params, and admin paths to then probe with http. LEADS, not findings.',
|
|
396
|
+
}
|