更新: 421 个文件 - 2026-03-17 18:30:02

这个提交包含在:
hao
2026-03-17 18:30:02 -07:00
父节点 29c3faaa28
当前提交 a3edc88834
修改 421 个文件,包含 12474 行新增5845 行删除

查看文件

@@ -4,6 +4,7 @@ import subprocess
from pathlib import Path
from typing import Any, Dict, List
from lab.runners.dispatcher import run_attack as run_runner_attack
from lab.utils import write_json
@@ -37,6 +38,9 @@ def _render_args(step: Dict[str, Any], profile: Dict[str, Any], advisory: Dict[s
def run_attack(profile: Dict[str, Any], advisory: Dict[str, Any], run_dir: Path, dry_run: bool = False) -> Dict[str, Any]:
if profile.get("runner_id") and not dry_run:
return run_runner_attack(profile, advisory, run_dir)
steps: List[Dict[str, Any]] = []
for step in profile.get("attack_actions", []):
tool_name = step.get("tool")
@@ -60,6 +64,8 @@ def run_attack(profile: Dict[str, Any], advisory: Dict[str, Any], run_dir: Path,
"result_path": str(output_path),
}
)
elif step.get("kind") == "note" and not dry_run:
record["status"] = "completed"
steps.append(record)
payload = {"steps": steps}
write_json(run_dir / "logs" / "attack.json", payload)

查看文件

@@ -10,6 +10,7 @@ from lab.utils import write_json
def collect(profile: Dict[str, Any], run_dir: Path, timeout: float = 8.0) -> Dict[str, Any]:
observations: List[Dict[str, Any]] = []
steps: List[Dict[str, Any]] = []
for url in profile.get("baseline_urls", []):
try:
response = requests.get(url, timeout=timeout, verify=False)
@@ -23,6 +24,41 @@ def collect(profile: Dict[str, Any], run_dir: Path, timeout: float = 8.0) -> Dic
)
except Exception as exc:
observations.append({"url": url, "error": str(exc)})
payload = {"observations": observations}
base_url = str((profile.get("baseline_urls") or [""])[0]).rstrip("/")
for action in profile.get("baseline_actions", []) or []:
kind = action.get("kind", "note")
if kind == "note":
steps.append({"kind": kind, "status": "recorded", "message": action.get("message", "")})
continue
try:
if kind == "http-get":
path = action.get("path", "/")
response = requests.get(f"{base_url}{path}", timeout=timeout)
steps.append(
{
"kind": kind,
"status": "completed",
"path": path,
"status_code": response.status_code,
"body_excerpt": response.text[:200],
}
)
elif kind == "http-post":
path = action.get("path", "/")
response = requests.post(f"{base_url}{path}", json=action.get("json", {}), timeout=timeout)
steps.append(
{
"kind": kind,
"status": "completed",
"path": path,
"status_code": response.status_code,
"body_excerpt": response.text[:200],
}
)
else:
steps.append({"kind": kind, "status": "skipped", "message": "baseline action type not automated"})
except Exception as exc:
steps.append({"kind": kind, "status": "failed", "message": str(exc)})
payload = {"observations": observations, "steps": steps}
write_json(run_dir / "logs" / "baseline.json", payload)
return payload

查看文件

@@ -12,10 +12,12 @@ def capture(url: str, run_dir: Path, prefix: str = "baseline") -> Dict[str, Any]
"present": False,
"refs": [],
"reason": "playwright runtime unavailable",
"error_kind": "import-error",
}
try:
from playwright.sync_api import sync_playwright # type: ignore
except Exception:
except Exception as exc:
payload["reason"] = f"playwright import failed: {exc}"
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)
return payload
@@ -33,11 +35,29 @@ def capture(url: str, run_dir: Path, prefix: str = "baseline") -> Dict[str, Any]
final_url = url
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
browser = p.chromium.launch(headless=True)
except Exception as exc:
payload["reason"] = f"chromium launch failed: {exc}"
payload["error_kind"] = "launch-failed"
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)
return payload
page = browser.new_page()
page.on("console", lambda msg: console_messages.append({"type": msg.type, "text": msg.text}))
page.on("request", lambda req: requests_seen.append({"method": req.method, "url": req.url}))
page.goto(url, wait_until="networkidle", timeout=20000)
response = page.goto(url, wait_until="networkidle", timeout=20000)
if response is None:
payload["reason"] = "page navigation returned no response"
payload["error_kind"] = "navigation-failed"
browser.close()
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)
return payload
if response.status >= 500:
payload["reason"] = f"page returned {response.status}"
payload["error_kind"] = "target-5xx"
browser.close()
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)
return payload
page.screenshot(path=str(screenshot_path), full_page=True)
dom_path.write_text(page.content(), encoding="utf-8")
final_url = page.url
@@ -46,6 +66,7 @@ def capture(url: str, run_dir: Path, prefix: str = "baseline") -> Dict[str, Any]
browser.close()
except Exception as exc:
payload["reason"] = str(exc)
payload["error_kind"] = "target-timeout" if "Timeout" in str(exc) else "navigation-failed"
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)
return payload
write_json(console_path, console_messages)
@@ -63,6 +84,7 @@ def capture(url: str, run_dir: Path, prefix: str = "baseline") -> Dict[str, Any]
"present": True,
"page_title": page_title,
"page_url": final_url,
"error_kind": None,
"refs": [str(screenshot_path), str(dom_path), str(console_path), str(network_path), str(page_path)],
}
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)

