@murk-security/runner-linux-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,1686 @@
1
+ #!/usr/bin/env python3
2
+ """Product env adapter: basic-harness (teams+directions) -> a Blackglass assessment target.
3
+
4
+ This is the in-PRODUCT sibling of security-eval/benches/apps_env.py. Where apps_env
5
+ reads a local lab's private/lab.json, this adapter reads the control-plane's RunSpec
6
+ (input-spec.json) for scope + identities and resolves each identity's credential from
7
+ the out-of-band BLACKGLASS_SECRETS_JSON env var (never from disk). It exposes the
8
+ target's HTTP surface to the prober as named identities, scope-bound to the RunSpec's
9
+ allowedHosts.
10
+
11
+ SAFETY: this adapter is READ-ONLY. Blackglass has no disposable-target provisioning,
12
+ so a real assessment hits the customer's registered origin directly; the beta posture
13
+ (matching the multiagent runner) is non-destructive, staging-only. Mutating methods
14
+ (POST/PUT/PATCH/DELETE) are refused whenever policy.destructiveActions is false (which
15
+ the control plane hard-forces). This limits what the agent DOES, not what it REPORTS:
16
+ it still probes and reports read-oversharing greedily.
17
+
18
+ Usage (set by basic_harness_run.py as the harness's -env command):
19
+ python3 prod_env.py --spec <session>/input-spec.json
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import base64
26
+ import hashlib
27
+ import hmac
28
+ import ipaddress
29
+ import json
30
+ import os
31
+ import re
32
+ import secrets
33
+ import socket
34
+ import struct
35
+ import sys
36
+ import time
37
+ import urllib.error
38
+ import urllib.request
39
+ from html.parser import HTMLParser
40
+ from pathlib import Path
41
+ from urllib.parse import urlencode, urljoin, urlparse
42
+
43
+ import recon # sibling module: WAF-independent EASM ops (dns/crt/tls/storage/...)
44
+ import skills # sibling module: on-demand playbook library (list_skills/get_skill)
45
+
46
+ MAX_OUTPUT = 20000
47
+ MAX_READ = 5_000_000 # cap a response body read to bound memory (huge/hostile responses)
48
+ HTTP_TIMEOUT = 25 # under the core's fixed 30s env guillotine (env.go), leaving margin
49
+ MAX_GREP_MATCHES = 50 # snippets returned when grepping a body (e.g. secret mining)
50
+ RECON_ENABLED = os.environ.get("BASIC_HARNESS_RECON", "1") != "0"
51
+ # Out-of-band interaction collaborator: a server the agent can mint unique callback
52
+ # URLs on and poll for hits. A hit proves a blind server-side request (SSRF/XXE).
53
+ # In the lab this points at the fixture's /mock/oob; in production at a real
54
+ # Blackglass-operated collaborator. Unset => the oob_* ops are unavailable.
55
+ OOB_COLLABORATOR_BASE = os.environ.get("OOB_COLLABORATOR_BASE", "").rstrip("/")
56
+ # Headless-browser render service: an external endpoint that loads a URL in a REAL
57
+ # browser (running its JavaScript) and returns the rendered DOM, console, and the
58
+ # requests the page made. The adapter stays dependency-free -- it just POSTs
59
+ # {url, cookie?, wait?} and parses the JSON back; the operator backs this URL with a
60
+ # Chrome/Playwright/CDP service. Unset => the `render` op is unavailable (like OOB).
61
+ BROWSER_RENDER_URL = os.environ.get("BROWSER_RENDER_URL", "").rstrip("/")
62
+
63
+
64
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
65
+ """Return the 3xx response unfollowed so the agent can see Location (open-redirect,
66
+ auth-flow analysis) instead of transparently landing on the redirect target."""
67
+
68
+ def redirect_request(self, *a, **k):
69
+ return None
70
+
71
+
72
+ class _ScopedRedirect(urllib.request.HTTPRedirectHandler):
73
+ """Follow a redirect only when its target is in scope; otherwise return the 3xx unfollowed,
74
+ exactly as allow_redirects:false would. A target that answers with a redirect to an
75
+ out-of-scope host (another local port, the metadata address, any other host) must not pull
76
+ the scan onto it: the agent sees the Location, but the runner never fetches it. This is the
77
+ backstop behind the per-request scope check, which only guards the FIRST hop."""
78
+
79
+ def __init__(self, target):
80
+ super().__init__()
81
+ self._target = target
82
+
83
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
84
+ if not self._target.url_in_scope(newurl):
85
+ return None
86
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
87
+ READONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
88
+
89
+
90
+ def _clip(text: str, limit: int = MAX_OUTPUT) -> str:
91
+ if len(text) <= limit:
92
+ return text
93
+ head = limit * 3 // 4
94
+ tail = limit - head
95
+ return f"{text[:head]}\n...{len(text) - limit} chars clipped...\n{text[-tail:]}"
96
+
97
+
98
+ def _host(origin: str) -> str:
99
+ return urlparse(origin).netloc
100
+
101
+
102
+ def _authority(url_or_host: str, default_scheme: str = "http") -> str:
103
+ """Effective host:port, with the default port filled from the scheme (http->80, https->443),
104
+ lowercased. So http://h and http://h:80 compare equal, while http://h (80), https://h (443)
105
+ and ftp://h (21) do not. A bare host[:port] is read under default_scheme."""
106
+ raw = url_or_host if "//" in url_or_host else f"{default_scheme}://{url_or_host}"
107
+ p = urlparse(raw)
108
+ host = (p.hostname or "").lower()
109
+ try:
110
+ port = p.port
111
+ except ValueError:
112
+ port = None
113
+ if port is None:
114
+ port = {"https": 443, "ftp": 21}.get(p.scheme, 80)
115
+ return f"{host}:{port}"
116
+
117
+
118
+ def _is_registrable_domain(host: str) -> bool:
119
+ """True only for a real multi-label DNS name. A loopback/IP literal or a bare local
120
+ hostname like "localhost" has no registrable root, so the subdomain scope carve-out must
121
+ not apply to it -- otherwise scanning 127.0.0.1 would put *.0.1 in scope, and localhost
122
+ would admit *.localhost."""
123
+ h = (host or "").strip().lower()
124
+ if h.startswith("["): # [bracketed] IPv6, maybe with :port
125
+ h = h[1:].split("]", 1)[0]
126
+ elif h.count(":") == 1: # host:port (a lone colon; an IPv6 literal has several)
127
+ h = h.split(":", 1)[0]
128
+ h = h.strip(".")
129
+ if not h or "." not in h:
130
+ return False
131
+ try:
132
+ ipaddress.ip_address(h)
133
+ return False # an IPv4/IPv6 literal is not a registrable domain
134
+ except ValueError:
135
+ return True
136
+
137
+
138
+ class Target:
139
+ """The assessment target described by a RunSpec + resolved secrets."""
140
+
141
+ def __init__(self, spec: dict, secrets: list[dict]):
142
+ scope = spec.get("scope") or {}
143
+ allowed = [h for h in (scope.get("allowedHosts") or []) if h]
144
+ origins = [o for o in ((spec.get("target") or {}).get("origins") or []) if o]
145
+ if not origins and not allowed:
146
+ raise SystemExit("RunSpec has no target.origins or scope.allowedHosts")
147
+ # Prefer an explicit origin; fall back to the first allowed host as https.
148
+ self.origin = (origins[0] if origins else f"https://{allowed[0]}").rstrip("/")
149
+ base_host = _host(self.origin) or (allowed[0] if allowed else "")
150
+ self.base_host = base_host
151
+ # Hosts the prober may reach: the allowlist, plus the origin's own host.
152
+ self.allowed_hosts = set(allowed) | ({base_host} if base_host else set())
153
+ # The verified registrable root (scope carve-out for recon + host pivots):
154
+ # the prober may probe any host UNDER this root, discovered via recon. Prefer
155
+ # an explicit scope.rootDomain (known from asset verification, and correct for
156
+ # multi-label TLDs like co.uk); otherwise derive it from the origin host.
157
+ # Subdomain carve-out (recon host-pivots): only a real DNS domain has a registrable
158
+ # root. For a loopback/IP or bare local hostname -- the local-runner target -- there is
159
+ # none, so scope stays exactly the origin host + explicit allowlist, and a redirect or
160
+ # a recon "subdomain" can't pull the scan to another host. `scope.exact` (set by the
161
+ # local-scan CLI) disables the carve-out outright -- even for a multi-label local name
162
+ # like service.team.internal -- so scope is exactly allowed_hosts and nothing else.
163
+ explicit_root = (scope.get("rootDomain") or "").strip().lower()
164
+ self.exact = bool(scope.get("exact"))
165
+ if self.exact:
166
+ self.root_domain = ""
167
+ else:
168
+ self.root_domain = explicit_root or (recon.registrable(base_host) if _is_registrable_domain(base_host) else "")
169
+ # Exact scope: the complete set of reachable authorities (scheme-normalized host:port) --
170
+ # the origin plus any explicit allowed host. A request or redirect must match one exactly,
171
+ # so a sibling host, a scheme/port switch (http->https/ftp), or a Host override can't pivot.
172
+ default_scheme = urlparse(self.origin).scheme or "http"
173
+ self.allowed_authorities = {_authority(self.origin)} | {_authority(h, default_scheme) for h in self.allowed_hosts}
174
+ self.excluded_paths = [p for p in (scope.get("excludedPaths") or []) if p]
175
+ # destructiveActions is hard-forced false by the control plane; honor it.
176
+ self.read_only = not bool((spec.get("policy") or {}).get("destructiveActions", False))
177
+ # The action tier bounds how far the agent may go (mirrors the harness's
178
+ # -action-tier). "passive" = discovery/read-only (the default, and the
179
+ # behaviour before this field existed); "safe-active" unlocks non-destructive
180
+ # active-inference ops (e.g. diff); "exploit" is mutating. Unknown/empty
181
+ # resolves to passive (fail-closed), so a typo never silently unlocks more.
182
+ self.action_tier = str((spec.get("policy") or {}).get("actionTier") or "passive")
183
+ # Dynamically-established sessions (name -> Cookie header), populated by the
184
+ # `login` op and reused by `http`/`diff` via identity:<name>. Empty until a
185
+ # login succeeds; a static spec identity still resolves as before.
186
+ self.sessions: dict[str, str] = {}
187
+ # Test accounts signed in automatically (sign_in): extra headers a session carries
188
+ # (a bearer token returned in the body), and why an account couldn't sign in, so a
189
+ # wrong password isn't retried on every request (and can't lock the account out).
190
+ self.session_headers: dict[str, dict[str, str]] = {}
191
+ self.sign_in_failed: dict[str, str] = {}
192
+ # Loot store + chain graph (safe-active): credentials/ids/endpoints the agent
193
+ # RECOVERS (loot, id -> {kind,value,source,note}) and the edges between
194
+ # findings (one enabling the next). Stashed loot is replayed on later
195
+ # requests via http/diff loot:<id>; edges feed the kill-chain report.
196
+ self.loot: dict[str, dict] = {}
197
+ self.edges: list[dict] = []
198
+ self._loot_seq = 0
199
+
200
+ # Preliminary-scan profile (default-OFF feature). When BLACKGLASS_PROFILE is
201
+ # enabled and BLACKGLASS_PROFILE_PATH points at a profile.json (produced by the
202
+ # deterministic profile.py pre-pass), it is loaded here and surfaced as a compact
203
+ # headline in describe() + a `profile` op. With the feature off, self.profile is
204
+ # None and nothing below it fires, so the adapter is byte-identical to before.
205
+ # The profile is SIGNALS to confirm, never findings (see docs/preliminary-scan-plan.md).
206
+ self.profile = None
207
+ if _profile_enabled():
208
+ ppath = os.environ.get("BLACKGLASS_PROFILE_PATH", "").strip()
209
+ if ppath:
210
+ try:
211
+ data = json.loads(Path(ppath).read_text())
212
+ if isinstance(data, dict):
213
+ self.profile = data
214
+ except Exception: # noqa: BLE001 - a bad/absent profile just disables the feature
215
+ self.profile = None
216
+
217
+ # The control plane emits ResolvedSecret.id == identity.id (server.ts launch),
218
+ # so match on the identity id first, then fall back to secretRef.
219
+ by_id = {s.get("id"): s for s in secrets if isinstance(s, dict)}
220
+ self.identities: dict[str, dict] = {}
221
+ for ident in spec.get("identities") or []:
222
+ iid = ident.get("id")
223
+ if not iid:
224
+ continue
225
+ secret = by_id.get(iid) or by_id.get(ident.get("secretRef"))
226
+ self.identities[iid] = {
227
+ "id": iid,
228
+ "kind": ident.get("kind"),
229
+ "how": ident.get("how"),
230
+ "secret": secret, # {id, kind, value} or None
231
+ }
232
+ # The adapter signs every password account in once at scan start and hands the
233
+ # sessions to each environment process out-of-band (never on disk), so the agents
234
+ # share one session instead of each signing in.
235
+ for iid, shared in load_shared_sessions().items():
236
+ if iid not in self.identities or not isinstance(shared, dict):
237
+ continue
238
+ if shared.get("error"):
239
+ self.sign_in_failed[iid] = str(shared["error"])
240
+ continue
241
+ if shared.get("cookie"):
242
+ self.sessions[iid] = str(shared["cookie"])
243
+ if isinstance(shared.get("headers"), dict):
244
+ self.session_headers[iid] = {str(k): str(v) for k, v in shared["headers"].items()}
245
+
246
+ def host_in_scope(self, host: str) -> bool:
247
+ """A host the prober may fetch: an explicit allowlisted host, the origin,
248
+ or any host under the verified root domain (recon-discovered subdomains)."""
249
+ host = (host or "").lower()
250
+ return host in self.allowed_hosts or recon.in_domain_scope(host, self.root_domain)
251
+
252
+ def url_in_scope(self, url: str) -> bool:
253
+ """Whether the runner may fetch this URL. Always: an http(s) scheme (so a redirect can't
254
+ switch to ftp://, file://, ...). Exact mode: the exact scheme-normalized host:port, so a
255
+ default-port target can't be pivoted to :443/:21 and a sibling host is refused. Otherwise
256
+ the host-level scope (host_in_scope)."""
257
+ p = urlparse(url)
258
+ if p.scheme not in ("http", "https"):
259
+ return False
260
+ if self.exact:
261
+ return _authority(url) in self.allowed_authorities
262
+ return self.host_in_scope(_host(url))
263
+
264
+ def tier_at_least(self, tier: str) -> bool:
265
+ """Whether the run's action tier permits ops at `tier` or above. Gates
266
+ which ops describe() advertises; mirrors the harness's tierAtLeast."""
267
+ return _tier_rank(self.action_tier) >= _tier_rank(tier)
268
+
269
+ def header_for(self, identity: str):
270
+ ident = self.identities.get(identity)
271
+ if ident is None:
272
+ return None, f"unknown identity {identity!r}; available: {', '.join(self.identities) or '(none)'}"
273
+ secret = ident.get("secret")
274
+ if not secret or not secret.get("value"):
275
+ return None, f"identity {identity!r} has no resolved credential (anonymous only)"
276
+ value = str(secret["value"])
277
+ kind = (secret.get("kind") or ident.get("kind") or "header").lower()
278
+ if kind == "password":
279
+ # A password account is not a header credential — the JSON broker blob must
280
+ # never be sent as one. Sign in with the `login` op instead.
281
+ return None, f"identity {identity!r} is a password account — sign in with the `login` op (do not attach it as a header)"
282
+ if kind == "cookie" and ":" not in value.split(";", 1)[0]:
283
+ return ("Cookie", value), None
284
+ if ":" in value: # a full header line, e.g. "Authorization: Basic ..."
285
+ name, _, val = value.partition(":")
286
+ return (name.strip(), val.strip()), None
287
+ return None, f"identity {identity!r} credential is not a usable header/cookie"
288
+
289
+
290
+ _TIER_RANK = {"passive": 0, "safe-active": 1, "exploit": 2}
291
+ MAX_DIFF_REQUESTS = 12 # bound the fan-out of one diff op (rate-awareness)
292
+
293
+
294
+ def _tier_rank(tier: str) -> int:
295
+ """Rank a tier; anything unrecognized is passive (0), fail-closed."""
296
+ return _TIER_RANK.get(str(tier or "").strip(), 0)
297
+
298
+
299
+ def _profile_enabled() -> bool:
300
+ """The single gate for the preliminary-scan profile feature (default OFF). Read at
301
+ Target construction so the feature can be toggled per run without re-importing."""
302
+ return os.environ.get("BLACKGLASS_PROFILE", "").strip().lower() in ("1", "true", "yes", "on")
303
+
304
+
305
+ def _skill_allowed(t: Target, skill_tier: str) -> bool:
306
+ """Whether a skill declaring `skill_tier` may be surfaced on this run. A skill
307
+ with no declared tier is passive discovery methodology, always available (the
308
+ existing skills). An explicit known tier gates the skill to runs at or above
309
+ it. An unrecognized non-empty tier string is fail-CLOSED (hidden) so a typo in
310
+ a skill's frontmatter never leaks an offensive playbook into a lower-tier run.
311
+ Note the fail direction is opposite to _tier_rank (which fail-closes the RUN's
312
+ tier to passive): here an unknown SKILL tier must be the most restrictive."""
313
+ skill_tier = str(skill_tier or "").strip()
314
+ if not skill_tier:
315
+ return True
316
+ if skill_tier not in _TIER_RANK:
317
+ return False
318
+ return _tier_rank(t.action_tier) >= _TIER_RANK[skill_tier]
319
+
320
+
321
+ def _skill_gate(t: Target):
322
+ """The predicate skills.* uses to filter by the run's action tier."""
323
+ return lambda skill_tier: _skill_allowed(t, skill_tier)
324
+
325
+
326
+ def _http(t: Target, args: dict) -> dict:
327
+ method = str(args.get("method", "GET")).upper()
328
+ path = args.get("path")
329
+ if not isinstance(path, str) or not path.startswith("/"):
330
+ return {"ok": False, "error": "http needs a 'path' starting with '/' (relative to the target origin)"}
331
+ if method not in READONLY_METHODS:
332
+ # A mutating request is the EXPLOIT-tier primitive. Two independent keys must
333
+ # both be turned or it is refused (fail-closed): the run must be at the exploit
334
+ # tier AND destructiveActions must be authorized (read_only False, which the
335
+ # control plane can hard-force off). `login` is the one non-exploit POST, and it
336
+ # does not route through here. This is strictly tighter than before (which let
337
+ # destructiveActions alone unlock mutation at any tier).
338
+ if not t.tier_at_least("exploit"):
339
+ return {"ok": False, "error": (
340
+ f"method {method} mutates target state and is an EXPLOIT-tier action; this run is "
341
+ f"'{t.action_tier}'. Probe access-control exposure with GET/HEAD only (use `login` to "
342
+ "authenticate); do not create, modify, or delete data.")}
343
+ if t.read_only:
344
+ return {"ok": False, "error": (
345
+ f"method {method} is refused: destructiveActions is not authorized for this run. A "
346
+ "mutating exploit needs BOTH the exploit tier AND destructiveActions enabled.")}
347
+ for ex in t.excluded_paths:
348
+ if path.startswith(ex):
349
+ return {"ok": False, "error": f"path {path} is out of scope (excluded)"}
350
+
351
+ # Optional host override: pivot to a recon-discovered host UNDER the verified
352
+ # root domain, using the same evidence-generating op. Defaults to the origin.
353
+ host_arg = args.get("host")
354
+ if host_arg:
355
+ host_arg = str(host_arg).lower()
356
+ if not t.host_in_scope(host_arg):
357
+ return {"ok": False, "error": (
358
+ f"host {host_arg} is out of scope: reachable hosts are the target origin "
359
+ f"and any host under the verified root domain {t.root_domain}")}
360
+ origin = f"https://{host_arg}"
361
+ else:
362
+ origin = t.origin
363
+
364
+ url = origin + path
365
+ if not t.url_in_scope(url):
366
+ return {"ok": False, "error": f"request left scope; reachable: the target origin and hosts under {t.root_domain}" if not t.exact else "request left scope; this scan is pinned to its exact target host:port"}
367
+
368
+ # Present a mainstream browser signature by default. Many real targets sit behind a
369
+ # WAF/CDN (e.g. Cloudflare) that outright bans the stock urllib client signature
370
+ # (error 1010 "browser_signature_banned"), which starves the assessment before it
371
+ # starts. The agent can still override any header per request via `headers`.
372
+ headers = {
373
+ "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
374
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"),
375
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
376
+ "Accept-Language": "en-US,en;q=0.9",
377
+ }
378
+ extra = args.get("headers")
379
+ if isinstance(extra, dict):
380
+ for k, v in extra.items():
381
+ headers[str(k)] = str(v)
382
+ # Under exact scope, drop any caller-supplied Host header: on a name-based reverse proxy a
383
+ # Host override reaches a sibling virtual host on the same address despite the host:port
384
+ # being in scope. Pin Host to the target (urllib sets it from the URL).
385
+ if t.exact:
386
+ for hk in [k for k in list(headers) if k.lower() == "host"]:
387
+ del headers[hk]
388
+
389
+ identity = args.get("identity") or args.get("as")
390
+ actor = "anonymous"
391
+ if identity:
392
+ # Never send an identity's credential to a host other than the one it was
393
+ # issued for: a cookie/token for the origin must not leak to a pivoted
394
+ # recon-discovered subdomain. Probe those anonymously.
395
+ if _host(url) != t.base_host:
396
+ return {"ok": False, "error": (
397
+ f"credentials are only injected for the primary origin host ({t.base_host}); "
398
+ f"omit identity to probe {_host(url)} anonymously")}
399
+ sid = str(identity)
400
+ creds, err = identity_headers(t, sid, renew=bool(args.get("_renew")))
401
+ if err:
402
+ return {"ok": False, "error": err}
403
+ headers.update(creds)
404
+ actor = sid
405
+
406
+ # A recovered credential (loot) stashed earlier, replayed by reference. Like an
407
+ # identity, it is only ever sent to the primary origin host -- a credential
408
+ # recovered from the origin must not leak to a pivoted recon-discovered host.
409
+ loot_ref = args.get("loot")
410
+ if loot_ref:
411
+ if _host(url) != t.base_host:
412
+ return {"ok": False, "error": (
413
+ f"recovered credentials (loot) are only replayed to the primary origin host "
414
+ f"({t.base_host}); omit loot to probe {_host(url)} anonymously")}
415
+ item = t.loot.get(str(loot_ref))
416
+ if not item:
417
+ return {"ok": False, "error": f"unknown loot {loot_ref!r}; stash it first with the loot op (action:'stash'), or list what you have"}
418
+ hk, hv = _loot_header(item)
419
+ if hk is None:
420
+ return {"ok": False, "error": hv}
421
+ headers[hk] = hv
422
+ if actor == "anonymous":
423
+ actor = f"loot:{loot_ref}"
424
+
425
+ data = None
426
+ body = args.get("body")
427
+ if body is not None:
428
+ if isinstance(body, (dict, list)):
429
+ data = json.dumps(body).encode("utf-8")
430
+ headers.setdefault("Content-Type", "application/json")
431
+ else:
432
+ data = str(body).encode("utf-8")
433
+
434
+ # By default follow redirects, but only to in-scope hosts (_ScopedRedirect): an off-scope
435
+ # redirect is returned to the agent as a 3xx, never fetched. Set allow_redirects:false to
436
+ # see any 3xx + Location unfollowed (open-redirect / auth-flow analysis).
437
+ follow = args.get("allow_redirects", True)
438
+ opener = urllib.request.build_opener(_ScopedRedirect(t) if follow else _NoRedirect).open
439
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
440
+ final_url = url
441
+ try:
442
+ with opener(req, timeout=HTTP_TIMEOUT) as resp:
443
+ raw = resp.read(MAX_READ)
444
+ status, resp_headers = resp.status, dict(resp.headers.items())
445
+ final_url = resp.geturl() or url
446
+ except urllib.error.HTTPError as e:
447
+ raw = e.read(MAX_READ) if hasattr(e, "read") else b""
448
+ status = e.code
449
+ resp_headers = dict(e.headers.items()) if e.headers else {}
450
+ except Exception as e: # noqa: BLE001 - surface to the agent, don't crash the adapter
451
+ return {"ok": False, "error": f"request failed: {e}"}
452
+
453
+ # A test account's session expired (a 401, or the app sent the request back to the
454
+ # sign-in page): sign in again and repeat the request once.
455
+ if identity and not args.get("_renew") and is_password_account(t.identities.get(str(identity))):
456
+ login_path = sign_in_path(t.identities.get(str(identity))).split("?", 1)[0]
457
+ landed = urlparse(urljoin(url, resp_headers.get("Location") or resp_headers.get("location") or final_url)).path
458
+ if path.split("?", 1)[0] != login_path and (status == 401 or landed == login_path):
459
+ return _http(t, {**args, "_renew": True})
460
+
461
+ text = raw.decode("utf-8", "replace")
462
+ # Keep headers that carry evidence for the checklist surfaces: content/redirect/auth,
463
+ # tech fingerprint (server/x-powered-by/via), CORS posture, and the presence or
464
+ # absence of security headers (a missing CSP/HSTS is itself a finding).
465
+ keep_exact = {"content-type", "content-length", "location", "www-authenticate",
466
+ "server", "x-powered-by", "via", "x-aspnet-version",
467
+ "access-control-allow-origin", "access-control-allow-credentials",
468
+ "access-control-allow-methods", "set-cookie",
469
+ "content-security-policy", "strict-transport-security",
470
+ "x-frame-options", "x-content-type-options", "referrer-policy"}
471
+ keep = {k: v for k, v in resp_headers.items() if k.lower() in keep_exact}
472
+ out = {"ok": True, "actor": actor, "method": method, "path": path,
473
+ "status": status, "bytes": len(raw), "headers": keep}
474
+ # Opt-in FULL header set (default off, so the curated `headers` output is unchanged):
475
+ # the profile pre-pass and header-hygiene analysis need every header, not the subset.
476
+ if args.get("raw_headers"):
477
+ out["raw_headers"] = resp_headers
478
+ if host_arg:
479
+ out["host"] = host_arg
480
+
481
+ # Optional server-side grep over the FULL body: for secret mining in large JS
482
+ # bundles / source maps where the clipped body would drop the middle. Returns
483
+ # snippets around each regex match instead of the (clipped) whole body.
484
+ grep = args.get("grep")
485
+ if grep:
486
+ try:
487
+ rx = re.compile(str(grep))
488
+ except re.error as e:
489
+ return {"ok": False, "error": f"bad grep regex {grep!r}: {e}"}
490
+ snippets = []
491
+ for m in rx.finditer(text):
492
+ lo, hi = max(0, m.start() - 100), min(len(text), m.end() + 100)
493
+ snippets.append(text[lo:hi])
494
+ if len(snippets) >= MAX_GREP_MATCHES:
495
+ break
496
+ out["grep"] = str(grep)
497
+ out["match_count"] = len(snippets)
498
+ out["matches"] = snippets
499
+ else:
500
+ out["body"] = _clip(text)
501
+ return out
502
+
503
+
504
+ def _diff(t: Target, args: dict) -> dict:
505
+ """Differential oracle (safe-active): run 2+ requests and report how their
506
+ responses differ. The precision primitive -- it turns "this endpoint might
507
+ leak per-user data" into a reproducible comparison (e.g. two owners' records
508
+ differ in content under no auth => IDOR; a true/false payload pair differs in
509
+ length => a boolean oracle). Each sub-request goes through the SAME read-only
510
+ http path (method gate, scope, identity, redirect handling), so diff can never
511
+ do more than http can. It returns FACTUAL signals, not a verdict -- the agent
512
+ reasons over them. Comparison uses the (clipped) body plus the full byte length,
513
+ so a difference only past the clip is caught by length, not content."""
514
+ if not t.tier_at_least("safe-active"):
515
+ return {"ok": False, "error": "diff requires the safe-active action tier"}
516
+ reqs = args.get("requests")
517
+ if not isinstance(reqs, list) or len(reqs) < 2:
518
+ return {"ok": False, "error": "diff needs 'requests': a list of 2 or more request objects (each shaped like http args)"}
519
+ if len(reqs) > MAX_DIFF_REQUESTS:
520
+ return {"ok": False, "error": f"diff takes at most {MAX_DIFF_REQUESTS} requests at once"}
521
+ results, bodies = [], []
522
+ for r in reqs:
523
+ if not isinstance(r, dict):
524
+ return {"ok": False, "error": "each request must be an object shaped like http args"}
525
+ r = dict(r)
526
+ label = r.pop("label", None)
527
+ # Accept BOTH shapes the model emits: flat http args ({"path": "/x"}) and the
528
+ # env envelope ({"op": "http", "args": {"path": "/x"}}). Models reach for the
529
+ # envelope out of habit, so unwrap it rather than fail the sub-request.
530
+ inner = r.get("args")
531
+ if isinstance(inner, dict):
532
+ r = dict(inner)
533
+ else:
534
+ r.pop("op", None) # a stray top-level "op" is not an http argument
535
+ r.pop("grep", None) # diff needs the body itself to compare, not grep snippets
536
+ res = _http(t, r)
537
+ if not res.get("ok"):
538
+ results.append({"ok": False, "label": label, "path": r.get("path"), "error": res.get("error")})
539
+ bodies.append(None)
540
+ continue
541
+ results.append({"ok": True, "label": label, "actor": res.get("actor"), "method": res.get("method"),
542
+ "path": res.get("path"), "host": res.get("host"), "status": res.get("status"), "bytes": res.get("bytes")})
543
+ bodies.append(res.get("body"))
544
+ oks = [x for x in results if x.get("ok")]
545
+ statuses = sorted({x["status"] for x in oks})
546
+ lengths = sorted({x["bytes"] for x in oks})
547
+ distinct_bodies = len({b for b in bodies if b is not None})
548
+ return {"ok": True, "tier": t.action_tier, "count": len(results), "results": results,
549
+ "signals": {
550
+ "status_varies": len(statuses) > 1,
551
+ "length_varies": len(lengths) > 1,
552
+ "content_varies": distinct_bodies > 1,
553
+ "distinct_bodies": distinct_bodies,
554
+ "statuses": statuses,
555
+ "lengths": lengths,
556
+ }}
557
+
558
+
559
+ def _oob_mint(t: Target, args: dict) -> dict:
560
+ """Mint a unique out-of-band callback URL + token (safe-active). The agent
561
+ plants the URL where the target might fetch it (an SSRF url param, a webhook,
562
+ an XXE entity); a server-side request to it is logged for oob_poll. This is the
563
+ proof primitive for BLIND vulns -- no callback, no critical."""
564
+ if not t.tier_at_least("safe-active"):
565
+ return {"ok": False, "error": "oob_mint requires the safe-active action tier"}
566
+ if not OOB_COLLABORATOR_BASE:
567
+ return {"ok": False, "error": "no OOB collaborator configured (OOB_COLLABORATOR_BASE unset); blind-callback proofs are unavailable on this run"}
568
+ token = secrets.token_hex(8)
569
+ return {"ok": True, "token": token, "url": f"{OOB_COLLABORATOR_BASE}/{token}",
570
+ "hint": "plant this url where the target may fetch it (SSRF url=, webhook, redirect, XXE), then oob_poll the token to prove a blind callback"}
571
+
572
+
573
+ def _oob_poll(t: Target, args: dict) -> dict:
574
+ """Check whether a minted callback was hit out-of-band. interaction:true proves
575
+ the target made a server-side request to the collaborator (blind SSRF/XXE);
576
+ zero hits is NOT proof (the payload may simply not have fired)."""
577
+ if not t.tier_at_least("safe-active"):
578
+ return {"ok": False, "error": "oob_poll requires the safe-active action tier"}
579
+ if not OOB_COLLABORATOR_BASE:
580
+ return {"ok": False, "error": "no OOB collaborator configured (OOB_COLLABORATOR_BASE unset)"}
581
+ token = str(args.get("token", "")).strip()
582
+ if not token:
583
+ return {"ok": False, "error": "oob_poll needs a 'token' from oob_mint"}
584
+ try:
585
+ with urllib.request.urlopen(f"{OOB_COLLABORATOR_BASE}/{token}/hits", timeout=HTTP_TIMEOUT) as r:
586
+ data = json.loads(r.read(MAX_READ).decode("utf-8", "replace"))
587
+ except Exception as e: # noqa: BLE001 - surface to the agent
588
+ return {"ok": False, "error": f"oob_poll failed: {e}"}
589
+ hits = int(data.get("hits", 0)) if isinstance(data, dict) else 0
590
+ return {"ok": True, "token": token, "hits": hits, "interaction": hits > 0,
591
+ "note": ("a hit proves the target made an out-of-band request to your collaborator "
592
+ "(blind SSRF/XXE confirmed)" if hits > 0 else "no callback yet; this is not proof of a blind vuln")}
593
+
594
+
595
+ # ─── Password-identity broker ─────────────────────────────────────────────────
596
+ # A 'password' identity carries a JSON blob {username, password, totp?(base32 seed)}
597
+ # resolved out-of-band. The adapter signs in with it and generates the TOTP code at
598
+ # login time; the model never sees these values — it drives `login` by identity name
599
+ # and may shape the request body with {{username}}/{{password}}/{{totp}} placeholders.
600
+ _CSRF_NAME = re.compile(r"csrf|xsrf|_token|authenticity_token|csrfmiddlewaretoken|__RequestVerificationToken", re.I)
601
+ _INPUT_TAG = re.compile(r"<input\b[^>]*>", re.I)
602
+ _ATTR = re.compile(r'([\w:-]+)\s*=\s*"([^"]*)"')
603
+
604
+
605
+ def _totp_now(seed: str, *, digits: int = 6, period: int = 30) -> str | None:
606
+ """RFC 6238 TOTP from a base32 seed (stdlib only). None on a malformed seed."""
607
+ cleaned = re.sub(r"\s+", "", seed or "").upper()
608
+ if not cleaned:
609
+ return None
610
+ try:
611
+ key = base64.b32decode(cleaned + "=" * (-len(cleaned) % 8))
612
+ except Exception: # noqa: BLE001 - a bad seed just disables TOTP
613
+ return None
614
+ digest = hmac.new(key, struct.pack(">Q", int(time.time() // period)), hashlib.sha1).digest()
615
+ offset = digest[-1] & 0x0F
616
+ code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % (10 ** digits)
617
+ return str(code).zfill(digits)
618
+
619
+
620
+ def _fill_secret_placeholders(value, mapping: dict):
621
+ """Substitute {{key}} tokens in a body (string / dict / list) with secret values."""
622
+ if isinstance(value, str):
623
+ for k, v in mapping.items():
624
+ value = value.replace("{{" + k + "}}", v)
625
+ return value
626
+ if isinstance(value, dict):
627
+ return {k: _fill_secret_placeholders(v, mapping) for k, v in value.items()}
628
+ if isinstance(value, list):
629
+ return [_fill_secret_placeholders(v, mapping) for v in value]
630
+ return value
631
+
632
+
633
+ def _scrape_hidden_tokens(html: str) -> dict:
634
+ """Best-effort: pull hidden CSRF-looking inputs (name->value) from a login page."""
635
+ out: dict[str, str] = {}
636
+ for tag in _INPUT_TAG.findall(html or ""):
637
+ attrs = {k.lower(): v for k, v in _ATTR.findall(tag)}
638
+ name, val = attrs.get("name"), attrs.get("value")
639
+ if name and val and _CSRF_NAME.search(name):
640
+ out[name] = val
641
+ return out
642
+
643
+ # ─── Automatic sign-in for test accounts ──────────────────────────────────────
644
+ # A 'password' test account signs itself in: the adapter opens the saved sign-in page, fills
645
+ # its form (username, password, any hidden CSRF token, a fresh authenticator code), or posts
646
+ # JSON when the page is a sign-in API, and keeps the session that comes back: its cookies, or
647
+ # a token returned in the body. Agents then act as the account with identity:"<id>" on
648
+ # http/diff/render, like a cookie account, and never see the credentials. A session that
649
+ # expires (a 401, or a redirect back to the sign-in page) is renewed once. Allowed at every
650
+ # tier: posting the customer's own credentials to their own sign-in page is exactly what
651
+ # adding the account asked for.
652
+ _BROWSER_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
653
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
654
+ _USER_FIELD = re.compile(r"user|email|login|account|identifier|name|phone", re.I)
655
+ _OTP_FIELD = re.compile(r"otp|totp|mfa|2fa|one.?time|verif\w*.?code|auth\w*.?code|^code$", re.I)
656
+ _TOKEN_KEYS = ("access_token", "accessToken", "token", "jwt", "id_token", "idToken",
657
+ "auth_token", "authToken", "sessionToken", "session_token")
658
+ MAX_SIGN_IN_HOPS = 5
659
+
660
+
661
+ class _Forms(HTMLParser):
662
+ """The page's forms with their inputs (enough to fill a sign-in form)."""
663
+
664
+ def __init__(self):
665
+ super().__init__(convert_charrefs=True)
666
+ self.forms: list[dict] = []
667
+ self._open: dict | None = None
668
+ self.loose: list[dict] = [] # inputs outside any <form> (script-driven pages)
669
+
670
+ def handle_starttag(self, tag, attrs):
671
+ a = {k.lower(): (v or "") for k, v in attrs}
672
+ if tag == "form":
673
+ self._open = {"action": a.get("action", ""), "method": (a.get("method") or "post").lower(), "inputs": []}
674
+ self.forms.append(self._open)
675
+ elif tag in ("input", "button") and a.get("name"):
676
+ field = {"name": a["name"], "type": (a.get("type") or ("submit" if tag == "button" else "text")).lower(),
677
+ "value": a.get("value", ""), "checked": "checked" in a,
678
+ "autocomplete": a.get("autocomplete", "").lower()}
679
+ (self._open["inputs"] if self._open is not None else self.loose).append(field)
680
+
681
+ def handle_endtag(self, tag):
682
+ if tag == "form":
683
+ self._open = None
684
+
685
+
686
+ def _page_forms(html: str) -> _Forms:
687
+ parser = _Forms()
688
+ try:
689
+ parser.feed(html or "")
690
+ except Exception: # noqa: BLE001 - a malformed page just yields fewer forms
691
+ pass
692
+ return parser
693
+
694
+
695
+ def _has_password_field(html: str) -> bool:
696
+ forms = _page_forms(html)
697
+ return any(f["type"] == "password" for form in forms.forms for f in form["inputs"]) or \
698
+ any(f["type"] == "password" for f in forms.loose)
699
+
700
+
701
+ def _jar_update(jar: dict[str, str], headers) -> None:
702
+ for raw in (headers.get_all("Set-Cookie") or []) if headers else []:
703
+ pair, _, attrs = raw.partition(";")
704
+ name, sep, value = pair.partition("=")
705
+ name = name.strip()
706
+ if not sep or not name:
707
+ continue
708
+ if re.search(r"max-age\s*=\s*0\b", attrs, re.I) or re.search(r"expires\s*=\s*thu,\s*01[ -]jan[ -]1970", attrs, re.I):
709
+ jar.pop(name, None) # the app cleared it
710
+ else:
711
+ jar[name] = value.strip()
712
+
713
+
714
+ def _jar_header(jar: dict[str, str]) -> str:
715
+ return "; ".join(f"{k}={v}" for k, v in jar.items())
716
+
717
+
718
+ def _send(t: "Target", url: str, jar: dict[str, str], method: str = "GET", data: bytes | None = None,
719
+ headers: dict | None = None):
720
+ """One request, redirects unfollowed (a sign-in's cookies often ride the 3xx). Updates
721
+ the cookie jar; returns (status, headers, body text)."""
722
+ if urlparse(url).scheme not in ("http", "https"):
723
+ raise ValueError(f"refused a non-http(s) URL during sign-in: {url}")
724
+ h = {"User-Agent": _BROWSER_UA, "Accept": "text/html,application/json;q=0.9,*/*;q=0.8"}
725
+ h.update(headers or {})
726
+ if jar:
727
+ h["Cookie"] = _jar_header(jar)
728
+ req = urllib.request.Request(url, data=data, method=method, headers=h)
729
+ try:
730
+ with urllib.request.build_opener(_NoRedirect).open(req, timeout=HTTP_TIMEOUT) as resp:
731
+ status, resp_headers, raw = resp.status, resp.headers, resp.read(MAX_READ)
732
+ except urllib.error.HTTPError as e:
733
+ status, resp_headers = e.code, e.headers
734
+ raw = e.read(MAX_READ) if hasattr(e, "read") else b""
735
+ _jar_update(jar, resp_headers)
736
+ return status, resp_headers, raw.decode("utf-8", "replace")
737
+
738
+
739
+ def _follow(t: Target, url: str, jar: dict[str, str], first: tuple | None = None):
740
+ """Follow redirects on the target's own host, keeping cookies. Returns
741
+ (status, headers, body, final url); a redirect to another site raises ValueError."""
742
+ status, headers, body = first if first else _send(t, url, jar)
743
+ for _ in range(MAX_SIGN_IN_HOPS):
744
+ if status not in (301, 302, 303, 307, 308) or not headers or not headers.get("Location"):
745
+ break
746
+ url = urljoin(url, headers["Location"])
747
+ if _host(url) != t.base_host:
748
+ raise ValueError(f"the sign-in redirects to {_host(url)} (single sign-on), which isn't supported yet")
749
+ if not t.url_in_scope(url):
750
+ raise ValueError(f"the sign-in redirects out of scope ({url})")
751
+ status, headers, body = _send(t, url, jar)
752
+ return status, headers, body, url
753
+
754
+
755
+ def _token_from(body: str) -> str | None:
756
+ try:
757
+ data = json.loads(body)
758
+ except (json.JSONDecodeError, ValueError):
759
+ return None
760
+ scopes = [data] + ([data.get(k) for k in ("data", "result", "session", "auth")] if isinstance(data, dict) else [])
761
+ for scope in scopes:
762
+ if isinstance(scope, dict):
763
+ for key in _TOKEN_KEYS:
764
+ value = scope.get(key)
765
+ if isinstance(value, str) and len(value) >= 16 and " " not in value:
766
+ return value
767
+ return None
768
+
769
+
770
+ def _fill(form: dict, creds: dict[str, str]) -> dict[str, str]:
771
+ """A sign-in form's fields: the credentials where they go, everything else as the page set it."""
772
+ fields: dict[str, str] = {}
773
+ user_field = None
774
+ passwords = 0
775
+ for f in form["inputs"]:
776
+ name, kind = f["name"], f["type"]
777
+ if kind == "password":
778
+ passwords += 1
779
+ if passwords == 1:
780
+ fields[name] = creds["password"]
781
+ elif kind in ("submit", "button", "image", "reset", "file"):
782
+ continue
783
+ elif kind in ("checkbox", "radio"):
784
+ if f["checked"]:
785
+ fields[name] = f["value"] or "on"
786
+ elif kind == "hidden":
787
+ fields[name] = f["value"]
788
+ elif creds.get("totp") and (_OTP_FIELD.search(name) or f["autocomplete"] == "one-time-code"):
789
+ fields[name] = creds["totp"]
790
+ elif user_field is None and (kind == "email" or f["autocomplete"] in ("username", "email")
791
+ or _USER_FIELD.search(name)):
792
+ user_field = name
793
+ fields[name] = creds["username"]
794
+ else:
795
+ fields[name] = f["value"]
796
+ if user_field is None and creds.get("username"):
797
+ # No field looked like a username: use the first plain text field.
798
+ for f in form["inputs"]:
799
+ if f["type"] in ("text", "email", "tel", "") and (f["name"] not in fields or not fields[f["name"]]):
800
+ fields[f["name"]] = creds["username"]
801
+ break
802
+ return fields
803
+
804
+
805
+ def sign_in_path(ident: dict | None) -> str:
806
+ """The saved sign-in page, as a path on the target origin. It's stored as a full URL, but
807
+ the scan reaches the app at its own origin, so only the path is used."""
808
+ try:
809
+ blob = json.loads(str(((ident or {}).get("secret") or {}).get("value") or "{}"))
810
+ except (json.JSONDecodeError, ValueError):
811
+ blob = {}
812
+ login = str(blob.get("login") or "").strip()
813
+ if not login:
814
+ return "/login"
815
+ parsed = urlparse(login)
816
+ path = (parsed.path or "/") + (f"?{parsed.query}" if parsed.query else "")
817
+ return path if path.startswith("/") else "/" + path
818
+
819
+
820
+ def is_password_account(ident: dict | None) -> bool:
821
+ secret = (ident or {}).get("secret") or {}
822
+ return str(secret.get("kind") or (ident or {}).get("kind") or "").lower() == "password"
823
+
824
+
825
+ def sign_in(t: Target, iid: str) -> dict:
826
+ """Sign a password test account in and keep its session on the target. Never returns or
827
+ logs the credentials. {ok, authenticated, status, via?, note|error}."""
828
+ ident = t.identities.get(iid)
829
+ if not is_password_account(ident):
830
+ return {"ok": False, "authenticated": False, "error": f"{iid!r} isn't a username-and-password test account"}
831
+ try:
832
+ blob = json.loads(str(ident["secret"].get("value") or "{}"))
833
+ except (json.JSONDecodeError, ValueError, AttributeError, TypeError):
834
+ blob = {}
835
+ creds = {"username": str(blob.get("username") or ""), "password": str(blob.get("password") or "")}
836
+
837
+ def failed(error: str, status=None) -> dict:
838
+ t.sign_in_failed[iid] = error
839
+ t.sessions.pop(iid, None)
840
+ t.session_headers.pop(iid, None)
841
+ return {"ok": True, "authenticated": False, "status": status, "error": error}
842
+
843
+ if not creds["username"] or not creds["password"]:
844
+ return failed("no username or password is saved for this test account")
845
+ if blob.get("totp"):
846
+ code = _totp_now(str(blob["totp"]))
847
+ if code is None:
848
+ return failed("the saved authenticator secret is invalid")
849
+ creds["totp"] = code
850
+ url = t.origin + sign_in_path(ident)
851
+ jar: dict[str, str] = {}
852
+ try:
853
+ _status, _h, page, page_url = _follow(t, url, jar)
854
+ before = dict(jar)
855
+ forms = _page_forms(page)
856
+ form = next((f for f in forms.forms if any(i["type"] == "password" for i in f["inputs"])), None)
857
+ if form is None and any(i["type"] == "password" for i in forms.loose):
858
+ form = {"action": "", "method": "post", "inputs": forms.loose}
859
+ if form is not None:
860
+ via = "form"
861
+ action = urljoin(page_url, form["action"] or page_url)
862
+ if _host(action) != t.base_host:
863
+ return failed(f"the sign-in form posts to {_host(action)}, another site (single sign-on), "
864
+ "which isn't supported yet")
865
+ fields = _fill(form, creds)
866
+ if form["method"] == "get":
867
+ action = action.split("?", 1)[0] + "?" + urlencode(fields)
868
+ first = _send(t, action, jar, "GET", headers={"Referer": page_url})
869
+ else:
870
+ first = _send(t, action, jar, "POST", urlencode(fields).encode(),
871
+ {"Content-Type": "application/x-www-form-urlencoded", "Referer": page_url})
872
+ else:
873
+ # No sign-in form on the page: treat the saved address as a sign-in API.
874
+ via = "api"
875
+ action = url
876
+ key = "email" if "@" in creds["username"] else "username"
877
+ body = {key: creds["username"], "password": creds["password"]}
878
+ if creds.get("totp"):
879
+ body["otp"] = creds["totp"]
880
+ first = _send(t, action, jar, "POST", json.dumps(body).encode(),
881
+ {"Content-Type": "application/json", "Accept": "application/json"})
882
+ posted = first[0]
883
+ token = _token_from(first[2])
884
+ _status, _h, landed, landed_url = _follow(t, action, jar, first)
885
+ # A second step that asks only for an authenticator code.
886
+ if creds.get("totp") and not token and not _has_password_field(landed):
887
+ step = next((f for f in _page_forms(landed).forms
888
+ if any(_OTP_FIELD.search(i["name"]) or i["autocomplete"] == "one-time-code"
889
+ for i in f["inputs"] if i["type"] not in ("hidden", "submit", "button"))), None)
890
+ if step is not None:
891
+ step_action = urljoin(landed_url, step["action"] or landed_url)
892
+ if _host(step_action) == t.base_host:
893
+ first = _send(t, step_action, jar, "POST", urlencode(_fill(step, creds)).encode(),
894
+ {"Content-Type": "application/x-www-form-urlencoded", "Referer": landed_url})
895
+ posted = first[0]
896
+ _status, _h, landed, landed_url = _follow(t, step_action, jar, first)
897
+ except ValueError as e:
898
+ return failed(str(e))
899
+ except Exception as e: # noqa: BLE001 - an unreachable app or a timeout: report it, don't crash
900
+ return failed(f"the sign-in page couldn't be reached: {e}")
901
+
902
+ changed = {k for k, v in jar.items() if before.get(k) != v}
903
+ if posted >= 500 and not token:
904
+ return failed(f"the app answered the sign-in with a server error (HTTP {posted})", posted)
905
+ if posted in (400, 401, 403, 404, 405, 409, 422, 429) and not token:
906
+ return failed(f"the sign-in was refused (HTTP {posted}): check the username and password", posted)
907
+ if not token and _has_password_field(landed):
908
+ return failed("the sign-in page came back: the username or password is wrong, or the page needs "
909
+ "an extra step such as a CAPTCHA", posted)
910
+ if not token and not changed:
911
+ return failed("the sign-in went through, but the app returned no session (no cookie and no token)", posted)
912
+ t.sign_in_failed.pop(iid, None)
913
+ t.sessions[iid] = _jar_header(jar)
914
+ if token:
915
+ t.session_headers[iid] = {"Authorization": f"Bearer {token}"}
916
+ else:
917
+ t.session_headers.pop(iid, None)
918
+ note = f"signed in through the sign-in {'form' if via == 'form' else 'API'}" + (" with a token" if token else "")
919
+ return {"ok": True, "authenticated": True, "status": posted, "via": via, "note": note}
920
+
921
+
922
+ def identity_headers(t: Target, sid: str, *, renew: bool = False) -> tuple[dict | None, str | None]:
923
+ """The headers that make a request come from a test account or a `login` session. A
924
+ password account signs in on first use, and again when `renew` (its session expired)."""
925
+ ident = t.identities.get(sid)
926
+ if is_password_account(ident):
927
+ if renew or not (t.sessions.get(sid) or t.session_headers.get(sid)):
928
+ if sid in t.sign_in_failed and not renew:
929
+ return None, f"test account {sid!r} could not sign in: {t.sign_in_failed[sid]}"
930
+ result = sign_in(t, sid)
931
+ if not result.get("authenticated"):
932
+ return None, f"test account {sid!r} could not sign in: {result.get('error') or result.get('note')}"
933
+ headers = dict(t.session_headers.get(sid) or {})
934
+ if t.sessions.get(sid):
935
+ headers["Cookie"] = t.sessions[sid]
936
+ return headers, None
937
+ if sid in t.sessions:
938
+ return {"Cookie": t.sessions[sid]}, None # a session established with the `login` op
939
+ hdr, err = t.header_for(sid)
940
+ if err:
941
+ return None, err
942
+ return {hdr[0]: hdr[1]}, None
943
+
944
+
945
+ def load_shared_sessions() -> dict:
946
+ raw = os.environ.get("BLACKGLASS_SESSIONS_JSON")
947
+ if not raw:
948
+ return {}
949
+ try:
950
+ val = json.loads(raw)
951
+ return val if isinstance(val, dict) else {}
952
+ except json.JSONDecodeError:
953
+ return {}
954
+
955
+
956
+ def _login(t: Target, args: dict) -> dict:
957
+ """Establish a session (safe-active). The one POST carve-out from the GET-only
958
+ posture: authentication is non-destructive, so at safe-active the agent may POST
959
+ credentials to a login endpoint, and the adapter captures the resulting session
960
+ cookie under a name to reuse via http/diff (identity:<name>). Credentials come
961
+ from a named identity's injected secret (the agent never sees it) or an explicit
962
+ body. Only the primary origin host is targeted, like any credential."""
963
+ identity = args.get("identity") or args.get("as")
964
+ account = is_password_account(t.identities.get(str(identity))) if identity else False
965
+ if account and not isinstance(args.get("body"), dict):
966
+ # A configured test account: sign it in from its saved sign-in page (allowed at any
967
+ # tier). It's then used with http/diff/render identity:"<id>".
968
+ result = sign_in(t, str(identity))
969
+ if result.get("authenticated"):
970
+ # For the agent; `note` is what the dashboard shows as the account's status.
971
+ result["use"] = f"act as it with http identity:'{identity}'"
972
+ return result
973
+ if not account and not t.tier_at_least("safe-active"):
974
+ return {"ok": False, "error": "login with an explicit body requires the safe-active action tier; "
975
+ "test accounts sign in automatically -- pass identity:'<id>' to http"}
976
+ path = args.get("path")
977
+ if not isinstance(path, str) or not path.startswith("/"):
978
+ return {"ok": False, "error": "login needs a 'path' (the login endpoint, starting with '/')"}
979
+ for ex in t.excluded_paths:
980
+ if path.startswith(ex):
981
+ return {"ok": False, "error": f"path {path} is out of scope (excluded)"}
982
+ url = t.origin + path
983
+ if _host(url) != t.base_host:
984
+ return {"ok": False, "error": f"login only authenticates against the primary origin host ({t.base_host})"}
985
+ name = str(args.get("session") or (identity if account else "session")).strip() or "session"
986
+ headers = {"User-Agent": _BROWSER_UA}
987
+ extra = args.get("headers")
988
+ if isinstance(extra, dict):
989
+ for k, v in extra.items():
990
+ headers[str(k)] = str(v)
991
+ fill: dict[str, str] = {} # secret substitutions for a password identity
992
+ scraped: dict[str, str] = {}
993
+ if identity:
994
+ ident = t.identities.get(str(identity))
995
+ secret = (ident or {}).get("secret") or {}
996
+ if (secret.get("kind") or (ident or {}).get("kind")) == "password":
997
+ # Password broker: fill username/password/TOTP server-side; the model never
998
+ # sees them. It supplies field NAMES (body) and placeholders; we supply values.
999
+ try:
1000
+ blob = json.loads(str(secret.get("value") or "{}"))
1001
+ except Exception: # noqa: BLE001
1002
+ return {"ok": False, "error": f"identity {identity!r} has a malformed password credential"}
1003
+ fill["username"] = str(blob.get("username") or "")
1004
+ fill["password"] = str(blob.get("password") or "")
1005
+ if blob.get("totp"):
1006
+ code = _totp_now(str(blob["totp"]))
1007
+ if code is None:
1008
+ return {"ok": False, "error": f"identity {identity!r} has an invalid TOTP secret"}
1009
+ fill["totp"] = fill["otp"] = code
1010
+ # CSRF two-step (best-effort, on by default): GET the login page to capture a
1011
+ # session cookie and any hidden CSRF token, then carry them into the POST.
1012
+ if args.get("prefetch", True):
1013
+ try:
1014
+ pre = urllib.request.Request(url, headers={"User-Agent": headers["User-Agent"]})
1015
+ with urllib.request.build_opener(_NoRedirect).open(pre, timeout=HTTP_TIMEOUT) as pr:
1016
+ pre_html = pr.read(MAX_READ).decode("utf-8", "replace")
1017
+ pre_cookies = pr.headers.get_all("Set-Cookie") or []
1018
+ if pre_cookies:
1019
+ headers["Cookie"] = "; ".join(c.split(";", 1)[0] for c in pre_cookies)
1020
+ scraped = _scrape_hidden_tokens(pre_html)
1021
+ except Exception: # noqa: BLE001 - prefetch is best-effort; the POST still runs
1022
+ scraped = {}
1023
+ else: # cookie/header identity: inject its credential as a header, as before
1024
+ hdr, err = t.header_for(str(identity))
1025
+ if err:
1026
+ return {"ok": False, "error": err}
1027
+ headers[hdr[0]] = hdr[1]
1028
+ data = None
1029
+ body = args.get("body")
1030
+ if fill and not isinstance(body, dict):
1031
+ # No body shape supplied: default to common field names.
1032
+ body = {"username": fill["username"], "password": fill["password"]}
1033
+ if "totp" in fill:
1034
+ body["otp"] = fill["totp"]
1035
+ if isinstance(body, dict):
1036
+ if fill:
1037
+ body = _fill_secret_placeholders(body, fill)
1038
+ for _fname, _fval in scraped.items(): # own vars — must not shadow `name` (the session key)
1039
+ body.setdefault(_fname, _fval) # include a scraped CSRF token unless already set
1040
+ if headers.get("Content-Type", "").lower().startswith("application/json"):
1041
+ data = json.dumps(body).encode("utf-8")
1042
+ else:
1043
+ data = urlencode(body).encode("utf-8")
1044
+ headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
1045
+ elif body is not None:
1046
+ data = str(body).encode("utf-8")
1047
+ method = str(args.get("method", "POST")).upper()
1048
+ # Do not follow redirects: a login's Set-Cookie often rides the 3xx itself.
1049
+ opener = urllib.request.build_opener(_NoRedirect).open
1050
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
1051
+ try:
1052
+ with opener(req, timeout=HTTP_TIMEOUT) as resp:
1053
+ status, resp_headers = resp.status, resp.headers
1054
+ resp.read(MAX_READ)
1055
+ except urllib.error.HTTPError as e:
1056
+ status = e.code
1057
+ resp_headers = e.headers
1058
+ except Exception as e: # noqa: BLE001 - surface to the agent
1059
+ return {"ok": False, "error": f"login request failed: {e}"}
1060
+ set_cookies = resp_headers.get_all("Set-Cookie") if resp_headers else None
1061
+ cookie = "; ".join(c.split(";", 1)[0] for c in set_cookies) if set_cookies else ""
1062
+ if cookie:
1063
+ t.sessions[name] = cookie
1064
+ return {"ok": True, "session": name, "status": status, "authenticated": bool(cookie),
1065
+ "note": (f"session '{name}' established; reuse it with http identity:'{name}'" if cookie
1066
+ else "no Set-Cookie returned -- wrong credentials, or the session is token-based (read it from the response and send it via headers)")}
1067
+
1068
+
1069
+ MAX_PROBE_PORTS = 16 # bound the fan-out of one probe (rate-awareness, like diff)
1070
+ PROBE_READ_DEFAULT = 2048
1071
+ PROBE_READ_MAX = 8192
1072
+ PROBE_TIMEOUT_DEFAULT = 4.0
1073
+ PROBE_TIMEOUT_MAX = 10.0
1074
+ SEND_MAX_BYTES = 65536 # bound one raw-send payload (exploit tier)
1075
+ MAX_RENDER_ACTIONS = 20 # bound the browser actions driven in one render (exploit tier)
1076
+
1077
+
1078
+ def _bare_host(h: str) -> str:
1079
+ """The hostname without a :port suffix (leaving IPv6 literals alone)."""
1080
+ h = (h or "").lower()
1081
+ if h.count(":") == 1:
1082
+ head, _, tail = h.partition(":")
1083
+ if tail.isdigit():
1084
+ return head
1085
+ return h
1086
+
1087
+
1088
+ def _probe_one(host: str, port: int, timeout: float, read_bytes: int) -> dict:
1089
+ """TCP-connect to one port and read whatever banner the service volunteers on
1090
+ connect. Sends nothing -- a pure, non-destructive banner grab."""
1091
+ try:
1092
+ conn = socket.create_connection((host, port), timeout=timeout)
1093
+ except OSError as e:
1094
+ return {"port": port, "open": False, "error": str(e)}
1095
+ try:
1096
+ conn.settimeout(timeout)
1097
+ try:
1098
+ data = conn.recv(read_bytes)
1099
+ except (socket.timeout, OSError):
1100
+ data = b""
1101
+ finally:
1102
+ try:
1103
+ conn.close()
1104
+ except OSError:
1105
+ pass
1106
+ out = {"port": port, "open": True, "bytes": len(data), "banner": _clip(data.decode("utf-8", "replace"), 2000)}
1107
+ # A binary handshake (e.g. MySQL) has little printable text; add a hex preview.
1108
+ if data and any(c < 9 or 13 < c < 32 for c in data):
1109
+ out["banner_hex"] = data[:64].hex()
1110
+ return out
1111
+
1112
+
1113
+ def _probe(t: Target, args: dict) -> dict:
1114
+ """Raw service probe (safe-active): reach NON-HTTP services the web surface
1115
+ hides. TCP-connect to a port (or a bounded list) and read the connect-banner --
1116
+ SSH/SMTP/FTP/MySQL greet with version strings; HTTP/Redis stay silent (still
1117
+ detected open). Scope-bound to in-scope HOSTS (any port). It CONNECTS AND READS
1118
+ only -- it sends nothing, so it cannot change a service's state; active protocol
1119
+ interaction (sending commands) is exploit-tier. Reports facts (open/closed,
1120
+ banner), not a verdict; the agent reasons about the version/service."""
1121
+ if not t.tier_at_least("safe-active"):
1122
+ return {"ok": False, "error": "probe requires the safe-active action tier"}
1123
+ proto = str(args.get("proto") or "tcp").lower()
1124
+ if proto != "tcp":
1125
+ return {"ok": False, "error": "only proto 'tcp' is supported (connect + banner read); sending protocol commands is exploit-tier"}
1126
+ host = _bare_host(str(args.get("host") or _bare_host(t.base_host)))
1127
+ if not host:
1128
+ return {"ok": False, "error": "probe needs a 'host' (defaults to the origin host)"}
1129
+ allowed_bares = {_bare_host(h) for h in t.allowed_hosts}
1130
+ if not (host in allowed_bares or recon.in_domain_scope(host, t.root_domain)):
1131
+ return {"ok": False, "error": (
1132
+ f"host {host} is out of scope: probe the target host or a host under the "
1133
+ f"verified root domain {t.root_domain}")}
1134
+ ports = args.get("ports")
1135
+ if ports is None:
1136
+ p = args.get("port")
1137
+ if p is None:
1138
+ return {"ok": False, "error": "probe needs a 'port' (int) or 'ports' (a list of ints)"}
1139
+ ports = [p]
1140
+ if not isinstance(ports, list):
1141
+ return {"ok": False, "error": "'ports' must be a list of integers"}
1142
+ try:
1143
+ ports = [int(x) for x in ports]
1144
+ except (TypeError, ValueError):
1145
+ return {"ok": False, "error": "ports must be integers"}
1146
+ if not ports:
1147
+ return {"ok": False, "error": "no ports given"}
1148
+ if len(ports) > MAX_PROBE_PORTS:
1149
+ return {"ok": False, "error": f"probe takes at most {MAX_PROBE_PORTS} ports at once"}
1150
+ for pp in ports:
1151
+ if not 1 <= pp <= 65535:
1152
+ return {"ok": False, "error": f"port {pp} is out of range (1-65535)"}
1153
+ try:
1154
+ read_bytes = max(1, min(int(args.get("read_bytes", PROBE_READ_DEFAULT)), PROBE_READ_MAX))
1155
+ except (TypeError, ValueError):
1156
+ read_bytes = PROBE_READ_DEFAULT
1157
+ try:
1158
+ timeout = max(0.1, min(float(args.get("timeout", PROBE_TIMEOUT_DEFAULT)), PROBE_TIMEOUT_MAX))
1159
+ except (TypeError, ValueError):
1160
+ timeout = PROBE_TIMEOUT_DEFAULT
1161
+ results = [_probe_one(host, pp, timeout, read_bytes) for pp in ports]
1162
+ return {"ok": True, "host": host, "proto": "tcp",
1163
+ "open_ports": [r["port"] for r in results if r.get("open")], "results": results}
1164
+
1165
+
1166
+ def _raw_send(t: Target, args: dict) -> dict:
1167
+ """Raw service exploitation (EXPLOIT tier): TCP-connect to a NON-HTTP service,
1168
+ SEND caller-supplied bytes, then read the reply -- the exploit-tier counterpart of
1169
+ `probe` (which only reads the connect banner, sending nothing). This is how you
1170
+ drive an exposed line protocol to prove impact: an unauthenticated Redis accepting
1171
+ INFO/CONFIG, a memcached answering `stats`, an open SMTP relay. Two-key gated
1172
+ (exploit tier AND destructiveActions) and scope-bound exactly like probe."""
1173
+ if not t.tier_at_least("exploit"):
1174
+ return {"ok": False, "error": (
1175
+ f"send transmits data to a service and is an EXPLOIT-tier op; this run is '{t.action_tier}'. "
1176
+ "Use `probe` to read a service banner non-destructively (it sends nothing).")}
1177
+ if t.read_only:
1178
+ return {"ok": False, "error": (
1179
+ "send is refused: destructiveActions is not authorized. Transmitting protocol commands needs "
1180
+ "BOTH the exploit tier AND destructiveActions enabled.")}
1181
+ proto = str(args.get("proto") or "tcp").lower()
1182
+ if proto != "tcp":
1183
+ return {"ok": False, "error": "only proto 'tcp' is supported"}
1184
+ host = _bare_host(str(args.get("host") or _bare_host(t.base_host)))
1185
+ if not host:
1186
+ return {"ok": False, "error": "send needs a 'host' (defaults to the origin host)"}
1187
+ allowed_bares = {_bare_host(h) for h in t.allowed_hosts}
1188
+ if not (host in allowed_bares or recon.in_domain_scope(host, t.root_domain)):
1189
+ return {"ok": False, "error": (
1190
+ f"host {host} is out of scope: send only to the target host or a host under the "
1191
+ f"verified root domain {t.root_domain}")}
1192
+ port = args.get("port")
1193
+ if port is None:
1194
+ return {"ok": False, "error": "send needs a 'port' (int)"}
1195
+ try:
1196
+ port = int(port)
1197
+ except (TypeError, ValueError):
1198
+ return {"ok": False, "error": "port must be an integer"}
1199
+ if not 1 <= port <= 65535:
1200
+ return {"ok": False, "error": f"port {port} is out of range (1-65535)"}
1201
+ # Payload: binary via "hex", or text via "data" (lone \n normalized to CRLF for
1202
+ # line protocols unless crlf:false). One of the two is required.
1203
+ if args.get("hex") is not None:
1204
+ try:
1205
+ payload = bytes.fromhex(re.sub(r"\s+", "", str(args["hex"])))
1206
+ except ValueError as e:
1207
+ return {"ok": False, "error": f"bad hex payload: {e}"}
1208
+ else:
1209
+ data = args.get("data")
1210
+ if data is None:
1211
+ return {"ok": False, "error": "send needs 'data' (text to transmit) or 'hex' (binary bytes)"}
1212
+ text = str(data)
1213
+ if args.get("crlf", True):
1214
+ text = re.sub(r"(?<!\r)\n", "\r\n", text)
1215
+ payload = text.encode("utf-8", "replace")
1216
+ if len(payload) > SEND_MAX_BYTES:
1217
+ return {"ok": False, "error": f"payload too large ({len(payload)} bytes; max {SEND_MAX_BYTES})"}
1218
+ try:
1219
+ read_bytes = max(1, min(int(args.get("read_bytes", PROBE_READ_DEFAULT)), PROBE_READ_MAX))
1220
+ except (TypeError, ValueError):
1221
+ read_bytes = PROBE_READ_DEFAULT
1222
+ try:
1223
+ timeout = max(0.1, min(float(args.get("timeout", PROBE_TIMEOUT_DEFAULT)), PROBE_TIMEOUT_MAX))
1224
+ except (TypeError, ValueError):
1225
+ timeout = PROBE_TIMEOUT_DEFAULT
1226
+ try:
1227
+ conn = socket.create_connection((host, port), timeout=timeout)
1228
+ except OSError as e:
1229
+ return {"ok": False, "error": f"connect failed: {e}", "host": host, "port": port}
1230
+ try:
1231
+ conn.settimeout(timeout)
1232
+ conn.sendall(payload)
1233
+ chunks, total = [], 0
1234
+ try:
1235
+ while total < read_bytes:
1236
+ b = conn.recv(min(4096, read_bytes - total))
1237
+ if not b:
1238
+ break
1239
+ chunks.append(b)
1240
+ total += len(b)
1241
+ except (socket.timeout, OSError):
1242
+ pass
1243
+ finally:
1244
+ try:
1245
+ conn.close()
1246
+ except OSError:
1247
+ pass
1248
+ data_in = b"".join(chunks)
1249
+ out = {"ok": True, "host": host, "port": port, "sent_bytes": len(payload),
1250
+ "recv_bytes": len(data_in), "response": _clip(data_in.decode("utf-8", "replace"), 4000)}
1251
+ if data_in and any(c < 9 or 13 < c < 32 for c in data_in):
1252
+ out["response_hex"] = data_in[:128].hex()
1253
+ return out
1254
+
1255
+
1256
+ def _loot_header(item: dict):
1257
+ """Turn a stashed credential into the (header, value) to inject. The agent
1258
+ declares the kind; the shape follows it so the common recovered-credential
1259
+ cases just work: a cookie string, an explicit 'Name: value' header line, or
1260
+ (the default) a bearer token."""
1261
+ value = str(item.get("value") or "")
1262
+ if not value:
1263
+ return None, f"loot {item.get('id')!r} has no value to replay"
1264
+ kind = str(item.get("kind") or "").lower()
1265
+ if kind == "cookie":
1266
+ return "Cookie", value
1267
+ if ":" in value and kind in ("", "header"):
1268
+ name, _, val = value.partition(":")
1269
+ return name.strip(), val.strip()
1270
+ return "Authorization", f"Bearer {value}"
1271
+
1272
+
1273
+ def _loot(t: Target, args: dict) -> dict:
1274
+ """Loot store + chain graph (safe-active). Structured, cross-step memory for
1275
+ what the assessment RECOVERS and how one finding ENABLES the next. It is the
1276
+ third credential source beside static identities (injected, never seen) and
1277
+ login-captured sessions -- this one for values the agent recovered itself (a
1278
+ key in a JS bundle, a token from an IDOR, a cookie from a response). `stash`
1279
+ stores a recovered credential/id/endpoint and returns an id to replay with
1280
+ http/diff loot:<id>; `link` records a chain edge so the coordinator can report
1281
+ the full kill-chain with evidence at each hop; `list` returns both. The agent
1282
+ decides what is loot and what the edges are -- this only stores them."""
1283
+ if not t.tier_at_least("safe-active"):
1284
+ return {"ok": False, "error": "loot requires the safe-active action tier"}
1285
+ action = str(args.get("action") or "").strip().lower()
1286
+ if action == "stash":
1287
+ value = args.get("value")
1288
+ t._loot_seq += 1
1289
+ lid = f"loot-{t._loot_seq}"
1290
+ item = {"id": lid, "kind": str(args.get("kind") or "credential"),
1291
+ "value": None if value is None else str(value),
1292
+ "source": args.get("source"), "note": args.get("note")}
1293
+ t.loot[lid] = item
1294
+ return {"ok": True, "id": lid, "kind": item["kind"],
1295
+ "hint": f"replay a recovered credential on a later request with http \"loot\":\"{lid}\"; "
1296
+ "record what it unlocks with loot action:'link'"}
1297
+ if action == "link":
1298
+ frm, to = args.get("from"), args.get("to")
1299
+ if not frm or not to:
1300
+ return {"ok": False, "error": "link needs 'from' and 'to' (loot/finding ids or short labels) to record a chain edge"}
1301
+ edge = {"from": str(frm), "to": str(to), "note": args.get("note")}
1302
+ t.edges.append(edge)
1303
+ return {"ok": True, "edges": len(t.edges), "edge": edge}
1304
+ if action in ("list", ""):
1305
+ return {"ok": True, "loot": list(t.loot.values()), "edges": t.edges}
1306
+ return {"ok": False, "error": f"unknown loot action {action!r}; use 'stash' | 'list' | 'link'"}
1307
+
1308
+
1309
+ def _render(t: Target, args: dict) -> dict:
1310
+ """Headless-browser render (safe-active). Loads a page in a REAL browser so its
1311
+ JavaScript runs, then returns the rendered DOM, console messages, and the URLs
1312
+ the page fetched -- the surface the raw `http` body cannot show (a near-empty
1313
+ SPA shell, a DOM-based XSS that fires only after JS, client-rendered routes).
1314
+ LOAD-AND-OBSERVE ONLY: it navigates and reads; it does not click, submit, or
1315
+ otherwise drive the page (that is exploit-tier). Scope- and origin-bound exactly
1316
+ like `http`. The browser is an external service (BROWSER_RENDER_URL); the adapter
1317
+ only posts to it, so the runner carries no browser dependency."""
1318
+ if not t.tier_at_least("safe-active"):
1319
+ return {"ok": False, "error": "render requires the safe-active action tier"}
1320
+ if not BROWSER_RENDER_URL:
1321
+ return {"ok": False, "error": "no headless browser configured (BROWSER_RENDER_URL unset); client-side rendering is unavailable on this run"}
1322
+ path = args.get("path")
1323
+ if not isinstance(path, str) or not path.startswith("/"):
1324
+ return {"ok": False, "error": "render needs a 'path' starting with '/' (a DOM-XSS payload goes in the fragment, e.g. /app#<payload>)"}
1325
+ for ex in t.excluded_paths:
1326
+ if path.startswith(ex):
1327
+ return {"ok": False, "error": f"path {path} is out of scope (excluded)"}
1328
+ host_arg = args.get("host")
1329
+ if host_arg:
1330
+ host_arg = str(host_arg).lower()
1331
+ if not t.host_in_scope(host_arg):
1332
+ return {"ok": False, "error": f"host {host_arg} is out of scope (reachable: the origin and hosts under {t.root_domain})"}
1333
+ origin = f"https://{host_arg}"
1334
+ else:
1335
+ origin = t.origin
1336
+ url = origin + path
1337
+ if not t.host_in_scope(_host(url)):
1338
+ return {"ok": False, "error": f"request left scope; reachable: the origin and hosts under {t.root_domain}"}
1339
+
1340
+ # A browser carries credentials as cookies. Resolve an identity/session to a
1341
+ # Cookie for the ORIGIN only (never a pivoted host), as http does.
1342
+ cookie = ""
1343
+ cred = args.get("identity") or args.get("as")
1344
+ if cred:
1345
+ if _host(url) != t.base_host:
1346
+ return {"ok": False, "error": f"credentials are only sent to the primary origin host ({t.base_host}); omit identity to render {_host(url)} anonymously"}
1347
+ sid = str(cred)
1348
+ creds, err = identity_headers(t, sid)
1349
+ if err:
1350
+ return {"ok": False, "error": err}
1351
+ cookie = creds.get("Cookie", "")
1352
+ if not cookie:
1353
+ return {"ok": False, "error": f"identity {sid!r} carries no cookie session; render carries cookie sessions only -- use http for it"}
1354
+ try:
1355
+ timeout_ms = min(max(int(args.get("timeout_ms", 8000)), 500), 20000)
1356
+ except (TypeError, ValueError):
1357
+ timeout_ms = 8000
1358
+ payload = {"url": url, "wait": ("networkidle" if args.get("wait") == "networkidle" else "load"),
1359
+ "timeout_ms": timeout_ms}
1360
+ if cookie:
1361
+ payload["cookie"] = cookie
1362
+ # Browser INTERACTION (exploit tier): actions that DRIVE the page (click/fill/
1363
+ # submit/press) rather than just load it. Loading + observing is the safe-active
1364
+ # posture; interacting is two-key gated (exploit tier AND destructiveActions), the
1365
+ # same line the render skill draws. Without actions, render stays load-and-observe.
1366
+ actions = args.get("actions")
1367
+ if actions:
1368
+ if not t.tier_at_least("exploit"):
1369
+ return {"ok": False, "error": (
1370
+ "driving the page (actions: click/fill/submit) is an EXPLOIT-tier capability; at this "
1371
+ "tier render is load-and-observe only -- navigate and read, do not interact.")}
1372
+ if t.read_only:
1373
+ return {"ok": False, "error": (
1374
+ "render actions are refused: destructiveActions is not authorized. Interacting with the "
1375
+ "page needs BOTH the exploit tier AND destructiveActions enabled.")}
1376
+ if not isinstance(actions, list) or not all(isinstance(x, dict) for x in actions):
1377
+ return {"ok": False, "error": "'actions' must be a list of {\"type\", \"selector\"?, \"value\"?} objects"}
1378
+ if len(actions) > MAX_RENDER_ACTIONS:
1379
+ return {"ok": False, "error": f"at most {MAX_RENDER_ACTIONS} actions per render"}
1380
+ payload["actions"] = actions
1381
+ try:
1382
+ req = urllib.request.Request(BROWSER_RENDER_URL, data=json.dumps(payload).encode(),
1383
+ method="POST", headers={"Content-Type": "application/json"})
1384
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
1385
+ body = json.loads(resp.read(MAX_READ).decode("utf-8", "replace"))
1386
+ except Exception as e: # noqa: BLE001 - surface to the agent
1387
+ return {"ok": False, "error": f"render failed: {e}"}
1388
+ if not isinstance(body, dict) or not body.get("ok"):
1389
+ err = body.get("error") if isinstance(body, dict) else body
1390
+ return {"ok": False, "error": f"render service reported an error: {err}"}
1391
+ return {"ok": True, "url": url, "final_url": body.get("final_url"), "title": body.get("title"),
1392
+ "status": body.get("status"), "console": (body.get("console") or [])[:50],
1393
+ "requests": (body.get("requests") or [])[:100], "dom": _clip(str(body.get("dom") or ""))}
1394
+
1395
+
1396
+ def describe(t: Target) -> dict:
1397
+ return {
1398
+ "ok": True,
1399
+ "description": (
1400
+ f"A running web application reachable ONLY at {t.origin}. You are performing a broad "
1401
+ "security assessment of its reachable HTTP surface: data exposure, access-control / "
1402
+ "auth-boundary violations, secrets in JS bundles & source maps, unauthenticated APIs & "
1403
+ "GraphQL introspection, IDOR, exposed paths (.git/.env/backups/debug/swagger), verbose "
1404
+ "errors, and CORS / security-header misconfig. Use the `http` op to send requests; pass a "
1405
+ "named identity to act AS that account (the adapter injects its credentials and signs "
1406
+ "username-and-password accounts in for you, so you never see the raw secret), or omit it to probe "
1407
+ "ANONYMOUSLY -- the anonymous surface matters "
1408
+ "even when no identities exist. Report each demonstrated issue citing the exact request "
1409
+ "and the response that proves it. "
1410
+ + ("THIS IS A READ-ONLY, NON-DESTRUCTIVE assessment: GET/HEAD only -- do not create, modify, "
1411
+ "or delete data (mutating requests are refused). "
1412
+ if t.read_only or not t.tier_at_least("exploit") else
1413
+ "This is an EXPLOIT-tier engagement: you MAY send mutating requests (POST/PUT/PATCH/DELETE "
1414
+ "with a body) to PROVE real impact. Prove every change with a before/after read showing the "
1415
+ "state actually moved, prefer the smallest reversible blast radius (write a throwaway marker "
1416
+ "rather than destroy real data, and undo it), and never send a mutation to any host but the "
1417
+ "primary origin. ")
1418
+ + (f"Beyond the origin you may also enumerate and probe the VERIFIED root domain {t.root_domain} "
1419
+ "and its subdomains: use the recon ops (dns/crt/tls/storage/registries) to discover hosts, "
1420
+ "buckets, and public artifacts -- these hit external data sources, NOT the protected origin, so "
1421
+ "a WAF/CDN does not block them -- then pivot `http` (with a \"host\" arg, anonymous) to any host "
1422
+ f"under {t.root_domain}. "
1423
+ if RECON_ENABLED else "No host other than the target origin is reachable. ")
1424
+ + "Report each demonstrated issue with the exact request and the proving response."
1425
+ + (" The `diff` op is available: run several requests and compare their responses to PROVE "
1426
+ "access-control and blind-inference findings (e.g. two owners reading each other's records, "
1427
+ "or a true/false payload pair) instead of merely asserting them." if t.tier_at_least("safe-active") else "")
1428
+ + (" For BLIND findings, oob_mint gives a unique callback URL and oob_poll reports whether the "
1429
+ "target fetched it out-of-band -- the proof for blind SSRF/XXE (no callback, no critical)."
1430
+ if t.tier_at_least("safe-active") and OOB_COLLABORATOR_BASE else "")
1431
+ + (" You may also `login` (the one permitted POST, for authentication only) to establish a "
1432
+ "session and then act as it, to test authenticated and cross-account access-control."
1433
+ if t.tier_at_least("safe-active") else "")
1434
+ + (" Use the `loot` op to stash any credential/id/endpoint you RECOVER and replay it on later "
1435
+ "requests (http \"loot\":\"<id>\"), and to record chain edges (one finding enabling the next) "
1436
+ "so a multi-step kill-chain is reported end to end." if t.tier_at_least("safe-active") else "")
1437
+ + (" The `probe` op reaches NON-HTTP services: TCP-connect to a port and read the banner it "
1438
+ "volunteers (e.g. an SSH/MySQL version), to find exposed services and vulnerable versions "
1439
+ "the web surface hides." if t.tier_at_least("safe-active") else "")
1440
+ + (" A real headless browser is available via `render`: load a page so its JavaScript runs, then "
1441
+ "inspect the rendered DOM / console / fetched URLs -- for DOM-based XSS and client-rendered (SPA) "
1442
+ "surface the raw http body cannot show." if t.tier_at_least("safe-active") and BROWSER_RENDER_URL else "")
1443
+ + (" The `send` op reaches NON-HTTP services at the exploit tier: TCP-connect and transmit protocol "
1444
+ "bytes, then read the reply (the counterpart of probe, which only reads the banner) -- to drive an "
1445
+ "exposed line protocol (Redis/memcached/SMTP) and prove it accepts your commands."
1446
+ if t.tier_at_least("exploit") and not t.read_only else "")
1447
+ + (" At the exploit tier `render` also DRIVES the page: pass \"actions\" (fill/click/submit) to prove "
1448
+ "interaction-only bugs a mere load cannot reach (stored/DOM XSS behind a form, client-side state "
1449
+ "changes), then read the resulting DOM."
1450
+ if t.tier_at_least("exploit") and not t.read_only and BROWSER_RENDER_URL else "")
1451
+ + _profile_blurb(t)
1452
+ + _skills_blurb(t)
1453
+ ),
1454
+ "ops": ops_for(t),
1455
+ }
1456
+
1457
+
1458
+ def _profile_blurb(t: Target) -> str:
1459
+ """A compact, anti-anchoring headline for the preliminary profile, injected into the
1460
+ brief when the feature is on and a profile loaded. It frames the profile as a baseline
1461
+ of SIGNALS to confirm and go beyond -- never findings -- and points at the `profile`
1462
+ op for the detail. Empty string when no profile (so describe() is unchanged when off)."""
1463
+ p = getattr(t, "profile", None)
1464
+ if not p:
1465
+ return ""
1466
+ tgt = p.get("target") or {}
1467
+ fp = tgt.get("x_powered_by") or tgt.get("server") or "unknown stack"
1468
+ n = len(p.get("signals") or [])
1469
+ return ("\n\nPRELIMINARY PROFILE (a deterministic pre-pass already swept the rote surface "
1470
+ "before you): treat everything in it as SIGNALS/observations to CONFIRM, never as "
1471
+ "findings, and GO BEYOND it -- it does not cover logic, authz, or chained bugs, and a "
1472
+ f"swept check is not a safe area. Stack: {fp}. {n} candidate signal(s) + security-posture "
1473
+ "facts are available via the `profile` op; build on them, don't just re-run them.")
1474
+
1475
+
1476
+ def _skills_blurb(t: Target) -> str:
1477
+ gate = _skill_gate(t)
1478
+ if not skills.available(gate):
1479
+ return ""
1480
+ idx = skills.render_index(gate)
1481
+ return ("\n\nDEEP PLAYBOOKS (get_skill op): detailed methodologies for specific surfaces. When "
1482
+ "your task matches a playbook's trigger below, you MUST FIRST call get_skill with its "
1483
+ "name and follow it before probing that surface:\n" + idx) if idx else ""
1484
+
1485
+
1486
+ def ops_for(t: Target) -> dict:
1487
+ mutating = t.tier_at_least("exploit") and not t.read_only
1488
+ ops = {
1489
+ "http": ('{"identity"?: string, "method"?: '
1490
+ + ('"GET"|"HEAD"|"OPTIONS"|"POST"|"PUT"|"PATCH"|"DELETE"' if mutating else '"GET"|"HEAD"|"OPTIONS"')
1491
+ + ', "path": "/...", "host"?: string, "body"?: object|string, '
1492
+ '"headers"?: object, "grep"?: regex, "allow_redirects"?: bool} send a request. Omit identity '
1493
+ 'for anonymous. "host" pivots to a recon-discovered host UNDER the verified root domain '
1494
+ f'{t.root_domain} (anonymous only; defaults to the origin). "grep" returns regex-matched snippets '
1495
+ 'over the FULL body (use for secret mining in large JS/source-map files). "allow_redirects":false '
1496
+ 'returns a 3xx + Location unfollowed (open-redirect / auth-flow checks).'
1497
+ + (' At this EXPLOIT tier you may send a MUTATING method with a "body" (a dict is sent as JSON) to '
1498
+ 'change state and PROVE impact; confirm every change with a follow-up read, and keep the blast '
1499
+ 'radius minimal and reversible.' if mutating else '')),
1500
+ "list_identities": "{} list the named identities you can act as, with their roles (no secrets).",
1501
+ }
1502
+ if t.tier_at_least("safe-active"):
1503
+ # Active-inference ops, unlocked above the passive tier. They do no more
1504
+ # than http can (read-only, scope-bound) but drive it systematically.
1505
+ ops["diff"] = ('{"requests": [ {<http args>, "label"?: string}, ... ]} run 2+ requests (each shaped '
1506
+ 'like http args) and report how the responses differ: per-request status/bytes plus signals '
1507
+ '(status_varies / length_varies / content_varies / distinct_bodies). The differential oracle '
1508
+ 'for proving access-control and blind-inference findings -- e.g. fetch the same record as two '
1509
+ 'owners to show one reads the other’s data, or send a true/false payload pair to detect a '
1510
+ 'boolean oracle. Returns facts, not a verdict; you interpret the signals.')
1511
+ if OOB_COLLABORATOR_BASE:
1512
+ ops["oob_mint"] = ('{} mint a unique out-of-band callback URL + token. Plant the URL where the '
1513
+ 'target might fetch it (SSRF url=, webhook, open redirect, XXE entity); a '
1514
+ 'server-side request to it is logged.')
1515
+ ops["oob_poll"] = ('{"token": "..."} check whether a minted callback was hit. interaction:true '
1516
+ 'proves a blind server-side request (SSRF/XXE); no hit is not proof. The rule: '
1517
+ 'no OOB callback, no SSRF critical.')
1518
+ ops["login"] = ('{"path": "/login", "body"?: {..}, "identity"?: string, "session"?: name, "headers"?: object} '
1519
+ 'authenticate (the one allowed POST) and capture the session under "session" (default '
1520
+ '"session"); then act as it with http/diff identity:"<session>". Test accounts '
1521
+ '(list_identities) need none of this: pass identity:"<id>" to http/diff/render and they sign '
1522
+ 'in automatically. Use login for credentials you RECOVERED, with an explicit body; for an '
1523
+ 'unusual sign-in form you may give a test account a body whose field VALUES are the '
1524
+ 'placeholders {{username}}/{{password}}/{{totp}} (the adapter fills the real values). '
1525
+ 'authenticated:false means no cookie came back.')
1526
+ ops["loot"] = ('{"action": "stash"|"list"|"link", ...} record and reuse what you RECOVER. '
1527
+ 'stash {"kind","value","source"?,"note"?} stores a recovered credential/id/endpoint and '
1528
+ 'returns an id; replay a stashed credential on a later request with http "loot":"<id>" '
1529
+ '(injected only for the origin, like an identity -- "kind":"cookie"|"bearer"|"header" '
1530
+ 'picks how). link {"from","to","note"?} records a chain edge (finding A enabled B) for '
1531
+ 'the kill-chain report. list returns the stashed loot and recorded edges.')
1532
+ ops["probe"] = ('{"host"?: string, "port": int | "ports": [int,...], "timeout"?: sec, "read_bytes"?: n} '
1533
+ 'TCP-connect to a NON-HTTP service and read the banner it volunteers on connect '
1534
+ '(SSH/SMTP/FTP/MySQL greet with versions; HTTP/Redis stay silent but are still detected '
1535
+ f'open). Scope-bound to the target host and hosts under the root domain {t.root_domain}, '
1536
+ 'any port. Reports open/closed + banner per port. Connect-and-read only -- it sends '
1537
+ 'nothing and cannot change service state (sending protocol commands is exploit-tier).')
1538
+ if mutating:
1539
+ ops["send"] = ('{"host"?: string, "port": int, "data"?: string, "hex"?: string, "crlf"?: bool, '
1540
+ '"read_bytes"?: n, "timeout"?: sec} the EXPLOIT-tier counterpart of probe: TCP-connect '
1541
+ 'and TRANSMIT bytes to a non-HTTP service, then read the reply. Drive an exposed line '
1542
+ 'protocol to prove impact -- e.g. send "INFO\\r\\n" to an unauthenticated Redis, or '
1543
+ '"stats" to memcached. Give "data" (text; a lone \\n becomes CRLF unless crlf:false) or '
1544
+ '"hex" (binary bytes). Scope-bound to the origin host like probe; keep commands '
1545
+ 'reversible where you can and prove the service accepted them.')
1546
+ if BROWSER_RENDER_URL:
1547
+ ops["render"] = ('{"path": "/...", "identity"?: session, "host"?: string, "wait"?: "load"|"networkidle", '
1548
+ '"timeout_ms"?: n' + (', "actions"?: [{"type":"click"|"fill"|"press"|"submit"|"wait", '
1549
+ '"selector"?: css, "value"?: string}, ...]' if mutating else '') + '} load the page in a '
1550
+ 'REAL headless browser (runs its JavaScript) and return the rendered DOM, console '
1551
+ 'messages, and the URLs the page fetched. Use it where the raw http body is a near-empty '
1552
+ 'SPA shell: to find DOM-based XSS (put the payload in the fragment, e.g. "/app#<payload>", '
1553
+ 'and look for it executed in the DOM/console) and to enumerate client-rendered routes/APIs '
1554
+ '(the fetched URLs). Scope- and origin-bound like http.'
1555
+ + (' At this EXPLOIT tier you may DRIVE the page with "actions" (fill a field, click, '
1556
+ 'submit a form) to prove interaction-only bugs -- stored/DOM XSS behind a form, a '
1557
+ 'client-side state change -- then read the resulting DOM; keep actions minimal.'
1558
+ if mutating else ' Load-and-observe only -- no clicking or submitting (interacting is '
1559
+ 'exploit-tier).'))
1560
+ if getattr(t, "profile", None):
1561
+ ops["profile"] = ("{} the deterministic preliminary-scan profile: fingerprint + "
1562
+ "security-posture FACTS + candidate SIGNALS (observations to CONFIRM, "
1563
+ "NOT findings). Reason about it and go BEYOND it; a swept check is not a "
1564
+ "safe area, and the profile never covers logic/authz/chained bugs.")
1565
+ if RECON_ENABLED:
1566
+ # WAF-independent EASM ops: these query fixed external data sources about the
1567
+ # verified root domain/org, never the protected origin, so a CDN/WAF can't
1568
+ # block them. Discover hosts here, then pivot `http` to them.
1569
+ ops.update(recon.describe_ops(t.root_domain))
1570
+ if skills.available(_skill_gate(t)):
1571
+ # On-demand deep playbooks (progressive disclosure): the index is in the
1572
+ # brief; load a full procedure with get_skill when a direction matches.
1573
+ # Advertised whenever any tier-permitted skill exists; get_skill itself
1574
+ # still refuses a skill above the run's tier.
1575
+ ops.update(skills.describe_ops())
1576
+ if "login" not in ops and any(is_password_account(i) for i in t.identities.values()):
1577
+ ops["login"] = ('{"identity": string} sign a username-and-password test account in again. Rarely '
1578
+ 'needed: passing identity:"<id>" to http/diff/render signs it in automatically and '
1579
+ 'renews an expired session.')
1580
+ return ops
1581
+
1582
+
1583
+ def list_identities(t: Target) -> dict:
1584
+ out = []
1585
+ for i in t.identities.values():
1586
+ secret = i.get("secret") or {}
1587
+ kind = (secret.get("kind") or i.get("kind") or "").lower()
1588
+ entry = {
1589
+ "id": i["id"],
1590
+ "role": i.get("how") or "",
1591
+ "authenticated": bool(i.get("secret")),
1592
+ "kind": kind or None,
1593
+ "use": f"pass identity:'{i['id']}' to http/diff/render",
1594
+ }
1595
+ if kind == "password":
1596
+ # Signed in automatically on first use; the session is renewed when it expires.
1597
+ entry["sign_in"] = "automatic"
1598
+ entry["signed_in"] = bool(t.sessions.get(i["id"]) or t.session_headers.get(i["id"]))
1599
+ if i["id"] in t.sign_in_failed:
1600
+ entry["sign_in_error"] = t.sign_in_failed[i["id"]]
1601
+ out.append(entry)
1602
+ return {"ok": True, "identities": out}
1603
+
1604
+
1605
+ def handle(t: Target, req: dict) -> dict:
1606
+ op = req.get("op")
1607
+ args = req.get("args") or {}
1608
+ if not isinstance(args, dict):
1609
+ return {"ok": False, "error": "args must be an object"}
1610
+ if op == "describe":
1611
+ return describe(t)
1612
+ if op == "http":
1613
+ return _http(t, args)
1614
+ if op == "diff":
1615
+ return _diff(t, args)
1616
+ if op == "oob_mint":
1617
+ return _oob_mint(t, args)
1618
+ if op == "oob_poll":
1619
+ return _oob_poll(t, args)
1620
+ if op == "login":
1621
+ return _login(t, args)
1622
+ if op == "loot":
1623
+ return _loot(t, args)
1624
+ if op == "probe":
1625
+ return _probe(t, args)
1626
+ if op == "send":
1627
+ return _raw_send(t, args)
1628
+ if op == "render":
1629
+ return _render(t, args)
1630
+ if op == "list_identities":
1631
+ return list_identities(t)
1632
+ if op == "profile":
1633
+ if getattr(t, "profile", None):
1634
+ return {"ok": True, "profile": t.profile}
1635
+ return {"ok": False, "error": "no preliminary-scan profile is available on this run"}
1636
+ if RECON_ENABLED:
1637
+ result = recon.handle(op, args, t.root_domain)
1638
+ if result is not None:
1639
+ return result
1640
+ skill_result = skills.handle(op, args, _skill_gate(t))
1641
+ if skill_result is not None:
1642
+ return skill_result
1643
+ return {"ok": False, "error": f"unknown op {op!r}; ops: {', '.join(['describe'] + list(ops_for(t)))}"}
1644
+
1645
+
1646
+ def load_secrets() -> list[dict]:
1647
+ raw = os.environ.get("BLACKGLASS_SECRETS_JSON")
1648
+ if not raw:
1649
+ return []
1650
+ try:
1651
+ val = json.loads(raw)
1652
+ return val if isinstance(val, list) else []
1653
+ except json.JSONDecodeError:
1654
+ return []
1655
+
1656
+
1657
+ def main() -> int:
1658
+ ap = argparse.ArgumentParser()
1659
+ ap.add_argument("--spec", required=True, help="path to the RunSpec input-spec.json")
1660
+ a = ap.parse_args()
1661
+ spec = json.loads(Path(a.spec).read_text())
1662
+ target = Target(spec, load_secrets())
1663
+ for line in sys.stdin:
1664
+ line = line.strip()
1665
+ if not line:
1666
+ continue
1667
+ try:
1668
+ req = json.loads(line)
1669
+ if not isinstance(req, dict):
1670
+ raise ValueError("request must be a JSON object")
1671
+ except (json.JSONDecodeError, ValueError) as e:
1672
+ sys.stdout.write(json.dumps({"ok": False, "error": f"bad request: {e}"}) + "\n")
1673
+ sys.stdout.flush()
1674
+ continue
1675
+ try:
1676
+ resp = handle(target, req)
1677
+ out = json.dumps(resp)
1678
+ except Exception as e: # noqa: BLE001 - never let one op crash the env process
1679
+ out = json.dumps({"ok": False, "error": f"env error handling op {req.get('op')!r}: {e}"})
1680
+ sys.stdout.write(out + "\n")
1681
+ sys.stdout.flush()
1682
+ return 0
1683
+
1684
+
1685
+ if __name__ == "__main__":
1686
+ raise SystemExit(main())