diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ef67b..f27abbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Attack graph Phase 3c polish** — FixKind registry with runtime `graph_mutate` engine (apply registry `mutates` and simulate template elimination), inventory multi-server graph layer (`graph_inventory`), counterfactual fix simulation on paths, default-on counterfactuals and UI compression (`--no-attack-graph-counterfactuals`, `--no-attack-graph-compress-ui`), dashboard layer filter + policy/inferred edge styling, `mcts doctor --suggest-fixes --report` +- **`mcts inventory` privacy controls** — `--paths-only`, `--config-path`, `--redact-paths`; documented in `SECURITY.md` (#87) - **Attack graph v3 rollout (Phase 3a/3b)** — default `attack_graph_version=3`; YAML template matcher replaces `AttackChainAnalyzer`; 12 chain templates including `SSRF_RESOURCE`, `ENV_SAMPLING`, `GIT_UNSCOPED`, `PROMPT_BYPASS`, `ELICIT_PHISH`, `TOCTOU_READ`, `READ_EXEC`, `CRED_THEFT`; capability overlap fallbacks; dashboard v3 paths + SARIF `mcts/attackPathExplanation`; R-23–R-25 regression fixtures + `tests/scoring/test_phase_3b_templates.py` - **Fact provenance metrics** — `fact_coverage()` reports `native_pct` / `silver_pct`; dashboard exposes `fact_provenance`; CI gates via `check_ttu_baseline.py` + corpus `--check-only` diff --git a/SECURITY.md b/SECURITY.md index 4a83b7d..5796ef6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,27 @@ MCTS is a security analysis tool. Only scan MCP servers you own or have explicit HTML reports are self-contained files with embedded scan data and vendored chart assets. They do not transmit data to MCTS or third parties when you open the file in a browser. +## Inventory Privacy + +`mcts inventory` discovers MCP configuration files in well-known locations +(Cursor, Claude Desktop, VS Code, Windsurf, Gemini, Codex, and others). This is +a **read-only** operation — no data is sent externally, no files are modified, +and environment variable **values** are not exported (only key names when parsing +configs). + +To limit exposure on shared or sensitive machines: + +- `--paths-only` — list config file locations without parsing server details +- `--config-path ` — scope discovery to a single explicit file +- `--redact-paths` — replace home directory with `~` in inventory entry and + skill paths in terminal output and default inventory JSON (`-o inventory.json`) + +`--redact-paths` does not redact paths inside nested scan reports produced by +`--scan-all`. Treat exported inventory JSON like any config audit artifact. + +In CI environments, ephemeral runners typically have no MCP configs and inventory +will report zero entries. + ## Documentation - [Documentation index](docs/index.md) diff --git a/docs/platform/cli.md b/docs/platform/cli.md index dd9d5b1..0da8acf 100644 --- a/docs/platform/cli.md +++ b/docs/platform/cli.md @@ -349,6 +349,9 @@ mcts inventory [options] | `--skills` | false | Discover and scan `SKILL.md` files in agent skill directories | | `--findings-trust-mode` | unset (`off`) | Apply trust validator to inventory/toxic-flow findings. Omit to inherit from `.mcts/policy.yaml`; pass explicitly (including `off`) to override policy. | | `--ignore-policy` | off | Skip merging `.mcts/policy.yaml` | +| `--paths-only` | false | List config file paths without parsing server details | +| `--config-path` | — | Scope discovery to a single config file | +| `--redact-paths` | false | Replace home directory with `~` in inventory entry and skill paths | | `--output`, `-o` | — | Write inventory JSON | | `--theme` | `cyber` | Terminal theme | diff --git a/docs/scanning/inventory.md b/docs/scanning/inventory.md index 1a107c6..e401b97 100644 --- a/docs/scanning/inventory.md +++ b/docs/scanning/inventory.md @@ -59,8 +59,22 @@ mcts inventory --full-toxic-flows # Theme for saved-notice styling only mcts inventory --theme minimal -o inventory.json + +# Privacy controls (see SECURITY.md — Inventory Privacy) +mcts inventory --paths-only +mcts inventory --paths-only --redact-paths +mcts inventory --config-path ~/.cursor/mcp.json +mcts inventory --config-path ./team-mcp.json -o team.json --redact-paths +mcts inventory --config-path ~/.cursor/mcp.json --scan-all -o inventory-scan-all.json ``` +`--paths-only` lists config locations without parsing server commands or env keys. It +cannot be combined with `--scan`, `--scan-all`, `--skills`, or `--output`. + +`--config-path` inventories every server in one file (contrast with +`mcts scan --config --server `, which runs a full security scan on one +named server). + ### Exit codes | Code | When | @@ -260,6 +274,9 @@ Full list: [Feature Expansion Plan — Discovery](../more/feature-expansion-plan Inventory reads configuration files that may **reference** secrets via environment variables. MCTS does not print env values in inventory output. Treat exported JSON like any config audit artifact. +For privacy controls (`--paths-only`, `--config-path`, `--redact-paths`) and the +read-only inventory model, see [SECURITY.md — Inventory Privacy](../../SECURITY.md#inventory-privacy). + --- ## Related diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index fba762f..f665acd 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -1280,6 +1280,18 @@ def inventory( help="Skip merging .mcts/policy.yaml into inventory scans", ), ] = False, + redact_paths: Annotated[ + bool, + typer.Option("--redact-paths", help="Replace home directory with ~ in output"), + ] = False, + paths_only: Annotated[ + bool, + typer.Option("--paths-only", help="List config file paths without parsing server details"), + ] = False, + config_path_opt: Annotated[ + Path | None, + typer.Option("--config-path", help="Scope to a single config file instead of auto-discovery"), + ] = None, ) -> None: """Discover MCP servers configured across 12+ agent clients.""" from mcts.analyzers.cross_server import CrossServerAnalyzer @@ -1287,6 +1299,12 @@ def inventory( from mcts.analyzers.toxic_flows import analyze_inventory as analyze_toxic_flows from mcts.core.config import ScanConfig from mcts.governance import load_policy, merge_scan_config_with_policy + from mcts.inventory.discoverers import ( + discover_config_paths, + redact_entry_dict, + redact_home, + redact_skill_dict, + ) from mcts.inventory.runner import enrich_with_tool_names, run_inventory from mcts.inventory.scan_all import ( collect_scan_all_gate_violations, @@ -1304,6 +1322,30 @@ def inventory( console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(code=2) from exc + if paths_only and (scan or scan_all or skills): + console.print( + "[red]Error:[/red] --paths-only cannot be combined with --scan, --scan-all, or --skills." + ) + raise typer.Exit(code=2) + if paths_only and output is not None: + console.print("[red]Error:[/red] --paths-only does not support --output.") + raise typer.Exit(code=2) + + if paths_only: + if config_path_opt is not None: + scoped = config_path_opt.expanduser().resolve() + rows = [("user", scoped)] if scoped.exists() else [] + else: + rows = discover_config_paths() + if not rows: + console.print("[dim]No MCP config files found.[/dim]") + return + console.print(f"[bold]MCP config files[/bold] — {len(rows)} found") + for client, path in rows: + display = redact_home(str(path)) if redact_paths else str(path) + console.print(f" [{client}] {display}") + return + inv_config = merge_scan_config_with_policy( ScanConfig( target=Path("."), @@ -1315,7 +1357,12 @@ def inventory( ) if scan_all: - inventory_report, scan_rows = run_inventory_scan_all(inv_config) + inventory_report, scan_rows = run_inventory_scan_all( + inv_config, + config_path=config_path_opt, + skills=skills, + skills_dirs=skills_dir, + ) console.print( f"[bold]Inventory scan-all[/bold] — {len(scan_rows)} server(s), " f"{inventory_report.config_files_found} config file(s)" @@ -1339,7 +1386,11 @@ def inventory( raise typer.Exit(code=1) return - report = run_inventory(skills=skills, skills_dirs=skills_dir) + report = run_inventory( + skills=skills, + skills_dirs=skills_dir, + config_path=config_path_opt, + ) entries = enrich_with_tool_names(report.entries) if scan else report.entries shadow_findings = enrich_findings(CrossServerAnalyzer(entries).analyze_inventory(entries)) @@ -1357,12 +1408,14 @@ def inventory( console.print(f" • {client}") for entry in entries: tools = f" ({len(entry.tools)} tools)" if entry.tools else "" - console.print(f" [{entry.client}] {entry.server_name}{tools} — {entry.config_path}") + display_path = redact_home(entry.config_path) if redact_paths else entry.config_path + console.print(f" [{entry.client}] {entry.server_name}{tools} — {display_path}") if skills: console.print(f"\n[bold]Skills[/bold] — {len(report.skills)} SKILL.md file(s)") for skill in report.skills: - console.print(f" [{skill.client}] {skill.skill_name} — {skill.skill_path}") + skill_path = redact_home(skill.skill_path) if redact_paths else skill.skill_path + console.print(f" [{skill.client}] {skill.skill_name} — {skill_path}") if skill_findings: console.print(f"\n[yellow]Skill findings:[/yellow] {len(skill_findings)} issue(s)") for finding in skill_findings[:5]: @@ -1381,11 +1434,13 @@ def inventory( payload = { "clients_scanned": report.clients_scanned, "config_files_found": report.config_files_found, - "entries": [entry.model_dump() for entry in entries], + "entries": [redact_entry_dict(entry.model_dump(), redact=redact_paths) for entry in entries], "shadow_findings": [f.model_dump() for f in shadow_findings], } if skills: - payload["skills"] = [skill.model_dump() for skill in report.skills] + payload["skills"] = [ + redact_skill_dict(skill.model_dump(), redact=redact_paths) for skill in report.skills + ] payload["skill_findings"] = [f.model_dump() for f in skill_findings] if toxic_findings: payload["toxic_flow_findings"] = [f.model_dump() for f in toxic_findings] diff --git a/src/mcts/inventory/discoverers.py b/src/mcts/inventory/discoverers.py index ff11d38..d3df753 100644 --- a/src/mcts/inventory/discoverers.py +++ b/src/mcts/inventory/discoverers.py @@ -9,6 +9,46 @@ from mcts.inventory.models import InventoryEntry +def redact_home(path_str: str) -> str: + """Replace the user's home directory prefix with ``~`` when paths resolve under it.""" + if not path_str: + return path_str + try: + home = Path.home().resolve() + expanded = Path(path_str).expanduser() + resolved = expanded.resolve() + except (OSError, RuntimeError): + return path_str + + home_text = str(home) + resolved_text = str(resolved) + if resolved_text == home_text: + return "~" + for sep in ("/", "\\"): + prefix = home_text + sep + if resolved_text.startswith(prefix): + return "~" + resolved_text[len(home_text) :].replace("\\", "/") + return path_str + + +def redact_entry_dict(data: dict, *, redact: bool) -> dict: + if not redact: + return data + out = dict(data) + if "config_path" in out: + out["config_path"] = redact_home(str(out["config_path"])) + return out + + +def redact_skill_dict(data: dict, *, redact: bool) -> dict: + if not redact: + return data + out = dict(data) + if "skill_path" in out: + out["skill_path"] = redact_home(str(out["skill_path"])) + return out + + def discover_config_paths() -> list[tuple[str, Path]]: rows: list[tuple[str, Path]] = [] seen: set[Path] = set() diff --git a/src/mcts/inventory/runner.py b/src/mcts/inventory/runner.py index 8228773..dc3e2d6 100644 --- a/src/mcts/inventory/runner.py +++ b/src/mcts/inventory/runner.py @@ -10,7 +10,26 @@ from mcts.inventory.targets import resolve_entrypoint -def run_inventory(*, skills: bool = False, skills_dirs: list[Path] | None = None) -> InventoryReport: +def run_inventory( + *, + skills: bool = False, + skills_dirs: list[Path] | None = None, + config_path: Path | None = None, +) -> InventoryReport: + skill_entries = discover_skills(project_root=Path.cwd(), extra_dirs=skills_dirs) if skills else [] + + if config_path is not None: + path = config_path.expanduser().resolve() + if not path.exists(): + return InventoryReport(skills=skill_entries) + entries = parse_config_file("user", path) + return InventoryReport( + entries=entries, + clients_scanned=["user"] if entries else [], + config_files_found=1, + skills=skill_entries, + ) + entries: list[InventoryEntry] = [] clients: set[str] = set() files_found = 0 @@ -20,8 +39,6 @@ def run_inventory(*, skills: bool = False, skills_dirs: list[Path] | None = None clients.add(client) entries.extend(parse_config_file(client, path)) - skill_entries = discover_skills(project_root=Path.cwd(), extra_dirs=skills_dirs) if skills else [] - return InventoryReport( entries=entries, clients_scanned=sorted(clients), diff --git a/src/mcts/inventory/scan_all.py b/src/mcts/inventory/scan_all.py index 4573cd0..73f95a6 100644 --- a/src/mcts/inventory/scan_all.py +++ b/src/mcts/inventory/scan_all.py @@ -15,9 +15,19 @@ from mcts.reporting.models import ScanReport -def run_inventory_scan_all(base_config: ScanConfig) -> tuple[InventoryReport, list[dict]]: +def run_inventory_scan_all( + base_config: ScanConfig, + *, + config_path: Path | None = None, + skills: bool = False, + skills_dirs: list[Path] | None = None, +) -> tuple[InventoryReport, list[dict]]: """Run a full security scan for each resolvable inventory entry.""" - inventory = run_inventory() + inventory = run_inventory( + config_path=config_path, + skills=skills, + skills_dirs=skills_dirs, + ) rows: list[dict] = [] for entry in inventory.entries: scan_config = entry_to_scan_config(entry, base_config) diff --git a/tests/test_cli_inventory.py b/tests/test_cli_inventory.py new file mode 100644 index 0000000..cfc2328 --- /dev/null +++ b/tests/test_cli_inventory.py @@ -0,0 +1,81 @@ +"""CLI tests for mcts inventory privacy controls.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from mcts.cli.main import app + +runner = CliRunner() + + +def test_cli_paths_only_with_config_path(tmp_path: Path) -> None: + config = tmp_path / "team.json" + config.write_text(json.dumps({"mcpServers": {"hidden": {"command": "node"}}})) + + result = runner.invoke(app, ["inventory", "--paths-only", "--config-path", str(config)]) + + assert result.exit_code == 0 + assert "hidden" not in result.stdout + assert "team.json" in result.stdout + + +def test_cli_paths_only_conflicts_with_scan(tmp_path: Path) -> None: + config = tmp_path / "team.json" + config.write_text(json.dumps({"mcpServers": {"demo": {"command": "node"}}})) + + result = runner.invoke( + app, + ["inventory", "--paths-only", "--scan", "--config-path", str(config)], + ) + + assert result.exit_code == 2 + assert "--paths-only cannot be combined" in result.stdout + + +def test_cli_paths_only_rejects_output(tmp_path: Path) -> None: + config = tmp_path / "team.json" + config.write_text(json.dumps({"mcpServers": {"demo": {"command": "node"}}})) + output = tmp_path / "out.json" + + result = runner.invoke( + app, + ["inventory", "--paths-only", "--config-path", str(config), "-o", str(output)], + ) + + assert result.exit_code == 2 + assert "does not support --output" in result.stdout + + +def test_cli_redact_paths_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + config = fake_home / ".cursor" / "mcp.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({"mcpServers": {"myserver": {"command": "node", "args": ["server.js"]}}})) + output = tmp_path / "inventory.json" + + result = runner.invoke( + app, + [ + "inventory", + "--config-path", + str(config), + "--redact-paths", + "-o", + str(output), + "--theme", + "minimal", + ], + ) + + assert result.exit_code == 0, result.stdout + payload = json.loads(output.read_text(encoding="utf-8")) + entry = payload["entries"][0] + assert entry["config_path"] == "~/.cursor/mcp.json" + assert "confing_path" not in entry diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 2388409..1f12077 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -59,3 +59,88 @@ def test_taxonomy_catalog_loads() -> None: data = load_taxonomy() assert "MCTS-T-1001" in data["techniques"] assert data["mitigations"] + + +def test_redact_home() -> None: + from mcts.inventory.discoverers import redact_home + + home = str(Path.home()) + assert redact_home(f"{home}/.cursor/mcp.json") == "~/.cursor/mcp.json" + assert redact_home("/other/path") == "/other/path" + + +def test_redact_home_resolves_before_prefix() -> None: + from mcts.inventory.discoverers import redact_home + + home = Path.home().resolve() + target = home / ".cursor" / "mcp.json" + assert redact_home(str(target)) == "~/.cursor/mcp.json" + + +def test_redact_entry_dict_replaces_config_path() -> None: + from mcts.inventory.discoverers import redact_entry_dict + + home = str(Path.home()) + raw = {"config_path": f"{home}/.cursor/mcp.json", "server_name": "demo"} + redacted = redact_entry_dict(raw, redact=True) + assert redacted["config_path"] == "~/.cursor/mcp.json" + assert "confing_path" not in redacted + + +def test_config_path_scopes_to_single_file(tmp_path: Path) -> None: + config = tmp_path / "custom.json" + config.write_text(json.dumps({"mcpServers": {"myserver": {"command": "node", "args": ["server.js"]}}})) + from mcts.inventory.runner import run_inventory + + report = run_inventory(config_path=config) + assert len(report.entries) == 1 + assert report.entries[0].server_name == "myserver" + assert report.clients_scanned == ["user"] + assert report.config_files_found == 1 + + +def test_config_path_sets_config_files_found_for_empty_parse(tmp_path: Path) -> None: + config = tmp_path / "empty.json" + config.write_text(json.dumps({"other": []})) + from mcts.inventory.runner import run_inventory + + report = run_inventory(config_path=config) + assert report.entries == [] + assert report.config_files_found == 1 + + +def test_config_path_with_skills(tmp_path: Path, monkeypatch) -> None: + config = tmp_path / "custom.json" + config.write_text(json.dumps({"mcpServers": {"myserver": {"command": "node"}}})) + skill_dir = tmp_path / "skills" / "demo-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# Demo skill\n") + monkeypatch.chdir(tmp_path) + + from mcts.inventory.runner import run_inventory + + report = run_inventory(config_path=config, skills=True, skills_dirs=[tmp_path / "skills"]) + assert report.config_files_found == 1 + assert report.skills + + +def test_config_path_missing_file_returns_empty(tmp_path: Path) -> None: + from mcts.inventory.runner import run_inventory + + report = run_inventory(config_path=tmp_path / "nope.json") + assert report.entries == [] + assert report.config_files_found == 0 + + +def test_run_inventory_scan_all_respects_config_path(tmp_path: Path) -> None: + config = tmp_path / "custom.json" + config.write_text(json.dumps({"mcpServers": {"myserver": {"command": "node", "args": ["server.js"]}}})) + from mcts.core.config import ScanConfig + from mcts.inventory.scan_all import run_inventory_scan_all + + report, rows = run_inventory_scan_all(ScanConfig(target=Path(".")), config_path=config) + assert report.config_files_found == 1 + assert len(report.entries) == 1 + assert report.entries[0].server_name == "myserver" + assert len(rows) == 1 + assert rows[0]["server_name"] == "myserver"