查看文件

@@ -11,11 +11,22 @@ def compose_payload(profile: Dict[str, Any]) -> Dict[str, Any]:
for service_name, service in profile.get("services", {}).items():
payload = {
"image": service["image"],
"networks": ["labnet"],
}
if service.get("ports"):
payload["ports"] = service["ports"]
if service.get("environment"):
payload["environment"] = service["environment"]
if service.get("command"):
payload["command"] = service["command"]
if service.get("working_dir"):
payload["working_dir"] = service["working_dir"]
if service.get("volumes"):
payload["volumes"] = service["volumes"]
if service.get("healthcheck"):
payload["healthcheck"] = service["healthcheck"]
if service.get("build"):
payload["build"] = service["build"]
if service.get("depends_on"):
payload["depends_on"] = service["depends_on"]
services[service_name] = payload

131
scripts/lab/doctor.py 普通文件
查看文件

@@ -0,0 +1,131 @@
from __future__ import annotations
import socket
from contextlib import closing
from typing import Any, Dict, Iterable, List, Tuple
from lab.utils import command_available, run
def _result(name: str, ok: bool, detail: str, **extra: Any) -> Dict[str, Any]:
payload = {"name": name, "ok": ok, "detail": detail}
payload.update(extra)
return payload
def _parse_host_ports(profiles: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
ports: List[Dict[str, Any]] = []
for profile in profiles:
for service_name, service in (profile.get("services") or {}).items():
for binding in service.get("ports", []) or []:
host_port = None
value = str(binding)
parts = value.split(":")
if len(parts) == 3 and parts[1].isdigit():
host_port = int(parts[1])
elif len(parts) >= 2 and parts[0].isdigit():
host_port = int(parts[0])
elif value.isdigit():
host_port = int(value)
if host_port is None:
continue
ports.append(
{
"profile_id": profile.get("profile_id"),
"service": service_name,
"binding": value,
"port": host_port,
}
)
return ports
def _port_available(port: int) -> bool:
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("127.0.0.1", port))
except OSError:
return False
return True
def _check_docker_cli() -> Dict[str, Any]:
ok = command_available("docker")
return _result("docker-cli", ok, "docker CLI available" if ok else "docker CLI is not installed")
def _check_docker_daemon() -> Dict[str, Any]:
if not command_available("docker"):
return _result("docker-daemon", False, "docker CLI unavailable")
context = run(["docker", "context", "show"], check=False)
info = run(["docker", "info"], check=False)
detail = f"context={context.stdout.strip() or 'unknown'}"
if info.returncode != 0:
detail = info.stderr.strip() or info.stdout.strip() or "docker daemon unavailable"
return _result("docker-daemon", False, detail)
return _result("docker-daemon", True, detail or "docker daemon reachable")
def _check_playwright_import() -> Dict[str, Any]:
try:
from playwright.sync_api import sync_playwright # noqa: F401
except Exception as exc:
return _result("playwright-import", False, f"playwright import failed: {exc}")
return _result("playwright-import", True, "playwright Python package import passed")
def _check_chromium_launch() -> Dict[str, Any]:
try:
from playwright.sync_api import sync_playwright
except Exception as exc:
return _result("playwright-browser", False, f"playwright import failed: {exc}")
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page()
page.set_content("<html><body>ok</body></html>")
browser.close()
except Exception as exc:
return _result("playwright-browser", False, f"chromium launch failed: {exc}")
return _result("playwright-browser", True, "chromium runtime launch passed")
def _check_ports(profiles: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
requested = _parse_host_ports(profiles)
if not requested:
return _result("ports", True, "no host ports declared")
conflicts: List[Dict[str, Any]] = []
for item in requested:
if not _port_available(item["port"]):
conflicts.append(item)
if conflicts:
detail = ", ".join(
f"{item['port']}({item['profile_id']}::{item['service']})" for item in conflicts
)
return _result("ports", False, f"host ports already in use: {detail}", conflicts=conflicts)
return _result("ports", True, f"checked {len(requested)} host port bindings", bindings=requested)
def run_checks(profiles: Iterable[Dict[str, Any]] | None = None) -> Dict[str, Any]:
selected = list(profiles or [])
checks = [
_check_docker_cli(),
_check_docker_daemon(),
_check_playwright_import(),
_check_chromium_launch(),
_check_ports(selected),
]
ok = all(item["ok"] for item in checks)
failures = [item for item in checks if not item["ok"]]
return {
"status": "passed" if ok else "failed",
"ok": ok,
"checks": checks,
"profile_ids": [item.get("profile_id") for item in selected if item.get("profile_id")],
"failure_count": len(failures),
"summary": "; ".join(item["detail"] for item in failures) if failures else "all checks passed",
}

100
scripts/lab/evaluate.py 普通文件
查看文件

@@ -0,0 +1,100 @@
from __future__ import annotations
from typing import Any, Dict, List
def _assertion(name: str, kind: str, passed: bool, detail: str) -> Dict[str, Any]:
return {
"name": name,
"kind": kind,
"passed": passed,
"detail": detail,
}
def _baseline_ok(payload: Dict[str, Any]) -> bool:
observations = payload.get("observations", []) or []
if not observations:
return False
for item in observations:
if item.get("error"):
return False
status_code = item.get("status_code")
if status_code is None or int(status_code) >= 500:
return False
return True
def _attack_steps_ok(payload: Dict[str, Any]) -> bool:
steps = payload.get("steps", []) or []
if payload.get("success") is True:
return True
if not steps:
return False
return not any(step.get("status") == "failed" for step in steps)
def evaluate_run(
profile: Dict[str, Any],
provision_result: Dict[str, Any],
baseline_payload: Dict[str, Any],
attack_payload: Dict[str, Any],
browser_payload: Dict[str, Any],
) -> Dict[str, Any]:
assertions: List[Dict[str, Any]] = []
configured = profile.get("success_assertions", []) or []
browser_required = bool(profile.get("browser_assertions", {}).get("required"))
if not configured:
configured = [
{"name": "baseline-ok", "type": "baseline-ok"},
{"name": "attack-steps", "type": "attack-steps-ok"},
]
if browser_required:
configured.append({"name": "browser-present", "type": "browser-present"})
for item in configured:
assertion_type = item.get("type", "")
name = item.get("name") or assertion_type or "assertion"
if assertion_type == "runner-success":
passed = bool(attack_payload.get("success"))
detail = attack_payload.get("detail") or ("runner reported success" if passed else "runner did not confirm success")
elif assertion_type == "baseline-ok":
passed = _baseline_ok(baseline_payload)
detail = "baseline URLs responded without 5xx or transport errors" if passed else "baseline checks were incomplete"
elif assertion_type == "attack-steps-ok":
passed = _attack_steps_ok(attack_payload)
detail = "attack steps completed without failures" if passed else "attack steps failed or produced no usable result"
elif assertion_type == "browser-present":
passed = bool(browser_payload.get("present"))
detail = "browser evidence captured" if passed else (browser_payload.get("reason") or "browser evidence missing")
else:
passed = False
detail = f"unsupported assertion type: {assertion_type}"
assertions.append(_assertion(name, assertion_type, passed, detail))
blocked_reason = provision_result.get("blocked_reason")
if browser_required and not browser_payload.get("present"):
blocked_reason = blocked_reason or browser_payload.get("reason") or "browser evidence incomplete"
passed = all(item["passed"] for item in assertions)
artifact_mode = profile.get("artifact_mode", profile.get("provisioning_mode", "synthetic"))
verification_status = "triage-manual"
if provision_result.get("status") == "blocked-artifact":
verification_status = "blocked-artifact"
elif not passed:
verification_status = "triage-manual"
failed = next((item for item in assertions if not item["passed"]), None)
if failed and not blocked_reason:
blocked_reason = failed["detail"]
elif artifact_mode == "synthetic":
verification_status = "verified-synthetic"
else:
verification_status = "verified-real"
return {
"passed": passed and verification_status.startswith("verified-"),
"verification_status": verification_status,
"blocked_reason": blocked_reason,
"assertions": assertions,
}

查看文件

@@ -11,7 +11,7 @@ SCRIPTS_DIR = CURRENT_DIR.parent
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from lab import attack, baseline, browser, catalog, evidence, provision, render, repro, seed, task_queue, validators # noqa: E402
from lab import attack, baseline, browser, catalog, doctor, evaluate, evidence, provision, render, repro, seed, task_queue, validators # noqa: E402
from lab.config import ADVISORIES_DIR, CASE_RUNS_DIR, ENV_PROFILES_DIR, RUNS_DIR # noqa: E402
from lab.utils import command_available, ensure_dir, isoformat, load_json_dir, now_utc, read_json, read_yaml, write_json # noqa: E402
@@ -99,6 +99,7 @@ def _build_run_bundle(
compose_refs: List[str],
browser_evidence: Dict[str, Any],
timeline: List[Dict[str, Any]],
success_evaluation: Dict[str, Any],
started_at: str,
finished_at: str,
blocked_reason: str | None,
@@ -121,6 +122,9 @@ def _build_run_bundle(
"request_log_refs": request_log_refs,
"compose_refs": compose_refs,
"timeline": timeline,
"success_evaluation": success_evaluation,
"historical_status": verification_status,
"latest_status": verification_status,
"started_at": started_at,
"finished_at": finished_at,
"blocked_reason": blocked_reason,
@@ -141,6 +145,9 @@ def _dry_run_case_plan(advisory: Dict[str, Any], profile: Dict[str, Any], run_id
"compose_services": sorted(profile.get("services", {}).keys()),
"seed_actions": profile.get("seed_actions", []),
"attack_actions": profile.get("attack_actions", []),
"runner_id": profile.get("runner_id"),
"fixture_path": profile.get("fixture_path"),
"success_assertions": profile.get("success_assertions", []),
"compose_preview": provision_result.get("compose_preview", {}),
"note": "dry-run only; no bundle, report, compose file, or registry update was written",
}
@@ -159,7 +166,23 @@ def _execute_case(canonical_id: str, run_id: str | None = None, dry_run: bool =
_timeline_event(timeline, "resolve-repro-profile", "completed", profile["profile_id"])
run_dir = _run_dir(resolved_run_id)
provision_result = provision.prepare(profile, run_dir, dry_run=False)
doctor_result = doctor.run_checks([profile])
write_json(run_dir / "logs" / "doctor.json", doctor_result)
_timeline_event(
timeline,
"doctor",
"completed" if doctor_result.get("ok") else "failed",
doctor_result.get("summary", ""),
)
if doctor_result.get("ok"):
provision_result = provision.prepare(profile, run_dir, dry_run=False)
else:
provision_result = {
"compose_path": str(run_dir / "compose" / "compose.yaml"),
"status": "blocked-artifact",
"blocked_reason": doctor_result.get("summary", "doctor failed"),
}
_timeline_event(
timeline,
"provision-compose-environment",
@@ -168,11 +191,39 @@ def _execute_case(canonical_id: str, run_id: str | None = None, dry_run: bool =
)
allow_runtime_steps = provision_result.get("status") not in {"blocked-artifact"}
browser_required = bool(profile.get("browser_assertions", {}).get("required"))
compose_path = Path(provision_result.get("compose_path", run_dir / "compose" / "compose.yaml"))
ready_payload = {"status": "skipped", "detail": "provisioning blocked", "observations": []}
if allow_runtime_steps:
ready_payload = provision.wait_ready(profile, run_dir, compose_path)
allow_runtime_steps = ready_payload.get("status") == "completed"
_timeline_event(timeline, "wait-ready", ready_payload.get("status", "unknown"), ready_payload.get("detail", ""))
else:
_timeline_event(timeline, "wait-ready", "skipped", "provisioning blocked")
seed_payload = {"steps": [], "seeded": False}
if allow_runtime_steps:
seed_payload = seed.run_seed(profile, advisory, run_dir, dry_run=False)
seed_failed = any(step.get("status") == "failed" for step in seed_payload.get("steps", []))
_timeline_event(
timeline,
"seed-environment",
"failed" if seed_failed else "completed",
f"steps={len(seed_payload.get('steps', []))}",
)
else:
_timeline_event(timeline, "seed-environment", "skipped", "runtime steps unavailable")
baseline_payload = {"observations": []}
if profile.get("baseline_urls") and allow_runtime_steps:
baseline_payload = baseline.collect(profile, run_dir)
_timeline_event(timeline, "baseline-snapshot", "completed", f"urls={len(profile.get('baseline_urls', []))}")
baseline_failed = any(item.get("error") for item in baseline_payload.get("observations", []))
_timeline_event(
timeline,
"baseline-snapshot",
"failed" if baseline_failed else "completed",
f"urls={len(profile.get('baseline_urls', []))}",
)
else:
_timeline_event(timeline, "baseline-snapshot", "skipped", "no baseline urls or provisioning blocked")
@@ -213,7 +264,6 @@ def _execute_case(canonical_id: str, run_id: str | None = None, dry_run: bool =
elif browser_required:
_timeline_event(timeline, "browser-replay-after-attack", "skipped", "proof browser capture unavailable")
compose_path = Path(provision_result["compose_path"])
container_logs = evidence.collect_container_logs(run_dir, compose_path) if compose_path.exists() and allow_runtime_steps else []
_timeline_event(
timeline,
@@ -231,25 +281,31 @@ def _execute_case(canonical_id: str, run_id: str | None = None, dry_run: bool =
"proof_refs": proof_browser.get("refs", []),
"baseline_title": baseline_browser.get("page_title"),
"proof_title": proof_browser.get("page_title"),
"error_kind": proof_browser.get("error_kind") or baseline_browser.get("error_kind"),
"reason": proof_browser.get("reason") or baseline_browser.get("reason"),
}
blocked_reason = provision_result.get("blocked_reason")
if browser_required and not browser_present:
blocked_reason = blocked_reason or baseline_browser.get("reason") or proof_browser.get("reason") or "browser evidence incomplete"
verification_mode = profile.get("verification_mode", "synthetic")
artifact_mode = profile.get("artifact_mode", profile.get("provisioning_mode", "synthetic"))
verification_status = "triage-manual"
if provision_result.get("status") == "blocked-artifact":
verification_status = "blocked-artifact"
elif browser_required and not browser_present:
verification_status = "triage-manual"
elif any(step.get("status") == "failed" for step in attack_payload.get("steps", [])):
verification_status = "triage-manual"
elif artifact_mode == "synthetic":
verification_status = "verified-synthetic"
else:
verification_status = "verified-real"
success_evaluation = evaluate.evaluate_run(
profile=profile,
provision_result=provision_result,
baseline_payload=baseline_payload,
attack_payload=attack_payload,
browser_payload=browser_payload,
)
verification_status = success_evaluation["verification_status"]
blocked_reason = success_evaluation.get("blocked_reason")
cleanup_payload = {"status": "skipped", "detail": "cleanup_policy not destroy"}
if compose_path.exists() and profile.get("cleanup_policy") == "destroy":
cleanup_payload = provision.teardown(run_dir, compose_path)
_timeline_event(
timeline,
"cleanup-compose-environment",
cleanup_payload.get("status", "unknown"),
cleanup_payload.get("detail", ""),
)
finished_at = isoformat(now_utc())
bundle = _build_run_bundle(
@@ -267,6 +323,7 @@ def _execute_case(canonical_id: str, run_id: str | None = None, dry_run: bool =
compose_refs=[str(compose_path)] if compose_path.exists() else [],
browser_evidence=browser_payload,
timeline=timeline,
success_evaluation=success_evaluation,
started_at=started_at,
finished_at=finished_at,
blocked_reason=blocked_reason,
@@ -311,7 +368,8 @@ def cmd_provision(args) -> int:
def cmd_seed(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
print({"steps": seed.run_seed(profile)})
run_dir = _run_dir(args.run_id or _compose_run_id(advisory))
print(seed.run_seed(profile, advisory, run_dir, dry_run=args.dry_run))
return 0
@@ -355,7 +413,8 @@ def cmd_run_case(args) -> int:
def cmd_run_system(args) -> int:
advisories = [item for item in load_json_dir(ADVISORIES_DIR) if item.get("system_id") == args.system]
selected = advisories[: args.limit]
advisories = sorted(advisories, key=lambda item: item.get("canonical_id", ""))
selected = advisories if not args.limit or args.limit <= 0 else advisories[: args.limit]
for advisory in selected:
_execute_case(advisory["canonical_id"], run_id=None, dry_run=args.dry_run, sync_outputs=False)
if selected and not args.dry_run:
@@ -442,6 +501,20 @@ def cmd_validate(args) -> int:
return 0
def cmd_doctor(args) -> int:
profiles: List[Dict[str, Any]] = []
if getattr(args, "case", None):
advisory = _load_advisory(args.case)
profiles.append(_resolve_profile(advisory))
elif getattr(args, "system", None):
advisories = [item for item in load_json_dir(ADVISORIES_DIR) if item.get("system_id") == args.system]
if advisories:
profiles.append(_resolve_profile(advisories[0]))
result = doctor.run_checks(profiles)
print(result)
return 0 if result.get("ok") else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Websafe local lab orchestrator")
subparsers = parser.add_subparsers(dest="command", required=True)
@@ -479,7 +552,7 @@ def build_parser() -> argparse.ArgumentParser:
run_system = subparsers.add_parser("run-system", help="run the first N advisories for a system")
run_system.add_argument("--system", required=True)
run_system.add_argument("--limit", type=int, default=5)
run_system.add_argument("--limit", type=int, default=0)
run_system.add_argument("--dry-run", action="store_true")
run_system.set_defaults(func=cmd_run_system)
@@ -508,6 +581,11 @@ def build_parser() -> argparse.ArgumentParser:
validate = subparsers.add_parser("validate", help="validate lab assets")
validate.set_defaults(func=cmd_validate)
doctor_cmd = subparsers.add_parser("doctor", help="run environment preflight checks")
doctor_cmd.add_argument("--case")
doctor_cmd.add_argument("--system")
doctor_cmd.set_defaults(func=cmd_doctor)
return parser

查看文件

@@ -1,10 +1,13 @@
from __future__ import annotations
import time
from pathlib import Path
from typing import Any, Dict
import requests
from lab.compose import compose_payload, generate_compose
from lab.utils import command_available, run
from lab.utils import command_available, run, write_json
def prepare(profile: Dict[str, Any], run_dir: Path, dry_run: bool = False) -> Dict[str, Any]:
@@ -34,10 +37,58 @@ def prepare(profile: Dict[str, Any], run_dir: Path, dry_run: bool = False) -> Di
result["blocked_reason"] = config.stderr.strip() or "docker compose config failed"
return result
up = run(["docker", "compose", "-f", str(compose_path), "up", "-d"], cwd=run_dir)
up = run(["docker", "compose", "-f", str(compose_path), "up", "-d", "--wait"], cwd=run_dir)
result["compose_up_rc"] = up.returncode
if up.returncode != 0:
result["status"] = "blocked-artifact"
result["blocked_reason"] = up.stderr.strip() or "docker compose up failed"
result["blocked_reason"] = up.stderr.strip() or up.stdout.strip() or "docker compose up failed"
return result
return result
def wait_ready(profile: Dict[str, Any], run_dir: Path, compose_path: Path) -> Dict[str, Any]:
timeout_seconds = int(profile.get("ready_timeout_seconds") or 45)
baseline_urls = profile.get("baseline_urls", []) or []
started = time.monotonic()
observations = []
status = "completed"
detail = f"baseline urls ready ({len(baseline_urls)})"
while True:
observations = []
ready = True
for url in baseline_urls:
try:
response = requests.get(url, timeout=4)
observations.append({"url": url, "status_code": response.status_code})
if response.status_code >= 500:
ready = False
except Exception as exc:
observations.append({"url": url, "error": str(exc)})
ready = False
if ready:
break
if time.monotonic() - started >= timeout_seconds:
status = "failed"
detail = f"services not ready within {timeout_seconds}s"
break
time.sleep(1)
payload = {
"status": status,
"detail": detail,
"elapsed_seconds": round(time.monotonic() - started, 1),
"observations": observations,
"compose_path": str(compose_path),
}
write_json(run_dir / "logs" / "ready.json", payload)
return payload
def teardown(run_dir: Path, compose_path: Path) -> Dict[str, Any]:
if not command_available("docker") or not compose_path.exists():
return {"status": "skipped", "detail": "docker unavailable or compose file missing"}
down = run(["docker", "compose", "-f", str(compose_path), "down", "-v", "--remove-orphans"], cwd=run_dir)
if down.returncode != 0:
return {"status": "failed", "detail": down.stderr.strip() or down.stdout.strip() or "docker compose down failed"}
return {"status": "completed", "detail": "docker compose down completed"}

查看文件

@@ -88,6 +88,11 @@ def resolve_profile(advisory_id: str, advisory: Optional[Dict[str, Any]] = None)
return direct_profile
family = resolve_repro_family(advisory, system_map)
system_family_profile = profiles.get(f"{advisory.get('system_id', '')}-{family.replace('-generic', '')}")
if system_family_profile:
resolved = dict(system_family_profile)
resolved.setdefault("resolved_via", "system-family")
return resolved
profile = profiles.get(family)
if profile:
resolved = dict(profile)
@@ -103,6 +108,7 @@ def resolve_profile(advisory_id: str, advisory: Optional[Dict[str, Any]] = None)
"attack_actions": [],
"baseline_actions": [],
"success_criteria": ["manual triage required"],
"success_assertions": [],
"cleanup_policy": "destroy",
"destructive_risk": "medium",
"allowed_target_types": ["lab-local", "lab-public", "authorized-third-party"],
@@ -152,6 +158,8 @@ def annotate_with_latest_run(advisory: Dict[str, Any], run: Optional[Dict[str, A
"repro_profile_id": run.get("repro_profile_id", merged["repro_profile_id"]),
"artifact_mode": run.get("artifact_mode", merged["artifact_mode"]),
"blocked_reason": run.get("blocked_reason"),
"historical_status": run.get("verification_status", merged["verification_status"]),
"latest_status": run.get("verification_status", merged["verification_status"]),
}
)
return merged

查看文件

@@ -0,0 +1,4 @@
from lab.runners.dispatcher import run_attack, run_seed
__all__ = ["run_seed", "run_attack"]

查看文件

@@ -0,0 +1,169 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
import requests
from lab.utils import ensure_dir, write_json
@dataclass
class RunnerContext:
profile: Dict[str, Any]
advisory: Dict[str, Any]
run_dir: Path
@property
def base_url(self) -> str:
return str((self.profile.get("baseline_urls") or [""])[0]).rstrip("/")
@property
def family(self) -> str:
return str(self.profile.get("vuln_family") or "").strip()
@property
def runner_id(self) -> str:
return str(self.profile.get("runner_id") or "").strip()
@property
def logs_dir(self) -> Path:
path = self.run_dir / "logs"
ensure_dir(path)
return path
def _request(
context: RunnerContext,
method: str,
path: str,
*,
json_payload: Dict[str, Any] | None = None,
timeout: float = 15.0,
) -> Dict[str, Any]:
response = requests.request(
method=method,
url=f"{context.base_url}{path}",
json=json_payload,
timeout=timeout,
)
body: Dict[str, Any]
try:
parsed = response.json()
body = parsed if isinstance(parsed, dict) else {"data": parsed}
except Exception:
body = {"text": response.text[:1000]}
return {
"status_code": response.status_code,
"ok": response.ok,
"body": body,
}
def _seed_payload(context: RunnerContext) -> Dict[str, Any]:
return {
"system_id": context.advisory["system_id"],
"family": context.family,
"case_id": context.advisory["canonical_id"],
"title": context.advisory.get("title"),
}
def _attack_payload(context: RunnerContext, family: str) -> Dict[str, Any]:
case_id = context.advisory["canonical_id"]
payloads = {
"proxy-boundary": {
"family": family,
"case_id": case_id,
"headers": {"x-forwarded-user": "admin", "x-forwarded-host": "internal.lab"},
},
"authz-bypass": {
"family": family,
"case_id": case_id,
"actor": "guest",
"target": "/admin",
"override": "bypass",
},
"ssrf": {
"family": family,
"case_id": case_id,
"target_url": "http://127.0.0.1/sink",
},
"xss": {
"family": family,
"case_id": case_id,
"payload": f"<script>document.body.dataset.case='{case_id}'</script>",
},
"file-upload": {
"family": family,
"case_id": case_id,
"filename": f"{case_id}.txt",
"content": f"fixture upload marker for {case_id}",
},
"deserialization": {
"family": family,
"case_id": case_id,
"payload": {"marker": case_id, "mode": "inert-object"},
},
}
return payloads[family]
def run_fixture_seed(context: RunnerContext, family: str) -> Dict[str, Any]:
result = _request(context, "POST", "/seed", json_payload=_seed_payload(context))
payload = {
"steps": [
{
"kind": "runner",
"tool": context.runner_id or f"{context.advisory['system_id']}.{family}",
"status": "completed" if result["ok"] else "failed",
"status_code": result["status_code"],
"detail": result["body"].get("detail") or "seed request completed",
}
],
"seeded": bool(result["ok"]),
"result": result,
}
write_json(context.logs_dir / "seed.json", payload)
return payload
def run_fixture_attack(context: RunnerContext, family: str) -> Dict[str, Any]:
before: Dict[str, Any] = {}
if family in {"proxy-boundary", "authz-bypass"}:
before = _request(context, "GET", "/admin")
attack = _request(context, "POST", "/attack", json_payload=_attack_payload(context, family))
proof = _request(context, "GET", "/proof")
after: Dict[str, Any] = {}
if family in {"proxy-boundary", "authz-bypass"}:
after = _request(context, "GET", "/admin")
success = bool(attack["ok"] and proof["ok"] and proof["body"].get("success"))
step = {
"kind": "runner",
"tool": context.runner_id or f"{context.advisory['system_id']}.{family}",
"status": "completed" if success else "failed",
"status_code": attack["status_code"],
"result_path": str(context.logs_dir / "attack.json"),
}
payload = {
"steps": [step],
"success": success,
"detail": proof["body"].get("detail") or attack["body"].get("detail") or "runner attack finished",
"before": before,
"attack": attack,
"after": after,
"proof": proof,
"assertions": [
{
"name": "proof-success",
"kind": "runner-proof",
"passed": success,
"detail": proof["body"].get("detail") or "runner proof endpoint returned success",
}
],
}
write_json(context.logs_dir / "attack.json", payload)
return payload

查看文件

@@ -0,0 +1,36 @@
from __future__ import annotations
import importlib
from pathlib import Path
from typing import Any, Dict
from lab.runners.common import RunnerContext
def _module_name(profile: Dict[str, Any]) -> str:
runner_id = str(profile.get("runner_id") or "").strip()
if runner_id:
system_name, family_name = runner_id.split(".", 1)
else:
system_name = str(profile.get("system_id") or "").strip()
family_name = str(profile.get("vuln_family") or "").strip()
system_name = system_name.replace("-", "_")
family_name = family_name.replace("-", "_")
return f"lab.runners.{system_name}.{family_name}"
def _load_runner(profile: Dict[str, Any]):
return importlib.import_module(_module_name(profile))
def run_seed(profile: Dict[str, Any], advisory: Dict[str, Any], run_dir: Path) -> Dict[str, Any]:
module = _load_runner(profile)
context = RunnerContext(profile=profile, advisory=advisory, run_dir=run_dir)
return module.run_seed(context)
def run_attack(profile: Dict[str, Any], advisory: Dict[str, Any], run_dir: Path) -> Dict[str, Any]:
module = _load_runner(profile)
context = RunnerContext(profile=profile, advisory=advisory, run_dir=run_dir)
return module.run_attack(context)

查看文件

@@ -0,0 +1,2 @@
"""Gitea family runners."""

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "authz-bypass")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "authz-bypass")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "file-upload")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "file-upload")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "proxy-boundary")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "proxy-boundary")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "ssrf")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "ssrf")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "xss")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "xss")

查看文件

@@ -0,0 +1,2 @@
"""Next.js family runners."""

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "authz-bypass")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "authz-bypass")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "deserialization")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "deserialization")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "proxy-boundary")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "proxy-boundary")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "ssrf")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "ssrf")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "xss")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "xss")

