@murk-security/runner-darwin-arm64 0.0.0-stage → 0.7.1

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 (30) hide show
  1. package/README.md +2 -2
  2. package/bin/harness +0 -0
  3. package/package.json +15 -4
  4. package/runners/basic_harness_run.py +1276 -0
  5. package/runners/checklists/anonymous.json +24 -0
  6. package/runners/cve_feed.json +53 -0
  7. package/runners/prod_env.py +1686 -0
  8. package/runners/profile.py +307 -0
  9. package/runners/recon.py +396 -0
  10. package/runners/remediate.py +429 -0
  11. package/runners/report.py +365 -0
  12. package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
  13. package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
  14. package/runners/skills/exploit/business-logic/SKILL.md +53 -0
  15. package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
  16. package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
  17. package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
  18. package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
  19. package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
  20. package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
  21. package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
  22. package/runners/skills/exploit/service-probing/SKILL.md +51 -0
  23. package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
  24. package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
  25. package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
  26. package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
  27. package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
  28. package/runners/skills/web/content-discovery/SKILL.md +59 -0
  29. package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
  30. package/runners/skills.py +174 -0
@@ -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
+ }