@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.
- package/README.md +2 -2
- package/bin/harness +0 -0
- package/package.json +15 -4
- package/runners/basic_harness_run.py +1276 -0
- package/runners/checklists/anonymous.json +24 -0
- package/runners/cve_feed.json +53 -0
- package/runners/prod_env.py +1686 -0
- package/runners/profile.py +307 -0
- package/runners/recon.py +396 -0
- package/runners/remediate.py +429 -0
- package/runners/report.py +365 -0
- package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
- package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
- package/runners/skills/exploit/business-logic/SKILL.md +53 -0
- package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
- package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
- package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
- package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
- package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
- package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
- package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
- package/runners/skills/exploit/service-probing/SKILL.md +51 -0
- package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
- package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
- package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
- package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
- package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
- package/runners/skills/web/content-discovery/SKILL.md +59 -0
- package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
- package/runners/skills.py +174 -0
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Blackglass product runner adapter for basic-harness (teams+directions).
|
|
3
|
+
|
|
4
|
+
In-product sibling of the multiagent runner's adapter. The control-plane's
|
|
5
|
+
RunnerSupervisor spawns this as:
|
|
6
|
+
|
|
7
|
+
python3 $BASIC_HARNESS_ROOT/runners/basic_harness_run.py \
|
|
8
|
+
--spec <session>/input-spec.json --session-dir <session> --events stdout
|
|
9
|
+
|
|
10
|
+
It reads the RunSpec, runs the basic-harness Go binary against the target through
|
|
11
|
+
prod_env.py (READ-ONLY; identities from BLACKGLASS_SECRETS_JSON), and emits Blackglass
|
|
12
|
+
events on stdout: lifecycle events (run.started/completed/failed) on the main thread,
|
|
13
|
+
plus a LIVE activity stream tailed from the harness's own event log. Like the
|
|
14
|
+
multiagent adapter -- which follows `cmd/run-events` -- we map core events to v2 public
|
|
15
|
+
events; basic-harness has no run-events subcommand, so a follower thread tails the
|
|
16
|
+
harness state DB's `events` table (and newly-confirmed findings in `nodes`) directly
|
|
17
|
+
and emits mapped events with periodic heartbeats so the dashboard timeline stays live.
|
|
18
|
+
|
|
19
|
+
No Go-core change: the harness is driven entirely through its existing flags. Exit
|
|
20
|
+
codes mirror the core runner contract: 0 ok, 2 invalid input, 3 policy refusal,
|
|
21
|
+
4 cancelled.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import datetime as dt
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import signal
|
|
33
|
+
import sqlite3
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from urllib.parse import urlparse
|
|
41
|
+
|
|
42
|
+
ROOT = Path(os.environ.get("BASIC_HARNESS_ROOT", Path(__file__).resolve().parent.parent))
|
|
43
|
+
HARNESS = os.environ.get("BASIC_HARNESS_BIN", str(ROOT / "bin" / "harness"))
|
|
44
|
+
PROD_ENV = str(Path(__file__).resolve().parent / "prod_env.py")
|
|
45
|
+
PROFILE_PY = os.environ.get("PROFILE_PY", str(Path(__file__).resolve().parent / "profile.py"))
|
|
46
|
+
KEY_FILE = os.environ.get("BASIC_HARNESS_KEY_FILE", str(Path.home() / "Code" / "keys" / ".env"))
|
|
47
|
+
|
|
48
|
+
DEFAULT_MODEL = os.environ.get("BASIC_HARNESS_MODEL", "z-ai/glm-5.3-flash")
|
|
49
|
+
# Premium models sit at two roles; everything else runs on the flash base model:
|
|
50
|
+
# - COORDINATOR (kimi-k3): the per-team synthesis locus that authors every finding. The
|
|
51
|
+
# Juice Shop sweep (2026-10-28) showed K3 here wins on recall + category coverage, while
|
|
52
|
+
# the SAME model at the orchestrator made recall worse and was unstable (a full-length
|
|
53
|
+
# run that scored 0). This is where the expensive reasoning earns its keep.
|
|
54
|
+
# - ORCHESTRATOR (glm-5.3): the council chair + members that prioritize directions across a
|
|
55
|
+
# large surface. Kept premium (but NOT k3) on purpose: the sweep ran single-planner
|
|
56
|
+
# (baseline) orchestration on one mid-size target, which under-weights the orchestrator's
|
|
57
|
+
# real value on big multi-host targets where council planning over a capable model pays
|
|
58
|
+
# off. In council mode all 3 members and the chair run on this model (orchestrator.go).
|
|
59
|
+
DEFAULT_COORD_MODEL = os.environ.get("BASIC_HARNESS_COORD_MODEL", "moonshotai/kimi-k3")
|
|
60
|
+
DEFAULT_ORCH_MODEL = os.environ.get("BASIC_HARNESS_ORCH_MODEL", "z-ai/glm-5.3")
|
|
61
|
+
DEFAULT_ORCHESTRATOR = os.environ.get("BASIC_HARNESS_ORCHESTRATOR", "council")
|
|
62
|
+
|
|
63
|
+
# Remediation (patch generation) runs a small best-of-N + reviewer pipeline, NOT the flash
|
|
64
|
+
# base model: a patch is one cheap job (not a 45-min scan), so premium models earn their keep
|
|
65
|
+
# on correctness. Generator = glm-5.3 (full, stronger than flash); reviewer = kimi-k3 (the
|
|
66
|
+
# strongest reasoner, used to judge "does this actually fix it, minimally, no regression").
|
|
67
|
+
# These literals ship in the hosted path (BASIC_HARNESS_* env is not forwarded to the runner).
|
|
68
|
+
DEFAULT_REMEDIATE_MODEL = os.environ.get("BASIC_HARNESS_REMEDIATE_MODEL", DEFAULT_ORCH_MODEL)
|
|
69
|
+
DEFAULT_REMEDIATE_REVIEWER = os.environ.get("BASIC_HARNESS_REMEDIATE_REVIEWER", DEFAULT_COORD_MODEL)
|
|
70
|
+
try:
|
|
71
|
+
DEFAULT_REMEDIATE_CANDIDATES = max(1, int(os.environ.get("BASIC_HARNESS_REMEDIATE_CANDIDATES", "3")))
|
|
72
|
+
except ValueError:
|
|
73
|
+
DEFAULT_REMEDIATE_CANDIDATES = 3
|
|
74
|
+
|
|
75
|
+
# Two scan profiles, selected by RunSpec.scanProfile (default 'full'). PR = fast, cheap,
|
|
76
|
+
# diff-scoped gate that must return in minutes so it can block a PR; FULL = the deep
|
|
77
|
+
# whole-repo audit. The control plane curates the child env, so these LITERALS (not the
|
|
78
|
+
# BASIC_HARNESS_* env, which only reaches local dev) are what ship in the hosted path.
|
|
79
|
+
# Each profile sets the model placement, whether the council runs, breadth, and the cost
|
|
80
|
+
# backstop; the time cap comes from policy.maxDurationMinutes, falling back to the
|
|
81
|
+
# profile's default_minutes. See [[murk-pricing-launch-plan]].
|
|
82
|
+
SCAN_PROFILES: dict[str, dict] = {
|
|
83
|
+
"pr": {
|
|
84
|
+
"orchestrator": "baseline", # no council: keep the PR gate fast and cheap
|
|
85
|
+
"coord_model": "", # flash everywhere incl. the coordinator
|
|
86
|
+
"orch_model": "",
|
|
87
|
+
"teams": "3", "cells": "2", "team_calls": "40", "workers": "6",
|
|
88
|
+
"max_cost": "3", "default_minutes": 10.0,
|
|
89
|
+
},
|
|
90
|
+
"full": {
|
|
91
|
+
"orchestrator": DEFAULT_ORCHESTRATOR, # council
|
|
92
|
+
"coord_model": DEFAULT_COORD_MODEL, # kimi-k3 at the synthesis locus
|
|
93
|
+
"orch_model": DEFAULT_ORCH_MODEL, # glm-5.3 council chair + members
|
|
94
|
+
"teams": "5", "cells": "3", "team_calls": "60", "workers": "8",
|
|
95
|
+
"max_cost": "15", "default_minutes": 45.0,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scan_profile_for(spec: dict) -> tuple[str, dict]:
|
|
101
|
+
"""Resolve (name, config) for a RunSpec's scanProfile. Unknown/absent -> 'full'
|
|
102
|
+
(the pre-two-profile default), so existing assessments keep their behavior."""
|
|
103
|
+
name = str((spec or {}).get("scanProfile") or "full").lower()
|
|
104
|
+
if name not in SCAN_PROFILES:
|
|
105
|
+
name = "full"
|
|
106
|
+
return name, SCAN_PROFILES[name]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pr_changed_files(checkout: str, base: str, limit: int = 100) -> list[str]:
|
|
110
|
+
"""Files changed between `base` and HEAD in the checkout (name-only). Safe: returns
|
|
111
|
+
[] on any git error, so a scoping failure degrades to a whole-repo PR scan rather
|
|
112
|
+
than breaking the run."""
|
|
113
|
+
try:
|
|
114
|
+
out = subprocess.run(
|
|
115
|
+
["git", "-C", checkout, "diff", "--name-only", f"{base}...HEAD"],
|
|
116
|
+
capture_output=True, text=True, timeout=30, check=False,
|
|
117
|
+
)
|
|
118
|
+
files = [ln.strip() for ln in out.stdout.splitlines() if ln.strip()]
|
|
119
|
+
return files[:limit]
|
|
120
|
+
except (OSError, subprocess.SubprocessError):
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _pr_scope_text(files: list[str]) -> str:
|
|
125
|
+
"""Brief addendum that focuses a PR scan on the changed files + their blast radius."""
|
|
126
|
+
listing = "\n".join(f"- {f}" for f in files)
|
|
127
|
+
return (
|
|
128
|
+
"\n\n## PR scope (focus here)\n"
|
|
129
|
+
"This is a fast pull-request gate. Concentrate on the code CHANGED in this PR and "
|
|
130
|
+
"the code that directly calls into or is reached from it (its blast radius). Do NOT "
|
|
131
|
+
"attempt a whole-repo audit. Changed files:\n" + listing
|
|
132
|
+
)
|
|
133
|
+
HEARTBEAT_S = 10.0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Emitter:
|
|
137
|
+
"""Serialize public events onto stdout with a single monotonic sequence.
|
|
138
|
+
|
|
139
|
+
Thread-safe: the main-thread lifecycle emits and the follower-thread activity
|
|
140
|
+
emits share one counter and one lock, so lines never interleave and sequence
|
|
141
|
+
numbers stay strictly increasing.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, run_id: str):
|
|
145
|
+
self.run_id = run_id
|
|
146
|
+
self.seq = 0
|
|
147
|
+
self.start_ts = time.time()
|
|
148
|
+
self._lock = threading.Lock()
|
|
149
|
+
|
|
150
|
+
def emit(self, type_: str, payload: dict, source: dict | None = None) -> None:
|
|
151
|
+
with self._lock:
|
|
152
|
+
self.seq += 1
|
|
153
|
+
event = {
|
|
154
|
+
"protocolVersion": "2",
|
|
155
|
+
"eventId": uuid.uuid4().hex,
|
|
156
|
+
"runId": self.run_id,
|
|
157
|
+
"sequence": self.seq,
|
|
158
|
+
"timestamp": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
159
|
+
"type": type_,
|
|
160
|
+
"source": source or {},
|
|
161
|
+
"payload": payload,
|
|
162
|
+
}
|
|
163
|
+
sys.stdout.write(json.dumps(event) + "\n")
|
|
164
|
+
sys.stdout.flush()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fingerprint(title: str) -> str:
|
|
168
|
+
# Stable across runs so the control plane dedupes a recurring finding per application.
|
|
169
|
+
return "bh-" + hashlib.sha256((title or "").strip().lower().encode("utf-8")).hexdigest()[:24]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_EVIDENCE_RE = re.compile(r"Evidence:\s*\[([0-9,\s]+)\]")
|
|
173
|
+
MAX_RECIPE_OPS = 24 # bound a reproduction recipe (findings cite a handful of calls)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def reproduction_ops(con, body: str) -> list[dict]:
|
|
177
|
+
"""Rebuild a finding's replayable recipe from the evidence it cited. A confirmed
|
|
178
|
+
finding embeds its supporting evidence ids in the body as 'Evidence: [id id ...]';
|
|
179
|
+
each id is an env_log row whose `request` is the exact {op, args} that was sent.
|
|
180
|
+
We fetch those requests in id order (= the order they were run) and keep op+args,
|
|
181
|
+
so a later retest can replay precisely the calls that proved the finding. Read-only
|
|
182
|
+
and best-effort: any parse/DB hiccup yields no recipe rather than raising."""
|
|
183
|
+
m = _EVIDENCE_RE.search(body or "")
|
|
184
|
+
if not m:
|
|
185
|
+
return []
|
|
186
|
+
ids = [int(tok) for tok in re.split(r"[,\s]+", m.group(1).strip()) if tok.isdigit()]
|
|
187
|
+
if not ids:
|
|
188
|
+
return []
|
|
189
|
+
ids = ids[:MAX_RECIPE_OPS]
|
|
190
|
+
placeholders = ",".join("?" * len(ids))
|
|
191
|
+
ops: list[dict] = []
|
|
192
|
+
try:
|
|
193
|
+
for (req,) in con.execute(
|
|
194
|
+
f"SELECT request FROM env_log WHERE id IN ({placeholders}) ORDER BY id", ids
|
|
195
|
+
).fetchall():
|
|
196
|
+
try:
|
|
197
|
+
r = json.loads(req) if req else {}
|
|
198
|
+
except (json.JSONDecodeError, TypeError):
|
|
199
|
+
continue
|
|
200
|
+
if isinstance(r, dict) and r.get("op"):
|
|
201
|
+
ops.append({"op": str(r.get("op")), "args": r.get("args") if isinstance(r.get("args"), dict) else {}})
|
|
202
|
+
except sqlite3.Error:
|
|
203
|
+
return []
|
|
204
|
+
return ops
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_CODE_RE = re.compile(r"\nCode:\s*(.+)")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _source_from_body(body: str) -> dict | None:
|
|
211
|
+
"""Extract a structured source location {file, line} from a finding's body.
|
|
212
|
+
|
|
213
|
+
A confirmed finding's body carries its code citations on a 'Code: path:line, ...'
|
|
214
|
+
line (team.go writes refs there). The control plane stores this as finding.source
|
|
215
|
+
and both the dashboard's 'Create patch' button and the remediate flow gate on
|
|
216
|
+
source.file -- without it the button is disabled and no patch can be generated.
|
|
217
|
+
We take the FIRST ref (unix paths have no ':', so the last ':<n>' is the line;
|
|
218
|
+
'path:12-20' ranges keep the start line). Best-effort: None when absent/unparseable."""
|
|
219
|
+
m = _CODE_RE.search(body or "")
|
|
220
|
+
if not m or not m.group(1).strip():
|
|
221
|
+
return None
|
|
222
|
+
first = re.split(r"[,\s]+", m.group(1).strip())[0]
|
|
223
|
+
mm = re.match(r"^(.*):(\d+)(?:-\d+)?$", first)
|
|
224
|
+
if mm:
|
|
225
|
+
return {"file": mm.group(1), "line": int(mm.group(2))}
|
|
226
|
+
return {"file": first} if first else None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
MAX_SNIPPET = 800 # bound every evidence/code snippet (sensitive data + report size)
|
|
230
|
+
_ALL_CODE_RE = re.compile(r"\nCode:\s*(.+)")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _clip(s: str, n: int = MAX_SNIPPET) -> str:
|
|
234
|
+
s = s or ""
|
|
235
|
+
return s if len(s) <= n else s[:n] + " …[truncated]"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _response_proof(resp_text: str) -> dict:
|
|
239
|
+
"""A bounded, structured view of what a cited env call actually returned — the proof.
|
|
240
|
+
Read verbatim from env_log.response; never synthesized."""
|
|
241
|
+
out: dict = {}
|
|
242
|
+
try:
|
|
243
|
+
r = json.loads(resp_text) if resp_text else {}
|
|
244
|
+
except (json.JSONDecodeError, TypeError):
|
|
245
|
+
return {"snippet": _clip(str(resp_text))}
|
|
246
|
+
if isinstance(r, dict):
|
|
247
|
+
for k in ("status", "ok", "authenticated", "host", "path"):
|
|
248
|
+
if k in r:
|
|
249
|
+
out[k] = r[k]
|
|
250
|
+
body = r.get("body", r.get("text", r))
|
|
251
|
+
out["snippet"] = _clip(body if isinstance(body, str) else json.dumps(body, ensure_ascii=False))
|
|
252
|
+
else:
|
|
253
|
+
out["snippet"] = _clip(json.dumps(r, ensure_ascii=False))
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def evidence_ops(con, body: str) -> list[dict]:
|
|
258
|
+
"""The cited env calls as {evidence_id, op, args, response} — request AND the real
|
|
259
|
+
response that proved the finding. Read verbatim from env_log; best-effort, no recipe
|
|
260
|
+
on any DB/parse hiccup rather than raising or guessing."""
|
|
261
|
+
m = _EVIDENCE_RE.search(body or "")
|
|
262
|
+
if not m:
|
|
263
|
+
return []
|
|
264
|
+
ids = [int(t) for t in re.split(r"[,\s]+", m.group(1).strip()) if t.isdigit()][:MAX_RECIPE_OPS]
|
|
265
|
+
if not ids:
|
|
266
|
+
return []
|
|
267
|
+
ph = ",".join("?" * len(ids))
|
|
268
|
+
ops: list[dict] = []
|
|
269
|
+
try:
|
|
270
|
+
for eid, req, resp in con.execute(
|
|
271
|
+
f"SELECT id, request, response FROM env_log WHERE id IN ({ph}) ORDER BY id", ids
|
|
272
|
+
).fetchall():
|
|
273
|
+
try:
|
|
274
|
+
r = json.loads(req) if req else {}
|
|
275
|
+
except (json.JSONDecodeError, TypeError):
|
|
276
|
+
continue
|
|
277
|
+
if isinstance(r, dict) and r.get("op"):
|
|
278
|
+
ops.append({
|
|
279
|
+
"evidence_id": eid,
|
|
280
|
+
"op": str(r.get("op")),
|
|
281
|
+
"args": r.get("args") if isinstance(r.get("args"), dict) else {},
|
|
282
|
+
"response": _response_proof(resp),
|
|
283
|
+
})
|
|
284
|
+
except sqlite3.Error:
|
|
285
|
+
return []
|
|
286
|
+
return ops
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _read_code_snippet(checkout: str | None, file: str | None, line: int | None, ctx: int = 3) -> str:
|
|
290
|
+
"""The actual source lines around a cited path:line, read from the checkout the harness
|
|
291
|
+
audited. Returns '' (never a guess) if the file/line is missing or escapes the checkout."""
|
|
292
|
+
if not checkout or not file:
|
|
293
|
+
return ""
|
|
294
|
+
try:
|
|
295
|
+
base = Path(checkout).resolve()
|
|
296
|
+
target = (base / file).resolve()
|
|
297
|
+
if base not in target.parents and target != base:
|
|
298
|
+
return "" # path-traversal guard: only inside the checkout
|
|
299
|
+
if not target.is_file():
|
|
300
|
+
return ""
|
|
301
|
+
text = target.read_text(errors="replace").splitlines()
|
|
302
|
+
if line and line > 0:
|
|
303
|
+
lo, hi = max(0, line - 1 - ctx), min(len(text), line + ctx)
|
|
304
|
+
return _clip("\n".join(f"{lo + 1 + i}: {text[lo + i]}" for i in range(hi - lo)))
|
|
305
|
+
return _clip("\n".join(text[: ctx * 2]))
|
|
306
|
+
except OSError:
|
|
307
|
+
return ""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def code_refs(body: str, checkout: str | None) -> tuple[list[dict], list[str]]:
|
|
311
|
+
"""All cited code locations ('Code: a:12, b:30-40') as {file, line, snippet} plus their
|
|
312
|
+
'file:line' strings. Snippets are read from the checkout; absent when unavailable."""
|
|
313
|
+
m = _ALL_CODE_RE.search(body or "")
|
|
314
|
+
if not m or not m.group(1).strip():
|
|
315
|
+
return [], []
|
|
316
|
+
refs: list[dict] = []
|
|
317
|
+
strs: list[str] = []
|
|
318
|
+
for tok in re.split(r"[,\s]+", m.group(1).strip())[:MAX_RECIPE_OPS]:
|
|
319
|
+
mm = re.match(r"^(.*?):(\d+)(?:-\d+)?$", tok)
|
|
320
|
+
if mm:
|
|
321
|
+
ref = {"file": mm.group(1), "line": int(mm.group(2))}
|
|
322
|
+
elif tok:
|
|
323
|
+
ref = {"file": tok}
|
|
324
|
+
else:
|
|
325
|
+
continue
|
|
326
|
+
strs.append(tok)
|
|
327
|
+
snip = _read_code_snippet(checkout, ref.get("file"), ref.get("line"))
|
|
328
|
+
if snip:
|
|
329
|
+
ref["snippet"] = snip
|
|
330
|
+
refs.append(ref)
|
|
331
|
+
return refs, strs
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def parse_meta(raw: str | None) -> dict:
|
|
335
|
+
"""A finding node's `meta` is a JSON object of the model-authored assessment
|
|
336
|
+
(severity, category, cwe, impact, remediation) that team.go writes for a
|
|
337
|
+
confirmed finding. Best-effort: a bad/absent value yields {}."""
|
|
338
|
+
if not raw:
|
|
339
|
+
return {}
|
|
340
|
+
try:
|
|
341
|
+
m = json.loads(raw)
|
|
342
|
+
return m if isinstance(m, dict) else {}
|
|
343
|
+
except (json.JSONDecodeError, TypeError):
|
|
344
|
+
return {}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# HTTP-family ops that name a request (path/method/host, like the `http` op's args).
|
|
348
|
+
_REQUEST_OPS = ("http", "login", "render", "oob")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _request_url(origin: str, host: str | None, path: str) -> str:
|
|
352
|
+
"""Absolute URL for a cited request. A recon-pivoted request carries its own `host`
|
|
353
|
+
(a subdomain), which takes precedence over the primary origin's host."""
|
|
354
|
+
if host:
|
|
355
|
+
scheme = (urlparse(origin).scheme or "https") if origin else "https"
|
|
356
|
+
sep = "" if path.startswith("/") else "/"
|
|
357
|
+
return f"{scheme}://{host}{sep}{path}"
|
|
358
|
+
if origin and path.startswith("/"):
|
|
359
|
+
return origin.rstrip("/") + path
|
|
360
|
+
return path
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def endpoint_and_steps(ops: list[dict] | None, origin: str) -> tuple[dict | None, list[str]]:
|
|
364
|
+
"""Derive the affected endpoint and human reproduction steps from the cited env ops.
|
|
365
|
+
Deterministic and evidence-derived (not guessed): the endpoint is the last concrete
|
|
366
|
+
request that names a path -- the call that demonstrates the issue. Honours a
|
|
367
|
+
recon-pivoted `host`, treats `login` as POST, and expands a `diff` op's sub-requests."""
|
|
368
|
+
steps: list[str] = []
|
|
369
|
+
endpoint: dict | None = None
|
|
370
|
+
|
|
371
|
+
def _request(name: str, args: dict) -> None:
|
|
372
|
+
nonlocal endpoint
|
|
373
|
+
path = args.get("path") if isinstance(args.get("path"), str) else None
|
|
374
|
+
host = args.get("host") if isinstance(args.get("host"), str) else None
|
|
375
|
+
who = args.get("identity") or args.get("as")
|
|
376
|
+
if path:
|
|
377
|
+
method = str(args.get("method") or ("POST" if name == "login" else "GET")).upper()
|
|
378
|
+
label = (host + path) if host else path
|
|
379
|
+
step = f"{method} {label}" + (f" (as {who})" if who else "")
|
|
380
|
+
steps.append(step)
|
|
381
|
+
endpoint = {"method": method, "path": path, "url": _request_url(origin, host, path)}
|
|
382
|
+
elif name == "login" and who:
|
|
383
|
+
steps.append(f"Sign in as {who}")
|
|
384
|
+
else:
|
|
385
|
+
subject = args.get("subject") or who or ""
|
|
386
|
+
steps.append(f"{name} {subject}".strip())
|
|
387
|
+
|
|
388
|
+
for op in ops or []:
|
|
389
|
+
name = str(op.get("op") or "")
|
|
390
|
+
if not name:
|
|
391
|
+
continue
|
|
392
|
+
args = op.get("args") if isinstance(op.get("args"), dict) else {}
|
|
393
|
+
if name == "diff" and isinstance(args.get("requests"), list):
|
|
394
|
+
for sub in args["requests"]: # a differential oracle runs 2+ http-shaped requests
|
|
395
|
+
if isinstance(sub, dict):
|
|
396
|
+
_request("http", sub)
|
|
397
|
+
elif name in _REQUEST_OPS:
|
|
398
|
+
_request(name, args)
|
|
399
|
+
else:
|
|
400
|
+
subject = args.get("subject") or args.get("identity") or args.get("as") or ""
|
|
401
|
+
steps.append(f"{name} {subject}".strip())
|
|
402
|
+
return endpoint, steps
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def finding_payload(title: str, body: str, ops: list[dict] | None = None,
|
|
406
|
+
fingerprint_override: str | None = None, verification: str | None = None,
|
|
407
|
+
meta: dict | None = None, origin: str = "",
|
|
408
|
+
con=None, checkout: str | None = None) -> dict:
|
|
409
|
+
title = (title or "Untitled finding").strip()
|
|
410
|
+
body = (body or "").strip()
|
|
411
|
+
meta = meta or {}
|
|
412
|
+
# With a DB connection, build the concrete, verbatim proof the finding cited: each env
|
|
413
|
+
# call's request AND response, and every code reference (with a snippet from the audited
|
|
414
|
+
# checkout). All read from the run's own records -- nothing is generated. Without a
|
|
415
|
+
# connection (unit tests / callers that pass ops directly), fall back to the given ops.
|
|
416
|
+
code_ref_list: list[dict] = []
|
|
417
|
+
ref_strings: list[str] = []
|
|
418
|
+
if con is not None:
|
|
419
|
+
ops = evidence_ops(con, body)
|
|
420
|
+
code_ref_list, ref_strings = code_refs(body, checkout)
|
|
421
|
+
payload = {
|
|
422
|
+
# On a retest we re-emit under the ORIGINAL finding's fingerprint so the control
|
|
423
|
+
# plane reconciles identity (fingerprint = sha256(title), but the retest title may
|
|
424
|
+
# paraphrase, so the adapter passes the matched original's fingerprint explicitly).
|
|
425
|
+
"fingerprint": fingerprint_override or fingerprint(title),
|
|
426
|
+
"title": title[:300],
|
|
427
|
+
"verification": verification or "unverified", # 'reproduced' when re-confirmed on a retest
|
|
428
|
+
"confidence": "high", # emitted only for harness-confirmed findings
|
|
429
|
+
"description": body[:4000],
|
|
430
|
+
"evidence": body[:2000],
|
|
431
|
+
}
|
|
432
|
+
# The assessment the model authored for this finding (team.go gates a confirmed
|
|
433
|
+
# finding on it). Each field is passed through only when present -- never defaulted
|
|
434
|
+
# to a guessed severity/category, so an unrated finding stays honestly unrated.
|
|
435
|
+
for key in ("severity", "category", "cwe", "impact", "remediation"):
|
|
436
|
+
val = meta.get(key)
|
|
437
|
+
if isinstance(val, str) and val.strip():
|
|
438
|
+
payload[key] = val.strip()
|
|
439
|
+
endpoint, steps = endpoint_and_steps(ops, origin)
|
|
440
|
+
if endpoint:
|
|
441
|
+
payload["endpoint"] = endpoint
|
|
442
|
+
if steps:
|
|
443
|
+
payload["steps"] = steps[:MAX_RECIPE_OPS]
|
|
444
|
+
if ops or code_ref_list:
|
|
445
|
+
# The replayable recipe + proof: the cited env ops (request + the real response
|
|
446
|
+
# each returned) and the cited code locations with snippets. A retest replays the
|
|
447
|
+
# ops; the report/chat/MCP surface the responses + code as concrete evidence.
|
|
448
|
+
repro: dict = {}
|
|
449
|
+
if ops:
|
|
450
|
+
repro["ops"] = ops
|
|
451
|
+
if code_ref_list:
|
|
452
|
+
repro["codeRefs"] = code_ref_list
|
|
453
|
+
payload["reproduction"] = repro
|
|
454
|
+
if ref_strings:
|
|
455
|
+
payload["evidenceRefs"] = ref_strings
|
|
456
|
+
src = _source_from_body(body)
|
|
457
|
+
if src and src.get("file"):
|
|
458
|
+
# Structured source location (file[:line]) parsed from the finding's code
|
|
459
|
+
# citations, so the dashboard can offer 'Create patch' and the remediate flow
|
|
460
|
+
# knows which file to read. Absent when the finding cited no path:line.
|
|
461
|
+
payload["source"] = src
|
|
462
|
+
return payload
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def sign_in_test_accounts(spec: dict, em: "Emitter") -> dict:
|
|
466
|
+
"""Sign every username-and-password test account in, and report each outcome as an
|
|
467
|
+
identity.authenticated event (the dashboard shows whether the account works). Returns
|
|
468
|
+
{identity id: {cookie, headers} | {error}} for the agents' environment processes
|
|
469
|
+
(prod_env.load_shared_sessions). Credentials never leave prod_env."""
|
|
470
|
+
import prod_env # sibling module; imported here so other run kinds don't load it
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
target = prod_env.Target(spec, prod_env.load_secrets())
|
|
474
|
+
except SystemExit: # no origin to sign in to
|
|
475
|
+
return {}
|
|
476
|
+
shared: dict[str, dict] = {}
|
|
477
|
+
for iid, ident in target.identities.items():
|
|
478
|
+
if not prod_env.is_password_account(ident):
|
|
479
|
+
continue
|
|
480
|
+
result = prod_env.sign_in(target, iid)
|
|
481
|
+
ok = bool(result.get("authenticated"))
|
|
482
|
+
em.emit("identity.authenticated", {
|
|
483
|
+
"identityId": iid,
|
|
484
|
+
"ok": ok,
|
|
485
|
+
"status": result.get("status"),
|
|
486
|
+
"detail": str(result.get("note") or result.get("error") or "")[:500],
|
|
487
|
+
})
|
|
488
|
+
shared[iid] = ({"cookie": target.sessions.get(iid, ""), "headers": target.session_headers.get(iid, {})}
|
|
489
|
+
if ok else {"error": str(result.get("error") or "the sign-in failed")})
|
|
490
|
+
return shared
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def run_prescan(spec_path: str, session: Path, tier: str) -> dict | None:
|
|
494
|
+
"""Deterministic posture pre-pass over the target's public surface (security
|
|
495
|
+
headers, cookie flags, TLS, exposed paths, version->CVE) via profile.py. The
|
|
496
|
+
output profile.json is consumed by prod_env.py to STEER the agents toward the
|
|
497
|
+
signals it found (BLACKGLASS_PROFILE), and returned here to carry the coverage +
|
|
498
|
+
posture observations into the report. Best-effort: any failure or timeout disables
|
|
499
|
+
it without blocking the scan, and it never emits findings itself (signals, not
|
|
500
|
+
verdicts -- the agents confirm and the model assesses anything that is a real
|
|
501
|
+
vulnerability, so nothing is reported on a hardcoded heuristic)."""
|
|
502
|
+
out = session / "profile.json"
|
|
503
|
+
try:
|
|
504
|
+
subprocess.run(
|
|
505
|
+
[sys.executable, PROFILE_PY, "--spec", spec_path, "--out", str(out), "--tier", tier],
|
|
506
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120, check=False,
|
|
507
|
+
)
|
|
508
|
+
except (subprocess.SubprocessError, OSError):
|
|
509
|
+
return None
|
|
510
|
+
if not out.exists():
|
|
511
|
+
return None
|
|
512
|
+
try:
|
|
513
|
+
data = json.loads(out.read_text())
|
|
514
|
+
return data if isinstance(data, dict) else None
|
|
515
|
+
except (json.JSONDecodeError, OSError):
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def write_report(session: Path, run_id: str, spec: dict, origin: str, scan_name: str,
|
|
520
|
+
report_model: str, recs: list, prescan: dict | None, started_ts: float) -> None:
|
|
521
|
+
"""Assemble the customer report and write it to the session dir as report.md, which
|
|
522
|
+
the control plane serves at GET /api/runs/:id/report. Deterministic sections plus an
|
|
523
|
+
isolated LLM-authored executive summary (best-effort). Never raises: a report problem
|
|
524
|
+
must not fail the scan."""
|
|
525
|
+
try:
|
|
526
|
+
import report as _report
|
|
527
|
+
try:
|
|
528
|
+
import remediate as _rem # reused: isolated OpenRouter chat + key loader
|
|
529
|
+
except Exception: # noqa: BLE001
|
|
530
|
+
_rem = None
|
|
531
|
+
views = [payload for _fid, _title, payload in recs]
|
|
532
|
+
idents = [str(i.get("id") or i.get("kind")) for i in (spec.get("identities") or [])
|
|
533
|
+
if isinstance(i, dict) and (i.get("id") or i.get("kind"))]
|
|
534
|
+
run_meta = {
|
|
535
|
+
"target": origin or (spec.get("target") or {}).get("name") or "the target application",
|
|
536
|
+
"scanProfile": scan_name,
|
|
537
|
+
"runId": run_id,
|
|
538
|
+
"hosts": (spec.get("scope") or {}).get("allowedHosts") or [],
|
|
539
|
+
"actionTier": (spec.get("policy") or {}).get("actionTier") or "passive (read-only)",
|
|
540
|
+
"identities": idents,
|
|
541
|
+
"startedAt": time.strftime("%Y-%m-%d", time.gmtime(started_ts)),
|
|
542
|
+
"durationMin": max(0.0, (time.time() - started_ts) / 60.0),
|
|
543
|
+
}
|
|
544
|
+
key = os.environ.get("OPENROUTER_API_KEY") or ""
|
|
545
|
+
if not key and _rem is not None and os.path.exists(KEY_FILE):
|
|
546
|
+
try:
|
|
547
|
+
key = _rem.load_key(KEY_FILE)
|
|
548
|
+
except Exception: # noqa: BLE001
|
|
549
|
+
key = ""
|
|
550
|
+
call = _rem.call_model if _rem is not None else None
|
|
551
|
+
# LLM-authored executive summary: try the configured report model, then fall back
|
|
552
|
+
# to the base model (proven via the gateway) before the deterministic summary.
|
|
553
|
+
# Failures are logged to stderr (harness.log) so a silent fallback is diagnosable.
|
|
554
|
+
log = lambda msg: sys.stderr.write(f"[report] {msg}\n") # noqa: E731
|
|
555
|
+
narrative = None
|
|
556
|
+
for m in dict.fromkeys([report_model, DEFAULT_MODEL]): # ordered, de-duplicated
|
|
557
|
+
if not m:
|
|
558
|
+
continue
|
|
559
|
+
narrative = _report.llm_summary(views, prescan, run_meta, key or None, m, call=call, log=log)
|
|
560
|
+
if narrative:
|
|
561
|
+
break
|
|
562
|
+
if not narrative:
|
|
563
|
+
narrative = _report._fallback_summary(views, prescan)
|
|
564
|
+
(session / "report.md").write_text(
|
|
565
|
+
_report.build_report(run_meta=run_meta, findings=views, prescan=prescan, narrative=narrative))
|
|
566
|
+
except Exception as exc: # noqa: BLE001 — report generation is best-effort
|
|
567
|
+
sys.stderr.write(f"report generation skipped: {exc}\n")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def map_event(kind: str, agent: str, data: dict):
|
|
571
|
+
"""Map one harness `events` row to a Blackglass v2 public-event (type, payload, source).
|
|
572
|
+
|
|
573
|
+
Emits only flat, allowlisted keys (sanitizePublicEvent projects to the per-type
|
|
574
|
+
allowlist). Noisy/internal kinds (agent.msg, llm.*, node.add, board.*, tool.output)
|
|
575
|
+
are dropped to keep the timeline legible.
|
|
576
|
+
"""
|
|
577
|
+
src = {"agentId": agent} if agent else {}
|
|
578
|
+
if kind == "agent.start":
|
|
579
|
+
return "agent.started", {
|
|
580
|
+
"id": agent, "agentType": data.get("role"), "status": "running",
|
|
581
|
+
"model": data.get("model"), "requestedModel": data.get("model"),
|
|
582
|
+
}, src
|
|
583
|
+
if kind == "agent.end":
|
|
584
|
+
return "agent.completed", {
|
|
585
|
+
"id": agent, "agentType": data.get("role"), "status": "completed",
|
|
586
|
+
"summary": str(data.get("reason") or data.get("finish") or ""),
|
|
587
|
+
}, src
|
|
588
|
+
if kind == "tool.start":
|
|
589
|
+
return "activity.recorded", {"tool": data.get("name"), "outcome": "started", "callId": data.get("call")}, src
|
|
590
|
+
if kind == "tool.end":
|
|
591
|
+
return "activity.recorded", {
|
|
592
|
+
"tool": data.get("name"), "outcome": "ok",
|
|
593
|
+
"callId": data.get("call"), "durationMs": data.get("ms"),
|
|
594
|
+
}, src
|
|
595
|
+
if kind == "env.call":
|
|
596
|
+
# data = {id, request:{op,args}, response:{...}}. A `login` against a NAMED test
|
|
597
|
+
# identity becomes a first-class sign-in-outcome event so the control plane can
|
|
598
|
+
# record whether that credential actually worked (the only auth that matters is
|
|
599
|
+
# the one exercised in the real scan context).
|
|
600
|
+
req = data.get("request") if isinstance(data.get("request"), dict) else {}
|
|
601
|
+
resp = data.get("response") if isinstance(data.get("response"), dict) else {}
|
|
602
|
+
op = str((req.get("op") if isinstance(req, dict) else None) or "request")
|
|
603
|
+
if op == "login":
|
|
604
|
+
iargs = req.get("args") if isinstance(req.get("args"), dict) else {}
|
|
605
|
+
identity = iargs.get("identity") or iargs.get("as")
|
|
606
|
+
if identity:
|
|
607
|
+
return "identity.authenticated", {
|
|
608
|
+
"identityId": str(identity),
|
|
609
|
+
"ok": bool(resp.get("authenticated")),
|
|
610
|
+
"status": resp.get("status"),
|
|
611
|
+
"detail": str(resp.get("note") or resp.get("error") or "")[:500],
|
|
612
|
+
}, src
|
|
613
|
+
return "activity.recorded", {"tool": "http", "outcome": "ok", "summary": op}, src
|
|
614
|
+
if kind in ("round.phase", "round.start", "round.gather", "round.digest", "cell.start"):
|
|
615
|
+
return "phase.changed", {"phase": str(data.get("phase") or kind.split(".")[-1])}, None
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class Follower(threading.Thread):
|
|
620
|
+
"""Tail the harness state DB live: map `events` rows to activity events and stream
|
|
621
|
+
newly-confirmed findings, emitting heartbeats so the dashboard timer advances."""
|
|
622
|
+
|
|
623
|
+
def __init__(self, db: str, em: Emitter, emitted_findings: set[int], emit_findings: bool = True,
|
|
624
|
+
origin: str = "", checkout: str | None = None):
|
|
625
|
+
super().__init__(daemon=True)
|
|
626
|
+
self.db = db
|
|
627
|
+
self.em = em
|
|
628
|
+
self.emitted = emitted_findings
|
|
629
|
+
self.origin = origin # for deriving each finding's endpoint URL from its cited ops
|
|
630
|
+
self.checkout = checkout # source tree, for reading cited code snippets into findings
|
|
631
|
+
# A retest run suppresses live finding emission: findings are matched back to the
|
|
632
|
+
# originals and re-emitted (with the original fingerprint) in the final reconcile.
|
|
633
|
+
self.emit_findings = emit_findings
|
|
634
|
+
self._stopped = threading.Event() # NB: not `_stop` -- that shadows Thread._stop
|
|
635
|
+
|
|
636
|
+
def stop(self) -> None:
|
|
637
|
+
self._stopped.set()
|
|
638
|
+
|
|
639
|
+
def _connect(self):
|
|
640
|
+
# Open with mode=rw (uri), NOT a plain connect: a plain connect would CREATE the
|
|
641
|
+
# db file, and the harness refuses to start if its -db path already exists -- so
|
|
642
|
+
# the follower must never create it. mode=rw also reads WAL correctly (mode=ro
|
|
643
|
+
# cannot, as it can't create the -wal/-shm sidecars). query_only guards writes.
|
|
644
|
+
uri = f"file:{self.db}?mode=rw"
|
|
645
|
+
for _ in range(120): # wait for the harness to create the DB after spawn
|
|
646
|
+
try:
|
|
647
|
+
con = sqlite3.connect(uri, uri=True, timeout=2)
|
|
648
|
+
con.execute("PRAGMA query_only=1")
|
|
649
|
+
if con.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='events'").fetchone():
|
|
650
|
+
return con
|
|
651
|
+
con.close()
|
|
652
|
+
except sqlite3.Error:
|
|
653
|
+
pass
|
|
654
|
+
if self._stopped.is_set():
|
|
655
|
+
break
|
|
656
|
+
time.sleep(0.3)
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
def run(self) -> None:
|
|
660
|
+
con = self._connect()
|
|
661
|
+
if con is None:
|
|
662
|
+
return
|
|
663
|
+
last_seq = 0
|
|
664
|
+
last_hb = time.time()
|
|
665
|
+
draining = False
|
|
666
|
+
try:
|
|
667
|
+
while True:
|
|
668
|
+
try:
|
|
669
|
+
for seq, kind, agent, data in con.execute(
|
|
670
|
+
"SELECT seq, kind, agent, CAST(data AS TEXT) FROM events WHERE seq > ? ORDER BY seq",
|
|
671
|
+
(last_seq,),
|
|
672
|
+
).fetchall():
|
|
673
|
+
last_seq = seq
|
|
674
|
+
try:
|
|
675
|
+
d = json.loads(data) if data else {}
|
|
676
|
+
except json.JSONDecodeError:
|
|
677
|
+
d = {}
|
|
678
|
+
mapped = map_event(kind, agent or "", d if isinstance(d, dict) else {})
|
|
679
|
+
if mapped:
|
|
680
|
+
self.em.emit(*mapped)
|
|
681
|
+
if self.emit_findings:
|
|
682
|
+
for fid, title, body, meta in con.execute(
|
|
683
|
+
"SELECT id, title, body, coalesce(meta,'') FROM nodes WHERE kind='finding' AND status='confirmed' ORDER BY id"
|
|
684
|
+
).fetchall():
|
|
685
|
+
if fid not in self.emitted:
|
|
686
|
+
self.emitted.add(fid)
|
|
687
|
+
self.em.emit("finding.created", finding_payload(
|
|
688
|
+
title, body, meta=parse_meta(meta), origin=self.origin,
|
|
689
|
+
con=con, checkout=self.checkout))
|
|
690
|
+
except sqlite3.Error:
|
|
691
|
+
pass
|
|
692
|
+
now = time.time()
|
|
693
|
+
if now - last_hb >= HEARTBEAT_S:
|
|
694
|
+
self.em.emit("heartbeat", {"uptimeMs": int((now - self.em.start_ts) * 1000)})
|
|
695
|
+
last_hb = now
|
|
696
|
+
if draining:
|
|
697
|
+
break
|
|
698
|
+
if self._stopped.is_set():
|
|
699
|
+
draining = True # one more pass drains the tail before exiting
|
|
700
|
+
time.sleep(0.6)
|
|
701
|
+
finally:
|
|
702
|
+
con.close()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
CHECKLISTS = Path(__file__).resolve().parent / "checklists"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
RECON_ENABLED = os.environ.get("BASIC_HARNESS_RECON", "1") != "0"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _coverage_floor() -> str:
|
|
712
|
+
"""Render the checklist as a COVERAGE FLOOR appended to the brief.
|
|
713
|
+
|
|
714
|
+
This is deliberately framed as a floor, not a cage: it guarantees the standard
|
|
715
|
+
external attack surfaces are not skipped, while leaving the orchestrator free to
|
|
716
|
+
open novel directions beyond the list. The `web` surface is reachable through the
|
|
717
|
+
http op; the `recon` surface is included only when recon ops are enabled (Phase 2),
|
|
718
|
+
so the agent is never pointed at a capability it cannot call. Opt-in items (e.g.
|
|
719
|
+
token-gated code search) are omitted from the floor.
|
|
720
|
+
"""
|
|
721
|
+
try:
|
|
722
|
+
data = json.loads((CHECKLISTS / "anonymous.json").read_text())
|
|
723
|
+
except (OSError, json.JSONDecodeError):
|
|
724
|
+
return ""
|
|
725
|
+
surfaces = data.get("surfaces") or {}
|
|
726
|
+
items = [i for i in surfaces.get("web", []) if not i.get("opt_in")]
|
|
727
|
+
if RECON_ENABLED:
|
|
728
|
+
items += [i for i in surfaces.get("recon", []) if not i.get("opt_in")]
|
|
729
|
+
floor = ""
|
|
730
|
+
if items:
|
|
731
|
+
lines = [f"- {i['title']}: {i['hint']}" for i in items]
|
|
732
|
+
floor = (
|
|
733
|
+
"\n\nCOVERAGE FLOOR (consider ALL of these surfaces; this is a floor, not a limit -- "
|
|
734
|
+
"pursue any other promising direction you find, and do NOT let the run orbit only this list):\n"
|
|
735
|
+
+ "\n".join(lines)
|
|
736
|
+
)
|
|
737
|
+
return floor + _skill_index()
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _skill_index() -> str:
|
|
741
|
+
"""Advertise the on-demand deep playbooks (skill library). The index (name +
|
|
742
|
+
when-to-use) is cheap always-on context; the prober loads a full playbook with
|
|
743
|
+
the get_skill op only when a direction matches. Breadth stays with the coverage
|
|
744
|
+
floor above; skills add depth on demand (and the two will converge as more
|
|
745
|
+
surfaces become skills)."""
|
|
746
|
+
try:
|
|
747
|
+
import skills # sibling module
|
|
748
|
+
idx = skills.render_index()
|
|
749
|
+
except Exception: # noqa: BLE001 - never let the skill index break the brief
|
|
750
|
+
return ""
|
|
751
|
+
if not idx:
|
|
752
|
+
return ""
|
|
753
|
+
return ("\n\nDEEP PLAYBOOKS (load with the get_skill op): when a direction matches a playbook's "
|
|
754
|
+
"trigger below (e.g. GraphQL, IDOR, subdomain/takeover), you MUST FIRST call "
|
|
755
|
+
"get_skill with that playbook's name and follow its methodology before probing that "
|
|
756
|
+
"surface. These are detailed procedures, not a checklist:\n" + idx)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def build_source_audit_brief(has_identities: bool) -> str:
|
|
760
|
+
"""Mission for a SOURCE-ONLY (grey/white-box) target: a repository is connected but
|
|
761
|
+
there is NO reachable live host. The evidence is the code itself, so the audit is
|
|
762
|
+
driven through the reader (ask_reader -> list_files/grep/read_file), NOT live recon
|
|
763
|
+
against a placeholder host. Findings cite path:line, which the harness checks and the
|
|
764
|
+
patch flow consumes. Without this branch the brief points the team at a nonexistent
|
|
765
|
+
origin and the whole run finds nothing (see the 2026-10-30 zero-finding incident)."""
|
|
766
|
+
roles = ("You also have named application identities/roles (see list_identities): reason about "
|
|
767
|
+
"what each role can reach in the code versus what it should, and flag missing or broken "
|
|
768
|
+
"authorization checks.\n\n") if has_identities else ""
|
|
769
|
+
return (
|
|
770
|
+
"Perform a thorough SECURITY CODE AUDIT of the connected source repository. There is NO "
|
|
771
|
+
"running instance and NO reachable host -- do NOT attempt HTTP requests or external "
|
|
772
|
+
"reconnaissance; there is nothing to connect to and a probe only wastes the budget. Your "
|
|
773
|
+
"evidence is the SOURCE CODE itself.\n\n"
|
|
774
|
+
"Work through the reader (ask_reader -> list_files, grep, read_file). First MAP the app: its "
|
|
775
|
+
"entry points (routes/handlers/controllers/CLI), how untrusted input enters, how it flows to "
|
|
776
|
+
"dangerous sinks (SQL, shell/command, filesystem paths, deserialization, templates, HTTP "
|
|
777
|
+
"responses, redirects), and where authentication, authorization, and secret handling live. "
|
|
778
|
+
"Then HUNT for concrete vulnerabilities and trace each one from the untrusted source to the "
|
|
779
|
+
"sink.\n\n" + roles +
|
|
780
|
+
"Cover at least these classes: injection (SQLi, OS command, SSTI, path traversal, unsafe "
|
|
781
|
+
"deserialization), broken authentication / session handling, broken access control / IDOR / "
|
|
782
|
+
"missing authorization, SSRF, XSS, insecure or missing crypto, hardcoded secrets or "
|
|
783
|
+
"credentials, sensitive-data exposure, unsafe file upload/handling, open redirect, and "
|
|
784
|
+
"insecure configuration.\n\n"
|
|
785
|
+
"Report EACH distinct vulnerability you can substantiate with record_finding: name the "
|
|
786
|
+
"vulnerability class, cite the exact code location as path:line for every claim (the lines you "
|
|
787
|
+
"actually read), describe the source->sink data flow that makes it exploitable, and give the "
|
|
788
|
+
"concrete impact. Before confirming, follow the real code path and check for guards or "
|
|
789
|
+
"validation that would make it a false positive. Be exhaustive -- enumerate ALL the distinct "
|
|
790
|
+
"issues you can prove, not just the first few."
|
|
791
|
+
) + _skill_index()
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def build_brief(spec: dict) -> str:
|
|
795
|
+
target_obj = spec.get("target") or {}
|
|
796
|
+
live_origins = target_obj.get("origins") or []
|
|
797
|
+
has_source = bool((target_obj.get("repository") or {}).get("checkoutPath"))
|
|
798
|
+
has_identities = bool(spec.get("identities"))
|
|
799
|
+
|
|
800
|
+
if spec.get("prompt"):
|
|
801
|
+
# Honor an explicit operator brief as the primary mission; still append the
|
|
802
|
+
# coverage floor so the wide net applies even to a custom-scoped run.
|
|
803
|
+
return str(spec["prompt"]) + _coverage_floor()
|
|
804
|
+
|
|
805
|
+
# Source-only (grey/white-box): a repo is connected but there is no reachable live
|
|
806
|
+
# origin (scope.allowedHosts carries only a sentinel like 'source-only.invalid').
|
|
807
|
+
# Audit the CODE through the reader rather than probing a host that does not exist.
|
|
808
|
+
if has_source and not live_origins:
|
|
809
|
+
return build_source_audit_brief(has_identities)
|
|
810
|
+
|
|
811
|
+
origins = live_origins or (spec.get("scope") or {}).get("allowedHosts") or []
|
|
812
|
+
target = origins[0] if origins else "the target application"
|
|
813
|
+
|
|
814
|
+
if has_identities:
|
|
815
|
+
mission = (
|
|
816
|
+
f"Assess {target} for security vulnerabilities and data exposure. You have named "
|
|
817
|
+
"identities (see list_identities): compare what each role CAN reach against what it "
|
|
818
|
+
"SHOULD, and report each demonstrated access-control / over-exposure violation with the "
|
|
819
|
+
"acting identity, the exact request, and the proving response. Identities are one lever, "
|
|
820
|
+
"not the whole job -- also probe the anonymous (unauthenticated) surface aggressively."
|
|
821
|
+
)
|
|
822
|
+
else:
|
|
823
|
+
# No credentials provisioned: this is the common case (most targets are anonymous
|
|
824
|
+
# by default). Pivot hard to the external-attacker surface instead of stalling on
|
|
825
|
+
# the absence of identities.
|
|
826
|
+
mission = (
|
|
827
|
+
f"Assess {target} as an external, unauthenticated attacker with NO credentials. This is "
|
|
828
|
+
"the normal starting posture -- do NOT treat the absence of identities as a blocker or an "
|
|
829
|
+
"excuse to stop; most real exposure is reachable anonymously. Hunt relentlessly across the "
|
|
830
|
+
"reachable surface and report each demonstrated vulnerability or data exposure with the "
|
|
831
|
+
"exact request and the proving response. External RECONNAISSANCE is REQUIRED, not optional: "
|
|
832
|
+
"you MUST use the recon ops to enumerate the verified root domain -- crt and dns for "
|
|
833
|
+
"subdomains and dangling/takeover records, storage for public buckets, registries for "
|
|
834
|
+
"public artifacts -- and then pivot http to the hosts you discover. Do not stop at the "
|
|
835
|
+
"primary origin."
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
grey = ""
|
|
839
|
+
if has_source:
|
|
840
|
+
# Grey-box: a live host AND a source checkout. Point the team at the reader so it
|
|
841
|
+
# corroborates what it sees over HTTP against the actual code (and finds issues the
|
|
842
|
+
# black-box surface hides), citing path:line.
|
|
843
|
+
grey = (" A READ-ONLY SOURCE CHECKOUT of this application is also connected: use ask_reader "
|
|
844
|
+
"(the reader reads the code and cites path:line) both to find vulnerabilities in the "
|
|
845
|
+
"code and to confirm or refute what you observe over HTTP -- corroborate every finding "
|
|
846
|
+
"against the actual code path.")
|
|
847
|
+
return mission + " This is a READ-ONLY assessment (GET/HEAD only)." + grey + _coverage_floor()
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def build_retest_brief(findings: list[dict]) -> str:
|
|
851
|
+
"""A constrained brief for a RETEST run: re-verify each previously-confirmed finding
|
|
852
|
+
and judge only whether it still holds. No open-ended hunting -- the harness's validator
|
|
853
|
+
does the anti-false-positive work. Each finding is re-tested by the method it supports:
|
|
854
|
+
a finding with recorded env ops replays them; a CODE finding (a source location, no env
|
|
855
|
+
ops -- e.g. a source-only target, or a fix we just opened a PR for) is re-verified by
|
|
856
|
+
re-reading the cited code in the CURRENT checkout, which after a merge reflects the fix."""
|
|
857
|
+
lines = []
|
|
858
|
+
for i, f in enumerate(findings, 1):
|
|
859
|
+
title = (f.get("title") or "").strip()
|
|
860
|
+
desc = (f.get("description") or "").strip()
|
|
861
|
+
ops = (f.get("reproduction") or {}).get("ops") or []
|
|
862
|
+
src = f.get("source") or {}
|
|
863
|
+
if ops:
|
|
864
|
+
steps = "; ".join(
|
|
865
|
+
f"{o.get('op')} {json.dumps(o.get('args') or {}, separators=(',', ':'))}" for o in ops
|
|
866
|
+
)
|
|
867
|
+
how = f"RE-TEST (env): re-run these steps -- {steps}"
|
|
868
|
+
elif src.get("file"):
|
|
869
|
+
loc = str(src["file"]) + (f":{src['line']}" if src.get("line") else "")
|
|
870
|
+
how = (f"RE-TEST (code): this is a code finding at {loc}. Re-read that code in the CURRENT "
|
|
871
|
+
"checkout with the reader (ask_reader -> list_files/grep/read_file) and decide whether "
|
|
872
|
+
"the vulnerability is still present or has been fixed (input now validated/escaped, the "
|
|
873
|
+
"sink guarded, or the vulnerable path removed).")
|
|
874
|
+
else:
|
|
875
|
+
how = "RE-TEST: reconstruct the check from the description above."
|
|
876
|
+
lines.append(f"{i}. TITLE: {title}\n WHAT IT WAS: {desc[:500]}\n {how}")
|
|
877
|
+
listing = "\n".join(lines)
|
|
878
|
+
return (
|
|
879
|
+
"RE-TEST MODE. The findings below were CONFIRMED on a previous assessment of this exact "
|
|
880
|
+
"target. Your ONLY job is to re-verify each one as-is; do NOT hunt for new issues.\n\n"
|
|
881
|
+
"For EACH finding, re-test it by the method noted (re-run the env steps, or re-read the cited "
|
|
882
|
+
"source in the current checkout), then judge from what you observe:\n"
|
|
883
|
+
"- If it STILL holds (the insecure behavior still happens, or the vulnerable code is still "
|
|
884
|
+
"present), record a finding with verdict CONFIRMED, using the EXACT title shown.\n"
|
|
885
|
+
"- If it appears FIXED (the behavior no longer occurs -- blocked/403/404, the leaked data is "
|
|
886
|
+
"gone, the mutation refused -- or the code now validates/guards the input or the vulnerable path "
|
|
887
|
+
"is gone), record verdict REFUTED with that title.\n"
|
|
888
|
+
"Cite the exact request and response, or the path:line, that shows the current behavior.\n\n"
|
|
889
|
+
f"FINDINGS TO RE-TEST:\n{listing}"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _match_original(title: str, findings: list[dict]) -> dict | None:
|
|
894
|
+
"""Map a re-tested finding's (possibly paraphrased) title back to the original it
|
|
895
|
+
re-verifies: exact (case-insensitive) match first, then substring, then best token
|
|
896
|
+
overlap above a threshold. Returns the original finding dict or None."""
|
|
897
|
+
t = (title or "").strip().lower()
|
|
898
|
+
if not t:
|
|
899
|
+
return None
|
|
900
|
+
for f in findings:
|
|
901
|
+
if (f.get("title") or "").strip().lower() == t:
|
|
902
|
+
return f
|
|
903
|
+
tw = set(re.findall(r"[a-z0-9]+", t))
|
|
904
|
+
best, best_score = None, 0.0
|
|
905
|
+
for f in findings:
|
|
906
|
+
ft = (f.get("title") or "").strip().lower()
|
|
907
|
+
if not ft:
|
|
908
|
+
continue
|
|
909
|
+
if t in ft or ft in t:
|
|
910
|
+
return f
|
|
911
|
+
fw = set(re.findall(r"[a-z0-9]+", ft))
|
|
912
|
+
if tw and fw:
|
|
913
|
+
score = len(tw & fw) / len(tw | fw)
|
|
914
|
+
if score > best_score:
|
|
915
|
+
best, best_score = f, score
|
|
916
|
+
return best if best_score >= 0.6 else None
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _run_remediate(spec: dict, em: "Emitter") -> int:
|
|
920
|
+
"""Generate a code patch for ONE finding -- no harness run. Reads the finding's source
|
|
921
|
+
from the connected repo checkout, asks the model for a minimal unified diff, validates
|
|
922
|
+
it applies, and emits the patch on the finding; the control plane opens the PR."""
|
|
923
|
+
finding = (spec.get("remediate") or {}).get("finding") or {}
|
|
924
|
+
fp = finding.get("fingerprint")
|
|
925
|
+
checkout = ((spec.get("target") or {}).get("repository") or {}).get("checkoutPath")
|
|
926
|
+
if not fp or not (finding.get("source") or {}).get("file"):
|
|
927
|
+
em.emit("run.completed", {"stop": "halt", "findings": 0, "ok": True,
|
|
928
|
+
"message": "finding has no source location to patch"})
|
|
929
|
+
return 0
|
|
930
|
+
if not checkout:
|
|
931
|
+
em.emit("run.failed", {"category": "runtime",
|
|
932
|
+
"message": "no source repository is connected; connect a repo to generate patches",
|
|
933
|
+
"stop": "error"})
|
|
934
|
+
return 1
|
|
935
|
+
import remediate # stdlib-only isolated patch generator; imported only for this kind
|
|
936
|
+
key = remediate.load_key(KEY_FILE)
|
|
937
|
+
gen_model = spec.get("model") or DEFAULT_REMEDIATE_MODEL
|
|
938
|
+
em.emit("phase.changed", {"phase": "remediating"})
|
|
939
|
+
patch = remediate.generate_patch(
|
|
940
|
+
checkout, finding, key, gen_model,
|
|
941
|
+
reviewer_model=DEFAULT_REMEDIATE_REVIEWER,
|
|
942
|
+
candidates=DEFAULT_REMEDIATE_CANDIDATES,
|
|
943
|
+
)
|
|
944
|
+
em.emit("finding.updated", {"fingerprint": fp, "title": finding.get("title") or "Finding", "patch": patch})
|
|
945
|
+
ok = patch.get("status") != "failed"
|
|
946
|
+
verified = patch.get("verified")
|
|
947
|
+
notes = (patch.get("review") or {}).get("notes") or ""
|
|
948
|
+
if not ok:
|
|
949
|
+
message = f"patch generation failed: {patch.get('error', '')}"
|
|
950
|
+
elif not patch.get("applies"):
|
|
951
|
+
message = "patch generated but did not apply cleanly; review the diff"
|
|
952
|
+
elif verified is True:
|
|
953
|
+
message = f"patch generated, reviewed, and applies cleanly ({patch.get('strategy', 'diff')})"
|
|
954
|
+
elif verified is False:
|
|
955
|
+
message = f"patch applies but the reviewer flagged it — review before merging{(': ' + notes) if notes else ''}"
|
|
956
|
+
else:
|
|
957
|
+
message = "patch generated and applies cleanly"
|
|
958
|
+
em.emit("run.completed", {"stop": "halt", "findings": 1 if ok else 0, "ok": True, "message": message})
|
|
959
|
+
return 0
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def main() -> int:
|
|
963
|
+
ap = argparse.ArgumentParser()
|
|
964
|
+
ap.add_argument("--spec", required=True)
|
|
965
|
+
ap.add_argument("--session-dir", required=True)
|
|
966
|
+
ap.add_argument("--events", default="stdout")
|
|
967
|
+
a = ap.parse_args()
|
|
968
|
+
|
|
969
|
+
spec = json.loads(Path(a.spec).read_text())
|
|
970
|
+
run_id = spec.get("runId") or "unknown"
|
|
971
|
+
em = Emitter(run_id)
|
|
972
|
+
session = Path(a.session_dir)
|
|
973
|
+
session.mkdir(parents=True, exist_ok=True)
|
|
974
|
+
|
|
975
|
+
# Policy gate: this runner is non-destructive only.
|
|
976
|
+
if bool((spec.get("policy") or {}).get("destructiveActions", False)):
|
|
977
|
+
em.emit("run.failed", {"category": "policy",
|
|
978
|
+
"message": "basic-harness runner is read-only; destructiveActions must be false",
|
|
979
|
+
"stop": "policy"})
|
|
980
|
+
return 3
|
|
981
|
+
|
|
982
|
+
# Egress gate (fail-closed): a RunSpec may require full network confinement.
|
|
983
|
+
# This adapter cannot confine itself, so unless the host signals it provides the
|
|
984
|
+
# jail (BLACKGLASS_EGRESS_CONFINED=1, set by a provisioned confined runner), we
|
|
985
|
+
# refuse rather than reach the target unconfined. 'best_effort' (local dev) runs.
|
|
986
|
+
if (spec.get("egress") or {}).get("isolation") == "required" and os.environ.get("BLACKGLASS_EGRESS_CONFINED") != "1":
|
|
987
|
+
em.emit("run.failed", {"category": "policy",
|
|
988
|
+
"message": ("egress isolation 'required' but host confinement is not signaled "
|
|
989
|
+
"(set BLACKGLASS_EGRESS_CONFINED=1 on a confined runner); refusing to run unconfined"),
|
|
990
|
+
"stop": "policy"})
|
|
991
|
+
return 3
|
|
992
|
+
|
|
993
|
+
origins = (spec.get("target") or {}).get("origins") or []
|
|
994
|
+
hosts = (spec.get("scope") or {}).get("allowedHosts") or []
|
|
995
|
+
# The run's primary origin, used to build each finding's absolute endpoint URL.
|
|
996
|
+
origin = origins[0] if origins else (hosts[0] if hosts else "")
|
|
997
|
+
profile = (spec.get("policy") or {}).get("profile", "safe")
|
|
998
|
+
isolation = (spec.get("egress") or {}).get("isolation", "best_effort")
|
|
999
|
+
em.emit("run.started", {
|
|
1000
|
+
"runId": run_id, "origin": origin,
|
|
1001
|
+
"hosts": hosts, "packs": spec.get("packs") or [], "profile": profile,
|
|
1002
|
+
"isolation": isolation, "resumed": False, "ok": True,
|
|
1003
|
+
})
|
|
1004
|
+
em.emit("phase.changed", {"phase": "investigating"})
|
|
1005
|
+
|
|
1006
|
+
# Run kind: 'retest' re-verifies known findings (a constrained brief + fewer rounds)
|
|
1007
|
+
# instead of the open-ended hunt; 'remediate' generates a patch for one finding (no
|
|
1008
|
+
# harness run at all -- an isolated model call over the checkout); 'assess' (default)
|
|
1009
|
+
# is the normal assessment.
|
|
1010
|
+
kind = str(spec.get("kind") or "assess")
|
|
1011
|
+
if kind == "remediate":
|
|
1012
|
+
return _run_remediate(spec, em)
|
|
1013
|
+
retest_findings = (spec.get("retest") or {}).get("findings") or [] if kind == "retest" else []
|
|
1014
|
+
if kind == "retest" and not retest_findings:
|
|
1015
|
+
em.emit("run.completed", {"stop": "halt", "findings": 0, "ok": True,
|
|
1016
|
+
"message": "no findings with a reproduction recipe to retest"})
|
|
1017
|
+
return 0
|
|
1018
|
+
goal = build_retest_brief(retest_findings) if kind == "retest" else build_brief(spec)
|
|
1019
|
+
rounds = (os.environ.get("BASIC_HARNESS_RETEST_ROUNDS", "1") if kind == "retest"
|
|
1020
|
+
else os.environ.get("BASIC_HARNESS_ROUNDS", "3"))
|
|
1021
|
+
|
|
1022
|
+
# Scan depth profile (PR = fast/cheap gate, Full = deep audit): picks model
|
|
1023
|
+
# placement, whether the council runs, breadth, and the cost backstop.
|
|
1024
|
+
scan_name, prof = scan_profile_for(spec)
|
|
1025
|
+
|
|
1026
|
+
# A PR scan scopes to the pull request's diff when a base ref is available, so the
|
|
1027
|
+
# fast gate reviews the change + its blast radius rather than the whole repo.
|
|
1028
|
+
if scan_name == "pr" and kind == "assess":
|
|
1029
|
+
_repo = (spec.get("target") or {}).get("repository") or {}
|
|
1030
|
+
_base, _checkout = _repo.get("baseCommit"), _repo.get("checkoutPath")
|
|
1031
|
+
if _base and _checkout:
|
|
1032
|
+
_files = _pr_changed_files(str(_checkout), str(_base))
|
|
1033
|
+
if _files:
|
|
1034
|
+
goal = goal + _pr_scope_text(_files)
|
|
1035
|
+
|
|
1036
|
+
# Wall-clock budget is the PRIMARY stop condition (call/cost caps are off by default
|
|
1037
|
+
# below). Prefer the RunSpec's policy.maxDurationMinutes; otherwise fall back to the
|
|
1038
|
+
# profile's default (PR ~10 min, Full ~45 min). The harness enforces this itself via
|
|
1039
|
+
# -max-time and stops GRACEFULLY at the deadline, reporting what it found; the external
|
|
1040
|
+
# proc timeout further down is only a backstop, set a little longer so it wins.
|
|
1041
|
+
policy_minutes = (spec.get("policy") or {}).get("maxDurationMinutes")
|
|
1042
|
+
max_minutes = (float(policy_minutes) if isinstance(policy_minutes, (int, float)) and policy_minutes > 0
|
|
1043
|
+
else float(os.environ.get("BASIC_HARNESS_MAX_TIME_MIN", str(prof["default_minutes"]))))
|
|
1044
|
+
|
|
1045
|
+
# Posture pre-pass: on an assessment with a reachable origin, profile the public
|
|
1046
|
+
# surface before the hunt. Its signals steer the agents (BLACKGLASS_PROFILE, consumed
|
|
1047
|
+
# by prod_env) and its coverage + posture observations go into the report. Disabled by
|
|
1048
|
+
# BASIC_HARNESS_PRESCAN=0; skipped for source-only (no origin) and retest/remediate.
|
|
1049
|
+
prescan: dict | None = None
|
|
1050
|
+
if (kind == "assess" and bool((spec.get("target") or {}).get("origins"))
|
|
1051
|
+
and os.environ.get("BASIC_HARNESS_PRESCAN", "1") != "0"):
|
|
1052
|
+
em.emit("phase.changed", {"phase": "profiling"})
|
|
1053
|
+
tier = str((spec.get("policy") or {}).get("actionTier") or "passive")
|
|
1054
|
+
prescan = run_prescan(a.spec, session, tier)
|
|
1055
|
+
if prescan:
|
|
1056
|
+
os.environ["BLACKGLASS_PROFILE"] = "1"
|
|
1057
|
+
os.environ["BLACKGLASS_PROFILE_PATH"] = str(session / "profile.json")
|
|
1058
|
+
|
|
1059
|
+
# Test accounts: sign each username-and-password account in once, before the hunt, and
|
|
1060
|
+
# hand the sessions to every agent's environment out-of-band (the environment, never
|
|
1061
|
+
# disk), so all agents act as the account from their first request and share one
|
|
1062
|
+
# session. A wrong password is tried once, not once per agent.
|
|
1063
|
+
if (spec.get("target") or {}).get("origins"):
|
|
1064
|
+
shared_sessions = sign_in_test_accounts(spec, em)
|
|
1065
|
+
if shared_sessions:
|
|
1066
|
+
os.environ["BLACKGLASS_SESSIONS_JSON"] = json.dumps(shared_sessions)
|
|
1067
|
+
|
|
1068
|
+
db = session / "agents.db"
|
|
1069
|
+
cmd = [
|
|
1070
|
+
HARNESS,
|
|
1071
|
+
"-db", str(db),
|
|
1072
|
+
"-goal", goal,
|
|
1073
|
+
"-model", spec.get("model") or DEFAULT_MODEL,
|
|
1074
|
+
# Exact argv (JSON) for the harness to exec directly -- no shell, so a PROD_ENV or
|
|
1075
|
+
# spec path containing spaces survives intact. sys.executable (not literal python3)
|
|
1076
|
+
# keeps the env on the same interpreter this controller runs under, which matters
|
|
1077
|
+
# once a bundled runtime ships.
|
|
1078
|
+
"-env", json.dumps([sys.executable, PROD_ENV, "--spec", a.spec]),
|
|
1079
|
+
"-orchestrator", os.environ.get("BASIC_HARNESS_ORCHESTRATOR", prof["orchestrator"]),
|
|
1080
|
+
"-rounds", rounds,
|
|
1081
|
+
# Breadth from the scan profile: Full goes wide (the scaled arm that won the Juice
|
|
1082
|
+
# Shop sweep, 2026-10-28); PR stays lighter for a fast gate. Env overrides for dev.
|
|
1083
|
+
"-teams", os.environ.get("BASIC_HARNESS_TEAMS", prof["teams"]),
|
|
1084
|
+
"-cells", os.environ.get("BASIC_HARNESS_CELLS", prof["cells"]),
|
|
1085
|
+
"-team-calls", os.environ.get("BASIC_HARNESS_TEAM_CALLS", prof["team_calls"]),
|
|
1086
|
+
"-workers", os.environ.get("BASIC_HARNESS_WORKERS", prof["workers"]),
|
|
1087
|
+
# Bounded by WALL CLOCK, not calls: -max-time (set above) is the primary stop and
|
|
1088
|
+
# the harness ends gracefully at the deadline. No call cap (0 = unlimited); the
|
|
1089
|
+
# profile's cost backstop ($3 PR / $15 Full) guards a runaway without being the
|
|
1090
|
+
# binding limit. An operator can still override any of these via env.
|
|
1091
|
+
"-max-time", f"{max_minutes:g}m",
|
|
1092
|
+
"-max-calls", os.environ.get("BASIC_HARNESS_MAX_CALLS", "0"),
|
|
1093
|
+
"-max-cost", os.environ.get("BASIC_HARNESS_MAX_COST", prof["max_cost"]),
|
|
1094
|
+
"-compact-tokens", "100000", "-ui", "", "-hold=false",
|
|
1095
|
+
]
|
|
1096
|
+
# Route model calls through the murk model gateway when the control plane set
|
|
1097
|
+
# OPENROUTER_BASE_URL (then OPENROUTER_API_KEY is a signed gateway token, not the
|
|
1098
|
+
# OpenRouter key); direct to OpenRouter otherwise.
|
|
1099
|
+
base_url = os.environ.get("OPENROUTER_BASE_URL", "").strip()
|
|
1100
|
+
if base_url:
|
|
1101
|
+
cmd += ["-base-url", base_url]
|
|
1102
|
+
# Key resolution:
|
|
1103
|
+
# - Gateway mode (base_url set): OPENROUTER_API_KEY in the env is a SIGNED gateway token
|
|
1104
|
+
# that the harness must send as-is. A -key-file would override it with a raw OpenRouter
|
|
1105
|
+
# key (which the gateway rejects -> the harness fails its first model call and exits 1),
|
|
1106
|
+
# so NEVER pass a key file when routing through the gateway. This also stops a developer's
|
|
1107
|
+
# ~/Code/keys/.env from hijacking a hosted/local-scan run that already has a token.
|
|
1108
|
+
# - Direct mode (no gateway): prefer an explicit key file when one exists; otherwise the
|
|
1109
|
+
# harness reads OPENROUTER_API_KEY straight from the environment. Passing a -key-file that
|
|
1110
|
+
# does not exist is fatal, so only pass it when it is really there.
|
|
1111
|
+
if not base_url and os.path.exists(KEY_FILE):
|
|
1112
|
+
cmd += ["-key-file", KEY_FILE]
|
|
1113
|
+
# Each team gets a validator agent that independently re-tests a candidate finding
|
|
1114
|
+
# before the coordinator may confirm it (anti-false-positive). On by default; an
|
|
1115
|
+
# operator can disable it for an A/B via BASIC_HARNESS_VALIDATE=0.
|
|
1116
|
+
if os.environ.get("BASIC_HARNESS_VALIDATE", "1") != "0":
|
|
1117
|
+
cmd.append("-validate")
|
|
1118
|
+
repo = (spec.get("target") or {}).get("repository") or {}
|
|
1119
|
+
if repo.get("checkoutPath"):
|
|
1120
|
+
cmd += ["-source", str(repo["checkoutPath"])]
|
|
1121
|
+
roles: dict[str, dict] = {}
|
|
1122
|
+
if prof["coord_model"]: # premium model at the synthesis locus (Full only)
|
|
1123
|
+
roles["coordinator"] = {"model": prof["coord_model"]}
|
|
1124
|
+
if prof["orch_model"]: # glm-5.3 council chair + members (Full only; not k3)
|
|
1125
|
+
roles["orchestrator"] = {"model": prof["orch_model"]}
|
|
1126
|
+
if roles:
|
|
1127
|
+
roles_file = session / "roles.json"
|
|
1128
|
+
roles_file.write_text(json.dumps(roles))
|
|
1129
|
+
cmd += ["-roles", str(roles_file)]
|
|
1130
|
+
(session / "harness-cmd.txt").write_text(" ".join(cmd))
|
|
1131
|
+
|
|
1132
|
+
cancelled = {"flag": False}
|
|
1133
|
+
proc: subprocess.Popen | None = None
|
|
1134
|
+
|
|
1135
|
+
def on_term(_signum, _frame):
|
|
1136
|
+
cancelled["flag"] = True
|
|
1137
|
+
if proc and proc.poll() is None:
|
|
1138
|
+
proc.terminate()
|
|
1139
|
+
|
|
1140
|
+
signal.signal(signal.SIGTERM, on_term)
|
|
1141
|
+
|
|
1142
|
+
# The harness enforces the time box itself (-max-time = max_minutes, computed above)
|
|
1143
|
+
# and stops gracefully. This external timeout is only a BACKSTOP for a harness that
|
|
1144
|
+
# overruns its own deadline: set a little longer (grace) so the clean internal stop
|
|
1145
|
+
# wins; on a hard expiry here we still salvage whatever findings exist.
|
|
1146
|
+
timeout_s = max_minutes * 60 + 120
|
|
1147
|
+
|
|
1148
|
+
emitted_findings: set[int] = set()
|
|
1149
|
+
follower = Follower(str(db), em, emitted_findings, emit_findings=(kind != "retest"), origin=origin,
|
|
1150
|
+
checkout=((spec.get("target") or {}).get("repository") or {}).get("checkoutPath"))
|
|
1151
|
+
timed_out = False
|
|
1152
|
+
with open(session / "harness.log", "w") as log:
|
|
1153
|
+
proc = subprocess.Popen(cmd, cwd=str(ROOT), stdout=log, stderr=subprocess.STDOUT)
|
|
1154
|
+
follower.start() # tails the state DB and streams activity live
|
|
1155
|
+
try:
|
|
1156
|
+
code = proc.wait(timeout=timeout_s)
|
|
1157
|
+
except subprocess.TimeoutExpired:
|
|
1158
|
+
timed_out = True
|
|
1159
|
+
proc.terminate()
|
|
1160
|
+
try:
|
|
1161
|
+
code = proc.wait(timeout=30)
|
|
1162
|
+
except subprocess.TimeoutExpired:
|
|
1163
|
+
proc.kill()
|
|
1164
|
+
code = proc.wait()
|
|
1165
|
+
|
|
1166
|
+
follower.stop()
|
|
1167
|
+
follower.join(timeout=10)
|
|
1168
|
+
|
|
1169
|
+
if cancelled["flag"]:
|
|
1170
|
+
em.emit("run.cancelled", {"category": "cancelled", "message": "Run cancelled by operator"})
|
|
1171
|
+
return 4
|
|
1172
|
+
|
|
1173
|
+
def _log_tail() -> str:
|
|
1174
|
+
try:
|
|
1175
|
+
return (session / "harness.log").read_text(errors="replace").strip()[-600:]
|
|
1176
|
+
except OSError:
|
|
1177
|
+
return ""
|
|
1178
|
+
|
|
1179
|
+
# Final reconcile: emit any confirmed finding the follower did not already stream.
|
|
1180
|
+
# mode=rw (not a plain connect) so we never create a stray db, and if the harness
|
|
1181
|
+
# produced no usable state DB (crashed / refused to start) surface ITS log rather
|
|
1182
|
+
# than a confusing "no such table" SQLite error.
|
|
1183
|
+
recs: list = []
|
|
1184
|
+
run_cost: float | None = None
|
|
1185
|
+
try:
|
|
1186
|
+
con = sqlite3.connect(f"file:{db}?mode=rw", uri=True, timeout=5)
|
|
1187
|
+
con.execute("PRAGMA query_only=1")
|
|
1188
|
+
try:
|
|
1189
|
+
rows = con.execute(
|
|
1190
|
+
"SELECT id, title, body, coalesce(meta,'') FROM nodes WHERE kind='finding' AND status='confirmed' ORDER BY id"
|
|
1191
|
+
).fetchall()
|
|
1192
|
+
# Build each finding's full payload while the DB is open: the model's
|
|
1193
|
+
# assessment + the concrete, verbatim proof (cited request/response and code
|
|
1194
|
+
# refs with snippets from the audited checkout). Reused for emission + report.
|
|
1195
|
+
_checkout = ((spec.get("target") or {}).get("repository") or {}).get("checkoutPath")
|
|
1196
|
+
recs = [
|
|
1197
|
+
(fid, title, finding_payload(title, body, meta=parse_meta(meta), origin=origin,
|
|
1198
|
+
con=con, checkout=_checkout))
|
|
1199
|
+
for fid, title, body, meta in rows
|
|
1200
|
+
]
|
|
1201
|
+
# The harness records the run's total spend in its terminal run.end event;
|
|
1202
|
+
# surface it so the control plane can meter + bill the scan (usage ledger).
|
|
1203
|
+
crow = con.execute(
|
|
1204
|
+
"SELECT CAST(data AS TEXT) FROM events WHERE kind='run.end' ORDER BY seq DESC LIMIT 1"
|
|
1205
|
+
).fetchone()
|
|
1206
|
+
if crow and crow[0]:
|
|
1207
|
+
try:
|
|
1208
|
+
run_cost = float(json.loads(crow[0]).get("cost"))
|
|
1209
|
+
except (ValueError, TypeError):
|
|
1210
|
+
run_cost = None
|
|
1211
|
+
finally:
|
|
1212
|
+
con.close()
|
|
1213
|
+
except sqlite3.Error:
|
|
1214
|
+
em.emit("run.failed", {"category": "runtime",
|
|
1215
|
+
"message": f"harness produced no findings DB (exit {code}): {_log_tail() or 'no output'}",
|
|
1216
|
+
"stop": "error"})
|
|
1217
|
+
return 1
|
|
1218
|
+
if kind == "retest":
|
|
1219
|
+
# A re-confirmed finding (status 'confirmed' = still reproduces) is matched back
|
|
1220
|
+
# to the original it re-verifies and re-emitted under the ORIGINAL fingerprint,
|
|
1221
|
+
# verification='reproduced'. Originals with no match here were NOT reproduced; the
|
|
1222
|
+
# control plane reconciles them to 'fixed' from the absence of an occurrence.
|
|
1223
|
+
matched: set[str] = set()
|
|
1224
|
+
for _fid, title, payload in recs:
|
|
1225
|
+
orig = _match_original(title, retest_findings)
|
|
1226
|
+
fp = orig.get("fingerprint") if orig else None
|
|
1227
|
+
if not fp or fp in matched:
|
|
1228
|
+
continue
|
|
1229
|
+
matched.add(fp)
|
|
1230
|
+
# Re-emit this run's fresh payload (incl. the re-reproduced proof) under the
|
|
1231
|
+
# ORIGINAL finding's fingerprint/title so the control plane reconciles identity.
|
|
1232
|
+
p = dict(payload)
|
|
1233
|
+
p["fingerprint"] = fp
|
|
1234
|
+
p["verification"] = "reproduced"
|
|
1235
|
+
if orig.get("title"):
|
|
1236
|
+
p["title"] = str(orig["title"])[:300]
|
|
1237
|
+
em.emit("finding.updated", p)
|
|
1238
|
+
n_findings = len(matched)
|
|
1239
|
+
else:
|
|
1240
|
+
for fid, _title, payload in recs:
|
|
1241
|
+
if fid not in emitted_findings:
|
|
1242
|
+
emitted_findings.add(fid)
|
|
1243
|
+
em.emit("finding.created", payload)
|
|
1244
|
+
n_findings = len(emitted_findings)
|
|
1245
|
+
|
|
1246
|
+
# On an assess run, a non-zero harness exit with zero findings is a failure; on a
|
|
1247
|
+
# retest, zero re-confirmed findings is the SUCCESS case (everything was fixed).
|
|
1248
|
+
if kind != "retest" and code != 0 and n_findings == 0 and not timed_out:
|
|
1249
|
+
em.emit("run.failed", {"category": "runtime", "message": f"harness exited {code} with no findings",
|
|
1250
|
+
"stop": "error"})
|
|
1251
|
+
return 1
|
|
1252
|
+
|
|
1253
|
+
# The customer report: deterministic sections (scope, severity rollup, each finding in
|
|
1254
|
+
# full, the pre-scan's coverage + posture) plus an LLM-authored executive summary.
|
|
1255
|
+
# Written to the session dir and served at /api/runs/:id/report. Produced for a
|
|
1256
|
+
# completed/partial assessment (not a retest, and not a hard crash handled above).
|
|
1257
|
+
if kind == "assess":
|
|
1258
|
+
em.emit("phase.changed", {"phase": "reporting"})
|
|
1259
|
+
report_model = (os.environ.get("BASIC_HARNESS_REPORT_MODEL")
|
|
1260
|
+
or prof.get("orch_model") or DEFAULT_MODEL)
|
|
1261
|
+
write_report(session, run_id, spec, origin, scan_name, report_model, recs, prescan, em.start_ts)
|
|
1262
|
+
|
|
1263
|
+
completed = {
|
|
1264
|
+
"stop": "timeout" if timed_out else "halt",
|
|
1265
|
+
"findings": n_findings, "ok": True,
|
|
1266
|
+
"scanProfile": scan_name,
|
|
1267
|
+
"message": "time box reached; findings are partial" if timed_out else "",
|
|
1268
|
+
}
|
|
1269
|
+
if run_cost is not None: # metered for billing by the control plane
|
|
1270
|
+
completed["cost"] = run_cost
|
|
1271
|
+
em.emit("run.completed", completed)
|
|
1272
|
+
return 0
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
if __name__ == "__main__":
|
|
1276
|
+
raise SystemExit(main())
|