更新: 359 个文件 - 2026-03-16 23:30:01
这个提交包含在:
@@ -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
普通文件
1
scripts/lab/__init__.py
普通文件
@@ -0,0 +1 @@
|
||||
"""Local authorized lab orchestration package."""
|
||||
66
scripts/lab/attack.py
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
|
||||
在新工单中引用
屏蔽一个用户