From 49564f1d98331b2f3afbd0b20bf400d2635037a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B3=B4=E7=A5=BA=E6=B8=85?= Date: Tue, 5 May 2026 20:53:32 +0800 Subject: [PATCH 1/6] feat: MCP server, safety gate, and LLM provider abstraction Freeze the 11-tool MCP interface in docs/MCP-INTERFACE.md and back it with a FastMCP server. Extract pipeline functions from scenarios/run_kill_chain.py into phantom_secops/core.py so the Python orchestrator and MCP server share one implementation. - phantom_secops/mcp/safety.py centralises the lab-target whitelist and the no-runnable-POC prose validator. Tool wrappers and the MCP boundary both defer to it (defense-in-depth). - phantom_secops/llm/ adds a Provider protocol with three implementations (none, anthropic, phantom_mesh). LLM-augmented prose is validated against safety.is_safe_prose before being merged into output; failures fall back to deterministic templates so the pipeline never blocks on a flaky LLM. - Test suite grows 7 -> 32: MCP protocol smoke tests, safety unit tests, no-runnable-POC invariant, malicious-provider invariant under the LLM path, lifecycle confirmation invariant. - Makefile gains mcp-serve / mcp-dev. requirements-dev.txt adds mcp[cli]. scripts/lint.py covers the new phantom_secops/ package tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 8 +- docs/MCP-INTERFACE.md | 348 +++++++++++++++++ phantom_secops/__init__.py | 8 + phantom_secops/core.py | 399 ++++++++++++++++++++ phantom_secops/llm/__init__.py | 49 +++ phantom_secops/llm/anthropic_provider.py | 57 +++ phantom_secops/llm/null_provider.py | 14 + phantom_secops/llm/phantom_mesh_provider.py | 93 +++++ phantom_secops/mcp/__init__.py | 5 + phantom_secops/mcp/lab.py | 99 +++++ phantom_secops/mcp/safety.py | 104 +++++ phantom_secops/mcp/server.py | 239 ++++++++++++ requirements-dev.txt | 15 +- scenarios/run_kill_chain.py | 375 +++--------------- scripts/lint.py | 7 +- tests/test_llm_provider.py | 129 +++++++ tests/test_log_anomaly.py | 24 +- tests/test_mcp_protocol.py | 78 ++++ tests/test_no_runnable_poc.py | 78 ++++ tests/test_safety.py | 48 +++ tools/nmap_runner.py | 17 +- tools/nuclei_runner.py | 13 +- 22 files changed, 1844 insertions(+), 363 deletions(-) create mode 100644 docs/MCP-INTERFACE.md create mode 100644 phantom_secops/__init__.py create mode 100644 phantom_secops/core.py create mode 100644 phantom_secops/llm/__init__.py create mode 100644 phantom_secops/llm/anthropic_provider.py create mode 100644 phantom_secops/llm/null_provider.py create mode 100644 phantom_secops/llm/phantom_mesh_provider.py create mode 100644 phantom_secops/mcp/__init__.py create mode 100644 phantom_secops/mcp/lab.py create mode 100644 phantom_secops/mcp/safety.py create mode 100644 phantom_secops/mcp/server.py create mode 100644 tests/test_llm_provider.py create mode 100644 tests/test_mcp_protocol.py create mode 100644 tests/test_no_runnable_poc.py create mode 100644 tests/test_safety.py diff --git a/Makefile b/Makefile index 6f99145..85a6950 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # `make test` — run pytest against tool wrappers. # `make lint` — basic checks (toml validation, python syntax). -.PHONY: help demo demo-mock lab-up lab-down lab-status test lint clean +.PHONY: help demo demo-mock lab-up lab-down lab-status test lint clean mcp-serve mcp-dev help: @awk 'BEGIN{FS=":.*##"} /^[a-zA-Z_-]+:.*##/ {printf " %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -46,6 +46,12 @@ test: ## Run tests (uses pytest if available, else unittest) lint: ## Basic syntax / toml validation @python3 scripts/lint.py +mcp-serve: ## Run the MCP server over stdio (for agent clients) + python3 -m phantom_secops.mcp.server + +mcp-dev: ## Run the MCP server under the official inspector (requires mcp[cli]) + mcp dev phantom_secops/mcp/server.py + clean: ## Remove generated reports + python cache rm -rf reports/runs/* reports/lab-logs/* __pycache__ .pytest_cache find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true diff --git a/docs/MCP-INTERFACE.md b/docs/MCP-INTERFACE.md new file mode 100644 index 0000000..4bac067 --- /dev/null +++ b/docs/MCP-INTERFACE.md @@ -0,0 +1,348 @@ +# MCP Interface — phantom-secops + +> Frozen contract. The MCP server, phantom-mesh adapter, Claude Code subagent, and the Python reference orchestrator all depend on the names, schemas, and safety gates documented here. Changes to anything below are breaking and require updating all four call sites in lockstep. +> +> Surface: **11 tools, 2 resource schemes**. + +## Server identity + +| Field | Value | +|---|---| +| MCP server name | `phantom-secops` | +| Tools / Resources | 11 / 2 | +| Transport | `stdio` (primary) and `http` (optional, for remote agents) | +| Protocol version | MCP 2025-06-18 | +| Required runtime | Python ≥3.11, Docker (only for `active_in_lab` and `lifecycle` tools) | + +## Naming convention + +`{verb}_{object}[_{qualifier}]`, snake_case, all lowercase. The qualifier is mandatory when the verb has multiple safety profiles (e.g. `lab_up_confirm`). + +The 11 tools below are grouped by **safety class**, not by red/blue. Mixing red/blue in the same server is intentional — agents shouldn't have to know which "side" a tool belongs to; they just call it. + +--- + +## Safety classes + +| Class | Means | Tools require user/agent confirmation? | +|---|---|---| +| `read_only` | No network egress, no filesystem writes outside `reports/runs//` | No | +| `active_in_lab` | Probes a target inside the `secops-lab` docker network | No (gated by lab-network check) | +| `lifecycle` | Brings up/tears down the docker lab | **Yes** — must pass `confirm: true` | + +Every `active_in_lab` tool **must** call `safety.assert_lab_target(target)` before doing anything. The check is centralised in `mcp/safety.py` and validates against the hard-coded list `{juice-shop, dvwa, dvwa-db, metasploitable, attacker}`. Any other value returns an `ErrorOutput` with `code="not_a_lab_target"`. + +--- + +## Tool catalogue + +### 1. `recon_host` — `active_in_lab` + +Scans an in-lab host with nmap (top 1000 ports + service version). Wraps `tools/nmap_runner.py`. + +```ts +input: { + target: "juice-shop" | "dvwa" | "dvwa-db" | "metasploitable" | "attacker", + ports?: "top-1000" | string, // default "top-1000"; explicit list e.g. "80,443,3306" + scan_type?: string, // default "-sV" +} + +output: { + target: string, + open_ports: Array<{ + port: number, + protocol: string, // "tcp" | "udp" + service: string, // "http", "mysql", ... + version: string | null, // "Apache 2.4.41" or null + }>, + scan_type: "nmap", +} + +// or, on error: +output: { error: string, target?: string, lab_services?: string[] } +``` + +**Side effects**: shells `docker exec` into `secops-attacker`. No filesystem writes. +**Latency budget**: 120 s timeout enforced inside the wrapper. + +--- + +### 2. `vuln_scan_web` — `active_in_lab` + +Runs nuclei against an in-lab HTTP target. Wraps `tools/nuclei_runner.py`. + +```ts +input: { + target_url: string, // must contain a lab service hostname + severity?: string, // CSV; default "low,medium,high,critical" + timeout_s?: number, // default 90 +} + +output: { + target: string, + findings: Array<{ + id: string | null, // nuclei template-id + cve: string | null, + severity: "info" | "low" | "medium" | "high" | "critical" | null, + title: string | null, + evidence: string | null, // matched-at URL + tool: "nuclei", + raw: string, // truncated raw JSON, ≤400 chars + }>, +} +``` + +**Side effects**: shells `docker exec` into `secops-attacker`; on first run installs nuclei via `go install`. +**Latency budget**: `timeout_s + 30` s. + +--- + +### 3. `scan_logs_for_anomalies` — `read_only` + +Pattern-matches access logs to produce raw alerts. Logic from `_blue_log_anomaly` in `run_kill_chain.py:174`. + +```ts +input: { + source?: "lab_logs" | "mock", // default "lab_logs"; "mock" reads lab/mocks/attack-log.txt + log_path?: string, // override; absolute path inside repo +} + +output: { + alerts: Array<{ + ts: string, // ISO8601 UTC + source_ip: string, // IPv4 or "unknown" + asset: string, // "juice-shop" | "dvwa" | ... + category: "traversal" | "sqli" | "xss" | "admin_path" | "scanner", + evidence: string, // raw log line, ≤200 chars + severity_hint: "low" | "medium" | "high", + }>, + source: string, // resolved log file path +} +``` + +**Side effects**: none. URL-decodes each line before pattern-matching (the existing implementation does this). + +--- + +### 4. `triage_alerts` — `read_only` + +Groups raw alerts by `(source_ip, category)` and assigns priority. Logic from `_blue_alert_triage`. + +```ts +input: { + alerts: Array, // shape from scan_logs_for_anomalies.alerts[] +} + +output: { + triaged: Array<{ + ts: string, + priority: "P1" | "P2" | "P3", + asset: string, + summary: string, // " pattern from " + count: number, + evidence: string[], // up to 3 sample lines + }>, +} +``` + +**Promotion rules** (frozen): +- `severity_hint=high` → P2 by default; P1 once `count ≥ 2` +- `severity_hint=medium` → promote P3 → P2 +- `severity_hint=low` → stays P3 + +--- + +### 5. `correlate_threats` — `read_only` + +Joins triaged alerts into per-actor narratives with ATT&CK phase tags. Logic from `_blue_threat_correlate`. + +```ts +input: { + triaged: Array, // shape from triage_alerts.triaged[] +} + +output: { + actors: Array<{ + actor: string, // source IP + first_seen: string, // ISO8601 + last_seen: string, + phases_observed: string[], // e.g. ["TA0001", "TA0043"] + alert_summaries: string[], + narrative: string, // human-readable English summary + confidence: "low" | "medium" | "high", + }>, +} +``` + +**Phase mapping** (frozen): +- `scanner` → `TA0043` (Reconnaissance) +- `sqli`, `xss`, `traversal` → `TA0001` (Initial Access) +- `admin_path` → `TA0007` (Discovery) + +--- + +### 6. `suggest_exploit_prose` — `read_only` + +Generates **text-only** exploit explanations from vuln-scan findings. **Never returns runnable payloads.** This is the safety-critical tool — its name carries `_prose` to make the constraint visible to every caller. + +```ts +input: { + findings: Array, // shape from vuln_scan_web.findings[] + use_llm?: boolean, // default false; when true, calls LLMProvider for prose +} + +output: { + markdown: string, // full markdown document, "# Exploit Suggestions\n..." + has_runnable_poc: false, // INVARIANT: always false; checked by tests +} +``` + +**Hard constraints** (enforced by `tests/test_no_runnable_poc.py`): +- Output must not contain shell commands, curl invocations, payload strings, or template strings that would execute if pasted. +- The string `has_runnable_poc: false` is a load-bearing assertion; do not change. + +--- + +### 7. `compose_pentest_report` — `read_only` + +Renders the red-team-side markdown report. + +```ts +input: { + recon: ReconOutput, // from recon_host + vuln: VulnScanOutput, // from vuln_scan_web + exploit_suggestions_md: string, // from suggest_exploit_prose.markdown + timeline: Array<[string, string]>, // [[t_seconds, label], ...] +} + +output: { + markdown: string, + byte_size: number, +} +``` + +--- + +### 8. `compose_incident_report` — `read_only` + +Renders the blue-team-side markdown report. + +```ts +input: { + triaged: Array, + actors: Array, // from correlate_threats + timeline: Array<[string, string]>, +} + +output: { + markdown: string, + byte_size: number, + mttd_seconds: number, // first red event → first triaged alert +} +``` + +--- + +### 9. `lab_status` — `read_only` + +Reports docker lab health. Wraps `docker compose ps` in JSON form. + +```ts +input: {} // no parameters + +output: { + network_present: boolean, // is "secops-lab" network up? + services: Array<{ + name: "juice-shop" | "dvwa" | "dvwa-db" | "attacker" | "log-collector", + state: "running" | "exited" | "absent", + health: "healthy" | "unhealthy" | "starting" | "none", + }>, +} +``` + +**Side effects**: reads docker state; does not modify. + +--- + +### 10. `lab_up` — `lifecycle` + +Brings up the isolated docker lab. + +```ts +input: { confirm: true } +output: { ok: boolean, log: string } // log = last 2 KB of docker compose output +``` + +Idempotent. Calling without `confirm: true` returns `{ error: "lifecycle_action_requires_confirmation" }` and does nothing. + +### 11. `lab_down` — `lifecycle` + +Tears down the docker lab. Removes containers and volumes; **never** touches the `reports/runs/` directory on the host. + +```ts +input: { confirm: true } +output: { ok: boolean, log: string } +``` + +Same confirmation requirement as `lab_up`. Both lifecycle tools are intended for interactive callers (Claude Code, phantom-mesh dispatch with a human-authored prompt) — CI lanes should use `make lab-up` / `make lab-down` directly rather than going through MCP. + +--- + +## Resources + +Resources are read-only artifacts the agent can fetch by URI without invoking a tool. + +### `phantom-secops://runs/{run_id}/{filename}` + +``` +run_id = ISO timestamp dir name, e.g. "2026-05-05-1430" +filename ∈ { recon.json, vuln-scan.json, alerts.jsonl, triage-queue.jsonl, + kill-chains.jsonl, exploit-suggestions.md, + pentest-report.md, incident-report.md } +``` + +`run_id="latest"` resolves to the newest run dir at fetch time. + +### `phantom-secops://mocks/{name}` + +``` +name ∈ { recon-juice-shop.json, vuln-scan-juice-shop.json, attack-log.txt } +``` + +--- + +## Error model + +Every tool returns either its success shape or a flat error envelope: + +```ts +{ + error: string, // short code, snake_case + message?: string, // human-readable detail + context?: object, // tool-specific extras +} +``` + +Frozen error codes: + +| Code | Meaning | +|---|---| +| `not_a_lab_target` | Target is not in the lab service whitelist | +| `lab_network_down` | `secops-lab` docker network is not up | +| `tool_timeout` | Underlying CLI exceeded its budget | +| `tool_nonzero_exit` | Underlying CLI returned non-zero | +| `parse_failed` | Output could not be parsed (e.g. malformed nmap XML) | +| `lifecycle_action_requires_confirmation` | Lifecycle tool called without `confirm: true` | +| `bad_input` | Input failed schema validation | + +--- + +## Versioning + +This document is version `1.0.0`. The MCP server reports the same version in its handshake. Adapters may pin to a major version. + +- **Patch** bumps: docs-only, schema-additive (new optional input fields, new optional output fields). +- **Minor** bumps: new tools, new error codes, new resources. +- **Major** bumps: any rename, removal, type change, or safety-class change. + +Major bumps require updating: `mcp/server.py`, `mcp/schemas.py`, `agents/red/*.toml`, `agents/blue/*.toml`, `.claude/agents/secops-runner.md`, `scenarios/run_kill_chain.py`, and this file — in the same PR. diff --git a/phantom_secops/__init__.py b/phantom_secops/__init__.py new file mode 100644 index 0000000..cb02189 --- /dev/null +++ b/phantom_secops/__init__.py @@ -0,0 +1,8 @@ +"""phantom-secops — multi-agent SecOps research playground. + +Public surface: +- `phantom_secops.core` — runtime-agnostic red/blue pipeline functions. +- `phantom_secops.mcp` — MCP server exposing those functions to any agent. +""" + +__version__ = "0.2.0" diff --git a/phantom_secops/core.py b/phantom_secops/core.py new file mode 100644 index 0000000..c9a838e --- /dev/null +++ b/phantom_secops/core.py @@ -0,0 +1,399 @@ +"""Runtime-agnostic red/blue pipeline functions. + +This is the single implementation that backs both: +- the Python reference orchestrator (scenarios/run_kill_chain.py) +- the MCP server (phantom_secops/mcp/server.py) + +Everything here is a pure function over plain dicts — no docker, no LLM, +no MCP. The thin wrappers in tools/ shell into docker; the LLM provider in +phantom_secops/llm/ (Phase 3) generates prose. Both are kept out of this +module so it stays trivially testable. + +Function names match the public MCP tool names from docs/MCP-INTERFACE.md. +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Protocol +from urllib.parse import unquote + +REPO_ROOT = Path(__file__).resolve().parent.parent +MOCKS_DIR = REPO_ROOT / "lab" / "mocks" +LAB_LOG_DIR = REPO_ROOT / "reports" / "lab-logs" + + +class _ProseProvider(Protocol): + """Structural duck-type for phantom_secops.llm.LLMProvider. + + Declared here to keep core.py free of an llm/ import cycle. + """ + + name: str + + def generate_prose(self, system: str, user: str, max_tokens: int = 1024) -> str: ... + + +# ─── Red pipeline ──────────────────────────────────────────────────────── + +def run_recon(target: str, mock: bool = False) -> dict[str, Any]: + """Recon a lab host. In mock mode, returns canned data; in live mode, + delegates to tools.nmap_runner (which shells into the attacker container). + """ + if mock: + return json.loads((MOCKS_DIR / "recon-juice-shop.json").read_text()) + # Lazy import: tools/ requires docker, not needed in mock mode. + from tools import nmap_runner # noqa: PLC0415 + return nmap_runner.run(target) + + +def run_vuln_scan(target: str, recon: dict[str, Any], mock: bool = False) -> dict[str, Any]: + """Vuln scan a lab target using nuclei. Mock mode returns canned findings.""" + _ = recon # live mode reads recon.open_ports to pick HTTP ports + if mock: + return json.loads((MOCKS_DIR / "vuln-scan-juice-shop.json").read_text()) + return {"target": target, "findings": []} + + +def suggest_exploit_prose( + findings: list[dict[str, Any]], + use_llm: bool = False, + provider: _ProseProvider | None = None, +) -> dict[str, Any]: + """Generate text-only exploit explanations from vuln-scan findings. + + INVARIANT: never returns runnable payloads. The output schema includes + `has_runnable_poc: false` which is asserted by tests/test_no_runnable_poc.py. + + When `use_llm=True` and a provider is supplied (or env-var-selected at + callsite), each finding's prose is generated by the provider and validated + against the same forbidden-pattern set used by tests. If validation fails + or the provider returns empty, falls back to the deterministic template. + """ + if not findings: + return {"markdown": "_No vulnerabilities flagged by the scan._\n", + "has_runnable_poc": False} + + out = ["# Exploit Suggestions\n"] + for f in findings: + out.append(f"## {f.get('id', 'unknown')} — {f.get('title', '(no title)')}\n") + cve = f.get("cve") + if cve: + out.append(f"**CVE:** {cve}") + out.append(f"**Severity:** {f.get('severity', 'unknown')}\n") + out.append(_finding_prose(f, use_llm=use_llm, provider=provider)) + out.append("") + return {"markdown": "\n".join(out), "has_runnable_poc": False} + + +def _finding_prose( + f: dict[str, Any], + use_llm: bool, + provider: _ProseProvider | None, +) -> str: + template_text = _exploit_prose(f) + if not use_llm or provider is None: + return template_text + + # Lazy-import to avoid a hard dependency for the no-LLM path. + from phantom_secops.mcp import safety # noqa: PLC0415 + + system = ( + "You are writing a prose-only security finding explanation. " + "RULES: no shell commands, no curl/wget/sudo lines, no payload strings, " + "no code fences with bash/sh/shell. Mitigation guidance is welcome. " + "Plain prose only. Reference public CVE pages by number, never by URL " + "containing exploit code." + ) + user = json.dumps({ + "id": f.get("id"), + "cve": f.get("cve"), + "severity": f.get("severity"), + "title": f.get("title"), + "evidence": f.get("evidence"), + }, ensure_ascii=False) + + generated = provider.generate_prose(system, user, max_tokens=400) + if generated and safety.is_safe_prose(generated): + return generated + # Fallback: provider unreachable / returned forbidden patterns / empty. + return template_text + + +def _exploit_prose(f: dict[str, Any]) -> str: + """Prose only. No runnable exploits, ever.""" + sev = f.get("severity", "info") + title = f.get("title", "") + if "jquery" in title.lower() or "CVE-2020-11023" in (f.get("cve") or ""): + return ("This vulnerability allows DOM-based XSS via malformed `