更新: 359 个文件 - 2026-03-16 23:30:01

这个提交包含在:
hao
2026-03-16 23:30:01 -07:00
父节点 527990f535
当前提交 2974cd9ad9
修改 359 个文件,包含 6332 行新增673 行删除

查看文件

@@ -12,10 +12,13 @@ THREAT_INTEL_ROOT = ROOT / "08-threat-intel"
REGISTRY_ROOT = THREAT_INTEL_ROOT / "registry"
ADVISORIES_DIR = REGISTRY_ROOT / "advisories"
SYSTEMS_DIR = REGISTRY_ROOT / "systems"
RUNS_DIR = REGISTRY_ROOT / "runs"
TRIAGE_DIR = REGISTRY_ROOT / "triage"
GENERATED_DIR = THREAT_INTEL_ROOT / "generated"
SECURE_CODE_ROOT = ROOT / "05-defense" / "secure-code"
SOURCE_MAP_PATH = THREAT_INTEL_ROOT / "source-map.yaml"
REPRO_MAP_PATH = THREAT_INTEL_ROOT / "repro-map.yaml"
REPRO_PROFILES_DIR = THREAT_INTEL_ROOT / "repro-profiles"
STATE_DIR = Path.home() / ".local" / "state" / "websafe-intel"
STATE_PATH = STATE_DIR / "state.json"
@@ -33,6 +36,16 @@ def load_source_map() -> Dict[str, Any]:
return data
def load_repro_map() -> Dict[str, Any]:
if not REPRO_MAP_PATH.exists():
return {"systems": []}
with REPRO_MAP_PATH.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
if not isinstance(data, dict) or "systems" not in data:
return {"systems": []}
return data
def get_systems_by_group(source_map: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
groups: Dict[str, List[Dict[str, Any]]] = {}
for system in source_map["systems"]:

查看文件

@@ -64,6 +64,21 @@ class AdvisoryRecord:
secure_code_topics: List[str]
status: str
triage_reasons: List[str] = field(default_factory=list)
verification_status: str = "triage-manual"
verification_mode: str = "synthetic"
last_verified_at: Optional[str] = None
last_run_id: Optional[str] = None
evidence_bundle: Optional[str] = None
browser_evidence: Dict[str, Any] = field(
default_factory=lambda: {
"required": False,
"present": False,
"refs": [],
}
)
repro_profile_id: Optional[str] = None
artifact_mode: Optional[str] = None
blocked_reason: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:

查看文件

@@ -8,6 +8,8 @@ from typing import Any, Dict, Iterable, List
from intel.config import FRAMEWORK_ROOT, GENERATED_DIR, REGISTRY_ROOT, ROOT, SECURE_CODE_ROOT, SYSTEMS_DIR, TRIAGE_DIR
from intel.models import AdvisoryRecord
from intel.utils import ensure_dir, isoformat, now_utc, write_json, write_text
from lab.render import render_dashboard as render_lab_dashboard
from lab.repro import annotate_with_latest_run, latest_runs_by_advisory
UTC = timezone.utc
@@ -109,6 +111,25 @@ FORBIDDEN_SCENARIOS = [
]
def _merged_item(item: AdvisoryRecord, run_map: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
return annotate_with_latest_run(item.to_dict(), run_map.get(item.canonical_id))
def _status_counts(items: List[Dict[str, Any]]) -> Dict[str, int]:
counts = {"verified_real": 0, "verified_synthetic": 0, "blocked": 0, "manual": 0}
for item in items:
status = item.get("verification_status")
if status == "verified-real":
counts["verified_real"] += 1
elif status == "verified-synthetic":
counts["verified_synthetic"] += 1
elif status and status.startswith("blocked-"):
counts["blocked"] += 1
else:
counts["manual"] += 1
return counts
def _recent_count(items: Iterable[AdvisoryRecord], days: int = 30) -> int:
cutoff = now_utc() - timedelta(days=days)
total = 0
@@ -161,6 +182,7 @@ def _clear_json_dir(path: Path) -> None:
def render_system_scaffolding(source_map: Dict[str, Any], advisories: List[AdvisoryRecord]) -> None:
run_map = latest_runs_by_advisory()
grouped: Dict[str, List[AdvisoryRecord]] = defaultdict(list)
for advisory in advisories:
grouped[advisory.system_id].append(advisory)
@@ -172,7 +194,9 @@ def render_system_scaffolding(source_map: Dict[str, Any], advisories: List[Advis
ensure_dir(system_dir / "cases")
items = sorted(grouped.get(system["system_id"], []), key=lambda item: item.published_at or "", reverse=True)
merged_items = [_merged_item(item, run_map) for item in items]
markdown_count = len([item for item in items if item.render_markdown and item.case_path])
counts = _status_counts(merged_items)
index_lines = [
f"# {system['display_name']}",
"",
@@ -184,6 +208,10 @@ def render_system_scaffolding(source_map: Dict[str, Any], advisories: List[Advis
f"- 总案例数: `{len(items)}`",
f"- 近 30 天新增/更新: `{_recent_count(items)}`",
f"- 重点 Markdown 案例数: `{markdown_count}`",
f"- 已实证(真实版本): `{counts['verified_real']}`",
f"- 已实证(synthetic): `{counts['verified_synthetic']}`",
f"- 阻塞数: `{counts['blocked']}`",
f"- 待人工/缺浏览器证据: `{counts['manual']}`",
f"- 最近渲染时间: `{isoformat(now_utc())}`",
"",
"## 目标约束",
@@ -205,19 +233,19 @@ def render_system_scaffolding(source_map: Dict[str, Any], advisories: List[Advis
"",
"## 案例列表",
"",
"| 标题 | 严重度 | 状态 | 来源置信度 | 更新时间 | 案例页 |",
"|------|--------|------|------------|----------|--------|",
"| 标题 | 严重度 | 案例状态 | 实证状态 | 实证方式 | 来源置信度 | 更新时间 | 案例页 |",
"|------|--------|----------|----------|----------|------------|----------|--------|",
]
)
if items:
for item in items:
case_link = f"[link]({_abs_repo_path(item.case_path)})" if item.case_path else "-"
timestamp = item.updated_at or item.published_at or ""
if merged_items:
for item in merged_items:
case_link = f"[link]({_abs_repo_path(item['case_path'])})" if item.get("case_path") else "-"
timestamp = item.get("updated_at") or item.get("published_at") or ""
index_lines.append(
f"| {item.title} | `{item.severity}` | `{item.status}` | `{item.source_confidence}` | `{timestamp}` | {case_link} |"
f"| {item['title']} | `{item['severity']}` | `{item['status']}` | `{item.get('verification_status', 'triage-manual')}` | `{item.get('verification_mode', '-')}` | `{item['source_confidence']}` | `{timestamp}` | {case_link} |"
)
else:
index_lines.append("| No advisories yet | `n/a` | `empty` | `n/a` | `n/a` | - |")
index_lines.append("| No advisories yet | `n/a` | `empty` | `n/a` | `n/a` | `n/a` | `n/a` | - |")
write_text(system_dir / "INDEX.md", "\n".join(index_lines))
system_registry_path = _abs_repo_path("08-threat-intel", "registry", "systems", f"{system['system_id']}.json")
@@ -274,9 +302,11 @@ def render_system_scaffolding(source_map: Dict[str, Any], advisories: List[Advis
def render_case_pages(advisories: List[AdvisoryRecord]) -> None:
run_map = latest_runs_by_advisory()
for item in advisories:
if not item.render_markdown or not item.case_path:
continue
merged = _merged_item(item, run_map)
lines = [
"---",
f'title: "{item.title.replace(chr(34), chr(39))}"',
@@ -288,6 +318,10 @@ def render_case_pages(advisories: List[AdvisoryRecord]) -> None:
f'severity: "{item.severity}"',
f'exploit_status: "{item.exploit_status}"',
f'source_confidence: "{item.source_confidence}"',
f'verification_status: "{merged.get("verification_status", "triage-manual")}"',
f'verification_mode: "{merged.get("verification_mode", "synthetic")}"',
f'artifact_mode: "{merged.get("artifact_mode") or ""}"',
f'last_run_id: "{merged.get("last_run_id") or ""}"',
'target_types:',
' - "lab-local"',
' - "lab-public"',
@@ -315,6 +349,15 @@ def render_case_pages(advisories: List[AdvisoryRecord]) -> None:
"",
f"# {item.title}",
"",
"## 本地实证状态",
"",
f"- 实证状态: `{merged.get('verification_status', 'triage-manual')}`",
f"- 实证方式: `{merged.get('verification_mode', 'synthetic')}`",
f"- Artifact 模式: `{merged.get('artifact_mode') or 'unknown'}`",
f"- 最近运行: `{merged.get('last_run_id') or '-'}`",
f"- 浏览器证据: `{'present' if merged.get('browser_evidence', {}).get('present') else 'missing'}`",
f"- Run Bundle: `{merged.get('evidence_bundle') or '-'}`",
"",
"## 事件层",
"",
f"- Canonical ID: `{item.canonical_id}`",
@@ -362,9 +405,10 @@ def render_registry(source_map: Dict[str, Any], advisories: List[AdvisoryRecord]
_clear_json_dir(REGISTRY_ROOT / "systems")
_clear_json_dir(TRIAGE_DIR)
run_map = latest_runs_by_advisory()
grouped: Dict[str, List[AdvisoryRecord]] = defaultdict(list)
for advisory in advisories:
write_json(REGISTRY_ROOT / "advisories" / f"{advisory.canonical_id}.json", advisory.to_dict())
write_json(REGISTRY_ROOT / "advisories" / f"{advisory.canonical_id}.json", _merged_item(advisory, run_map))
grouped[advisory.system_id].append(advisory)
triage_by_system: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
@@ -375,6 +419,8 @@ def render_registry(source_map: Dict[str, Any], advisories: List[AdvisoryRecord]
for system in source_map["systems"]:
system_id = system["system_id"]
items = grouped.get(system_id, [])
merged_items = [_merged_item(item, run_map) for item in items]
counts = _status_counts(merged_items)
payload = {
"system_id": system_id,
"display_name": system["display_name"],
@@ -386,6 +432,10 @@ def render_registry(source_map: Dict[str, Any], advisories: List[AdvisoryRecord]
"latest_update": max((item.updated_at or item.published_at or "" for item in items), default=""),
"output_dir": system["output_dir"],
"secure_code_topics": system.get("secure_code_topics", []),
"verified_real": counts["verified_real"],
"verified_synthetic": counts["verified_synthetic"],
"blocked_count": counts["blocked"],
"manual_count": counts["manual"],
"items": [item.canonical_id for item in sorted(items, key=lambda item: item.published_at or "", reverse=True)],
}
write_json(SYSTEMS_DIR / f"{system_id}.json", payload)
@@ -400,6 +450,7 @@ def render_generated(
) -> None:
ensure_dir(GENERATED_DIR)
systems = {item["system_id"]: item for item in source_map["systems"]}
run_map = latest_runs_by_advisory()
change_summary = change_summary or {}
triage_by_system: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for item in triage:
@@ -408,19 +459,24 @@ def render_generated(
coverage_lines = [
"# 覆盖矩阵",
"",
"| 系统 | 分类 | 覆盖策略 | 历史全量 | 近两年全量 | 全量 registry | 重点案例 Markdown | secure-code 关联 | 自动同步状态 | triage | 最近更新 |",
"|------|------|----------|----------|------------|--------------|--------------------|------------------|--------------|--------|----------|",
"| 系统 | 分类 | 覆盖策略 | 历史全量 | 近两年全量 | 全量 registry | 重点案例 Markdown | secure-code 关联 | 自动同步状态 | 本地实证状态 | 浏览器证据 | run bundle | triage | 最近更新 |",
"|------|------|----------|----------|------------|--------------|--------------------|------------------|--------------|--------------|------------|-----------|--------|----------|",
]
by_system: Dict[str, List[AdvisoryRecord]] = defaultdict(list)
for advisory in advisories:
by_system[advisory.system_id].append(advisory)
for system_id, system in sorted(systems.items()):
items = by_system.get(system_id, [])
merged_items = [_merged_item(item, run_map) for item in items]
counts = _status_counts(merged_items)
markdown_count = len([item for item in items if item.case_path])
sync_state = "seeded" if items else "scaffolded"
recent = max((item.updated_at or item.published_at or "" for item in items), default="")
browser_present = len([item for item in merged_items if item.get("browser_evidence", {}).get("present")])
run_bundle_count = len([item for item in merged_items if item.get("last_run_id")])
proof_state = f"real:{counts['verified_real']}/synthetic:{counts['verified_synthetic']}/blocked:{counts['blocked']}"
coverage_lines.append(
f"| {system['display_name']} | `{system['category']}` | `{system['tier']}` | `{'yes' if system['tier'] == 'history-full' else '-'}` | `yes` | `{len(items)}` | `{markdown_count}` | `{len(system.get('secure_code_topics', []))}` | `{sync_state}` | `{len(triage_by_system.get(system_id, []))}` | `{recent}` |"
f"| {system['display_name']} | `{system['category']}` | `{system['tier']}` | `{'yes' if system['tier'] == 'history-full' else '-'}` | `yes` | `{len(items)}` | `{markdown_count}` | `{len(system.get('secure_code_topics', []))}` | `{sync_state}` | `{proof_state}` | `{browser_present}` | `{run_bundle_count}` | `{len(triage_by_system.get(system_id, []))}` | `{recent}` |"
)
write_text(GENERATED_DIR / "coverage-matrix.md", "\n".join(coverage_lines))
@@ -432,6 +488,7 @@ def render_generated(
f"- 系统数量: `{len(source_map['systems'])}`",
f"- Advisory 数量: `{len(advisories)}`",
f"- 重点 Markdown 数量: `{markdown_total}`",
f"- Run Bundle 数量: `{len(run_map)}`",
f"- 新增记录: `{change_summary.get('new_count', 0)}`",
f"- 更新记录: `{change_summary.get('updated_count', 0)}`",
f"- Triage 数量: `{len(triage)}`",
@@ -454,9 +511,11 @@ def render_generated(
"updated_count": change_summary.get("updated_count", 0),
"systems_touched": change_summary.get("systems_touched", []),
"triage_count": len(triage),
"run_bundle_count": len(run_map),
"failures": failures,
},
)
render_lab_dashboard()
def render_secure_code(source_map: Dict[str, Any]) -> None:

查看文件

@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
from intel.config import FRAMEWORK_ROOT, GENERATED_DIR, REGISTRY_ROOT, ROOT, SECURE_CODE_ROOT, SOURCE_MAP_PATH, SYSTEMS_DIR
from intel.config import FRAMEWORK_ROOT, GENERATED_DIR, REGISTRY_ROOT, REPRO_MAP_PATH, ROOT, SECURE_CODE_ROOT, SOURCE_MAP_PATH, SYSTEMS_DIR
from intel.render import LANGUAGES, TOPIC_DESCRIPTIONS
from intel.utils import load_all_json
@@ -15,6 +15,9 @@ REQUIRED_REGISTRY_FIELDS = {
"severity",
"source_confidence",
"status",
"verification_status",
"verification_mode",
"repro_profile_id",
}
REQUIRED_SYSTEM_FIELDS = {
@@ -36,6 +39,8 @@ def validate(source_map: Dict[str, Any]) -> List[str]:
errors: List[str] = []
if not SOURCE_MAP_PATH.exists():
errors.append("source-map.yaml is missing")
if not REPRO_MAP_PATH.exists():
errors.append("repro-map.yaml is missing")
systems = source_map.get("systems", [])
ids = set()
@@ -76,6 +81,8 @@ def validate(source_map: Dict[str, Any]) -> List[str]:
GENERATED_DIR / "coverage-matrix.md",
GENERATED_DIR / "latest-ingest.md",
GENERATED_DIR / "run-summary.json",
GENERATED_DIR / "dashboard" / "index.html",
GENERATED_DIR / "dashboard" / "summary.json",
ROOT / "08-threat-intel" / "registry" / "source-confidence.md",
]:
if not path.exists():

1
scripts/lab/__init__.py 普通文件
查看文件

@@ -0,0 +1 @@
"""Local authorized lab orchestration package."""

66
scripts/lab/attack.py 普通文件
查看文件

@@ -0,0 +1,66 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any, Dict, List
from lab.utils import write_json
TOOL_COMMANDS = {
"xss-fuzzer": ["python3", "/Users/x/websafe/02-xss/tools/xss-fuzzer.py"],
"xss-scanner": ["go", "run", "/Users/x/websafe/02-xss/tools/xss-scanner.go"],
"sqli-scanner": ["python3", "/Users/x/websafe/01-sql-injection/tools/sqli-scanner.py"],
"blind-sqli": ["python3", "/Users/x/websafe/01-sql-injection/tools/blind-sqli.py"],
"session-lab": ["python3", "/Users/x/websafe/03-authentication/session/tools/session-lab.py"],
"misconfig-lab": ["python3", "/Users/x/websafe/04-server-security/misconfiguration/tools/misconfig-lab.py"],
"tls-scanner": ["python3", "/Users/x/websafe/04-server-security/tls/tools/tls-scanner.py"],
"site-scope-mapper": ["python3", "/Users/x/websafe/04-server-security/infrastructure/tools/site-scope-mapper.py"],
}
def _render_args(step: Dict[str, Any], profile: Dict[str, Any], advisory: Dict[str, Any], run_dir: Path) -> List[str]:
target = (profile.get("baseline_urls") or [""])[0]
mapping = {
"{target_url}": target,
"{run_id}": run_dir.name,
"{case_id}": advisory["canonical_id"],
"{evidence_dir}": str(run_dir / "logs"),
}
args: List[str] = []
for item in step.get("args", []):
rendered = item
for key, value in mapping.items():
rendered = rendered.replace(key, value)
args.append(rendered)
return args
def run_attack(profile: Dict[str, Any], advisory: Dict[str, Any], run_dir: Path, dry_run: bool = False) -> Dict[str, Any]:
steps: List[Dict[str, Any]] = []
for step in profile.get("attack_actions", []):
tool_name = step.get("tool")
args = _render_args(step, profile, advisory, run_dir)
record = {
"kind": step.get("kind", "tool"),
"tool": tool_name,
"args": args,
"status": "planned" if dry_run else "skipped",
}
if step.get("kind") == "tool" and tool_name in TOOL_COMMANDS and not dry_run:
output_path = run_dir / "logs" / f"{tool_name}.json"
cmd = TOOL_COMMANDS[tool_name] + args + ["--ack-authorized", "--format", "json", "--output", str(output_path)]
completed = subprocess.run(cmd, text=True, capture_output=True)
record.update(
{
"status": "completed" if completed.returncode == 0 else "failed",
"returncode": completed.returncode,
"stdout_excerpt": completed.stdout[-400:],
"stderr_excerpt": completed.stderr[-400:],
"result_path": str(output_path),
}
)
steps.append(record)
payload = {"steps": steps}
write_json(run_dir / "logs" / "attack.json", payload)
return payload

28
scripts/lab/baseline.py 普通文件
查看文件

@@ -0,0 +1,28 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import requests
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]] = []
for url in profile.get("baseline_urls", []):
try:
response = requests.get(url, timeout=timeout, verify=False)
observations.append(
{
"url": url,
"status_code": response.status_code,
"headers": dict(response.headers),
"body_excerpt": response.text[:400],
}
)
except Exception as exc:
observations.append({"url": url, "error": str(exc)})
payload = {"observations": observations}
write_json(run_dir / "logs" / "baseline.json", payload)
return payload

47
scripts/lab/browser.py 普通文件
查看文件

@@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
from lab.utils import ensure_dir, write_json
def capture(url: str, run_dir: Path, prefix: str = "baseline") -> Dict[str, Any]:
payload: Dict[str, Any] = {
"required": True,
"present": False,
"refs": [],
"reason": "playwright runtime unavailable",
}
try:
from playwright.sync_api import sync_playwright # type: ignore
except Exception:
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)
return payload
assets_dir = run_dir / "assets"
ensure_dir(assets_dir)
screenshot_path = assets_dir / f"{prefix}.png"
dom_path = assets_dir / f"{prefix}-dom.html"
console_path = run_dir / "logs" / f"{prefix}-console.json"
network_path = run_dir / "logs" / f"{prefix}-network.json"
console_messages: List[Dict[str, Any]] = []
requests_seen: List[Dict[str, Any]] = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
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)
page.screenshot(path=str(screenshot_path), full_page=True)
dom_path.write_text(page.content(), encoding="utf-8")
browser.close()
write_json(console_path, console_messages)
write_json(network_path, requests_seen)
payload = {
"required": True,
"present": True,
"refs": [str(screenshot_path), str(dom_path), str(console_path), str(network_path)],
}
write_json(run_dir / "logs" / f"{prefix}-browser.json", payload)
return payload

254
scripts/lab/catalog.py 普通文件
查看文件

@@ -0,0 +1,254 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Tuple
import yaml
from intel.config import load_source_map
from lab.config import ENV_CATALOG_DIR, ENV_PROFILES_DIR, REPRO_MAP_PATH
from lab.utils import ensure_dir, read_yaml, slugify, write_yaml
IMAGE_HINTS: Dict[str, Dict[str, Any]] = {
"wordpress": {
"artifact_mode": "official-image",
"services": {
"app": {"image": "wordpress:php8.2-apache", "ports": ["18080:80"]},
"db": {
"image": "mariadb:10.11",
"environment": {
"MARIADB_DATABASE": "wordpress",
"MARIADB_USER": "wordpress",
"MARIADB_PASSWORD": "wordpress",
"MARIADB_ROOT_PASSWORD": "root",
},
},
},
"browser_required": True,
},
"drupal": {
"artifact_mode": "official-image",
"services": {
"app": {"image": "drupal:10-apache", "ports": ["18081:80"]},
"db": {
"image": "postgres:15",
"environment": {
"POSTGRES_DB": "drupal",
"POSTGRES_USER": "drupal",
"POSTGRES_PASSWORD": "drupal",
},
},
},
"browser_required": True,
},
"joomla": {
"artifact_mode": "official-image",
"services": {
"app": {"image": "joomla:latest", "ports": ["18082:80"]},
"db": {
"image": "mariadb:10.11",
"environment": {
"MARIADB_DATABASE": "joomla",
"MARIADB_USER": "joomla",
"MARIADB_PASSWORD": "joomla",
"MARIADB_ROOT_PASSWORD": "root",
},
},
},
"browser_required": True,
},
"prestashop": {
"artifact_mode": "official-image",
"services": {
"app": {"image": "prestashop/prestashop:latest", "ports": ["18083:80"]},
"db": {
"image": "mariadb:10.11",
"environment": {
"MARIADB_DATABASE": "prestashop",
"MARIADB_USER": "prestashop",
"MARIADB_PASSWORD": "prestashop",
"MARIADB_ROOT_PASSWORD": "root",
},
},
},
"browser_required": True,
},
"opencart": {
"artifact_mode": "official-image",
"services": {
"app": {"image": "bitnami/opencart:latest", "ports": ["18084:8080"]},
"db": {
"image": "mariadb:10.11",
"environment": {
"MARIADB_DATABASE": "opencart",
"MARIADB_USER": "opencart",
"MARIADB_PASSWORD": "opencart",
"MARIADB_ROOT_PASSWORD": "root",
},
},
},
"browser_required": True,
},
"gitea": {
"artifact_mode": "official-image",
"services": {
"app": {"image": "gitea/gitea:1.22.6", "ports": ["18085:3000"]},
},
"browser_required": True,
},
"nginx": {
"artifact_mode": "official-image",
"services": {"app": {"image": "nginx:1.27-alpine", "ports": ["18086:80"]}},
"browser_required": False,
},
"apache-httpd": {
"artifact_mode": "official-image",
"services": {"app": {"image": "httpd:2.4", "ports": ["18087:80"]}},
"browser_required": False,
},
"apache-tomcat": {
"artifact_mode": "official-image",
"services": {"app": {"image": "tomcat:10.1", "ports": ["18088:8080"]}},
"browser_required": False,
},
"nodejs": {
"artifact_mode": "official-source",
"services": {"app": {"image": "node:22-alpine", "ports": ["18089:3000"]}},
"browser_required": False,
},
"nextjs": {
"artifact_mode": "official-source",
"services": {"app": {"image": "node:22-alpine", "ports": ["18090:3000"]}},
"browser_required": True,
},
"vue": {
"artifact_mode": "official-source",
"services": {"app": {"image": "node:22-alpine", "ports": ["18091:5173"]}},
"browser_required": True,
},
"nuxt": {
"artifact_mode": "official-source",
"services": {"app": {"image": "node:22-alpine", "ports": ["18092:3000"]}},
"browser_required": True,
},
"vite": {
"artifact_mode": "official-source",
"services": {"app": {"image": "node:22-alpine", "ports": ["18093:5173"]}},
"browser_required": True,
},
}
def _default_repro_family(system: Dict[str, Any]) -> str:
topics = set(system.get("secure_code_topics", []))
text = " ".join([system["display_name"], system["system_id"], *topics]).lower()
if "xss" in text or "trusted types" in text:
return "xss-generic"
if "proxy" in text or "middleware" in text:
return "proxy-boundary-generic"
if "upload" in text:
return "file-upload-generic"
if "ssrf" in text:
return "ssrf-generic"
if "deserialization" in text:
return "deserialization-generic"
if "template" in text:
return "template-injection-generic"
if "token" in text or "cookie" in text or "session" in text:
return "session-token-generic"
if any(mode in {"plugin", "module", "extension"} for mode in system.get("advisory_modes", [])):
return "plugin-extension-generic"
return "authz-bypass-generic"
def _fallback_port(system_id: str) -> str:
base = 18100 + (sum(ord(ch) for ch in system_id) % 700)
return f"{base}:80"
def _system_catalog(system: Dict[str, Any]) -> Dict[str, Any]:
hint = IMAGE_HINTS.get(system["system_id"], {})
artifact_mode = hint.get("artifact_mode", "synthetic")
services = hint.get(
"services",
{"app": {"image": "nginxdemos/hello:latest", "ports": [_fallback_port(system["system_id"])]}},
)
return {
"system_id": system["system_id"],
"display_name": system["display_name"],
"category": system["category"],
"tier": system["tier"],
"artifact_mode_preference": [
artifact_mode,
"official-source" if artifact_mode != "official-source" else "synthetic",
"synthetic",
],
"default_repro_family": _default_repro_family(system),
"browser_required_default": bool(hint.get("browser_required", system["category"] in {"cms", "ecommerce", "frameworks", "platforms"})),
"log_collectors": ["docker-logs", "http-snapshot"],
"report_template": "default-lab-report",
"services": services,
"source_reference": system.get("official_sources", [])[:2],
}
def _profile_for_system(system: Dict[str, Any], catalog: Dict[str, Any]) -> Dict[str, Any]:
ports = []
for service in catalog["services"].values():
ports.extend(service.get("ports", []))
baseline_url = None
if ports:
first = str(ports[0]).split(":")[0]
baseline_url = f"http://127.0.0.1:{first}/"
return {
"profile_id": f"{system['system_id']}-core-current",
"system_id": system["system_id"],
"version": "current",
"artifact_mode": catalog["artifact_mode_preference"][0],
"verification_mode": "real" if catalog["artifact_mode_preference"][0] != "synthetic" else "synthetic",
"browser_required": catalog["browser_required_default"],
"services": catalog["services"],
"baseline_urls": [baseline_url] if baseline_url else [],
"seed_actions": [{"kind": "note", "message": "Use default seed strategy derived from repro profile."}],
"cleanup_policy": "destroy",
}
def _build_repro_map_entry(system: Dict[str, Any], catalog: Dict[str, Any]) -> Dict[str, Any]:
return {
"system_id": system["system_id"],
"default_repro_family": catalog["default_repro_family"],
"provisioning_mode_preference": catalog["artifact_mode_preference"],
"browser_required_default": catalog["browser_required_default"],
"seed_strategy": "default-seed" if catalog["browser_required_default"] else "minimal-seed",
"log_collectors": catalog["log_collectors"],
"report_template": catalog["report_template"],
}
def sync_catalog(write_profiles: bool = True, write_repro_map: bool = True) -> Dict[str, Any]:
source_map = load_source_map()
ensure_dir(ENV_CATALOG_DIR)
ensure_dir(ENV_PROFILES_DIR / "core")
written_catalogs = 0
written_profiles = 0
repro_entries: List[Dict[str, Any]] = []
for system in source_map["systems"]:
catalog = _system_catalog(system)
catalog_path = ENV_CATALOG_DIR / f"{system['system_id']}.yaml"
write_yaml(catalog_path, catalog)
written_catalogs += 1
if write_profiles:
profile_dir = ENV_PROFILES_DIR / "core" / system["system_id"]
write_yaml(profile_dir / "current.yaml", _profile_for_system(system, catalog))
written_profiles += 1
repro_entries.append(_build_repro_map_entry(system, catalog))
if write_repro_map:
write_yaml(REPRO_MAP_PATH, {"systems": repro_entries})
return {
"systems": len(source_map["systems"]),
"catalogs_written": written_catalogs,
"profiles_written": written_profiles,
"repro_map_written": write_repro_map,
}

32
scripts/lab/compose.py 普通文件
查看文件

@@ -0,0 +1,32 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Tuple
from lab.utils import write_yaml
def compose_payload(profile: Dict[str, Any]) -> Dict[str, Any]:
services = {}
for service_name, service in profile.get("services", {}).items():
payload = {
"image": service["image"],
}
if service.get("ports"):
payload["ports"] = service["ports"]
if service.get("environment"):
payload["environment"] = service["environment"]
if service.get("depends_on"):
payload["depends_on"] = service["depends_on"]
services[service_name] = payload
return {
"services": services,
"networks": {"labnet": {"driver": "bridge"}},
}
def generate_compose(profile: Dict[str, Any], run_dir: Path) -> Tuple[Path, Dict[str, Any]]:
payload = compose_payload(profile)
compose_path = run_dir / "compose" / "compose.yaml"
write_yaml(compose_path, payload)
return compose_path, payload

25
scripts/lab/config.py 普通文件
查看文件

@@ -0,0 +1,25 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS_ROOT = ROOT / "scripts"
INTEL_ROOT = ROOT / "08-threat-intel"
REGISTRY_ROOT = INTEL_ROOT / "registry"
ADVISORIES_DIR = REGISTRY_ROOT / "advisories"
SYSTEMS_DIR = REGISTRY_ROOT / "systems"
RUNS_DIR = REGISTRY_ROOT / "runs"
TRIAGE_DIR = REGISTRY_ROOT / "triage"
GENERATED_DIR = INTEL_ROOT / "generated"
DASHBOARD_DIR = GENERATED_DIR / "dashboard"
QUEUE_DIR = INTEL_ROOT / "queue"
QUEUE_PATH = QUEUE_DIR / "repro-queue.json"
SOURCE_MAP_PATH = INTEL_ROOT / "source-map.yaml"
REPRO_MAP_PATH = INTEL_ROOT / "repro-map.yaml"
REPRO_PROFILES_DIR = INTEL_ROOT / "repro-profiles"
CASE_RUNS_DIR = ROOT / "06-case-studies" / "generated-runs"
ENV_ROOT = ROOT / "00-environments"
ENV_CATALOG_DIR = ENV_ROOT / "catalog" / "systems"
ENV_PROFILES_DIR = ENV_ROOT / "profiles"
ENV_TEMPLATES_DIR = ENV_ROOT / "templates" / "synthetic"

32
scripts/lab/evidence.py 普通文件
查看文件

@@ -0,0 +1,32 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
from lab.utils import command_available, run, write_json
def collect_container_logs(run_dir: Path, compose_path: Path) -> List[str]:
if not command_available("docker"):
return []
log_dir = run_dir / "logs" / "docker"
log_dir.mkdir(parents=True, exist_ok=True)
ps = run(["docker", "compose", "-f", str(compose_path), "ps", "--services"], cwd=run_dir)
refs: List[str] = []
if ps.returncode != 0:
return refs
for service in ps.stdout.splitlines():
service = service.strip()
if not service:
continue
result = run(["docker", "compose", "-f", str(compose_path), "logs", "--no-color", service], cwd=run_dir)
path = log_dir / f"{service}.log"
path.write_text(result.stdout or result.stderr or "", encoding="utf-8")
refs.append(str(path))
return refs
def write_run_bundle(run_dir: Path, bundle: Dict[str, Any]) -> Path:
path = run_dir / "run.json"
write_json(path, bundle)
return path

380
scripts/lab/main.py 普通文件
查看文件

@@ -0,0 +1,380 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any, Dict, List
CURRENT_DIR = Path(__file__).resolve().parent
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.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
def _load_advisory(canonical_id: str) -> Dict[str, Any]:
advisory = read_json(ADVISORIES_DIR / f"{canonical_id}.json", default=None)
if not advisory:
raise ValueError(f"Unknown advisory: {canonical_id}")
return advisory
def _run_dir(run_id: str) -> Path:
path = CASE_RUNS_DIR / run_id
ensure_dir(path)
ensure_dir(path / "logs")
ensure_dir(path / "assets")
return path
def _compose_run_id(advisory: Dict[str, Any]) -> str:
return f"{advisory['system_id']}-{advisory['canonical_id']}-{now_utc().strftime('%Y%m%d%H%M%S')}"
def _resolve_profile(advisory: Dict[str, Any]) -> Dict[str, Any]:
profile = repro.resolve_profile(advisory["canonical_id"], advisory)
current_profile = read_yaml(ENV_PROFILES_DIR / "core" / advisory["system_id"] / "current.yaml", default={}) or {}
merged = dict(current_profile)
merged.update(profile)
if current_profile.get("services") and not merged.get("services"):
merged["services"] = current_profile["services"]
if current_profile.get("baseline_urls") and not merged.get("baseline_urls"):
merged["baseline_urls"] = current_profile["baseline_urls"]
if current_profile.get("artifact_mode") and not merged.get("artifact_mode"):
merged["artifact_mode"] = current_profile["artifact_mode"]
if current_profile.get("verification_mode") and not merged.get("verification_mode"):
merged["verification_mode"] = current_profile["verification_mode"]
if current_profile.get("browser_required"):
merged.setdefault("browser_assertions", {})
merged["browser_assertions"].setdefault("required", current_profile["browser_required"])
if not profile.get("system_id"):
merged["system_id"] = advisory["system_id"]
if not profile.get("profile_id"):
merged["profile_id"] = advisory["canonical_id"]
return merged
def _build_run_bundle(
advisory: Dict[str, Any],
profile: Dict[str, Any],
run_id: str,
verification_status: str,
verification_mode: str,
artifact_mode: str,
baseline_refs: List[str],
attack_steps: List[Dict[str, Any]],
browser_refs: List[str],
container_log_refs: List[str],
request_log_refs: List[str],
blocked_reason: str | None,
) -> Dict[str, Any]:
return {
"run_id": run_id,
"system_id": advisory["system_id"],
"advisory_id": advisory["canonical_id"],
"repro_profile_id": profile["profile_id"],
"verification_status": verification_status,
"verification_mode": verification_mode,
"artifact_mode": artifact_mode,
"target_env": "local-docker",
"compose_services": sorted(profile.get("services", {}).keys()),
"baseline_refs": baseline_refs,
"attack_steps": attack_steps,
"browser_refs": browser_refs,
"container_log_refs": container_log_refs,
"request_log_refs": request_log_refs,
"timeline": [],
"started_at": isoformat(now_utc()),
"finished_at": isoformat(now_utc()),
"blocked_reason": blocked_reason,
}
def cmd_catalog_sync(args) -> int:
summary = catalog.sync_catalog(write_profiles=True, write_repro_map=True)
print(summary)
return 0
def cmd_compose_generate(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
run_dir = _run_dir(args.run_id or f"compose-{advisory['canonical_id']}")
compose_result = provision.prepare(profile, run_dir, dry_run=True)
print(compose_result)
return 0
def cmd_provision(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
run_dir = _run_dir(args.run_id or _compose_run_id(advisory))
result = provision.prepare(profile, run_dir, dry_run=args.dry_run)
print(result)
return 0
def cmd_seed(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
print({"steps": seed.run_seed(profile)})
return 0
def cmd_baseline(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
run_dir = _run_dir(args.run_id or _compose_run_id(advisory))
result = baseline.collect(profile, run_dir)
print(result)
return 0
def cmd_attack(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
run_dir = _run_dir(args.run_id or _compose_run_id(advisory))
result = attack.run_attack(profile, advisory, run_dir, dry_run=args.dry_run)
print(result)
return 0
def cmd_verify(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
browser_required = bool(profile.get("browser_assertions", {}).get("required"))
payload = {
"advisory": advisory["canonical_id"],
"profile_id": profile["profile_id"],
"browser_required": browser_required,
"result": "ready-for-run",
}
print(payload)
return 0
def cmd_run_case(args) -> int:
advisory = _load_advisory(args.case)
profile = _resolve_profile(advisory)
run_id = args.run_id or _compose_run_id(advisory)
run_dir = _run_dir(run_id)
provision_result = provision.prepare(profile, run_dir, dry_run=args.dry_run)
baseline_payload = baseline.collect(profile, run_dir) if profile.get("baseline_urls") else {"observations": []}
attack_payload = attack.run_attack(profile, advisory, run_dir, dry_run=args.dry_run)
browser_payload = {"required": bool(profile.get("browser_assertions", {}).get("required")), "present": False, "refs": []}
blocked_reason = provision_result.get("blocked_reason")
if browser_payload["required"] and not args.dry_run and profile.get("baseline_urls"):
browser_payload = browser.capture(profile["baseline_urls"][0], run_dir, prefix="proof")
if not browser_payload.get("present"):
blocked_reason = blocked_reason or browser_payload.get("reason")
compose_path = Path(provision_result["compose_path"])
container_logs = evidence.collect_container_logs(run_dir, compose_path) if compose_path.exists() else []
verification_status = "triage-manual"
verification_mode = profile.get("verification_mode", "synthetic")
artifact_mode = profile.get("artifact_mode", profile.get("provisioning_mode", "synthetic"))
if args.dry_run:
verification_status = "triage-manual"
blocked_reason = blocked_reason or "dry-run only"
elif provision_result.get("status") == "blocked-artifact":
verification_status = "blocked-artifact"
elif browser_payload.get("required") and not browser_payload.get("present"):
verification_status = "triage-manual"
elif artifact_mode == "synthetic":
verification_status = "verified-synthetic"
else:
verification_status = "verified-real"
bundle = _build_run_bundle(
advisory=advisory,
profile=profile,
run_id=run_id,
verification_status=verification_status,
verification_mode=verification_mode,
artifact_mode=artifact_mode,
baseline_refs=[str(run_dir / "logs" / "baseline.json")] if baseline_payload.get("observations") else [],
attack_steps=attack_payload.get("steps", []),
browser_refs=browser_payload.get("refs", []),
container_log_refs=container_logs,
request_log_refs=[str(run_dir / "logs" / "attack.json"), str(run_dir / "logs" / "baseline.json")],
blocked_reason=blocked_reason,
)
report_refs = render.render_run(bundle)
bundle["report_refs"] = report_refs
evidence.write_run_bundle(run_dir, bundle)
ensure_dir(RUNS_DIR)
write_json(RUNS_DIR / f"{run_id}.json", bundle)
render.render_dashboard()
print(bundle)
return 0
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]
for advisory in selected:
cmd_run_case(argparse.Namespace(case=advisory["canonical_id"], run_id=None, dry_run=args.dry_run))
print({"system": args.system, "count": len(selected)})
return 0
def cmd_run_batch(args) -> int:
if args.from_queue:
items = task_queue.dequeue(limit=args.limit)
else:
task_queue.enqueue_from_registry(only_hotlane=args.only_hotlane, limit=args.limit)
items = task_queue.dequeue(limit=args.limit)
for item in items:
cmd_run_case(argparse.Namespace(case=item["advisory_id"], run_id=None, dry_run=args.dry_run))
print({"processed": len(items)})
return 0
def cmd_render_run(args) -> int:
run = read_json(RUNS_DIR / f"{args.run_id}.json", default=None)
if not run:
raise ValueError(f"Unknown run: {args.run_id}")
print(render.render_run(run))
return 0
def cmd_serve_dashboard(args) -> int:
render.render_dashboard()
import http.server
import socketserver
os_dir = str(render.DASHBOARD_DIR if hasattr(render, "DASHBOARD_DIR") else "")
if not os_dir:
from lab.config import DASHBOARD_DIR
os_dir = str(DASHBOARD_DIR)
handler = http.server.SimpleHTTPRequestHandler
with socketserver.TCPServer(("127.0.0.1", args.port), handler) as httpd:
print(f"serving dashboard at http://127.0.0.1:{args.port}/")
import os
os.chdir(os_dir)
httpd.serve_forever()
def cmd_cleanup(args) -> int:
run = read_json(RUNS_DIR / f"{args.run_id}.json", default=None)
if not run:
raise ValueError(f"Unknown run: {args.run_id}")
compose_path = Path(run["report_refs"]["bundle_dir"]) / "compose" / "compose.yaml"
if command_available("docker") and compose_path.exists():
from lab.utils import run as shell_run
shell_run(["docker", "compose", "-f", str(compose_path), "down", "-v"], cwd=compose_path.parent.parent)
print({"cleaned": args.run_id})
return 0
def cmd_retry_failures(args) -> int:
failed = [
item
for item in load_json_dir(RUNS_DIR)
if item.get("verification_status") in {"blocked-artifact", "triage-manual"}
]
task_queue.enqueue_items(
[{"advisory_id": item["advisory_id"], "system_id": item["system_id"], "priority": "retry"} for item in failed[: args.limit]]
)
print({"requeued": min(len(failed), args.limit)})
return 0
def cmd_validate(args) -> int:
errors = validators.validate_assets()
if errors:
print("Validation failed:")
for error in errors:
print(f"- {error}")
return 1
print("Validation passed.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Websafe local lab orchestrator")
subparsers = parser.add_subparsers(dest="command", required=True)
catalog_sync = subparsers.add_parser("catalog", help="catalog operations")
catalog_sub = catalog_sync.add_subparsers(dest="catalog_command", required=True)
catalog_sync_cmd = catalog_sub.add_parser("sync", help="sync environment catalog and repro map")
catalog_sync_cmd.set_defaults(func=cmd_catalog_sync)
compose_generate = subparsers.add_parser("compose", help="compose operations")
compose_sub = compose_generate.add_subparsers(dest="compose_command", required=True)
compose_generate_cmd = compose_sub.add_parser("generate", help="generate compose file for a case")
compose_generate_cmd.add_argument("--case", required=True)
compose_generate_cmd.add_argument("--run-id")
compose_generate_cmd.set_defaults(func=cmd_compose_generate)
for name, func in [
("provision", cmd_provision),
("seed", cmd_seed),
("baseline", cmd_baseline),
("attack", cmd_attack),
("verify", cmd_verify),
]:
sub = subparsers.add_parser(name)
sub.add_argument("--case", required=True)
sub.add_argument("--run-id")
sub.add_argument("--dry-run", action="store_true")
sub.set_defaults(func=func)
run_case = subparsers.add_parser("run-case", help="run a single advisory through the lab pipeline")
run_case.add_argument("--case", required=True)
run_case.add_argument("--run-id")
run_case.add_argument("--dry-run", action="store_true")
run_case.set_defaults(func=cmd_run_case)
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("--dry-run", action="store_true")
run_system.set_defaults(func=cmd_run_system)
run_batch = subparsers.add_parser("run-batch", help="process repro queue or enqueue from registry")
run_batch.add_argument("--limit", type=int, default=10)
run_batch.add_argument("--only-hotlane", action="store_true")
run_batch.add_argument("--from-queue", action="store_true")
run_batch.add_argument("--dry-run", action="store_true")
run_batch.set_defaults(func=cmd_run_batch)
render_run = subparsers.add_parser("render-run", help="re-render a stored run")
render_run.add_argument("--run-id", required=True)
render_run.set_defaults(func=cmd_render_run)
serve = subparsers.add_parser("serve-dashboard", help="serve the static dashboard locally")
serve.add_argument("--port", type=int, default=8734)
serve.set_defaults(func=cmd_serve_dashboard)
cleanup = subparsers.add_parser("cleanup", help="tear down a stored run compose environment")
cleanup.add_argument("--run-id", required=True)
cleanup.set_defaults(func=cmd_cleanup)
retry = subparsers.add_parser("retry-failures", help="requeue blocked or manual runs")
retry.add_argument("--limit", type=int, default=50)
retry.set_defaults(func=cmd_retry_failures)
validate = subparsers.add_parser("validate", help="validate lab assets")
validate.set_defaults(func=cmd_validate)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())

40
scripts/lab/provision.py 普通文件
查看文件

@@ -0,0 +1,40 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
from lab.compose import generate_compose
from lab.utils import command_available, run
def prepare(profile: Dict[str, Any], run_dir: Path, dry_run: bool = False) -> Dict[str, Any]:
compose_path, payload = generate_compose(profile, run_dir)
result = {
"compose_path": str(compose_path),
"service_count": len(payload.get("services", {})),
"docker_available": command_available("docker"),
"status": "ready",
}
if dry_run:
result["status"] = "planned"
return result
if not result["docker_available"]:
result["status"] = "blocked-artifact"
result["blocked_reason"] = "docker unavailable on this machine"
return result
config = run(["docker", "compose", "-f", str(compose_path), "config"], cwd=run_dir)
result["compose_config_rc"] = config.returncode
if config.returncode != 0:
result["status"] = "blocked-artifact"
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)
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"
return result
return result

166
scripts/lab/render.py 普通文件
查看文件

@@ -0,0 +1,166 @@
from __future__ import annotations
import html
from pathlib import Path
from typing import Any, Dict, List
from lab.config import CASE_RUNS_DIR, DASHBOARD_DIR, RUNS_DIR
from lab.utils import ensure_dir, load_json_dir, read_json, write_json, write_text
def mermaid_from_steps(run: Dict[str, Any]) -> str:
lines = [
"flowchart LR",
'A["Select Advisory"] --> B["Resolve Repro Profile"]',
'B --> C["Provision Compose Environment"]',
'C --> D["Baseline Snapshot"]',
'D --> E["Controlled Attack Steps"]',
'E --> F["Browser Replay"]',
'F --> G["Collect Logs and Evidence"]',
'G --> H["Update Registry and Reports"]',
]
if run.get("blocked_reason"):
lines.append(f'H --> I["Blocked: {run["blocked_reason"][:60]}"]')
return "\n".join(lines)
def render_run(run: Dict[str, Any]) -> Dict[str, str]:
run_dir = CASE_RUNS_DIR / run["run_id"]
ensure_dir(run_dir / "assets")
timeline_path = run_dir / "timeline.mmd"
write_text(timeline_path, mermaid_from_steps(run))
md_lines = [
f"# Run {run['run_id']}",
"",
"> `LAB ONLY` | `AUTHORIZED TARGETS ONLY` | 自动生成 run bundle",
"",
f"- Advisory: `{run['advisory_id']}`",
f"- 系统: `{run['system_id']}`",
f"- Repro Profile: `{run['repro_profile_id']}`",
f"- 实证状态: `{run['verification_status']}`",
f"- 实证方式: `{run['verification_mode']}`",
f"- Artifact 模式: `{run['artifact_mode']}`",
f"- 启动时间: `{run['started_at']}`",
f"- 完成时间: `{run['finished_at']}`",
f"- 阻塞原因: `{run.get('blocked_reason') or '-'}`",
"",
"## 运行时间线",
"",
f"- Mermaid: [{timeline_path.name}]({timeline_path})",
"",
"## 证据摘要",
"",
f"- Baseline: `{len(run.get('baseline_refs', []))}`",
f"- 攻击步骤: `{len(run.get('attack_steps', []))}`",
f"- 浏览器证据: `{len(run.get('browser_refs', []))}`",
f"- 容器日志: `{len(run.get('container_log_refs', []))}`",
f"- 请求日志: `{len(run.get('request_log_refs', []))}`",
"",
"## 最小化验证说明",
"",
"- 仅限自有资产、本地靶场或已授权实验目标。",
"- 默认执行 minimal-proof;不会把破坏性或不可回滚动作作为默认路径。",
"",
]
if run.get("browser_refs"):
md_lines.extend(["## 浏览器证据", ""])
for ref in run["browser_refs"]:
md_lines.append(f"- {ref}")
md_lines.append("")
if run.get("container_log_refs"):
md_lines.extend(["## 容器日志", ""])
for ref in run["container_log_refs"]:
md_lines.append(f"- {ref}")
md_lines.append("")
report_md = run_dir / "report.md"
write_text(report_md, "\n".join(md_lines))
html_body = [
"<!doctype html>",
"<html><head><meta charset='utf-8'><title>websafe run report</title>",
"<style>body{font-family:ui-monospace,Menlo,monospace;margin:2rem;line-height:1.5;} code,pre{background:#f5f5f5;padding:.2rem .4rem;} .grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;} .card{border:1px solid #ddd;padding:1rem;border-radius:.5rem;}</style>",
"</head><body>",
f"<h1>Run {html.escape(run['run_id'])}</h1>",
"<div class='grid'>",
f"<div class='card'><strong>Advisory</strong><br><code>{html.escape(run['advisory_id'])}</code></div>",
f"<div class='card'><strong>Status</strong><br><code>{html.escape(run['verification_status'])}</code></div>",
f"<div class='card'><strong>Profile</strong><br><code>{html.escape(run['repro_profile_id'])}</code></div>",
f"<div class='card'><strong>Artifact Mode</strong><br><code>{html.escape(run['artifact_mode'])}</code></div>",
"</div>",
"<h2>Mermaid Timeline</h2>",
f"<pre>{html.escape(mermaid_from_steps(run))}</pre>",
"<h2>Evidence</h2><ul>",
]
for ref in run.get("browser_refs", []) + run.get("container_log_refs", []) + run.get("request_log_refs", []):
html_body.append(f"<li><code>{html.escape(ref)}</code></li>")
html_body.extend(["</ul>", "</body></html>"])
report_html = run_dir / "report.html"
write_text(report_html, "\n".join(html_body))
return {"bundle_dir": str(run_dir), "report_md": str(report_md), "report_html": str(report_html), "timeline": str(timeline_path)}
def render_dashboard() -> Dict[str, str]:
ensure_dir(DASHBOARD_DIR)
runs = load_json_dir(RUNS_DIR)
summary = {
"run_count": len(runs),
"statuses": {},
"recent_runs": sorted(runs, key=lambda item: item.get("finished_at") or "", reverse=True)[:50],
}
for item in runs:
status = item.get("verification_status", "triage-manual")
summary["statuses"][status] = summary["statuses"].get(status, 0) + 1
write_json(DASHBOARD_DIR / "summary.json", summary)
write_json(DASHBOARD_DIR / "runs.json", summary["recent_runs"])
html_page = """<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>websafe dashboard</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 2rem; background: #f8fafc; color: #0f172a; }
h1, h2 { margin-bottom: .5rem; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin: 1rem 0 2rem; }
.card { background: white; border: 1px solid #cbd5e1; border-radius: 14px; padding: 1rem; box-shadow: 0 4px 18px rgba(15,23,42,.06); }
table { width: 100%%; border-collapse: collapse; background: white; border-radius: 12px; overflow: hidden; }
th, td { padding: .75rem; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: .92rem; }
code { background: #e2e8f0; padding: .1rem .35rem; border-radius: 6px; }
</style>
</head>
<body>
<h1>websafe Local Lab Dashboard</h1>
<p>LAB ONLY | AUTHORIZED TARGETS ONLY | 本地静态看板</p>
<div id="summary" class="cards"></div>
<h2>Recent Runs</h2>
<table>
<thead><tr><th>Run</th><th>Advisory</th><th>Status</th><th>Mode</th><th>Finished</th><th>Report</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<script>
async function main() {
const summary = await fetch('./summary.json').then(r => r.json());
const runs = await fetch('./runs.json').then(r => r.json());
const summaryRoot = document.getElementById('summary');
const cards = [{label: 'Run Count', value: summary.run_count}];
for (const [key, value] of Object.entries(summary.statuses)) {
cards.push({label: key, value});
}
summaryRoot.innerHTML = cards.map(item => `<div class="card"><strong>${item.label}</strong><div style="font-size:2rem;margin-top:.5rem;">${item.value}</div></div>`).join('');
const rows = document.getElementById('rows');
rows.innerHTML = runs.map(item => {
const report = item.report_refs && item.report_refs.report_html ? item.report_refs.report_html : '';
return `<tr><td><code>${item.run_id}</code></td><td><code>${item.advisory_id}</code></td><td>${item.verification_status}</td><td>${item.verification_mode}</td><td>${item.finished_at || ''}</td><td>${report ? `<a href="../../../../${report.replace('/Users/x/websafe/', '')}">open</a>` : '-'}</td></tr>`;
}).join('');
}
main();
</script>
</body>
</html>
"""
write_text(DASHBOARD_DIR / "index.html", html_page)
return {
"dashboard_dir": str(DASHBOARD_DIR),
"index_html": str(DASHBOARD_DIR / "index.html"),
}

157
scripts/lab/repro.py 普通文件
查看文件

@@ -0,0 +1,157 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from intel.utils import load_all_json
from lab.config import ADVISORIES_DIR, ENV_CATALOG_DIR, REPRO_MAP_PATH, REPRO_PROFILES_DIR, RUNS_DIR
from lab.utils import read_json, read_yaml, unique
FAMILY_KEYWORDS = {
"xss-generic": ["xss", "cross-site scripting", "dom xss", "trusted types", "content injection"],
"sqli-generic": ["sql injection", "sqli"],
"authz-bypass-generic": ["authorization bypass", "auth bypass", "access control", "permission"],
"ssrf-generic": ["ssrf", "server-side request forgery"],
"file-upload-generic": ["file upload", "attachment", "extension bypass"],
"request-smuggling-generic": ["request smuggling", "http desync"],
"template-injection-generic": ["template injection", "ssti"],
"deserialization-generic": ["deserialization", "serialization"],
"proxy-boundary-generic": ["proxy", "middleware", "header trust"],
"plugin-extension-generic": ["plugin", "module", "extension", "theme"],
"session-token-generic": ["token", "cookie", "session", "jwt", "localstorage"],
"path-traversal-generic": ["path traversal", "directory traversal"],
"misconfiguration-generic": ["misconfiguration", "default credentials", "admin panel", "debug"],
}
def load_repro_map() -> Dict[str, Any]:
return read_yaml(REPRO_MAP_PATH, default={"systems": []}) or {"systems": []}
def load_profiles() -> Dict[str, Dict[str, Any]]:
profiles: Dict[str, Dict[str, Any]] = {}
if not REPRO_PROFILES_DIR.exists():
return profiles
for file_path in sorted(REPRO_PROFILES_DIR.rglob("*.yaml")):
content = read_yaml(file_path, default=None)
if not isinstance(content, dict):
continue
profile_id = content.get("profile_id")
if profile_id:
profiles[profile_id] = content
return profiles
def latest_runs_by_advisory() -> Dict[str, Dict[str, Any]]:
runs: Dict[str, Dict[str, Any]] = {}
for item in load_all_json(RUNS_DIR):
advisory_id = item.get("advisory_id")
if not advisory_id:
continue
previous = runs.get(advisory_id)
if previous is None or (item.get("finished_at") or "") >= (previous.get("finished_at") or ""):
runs[advisory_id] = item
return runs
def resolve_repro_family(advisory: Dict[str, Any], system_map: Dict[str, Any]) -> str:
text = " ".join(
filter(
None,
[
advisory.get("title"),
advisory.get("summary"),
advisory.get("system_id"),
" ".join(advisory.get("aliases", [])),
" ".join(advisory.get("secure_code_topics", [])),
],
)
).lower()
for family, keywords in FAMILY_KEYWORDS.items():
if any(keyword in text for keyword in keywords):
return family
return system_map.get("default_repro_family", "authz-bypass-generic")
def resolve_profile(advisory_id: str, advisory: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
profiles = load_profiles()
if advisory_id in profiles:
return profiles[advisory_id]
advisory = advisory or read_json(ADVISORIES_DIR / f"{advisory_id}.json", default={}) or {}
repro_map = {item["system_id"]: item for item in load_repro_map().get("systems", [])}
system_map = repro_map.get(advisory.get("system_id", ""), {})
direct_profile = profiles.get(f"{advisory.get('system_id', '')}-{advisory_id}")
if direct_profile:
return direct_profile
family = resolve_repro_family(advisory, system_map)
profile = profiles.get(family)
if profile:
resolved = dict(profile)
resolved.setdefault("resolved_via", "family-generic")
resolved.setdefault("profile_id", family)
return resolved
return {
"profile_id": family,
"resolved_via": "implicit-fallback",
"vuln_family": family.replace("-generic", ""),
"provisioning_mode": "synthetic",
"browser_assertions": {"required": bool(system_map.get("browser_required_default"))},
"attack_actions": [],
"baseline_actions": [],
"success_criteria": ["manual triage required"],
"cleanup_policy": "destroy",
"destructive_risk": "medium",
"allowed_target_types": ["lab-local", "lab-public", "authorized-third-party"],
}
def advisory_defaults(advisory: Dict[str, Any]) -> Dict[str, Any]:
profile = resolve_profile(advisory["canonical_id"], advisory)
repro_map = {item["system_id"]: item for item in load_repro_map().get("systems", [])}
system_map = repro_map.get(advisory.get("system_id", ""), {})
mode = "synthetic" if profile.get("provisioning_mode") == "synthetic" else "real"
return {
"verification_status": advisory.get("verification_status") or "triage-manual",
"verification_mode": advisory.get("verification_mode") or mode,
"last_verified_at": advisory.get("last_verified_at"),
"last_run_id": advisory.get("last_run_id"),
"evidence_bundle": advisory.get("evidence_bundle"),
"browser_evidence": advisory.get("browser_evidence")
or {
"required": bool(profile.get("browser_assertions", {}).get("required", system_map.get("browser_required_default", False))),
"present": False,
"refs": [],
},
"repro_profile_id": advisory.get("repro_profile_id") or profile.get("profile_id"),
"artifact_mode": advisory.get("artifact_mode") or system_map.get("provisioning_mode_preference", ["synthetic"])[0],
"blocked_reason": advisory.get("blocked_reason"),
}
def annotate_with_latest_run(advisory: Dict[str, Any], run: Optional[Dict[str, Any]]) -> Dict[str, Any]:
merged = dict(advisory)
merged.update(advisory_defaults(advisory))
if not run:
return merged
merged.update(
{
"verification_status": run.get("verification_status", merged["verification_status"]),
"verification_mode": run.get("verification_mode", merged["verification_mode"]),
"last_verified_at": run.get("finished_at", merged["last_verified_at"]),
"last_run_id": run.get("run_id"),
"evidence_bundle": run.get("report_refs", {}).get("bundle_dir"),
"browser_evidence": {
"required": merged.get("browser_evidence", {}).get("required", False),
"present": bool(run.get("browser_refs")),
"refs": run.get("browser_refs", []),
},
"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"),
}
)
return merged

11
scripts/lab/run-queue.sh 普通文件
查看文件

@@ -0,0 +1,11 @@
#!/bin/bash
set -euo pipefail
cd /Users/x/websafe
LOG_DIR="/Users/x/websafe/08-threat-intel/generated/logs"
mkdir -p "$LOG_DIR"
STAMP="$(date '+%Y%m%d-%H%M%S')"
exec >> "$LOG_DIR/lab-queue-$STAMP.log" 2>&1
echo "[lab-queue] $(date -u '+%Y-%m-%dT%H:%M:%SZ') starting"
python3 /Users/x/websafe/scripts/lab/main.py run-batch --from-queue --limit 10

14
scripts/lab/seed.py 普通文件
查看文件

@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Any, Dict, List
def run_seed(profile: Dict[str, Any]) -> List[Dict[str, Any]]:
steps = []
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

49
scripts/lab/task_queue.py 普通文件
查看文件

@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Any, Dict, List
from lab.config import ADVISORIES_DIR, QUEUE_PATH
from lab.utils import load_json_dir, read_json, write_json
def load_queue() -> Dict[str, Any]:
return read_json(QUEUE_PATH, default={"items": []}) or {"items": []}
def save_queue(queue: Dict[str, Any]) -> None:
write_json(QUEUE_PATH, queue)
def enqueue_items(items: List[Dict[str, Any]]) -> Dict[str, Any]:
queue = load_queue()
existing = {item["advisory_id"] for item in queue.get("items", [])}
added = 0
for item in items:
if item["advisory_id"] in existing:
continue
queue.setdefault("items", []).append(item)
existing.add(item["advisory_id"])
added += 1
save_queue(queue)
return {"queued": len(queue["items"]), "added": added}
def enqueue_from_registry(only_hotlane: bool = False, limit: int = 50) -> Dict[str, Any]:
advisories = load_json_dir(ADVISORIES_DIR)
items = []
for advisory in advisories:
if only_hotlane:
hot = advisory.get("exploit_status") in {"known_exploited", "active_exploitation", "in_the_wild"}
if not hot and not (advisory.get("cvss_score") or 0) >= 8.8 and advisory.get("severity") != "critical":
continue
items.append({"advisory_id": advisory["canonical_id"], "system_id": advisory["system_id"], "priority": "hotlane" if only_hotlane else "default"})
return enqueue_items(items[:limit])
def dequeue(limit: int = 10) -> List[Dict[str, Any]]:
queue = load_queue()
items = queue.get("items", [])
selected = items[:limit]
queue["items"] = items[limit:]
save_queue(queue)
return selected

103
scripts/lab/utils.py 普通文件
查看文件

@@ -0,0 +1,103 @@
from __future__ import annotations
import json
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import yaml
UTC = timezone.utc
def now_utc() -> datetime:
return datetime.now(tz=UTC)
def isoformat(dt: datetime) -> str:
return dt.astimezone(UTC).replace(microsecond=0).isoformat()
def ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def read_json(path: Path, default: Any = None) -> Any:
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: Any) -> None:
ensure_dir(path.parent)
path.write_text(json.dumps(data, indent=2, ensure_ascii=True, sort_keys=False) + "\n", encoding="utf-8")
def read_yaml(path: Path, default: Any = None) -> Any:
if not path.exists():
return default
with path.open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle)
def write_yaml(path: Path, data: Any) -> None:
ensure_dir(path.parent)
with path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(data, handle, allow_unicode=False, sort_keys=False)
def write_text(path: Path, content: str) -> None:
ensure_dir(path.parent)
path.write_text(content.rstrip() + "\n", encoding="utf-8")
def load_json_dir(path: Path) -> List[Dict[str, Any]]:
if not path.exists():
return []
values: List[Dict[str, Any]] = []
for file_path in sorted(path.glob("*.json")):
content = read_json(file_path, default=None)
if isinstance(content, dict):
values.append(content)
return values
def run(cmd: List[str], cwd: Optional[Path] = None, check: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
text=True,
capture_output=True,
check=check,
)
def command_available(name: str) -> bool:
return shutil.which(name) is not None
def slugify(value: str) -> str:
safe = []
for ch in value.lower().strip():
if ch.isalnum():
safe.append(ch)
else:
safe.append("-")
result = "".join(safe)
while "--" in result:
result = result.replace("--", "-")
return result.strip("-") or "item"
def unique(values: Iterable[str]) -> List[str]:
seen = set()
result = []
for value in values:
if not value or value in seen:
continue
seen.add(value)
result.append(value)
return result

35
scripts/lab/validators.py 普通文件
查看文件

@@ -0,0 +1,35 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
from lab.config import ENV_CATALOG_DIR, REPRO_MAP_PATH, REPRO_PROFILES_DIR
from lab.utils import read_yaml
def validate_assets() -> List[str]:
errors: List[str] = []
if not REPRO_MAP_PATH.exists():
errors.append(f"missing repro map: {REPRO_MAP_PATH}")
if not ENV_CATALOG_DIR.exists():
errors.append(f"missing environment catalog dir: {ENV_CATALOG_DIR}")
for path in sorted(REPRO_PROFILES_DIR.rglob("*.yaml")):
content = read_yaml(path, default=None)
if not isinstance(content, dict):
errors.append(f"invalid repro profile yaml: {path}")
continue
for field in [
"profile_id",
"match_rules",
"vuln_family",
"provisioning_mode",
"attack_actions",
"baseline_actions",
"success_criteria",
"cleanup_policy",
"destructive_risk",
"allowed_target_types",
]:
if field not in content:
errors.append(f"repro profile missing {field}: {path}")
return errors

180
scripts/tool_contract.py 普通文件
查看文件

@@ -0,0 +1,180 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
UTC = timezone.utc
DEFAULT_AUTH_SCOPE = "lab-local, lab-public, authorized-third-party"
DEFAULT_MINIMAL_VALIDATION = "只读探测、最小化注入、可审计回显、可回滚验证。"
def now_iso() -> str:
return datetime.now(tz=UTC).replace(microsecond=0).isoformat()
def add_common_args(parser, include_input: bool = False, include_network: bool = True) -> None:
parser.add_argument(
"--ack-authorized",
action="store_true",
help="确认目标属于自有资产、测试环境或已明确授权",
)
parser.add_argument(
"--format",
choices=["text", "json", "ndjson"],
default="text",
help="输出格式,默认 text",
)
parser.add_argument("--output", help="将结果写入文件")
parser.add_argument("--evidence-dir", help="证据目录,工具会把结构化结果写入其中")
parser.add_argument("--run-id", help="关联的 run bundle ID")
parser.add_argument("--case-id", help="关联的 advisory/case ID")
if include_network:
parser.add_argument(
"--header",
action="append",
default=[],
help="附加请求头,可重复,格式 Name: Value",
)
parser.add_argument(
"--proxy",
help="调试或实验代理地址,例如 http://127.0.0.1:8080",
)
parser.add_argument(
"--rate",
type=float,
default=0.0,
help="每秒请求数上限,0 表示不额外限速",
)
if include_input:
parser.add_argument("--target-file", help="批量目标文件,每行一个目标")
parser.add_argument(
"--stdin",
action="store_true",
help="从标准输入读取目标,每行一个",
)
def ensure_authorized(args, parser) -> None:
if not getattr(args, "ack_authorized", False):
parser.error("必须显式提供 --ack-authorized 以确认目标范围合法")
def parse_headers(values: Iterable[str]) -> Dict[str, str]:
headers: Dict[str, str] = {}
for value in values or []:
if ":" not in value:
continue
name, raw = value.split(":", 1)
name = name.strip()
raw = raw.strip()
if not name:
continue
headers[name] = raw
return headers
def parse_cookie_string(raw: Optional[str]) -> Dict[str, str]:
cookies: Dict[str, str] = {}
if not raw:
return cookies
for part in raw.split(";"):
if "=" not in part:
continue
name, value = part.split("=", 1)
name = name.strip()
value = value.strip()
if name:
cookies[name] = value
return cookies
def read_targets(args, fallback: Optional[str] = None) -> List[str]:
values: List[str] = []
if fallback:
values.append(fallback)
target_file = getattr(args, "target_file", None)
if target_file:
for line in Path(target_file).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and line not in values:
values.append(line)
if getattr(args, "stdin", False):
import sys
for line in sys.stdin.read().splitlines():
line = line.strip()
if line and line not in values:
values.append(line)
return values
def make_report(
*,
tool: str,
mode: str,
target: str,
status: str,
severity: str,
payload_or_probe: Any,
request_summary: Dict[str, Any],
evidence_refs: List[str],
destructive_risk: str = "low",
minimal_validation: str = DEFAULT_MINIMAL_VALIDATION,
authorization_scope: str = DEFAULT_AUTH_SCOPE,
extra: Optional[Dict[str, Any]] = None,
args: Any = None,
) -> Dict[str, Any]:
payload = {
"tool": tool,
"mode": mode,
"target": target,
"status": status,
"severity": severity,
"timestamp": now_iso(),
"request_summary": request_summary,
"payload_or_probe": payload_or_probe,
"evidence_refs": evidence_refs,
"minimal_validation": minimal_validation,
"authorization_scope": authorization_scope,
"destructive_risk": destructive_risk,
"run_id": getattr(args, "run_id", None) if args else None,
"case_id": getattr(args, "case_id", None) if args else None,
}
if extra:
payload.update(extra)
return payload
def write_evidence(args, name: str, data: Any) -> Optional[str]:
evidence_dir = getattr(args, "evidence_dir", None)
if not evidence_dir:
return None
path = Path(evidence_dir) / name
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
if isinstance(data, str):
handle.write(data.rstrip() + "\n")
else:
json.dump(data, handle, indent=2, ensure_ascii=True, sort_keys=False)
handle.write("\n")
return str(path)
def emit_report(args, report: Dict[str, Any], text_lines: Optional[List[str]] = None) -> int:
if args.format == "json":
content = json.dumps(report, indent=2, ensure_ascii=True)
elif args.format == "ndjson":
records = report if isinstance(report, list) else [report]
content = "\n".join(json.dumps(item, ensure_ascii=True) for item in records)
else:
content = "\n".join(text_lines or [json.dumps(report, ensure_ascii=True)])
if getattr(args, "output", None):
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content.rstrip() + "\n", encoding="utf-8")
print(content)
return 0