From 14fb7a147cdd0771de9467bb4c92dbbd80ba9113 Mon Sep 17 00:00:00 2001 From: Sachin Kumar Jha Date: Tue, 16 Jun 2026 20:40:10 +0530 Subject: [PATCH 1/6] feat(inventory): add --redact-paths to hide home directory prefix (#87) --- src/mcts/cli/main.py | 18 ++++++++++++------ src/mcts/inventory/discoverers.py | 7 +++++++ tests/test_inventory.py | 7 +++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index 7384f11..b632847 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -1259,10 +1259,11 @@ def inventory( ] = None, ignore_policy: Annotated[ bool, - typer.Option( - "--ignore-policy", - help="Skip merging .mcts/policy.yaml into inventory scans", - ), + typer.Option("--ignore-policy", help="Skip merging .mcts/policy.yaml into inventory scans"), + ] = None, + redact_paths: Annotated[ + bool, + typer.Option("-redact-paths", help="Replace home directory with ~ in output"), ] = False, ) -> None: """Discover MCP servers configured across 12+ agent clients.""" @@ -1272,6 +1273,7 @@ def inventory( from mcts.core.config import ScanConfig from mcts.governance import load_policy, merge_scan_config_with_policy from mcts.inventory.runner import enrich_with_tool_names, run_inventory + from mcts.inventory.discoverers import redact_home from mcts.inventory.scan_all import ( collect_scan_all_gate_violations, default_output_path, @@ -1341,7 +1343,8 @@ 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)") @@ -1365,7 +1368,10 @@ def inventory( payload = { "clients_scanned": report.clients_scanned, "config_files_found": report.config_files_found, - "entries": [entry.model_dump() for entry in entries], + "entries": [{**entry.model_dump(), "confing_path": redact_home(entry.config_path)} + if redact_paths else entry.model_dump() + for entry in entries + ], "shadow_findings": [f.model_dump() for f in shadow_findings], } if skills: diff --git a/src/mcts/inventory/discoverers.py b/src/mcts/inventory/discoverers.py index ff11d38..8553ade 100644 --- a/src/mcts/inventory/discoverers.py +++ b/src/mcts/inventory/discoverers.py @@ -8,6 +8,13 @@ from mcts.inventory.client_registry import config_paths_for_platform from mcts.inventory.models import InventoryEntry +def redact_home(path_str: str) -> str: + """Replace the user's home directory prefix with ~.""" + home = str(Path.home()) + if path_str.startswith(home): + return "~" + path_str[len(home):] + return path_str + def discover_config_paths() -> list[tuple[str, Path]]: rows: list[tuple[str, Path]] = [] diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 2388409..76c0a41 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -59,3 +59,10 @@ 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" \ No newline at end of file From c120fa2c578e2ae68438d608a20bb750560da68c Mon Sep 17 00:00:00 2001 From: Sachin Kumar Jha Date: Tue, 16 Jun 2026 20:52:58 +0530 Subject: [PATCH 2/6] feat(inventory): add --paths-only to list config files without parsing (#87) --- src/mcts/cli/main.py | 16 ++++++++++++++++ tests/test_inventory.py | 11 ++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index b632847..aa2cdfe 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -1265,6 +1265,10 @@ def inventory( 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, ) -> None: """Discover MCP servers configured across 12+ agent clients.""" from mcts.analyzers.cross_server import CrossServerAnalyzer @@ -1289,6 +1293,18 @@ def inventory( except ValueError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(code=2) from exc + + if paths_only: + from mcts.inventory.discoverers import discover_config_paths, redact_home + 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( diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 76c0a41..6ddba56 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -65,4 +65,13 @@ 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" \ No newline at end of file + assert redact_home("/other/path") == "/other/path" + + +def test_path_only_returns_tuples(tmp_path: Path) -> None: + from mcts.inventory.discoverers import discover_config_paths + rows = discover_config_paths() + for client, path in rows: + assert isinstance(client, str) + assert isinstance(path, Path) + \ No newline at end of file From 1f6c9da3e3d3ef0a24bbb21069e5d9c18d9e5574 Mon Sep 17 00:00:00 2001 From: Sachin Kumar Jha Date: Tue, 16 Jun 2026 21:47:56 +0530 Subject: [PATCH 3/6] feat(inventory): add --config-path to scope to explicit file (#87) --- src/mcts/cli/main.py | 6 +++++- src/mcts/inventory/runner.py | 16 +++++++++++++++- tests/test_inventory.py | 19 ++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index aa2cdfe..a4a1461 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -1269,6 +1269,10 @@ def inventory( 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 @@ -1341,7 +1345,7 @@ 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)) diff --git a/src/mcts/inventory/runner.py b/src/mcts/inventory/runner.py index 8228773..6b45bcf 100644 --- a/src/mcts/inventory/runner.py +++ b/src/mcts/inventory/runner.py @@ -10,7 +10,21 @@ 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: + + if config_path is not None: + path = config_path.expanduser().resolve() + if not path.exists(): + return InventoryReport() + entries = parse_config_file("user", path) + skill_entries = discover_skills(project_root=Path.cwd(), extra_dirs=skills_dirs) if skills else [] + return InventoryReport( + entries=entries, + clients_scanned=["user"] if entries else [], + config_file_found=1 if path.exists() else 0, + skill=skill_entries, + ) + entries: list[InventoryEntry] = [] clients: set[str] = set() files_found = 0 diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 6ddba56..c3403db 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -74,4 +74,21 @@ def test_path_only_returns_tuples(tmp_path: Path) -> None: for client, path in rows: assert isinstance(client, str) assert isinstance(path, Path) - \ No newline at end of file + + +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": ["sever.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"] + + +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 len(report.entries) == 0 From d12210b01e36de6ba97cc3c82f0bf95611274d1d Mon Sep 17 00:00:00 2001 From: Sachin Kumar Jha Date: Tue, 16 Jun 2026 22:00:23 +0530 Subject: [PATCH 4/6] docs(security): document inventory privacy model and new flags (#87) --- CHANGELOG.md | 1 + SECURITY.md | 16 ++++++++++++++++ docs/platform/cli.md | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7037ed0..90b6cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v2 benchmark gauge** — doughnut gauge for `security_score` in v2 risk panel - **Terminal v2-first** — when `scoring_version=both`, Absolute Risk / Security Score appear first - **MCP IDE** — `min_category_score_v2` comma gates on `scan_mcp_target` +- 'mcts inventory' privacy controls: '--redact-paths', '--paths-only', '--config-path' (#87) ### Fixed diff --git a/SECURITY.md b/SECURITY.md index 4a83b7d..02a8783 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,22 @@ 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 and no files are modified. + +To limit exposure on shared or sensitive machines: + +- `--paths-only` — list config file locations without reading server details +- `--config-path ` — scope discovery to a single explicit file +- `--redact-paths` — replace home directory with `~` in output and reports + +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..a509ba9 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 output | | `--output`, `-o` | — | Write inventory JSON | | `--theme` | `cyber` | Terminal theme | @@ -432,6 +435,9 @@ Exit **0** on pass/medium verdict; **1** on critical/high; **2** on errors. When `--scoring v2` or `both` and `score_v2` is present under **`off`**, **verdict** may use v2 `risk_level`. Under **`warn`**, verdict follows display severity on security findings (overlap chains capped). Under **`enforce`**, verdict follows gate summary (display-aligned). **Static-only coverage:** when static discovery finds **zero MCP tools** (e.g. prompt-only servers), the `attack_chains` phase is marked `skipped` in the JSON report. Check `pentest_limits.coverage` (`static-only` vs `full`) and `pentest_limits.attack_chains_available` to see what ran. +> **Static-only mode:** Pentest always runs static analysis. Attack chains and protocol fuzz require +discovered MCP tools. when tools=0 `attack-chains` is marked `skiiped` and coverage is reported as +`static-only`. use `mcts doctor.` to verify your entrypoint if tools are not detected. --- From d55bb5d971b70f873f96e4f2f8736799da1a686f Mon Sep 17 00:00:00 2001 From: Sachin Kumar Jha Date: Wed, 17 Jun 2026 18:23:10 +0530 Subject: [PATCH 5/6] fix: resolve ruff lint and format errors --- src/mcts/cli/main.py | 15 +++++++++------ src/mcts/inventory/discoverers.py | 3 ++- src/mcts/inventory/runner.py | 9 +++++++-- tests/test_inventory.py | 8 +++++--- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index a4a1461..4efe4b3 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -1280,8 +1280,8 @@ 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.runner import enrich_with_tool_names, run_inventory from mcts.inventory.discoverers import redact_home + from mcts.inventory.runner import enrich_with_tool_names, run_inventory from mcts.inventory.scan_all import ( collect_scan_all_gate_violations, default_output_path, @@ -1297,9 +1297,10 @@ def inventory( except ValueError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(code=2) from exc - + if paths_only: from mcts.inventory.discoverers import discover_config_paths, redact_home + rows = discover_config_paths() if not rows: console.print("[dim]NO MCP config files found.[/dim]") @@ -1388,10 +1389,12 @@ def inventory( payload = { "clients_scanned": report.clients_scanned, "config_files_found": report.config_files_found, - "entries": [{**entry.model_dump(), "confing_path": redact_home(entry.config_path)} - if redact_paths else entry.model_dump() - for entry in entries - ], + "entries": [ + {**entry.model_dump(), "confing_path": redact_home(entry.config_path)} + if redact_paths + else entry.model_dump() + for entry in entries + ], "shadow_findings": [f.model_dump() for f in shadow_findings], } if skills: diff --git a/src/mcts/inventory/discoverers.py b/src/mcts/inventory/discoverers.py index 8553ade..047494f 100644 --- a/src/mcts/inventory/discoverers.py +++ b/src/mcts/inventory/discoverers.py @@ -8,11 +8,12 @@ from mcts.inventory.client_registry import config_paths_for_platform from mcts.inventory.models import InventoryEntry + def redact_home(path_str: str) -> str: """Replace the user's home directory prefix with ~.""" home = str(Path.home()) if path_str.startswith(home): - return "~" + path_str[len(home):] + return "~" + path_str[len(home) :] return path_str diff --git a/src/mcts/inventory/runner.py b/src/mcts/inventory/runner.py index 6b45bcf..eb37988 100644 --- a/src/mcts/inventory/runner.py +++ b/src/mcts/inventory/runner.py @@ -10,7 +10,12 @@ from mcts.inventory.targets import resolve_entrypoint -def run_inventory(*, skills: bool = False, skills_dirs: list[Path] | None = None, config_path: Path | None = None,) -> InventoryReport: +def run_inventory( + *, + skills: bool = False, + skills_dirs: list[Path] | None = None, + config_path: Path | None = None, +) -> InventoryReport: if config_path is not None: path = config_path.expanduser().resolve() @@ -24,7 +29,7 @@ def run_inventory(*, skills: bool = False, skills_dirs: list[Path] | None = None config_file_found=1 if path.exists() else 0, skill=skill_entries, ) - + entries: list[InventoryEntry] = [] clients: set[str] = set() files_found = 0 diff --git a/tests/test_inventory.py b/tests/test_inventory.py index c3403db..d9cba9d 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -63,6 +63,7 @@ def test_taxonomy_catalog_loads() -> None: 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" @@ -70,6 +71,7 @@ def test_redact_home() -> None: def test_path_only_returns_tuples(tmp_path: Path) -> None: from mcts.inventory.discoverers import discover_config_paths + rows = discover_config_paths() for client, path in rows: assert isinstance(client, str) @@ -78,10 +80,9 @@ def test_path_only_returns_tuples(tmp_path: Path) -> None: 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": ["sever.js"]}} - })) + config.write_text(json.dumps({"mcpServers": {"myserver": {"command": "node", "args": ["sever.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" @@ -90,5 +91,6 @@ def test_config_path_scopes_to_single_file(tmp_path: Path) -> None: 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 len(report.entries) == 0 From 9ee8fa768ccedf72d507bae666da409b67435bdb Mon Sep 17 00:00:00 2001 From: hello-args Date: Wed, 17 Jun 2026 23:36:53 +0530 Subject: [PATCH 6/6] fix(inventory): complete privacy controls for #87 (PR #279) - Fix --redact-paths flag, JSON redaction, and InventoryReport kwargs - Add paths-only flag conflicts, config_path through scan-all - Harden redact_home; add CLI and unit tests - Update SECURITY.md, inventory.md, and cli.md Co-authored-by: Sachin Kumar Jha --- CHANGELOG.md | 4 +- SECURITY.md | 13 ++-- docs/platform/cli.md | 5 +- docs/scanning/inventory.md | 17 +++++ src/mcts/cli/main.py | 105 ++++++++++++++++++++++++------ src/mcts/inventory/discoverers.py | 40 ++++++++++-- src/mcts/inventory/runner.py | 10 ++- src/mcts/inventory/scan_all.py | 14 +++- tests/test_cli_inventory.py | 81 +++++++++++++++++++++++ tests/test_inventory.py | 66 ++++++++++++++++--- 10 files changed, 305 insertions(+), 50 deletions(-) create mode 100644 tests/test_cli_inventory.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9d002..f27abbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ 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` - **Scoring corpus** — `single_tool_overlap` fixture under enforce; Spearman calibration validates without mutating fixtures in CI @@ -67,7 +70,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v2 benchmark gauge** — doughnut gauge for `security_score` in v2 risk panel - **Terminal v2-first** — when `scoring_version=both`, Absolute Risk / Security Score appear first - **MCP IDE** — `min_category_score_v2` comma gates on `scan_mcp_target` -- 'mcts inventory' privacy controls: '--redact-paths', '--paths-only', '--config-path' (#87) ### Fixed diff --git a/SECURITY.md b/SECURITY.md index 02a8783..5796ef6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,18 +33,23 @@ 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 and no files are modified. +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 reading server details +- `--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 output and reports +- `--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. diff --git a/docs/platform/cli.md b/docs/platform/cli.md index a509ba9..0da8acf 100644 --- a/docs/platform/cli.md +++ b/docs/platform/cli.md @@ -351,7 +351,7 @@ mcts inventory [options] | `--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 output | +| `--redact-paths` | false | Replace home directory with `~` in inventory entry and skill paths | | `--output`, `-o` | — | Write inventory JSON | | `--theme` | `cyber` | Terminal theme | @@ -435,9 +435,6 @@ Exit **0** on pass/medium verdict; **1** on critical/high; **2** on errors. When `--scoring v2` or `both` and `score_v2` is present under **`off`**, **verdict** may use v2 `risk_level`. Under **`warn`**, verdict follows display severity on security findings (overlap chains capped). Under **`enforce`**, verdict follows gate summary (display-aligned). **Static-only coverage:** when static discovery finds **zero MCP tools** (e.g. prompt-only servers), the `attack_chains` phase is marked `skipped` in the JSON report. Check `pentest_limits.coverage` (`static-only` vs `full`) and `pentest_limits.attack_chains_available` to see what ran. -> **Static-only mode:** Pentest always runs static analysis. Attack chains and protocol fuzz require -discovered MCP tools. when tools=0 `attack-chains` is marked `skiiped` and coverage is reported as -`static-only`. use `mcts doctor.` to verify your entrypoint if tools are not detected. --- 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 4efe4b3..f665acd 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -807,6 +807,20 @@ def scan( help="Disable chain multiplier (chain_factor=1.0); under v2/both the analyzer still runs", ), ] = False, + attack_graph_counterfactuals: Annotated[ + bool, + typer.Option( + "--attack-graph-counterfactuals/--no-attack-graph-counterfactuals", + help="Attach counterfactual remediation to attack graph template findings (default on)", + ), + ] = True, + attack_graph_compress_ui: Annotated[ + bool, + typer.Option( + "--attack-graph-compress-ui/--no-attack-graph-compress-ui", + help="Compress matched attack paths in report export for dashboard readability (default on)", + ), + ] = True, min_security_score: Annotated[ int | None, typer.Option( @@ -1077,6 +1091,8 @@ def scan( surface_scoped_analyzers=surface_scoped, scoring_mode=scoring.lower(), enable_attack_chains=not no_attack_chains, + attack_graph_enable_counterfactuals=attack_graph_counterfactuals, + attack_graph_compress_for_ui=attack_graph_compress_ui, min_security_score=min_security_score, max_absolute_risk=max_absolute_risk, max_risk_level=max_risk_level.lower() if max_risk_level else None, @@ -1259,11 +1275,14 @@ def inventory( ] = None, ignore_policy: Annotated[ bool, - typer.Option("--ignore-policy", help="Skip merging .mcts/policy.yaml into inventory scans"), - ] = None, + typer.Option( + "--ignore-policy", + 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"), + typer.Option("--redact-paths", help="Replace home directory with ~ in output"), ] = False, paths_only: Annotated[ bool, @@ -1280,7 +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 redact_home + 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, @@ -1298,17 +1322,28 @@ def inventory( console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(code=2) from exc - if paths_only: - from mcts.inventory.discoverers import discover_config_paths, redact_home + 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) - rows = discover_config_paths() + 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]") + console.print("[dim]No MCP config files found.[/dim]") return - console.print(f"[bold]MCP config files[/bold] - {len(rows)} found") + 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}") + console.print(f" [{client}] {display}") return inv_config = merge_scan_config_with_policy( @@ -1322,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)" @@ -1346,7 +1386,11 @@ def inventory( raise typer.Exit(code=1) return - report = run_inventory(skills=skills, skills_dirs=skills_dir, config_path=config_path_opt) + 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)) @@ -1370,7 +1414,8 @@ def inventory( 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]: @@ -1389,16 +1434,13 @@ def inventory( payload = { "clients_scanned": report.clients_scanned, "config_files_found": report.config_files_found, - "entries": [ - {**entry.model_dump(), "confing_path": redact_home(entry.config_path)} - if redact_paths - else 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] @@ -2118,11 +2160,32 @@ def doctor( bool, typer.Option("--json", help="Emit machine-readable JSON"), ] = False, + suggest_fixes: Annotated[ + bool, + typer.Option( + "--suggest-fixes", + help="List attack-graph template remediations from a prior scan report", + ), + ] = False, + report: Annotated[ + Path | None, + typer.Option( + "--report", + help="Scan JSON report for --suggest-fixes (e.g. mcts_analysis/scan-report.json)", + ), + ] = None, ) -> None: """Preflight checks before your first scan (no live probes).""" from mcts.cli.doctor import run_doctor - code = run_doctor(path, deep=deep, json_output=json_output, output=output) + code = run_doctor( + path, + deep=deep, + json_output=json_output, + output=output, + suggest_fixes=suggest_fixes, + report=report, + ) if code: raise typer.Exit(code=code) diff --git a/src/mcts/inventory/discoverers.py b/src/mcts/inventory/discoverers.py index 047494f..d3df753 100644 --- a/src/mcts/inventory/discoverers.py +++ b/src/mcts/inventory/discoverers.py @@ -10,13 +10,45 @@ def redact_home(path_str: str) -> str: - """Replace the user's home directory prefix with ~.""" - home = str(Path.home()) - if path_str.startswith(home): - return "~" + path_str[len(home) :] + """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 eb37988..dc3e2d6 100644 --- a/src/mcts/inventory/runner.py +++ b/src/mcts/inventory/runner.py @@ -16,18 +16,18 @@ def run_inventory( 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() + return InventoryReport(skills=skill_entries) entries = parse_config_file("user", path) - skill_entries = discover_skills(project_root=Path.cwd(), extra_dirs=skills_dirs) if skills else [] return InventoryReport( entries=entries, clients_scanned=["user"] if entries else [], - config_file_found=1 if path.exists() else 0, - skill=skill_entries, + config_files_found=1, + skills=skill_entries, ) entries: list[InventoryEntry] = [] @@ -39,8 +39,6 @@ def run_inventory( 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 d9cba9d..1f12077 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -69,28 +69,78 @@ def test_redact_home() -> None: assert redact_home("/other/path") == "/other/path" -def test_path_only_returns_tuples(tmp_path: Path) -> None: - from mcts.inventory.discoverers import discover_config_paths +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" + - rows = discover_config_paths() - for client, path in rows: - assert isinstance(client, str) - assert isinstance(path, Path) +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": ["sever.js"]}}})) + 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 len(report.entries) == 0 + 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"