查看文件

@@ -0,0 +1,2 @@
"""Undici family runners."""

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "ssrf")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "ssrf")

查看文件

@@ -0,0 +1,2 @@
"""Vite family runners."""

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "file-upload")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "file-upload")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "proxy-boundary")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "proxy-boundary")

查看文件

@@ -0,0 +1,10 @@
from lab.runners.common import RunnerContext, run_fixture_attack, run_fixture_seed
def run_seed(context: RunnerContext):
return run_fixture_seed(context, "xss")
def run_attack(context: RunnerContext):
return run_fixture_attack(context, "xss")

查看文件

@@ -1,14 +1,29 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
from lab.runners.dispatcher import run_seed as run_runner_seed
from lab.utils import write_json
def run_seed(profile: Dict[str, Any]) -> List[Dict[str, Any]]:
steps = []
def run_seed(profile: Dict[str, Any], advisory: Dict[str, Any], run_dir: Path, dry_run: bool = False) -> Dict[str, Any]:
if profile.get("runner_id") and not dry_run:
return run_runner_seed(profile, advisory, run_dir)
steps: List[Dict[str, Any]] = []
for action in profile.get("seed_actions", []):
kind = action.get("kind", "note")
if kind == "note":
steps.append({"kind": kind, "status": "recorded", "message": action.get("message", "")})
else:
steps.append({"kind": kind, "status": "skipped", "message": "Seed action type not yet automated"})
return steps
steps.append(
{
"kind": kind,
"status": "planned" if dry_run else "skipped",
"message": "Seed action type not yet automated",
}
)
payload = {"steps": steps, "seeded": not any(item["status"] == "skipped" for item in steps)}
write_json(run_dir / "logs" / "seed.json", payload)
return payload

查看文件

@@ -34,6 +34,9 @@ def validate_assets() -> List[str]:
]:
if field not in content:
errors.append(f"repro profile missing {field}: {path}")
fixture_path = content.get("fixture_path")
if fixture_path and not Path(fixture_path).exists():
errors.append(f"fixture path missing for {path}: {fixture_path}")
docker_available = command_available("docker")
profile_roots = sorted((ENV_CATALOG_DIR.parent.parent / "profiles").rglob("*.yaml"))
for path in profile_roots: