From 25888c2daccb0646d0c1777ef94c3726be8154fd Mon Sep 17 00:00:00 2001 From: Karthik Laishetti Date: Thu, 11 Jun 2026 09:03:15 +0530 Subject: [PATCH 1/6] fix: clarify category gate failure message for inclusive threshold --- src/mcts/report/data.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mcts/report/data.py b/src/mcts/report/data.py index f77b3d9..21cb3d9 100644 --- a/src/mcts/report/data.py +++ b/src/mcts/report/data.py @@ -411,7 +411,10 @@ def category_gate_failures(findings: list[Finding], gates: dict[str, int]) -> li if not row: continue if row["score"] >= limit: - failures.append(f"{row['label']} scored {row['display']} (limit {limit})") + failures.append( + f"{row['label']}: risk score {row['score']} >= limit {limit} " + f"(inclusive gate — '{row['display']}' is category label, not CI result)" + ) return failures From b618aee7886cefdc9f01296f2caa885571dc00ee Mon Sep 17 00:00:00 2001 From: Karthik Laishetti Date: Thu, 11 Jun 2026 09:15:52 +0530 Subject: [PATCH 2/6] clarify --fail-on-category help text with inclusive threshold semantics Updated help message for 'fail_on_category' option to clarify threshold behavior. --- src/mcts/cli/main.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index 8e3a2be..5b86092 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -235,13 +235,19 @@ def scan( int | None, typer.Option("--max-critical", help="Exit 1 if critical finding count exceeds this"), ] = None, - fail_on_category: Annotated[ + + fail_on_category: Annotated[ list[str] | None, typer.Option( "--fail-on-category", - help="Exit 1 when category risk score reaches threshold (e.g. permissions:10). Repeatable.", + help=( + "Exit 1 when category risk score meets or exceeds threshold (inclusive). " + "e.g. permissions:0 fails when score is 0 or more. " + "Use permissions:1 to allow zero-point categories. Repeatable." + ), ), ] = None, + theme: Annotated[ str, typer.Option( From e0c7eb28bfcda5b9edebbd9066ab15668f5c64aa Mon Sep 17 00:00:00 2001 From: Karthik Laishetti Date: Thu, 11 Jun 2026 09:32:46 +0530 Subject: [PATCH 3/6] add boundary tests for inclusive category gate failure message --- tests/test_category_gates.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_category_gates.py b/tests/test_category_gates.py index 64eb08e..0e27099 100644 --- a/tests/test_category_gates.py +++ b/tests/test_category_gates.py @@ -47,3 +47,26 @@ def test_category_gate_passes_below_limit() -> None: ) ] assert not category_gate_failures(findings, {"permissions": 10}) + +def test_category_gate_boundary_score_equals_limit() -> None: + """score == limit should FAIL with inclusive message.""" + failures = category_gate_failures([], {"permissions": 0}) + assert len(failures) == 1 + assert "inclusive gate" in failures[0] + assert ">=" in failures[0] + assert "0" in failures[0] + + +def test_category_gate_score_zero_limit_one_passes() -> None: + """score=0, limit=1 should NOT fail — score is below limit.""" + failures = category_gate_failures([], {"permissions": 1}) + assert len(failures) == 0 + + +def test_category_gate_failure_message_never_says_passed_alone() -> None: + """Failure message must not imply CI pass when gate fails.""" + failures = category_gate_failures([], {"permissions": 0}) + assert len(failures) == 1 + message = failures[0] + assert "inclusive gate" in message + assert ">=" in message From e749457b705b96bd8cbc10505f4ac37d2c86ab44 Mon Sep 17 00:00:00 2001 From: Karthik Laishetti Date: Thu, 11 Jun 2026 09:46:14 +0530 Subject: [PATCH 4/6] deduplicate Docker FROM findings by path and image ref Refactor Dockerfile scanning to deduplicate file paths and images. --- src/mcts/analyzers/supply_chain.py | 51 +++++++++++++++++++----------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/mcts/analyzers/supply_chain.py b/src/mcts/analyzers/supply_chain.py index 1557ae0..399bee4 100644 --- a/src/mcts/analyzers/supply_chain.py +++ b/src/mcts/analyzers/supply_chain.py @@ -125,25 +125,40 @@ def _scan_requirements(self, root: Path) -> list[Finding]: return findings def _scan_dockerfile(self, root: Path) -> list[Finding]: - findings: list[Finding] = [] - for path in list(_find_files(root, "Dockerfile")) + list(root.glob("**/Dockerfile*"))[:10]: - if not path.is_file(): - continue - text = path.read_text(encoding="utf-8", errors="ignore") - for match in DOCKER_FROM.finditer(text): - image = match.group(1) - if "@sha256:" not in image and (":latest" in image.lower() or ":" not in image): - findings.append( - _finding( - path, - f"supply-docker-{hash(image) & 0xFFFF}", - "Docker base image not digest-pinned", - f"FROM {image}", - Severity.HIGH, - "MCTS-T-1015", - ) + findings: list[Finding] = [] + # dedupe file paths — _find_files and glob overlap on same files + seen_paths: set[Path] = set() + for path in _find_files(root, "Dockerfile"): + seen_paths.add(path.resolve()) + for path in root.glob("**/Dockerfile*"): + if path.is_file(): + seen_paths.add(path.resolve()) + for path in root.glob("**/Containerfile*"): + if path.is_file(): + seen_paths.add(path.resolve()) + # dedupe by normalized image ref across all files + seen_images: set[str] = set() + for path in sorted(seen_paths): + text = path.read_text(encoding="utf-8", errors="ignore") + for match in DOCKER_FROM.finditer(text): + image = match.group(1) + if "@sha256:" not in image and (":latest" in image.lower() or ":" not in image): + norm = image.split("@")[0].lower() + if norm in seen_images: + continue + seen_images.add(norm) + findings.append( + _finding( + path, + f"supply-docker-{hash(norm) & 0xFFFF:04x}", + "Docker base image not digest-pinned", + f"FROM {image}", + Severity.HIGH, + "MCTS-T-1015", + evidence={"image": image}, ) - return findings + ) + return findings def _find_files(root: Path, name: str) -> list[Path]: From d7f58a3082625ab0c55b1bfadfb0b11bae160622 Mon Sep 17 00:00:00 2001 From: Karthik Laishetti Date: Thu, 11 Jun 2026 09:48:44 +0530 Subject: [PATCH 5/6] add Docker supply-chain deduplication tests Add tests for deduplication of Dockerfile findings in various scenarios. --- tests/test_analyzers.py | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_analyzers.py b/tests/test_analyzers.py index c550a76..2d1a198 100644 --- a/tests/test_analyzers.py +++ b/tests/test_analyzers.py @@ -24,3 +24,52 @@ def test_data_leakage_scans_source_files(example_server_path: Path) -> None: report = Scanner(ScanConfig(target=example_server_path)).run() source_findings = [f for f in report.findings if f.analyzer == "data_leakage" and f.location] assert source_findings or any(f.analyzer == "data_leakage" for f in report.findings) + + +def test_docker_dedupe_dockerfile_and_containerfile(tmp_path: Path) -> None: + """Dockerfile + Containerfile with same FROM → only 1 HIGH finding.""" + from mcts.analyzers.supply_chain import SupplyChainAnalyzer + from mcts.mcp.models import MCPServerInfo + + (tmp_path / "Dockerfile").write_text("FROM python:3.11-slim\n") + (tmp_path / "Containerfile").write_text("FROM python:3.11-slim\n") + findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) + docker_highs = [f for f in findings if "Docker base" in f.title] + assert len(docker_highs) == 1 + + +def test_docker_dedupe_same_file_not_scanned_twice(tmp_path: Path) -> None: + """Same Dockerfile must not produce duplicate findings.""" + from mcts.analyzers.supply_chain import SupplyChainAnalyzer + from mcts.mcp.models import MCPServerInfo + + (tmp_path / "Dockerfile").write_text("FROM python:3.11-slim\n") + findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) + docker_highs = [f for f in findings if "Docker base" in f.title] + assert len(docker_highs) == 1 + + +def test_docker_dedupe_multistage_same_image(tmp_path: Path) -> None: + """Multi-stage build with same FROM → only 1 HIGH finding.""" + from mcts.analyzers.supply_chain import SupplyChainAnalyzer + from mcts.mcp.models import MCPServerInfo + + (tmp_path / "Dockerfile").write_text( + "FROM node:20 AS builder\nFROM node:20 AS runtime\n" + ) + findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) + docker_highs = [f for f in findings if "Docker base" in f.title] + assert len(docker_highs) == 1 + + +def test_docker_digest_pinned_not_flagged(tmp_path: Path) -> None: + """Digest-pinned images must never be flagged.""" + from mcts.analyzers.supply_chain import SupplyChainAnalyzer + from mcts.mcp.models import MCPServerInfo + + (tmp_path / "Dockerfile").write_text( + "FROM python:3.11@sha256:abcdef1234567890\n" + ) + findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) + docker_highs = [f for f in findings if "Docker base" in f.title] + assert len(docker_highs) == 0 From 6108514805437f56c5b424a4d116a25ae9ebd1bd Mon Sep 17 00:00:00 2001 From: hello-args Date: Fri, 12 Jun 2026 00:15:27 +0530 Subject: [PATCH 6/6] Fix supply_chain IndentationError and merge main into PR #231. Correct _scan_dockerfile body indentation, sync with main's pyproject parsing, align docker dedupe tests with unpinned-image detection, and run ruff format on touched files. --- src/mcts/analyzers/supply_chain.py | 64 +++++++++++++++--------------- src/mcts/cli/main.py | 4 +- tests/test_analyzers.py | 14 +++---- tests/test_category_gates.py | 1 + 4 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/mcts/analyzers/supply_chain.py b/src/mcts/analyzers/supply_chain.py index b656e95..8d6de5a 100644 --- a/src/mcts/analyzers/supply_chain.py +++ b/src/mcts/analyzers/supply_chain.py @@ -137,40 +137,40 @@ def _scan_requirements(self, root: Path) -> list[Finding]: return findings def _scan_dockerfile(self, root: Path) -> list[Finding]: - findings: list[Finding] = [] - # dedupe file paths — _find_files and glob overlap on same files - seen_paths: set[Path] = set() - for path in _find_files(root, "Dockerfile"): - seen_paths.add(path.resolve()) - for path in root.glob("**/Dockerfile*"): - if path.is_file(): - seen_paths.add(path.resolve()) - for path in root.glob("**/Containerfile*"): - if path.is_file(): + findings: list[Finding] = [] + # dedupe file paths — _find_files and glob overlap on same files + seen_paths: set[Path] = set() + for path in _find_files(root, "Dockerfile"): seen_paths.add(path.resolve()) - # dedupe by normalized image ref across all files - seen_images: set[str] = set() - for path in sorted(seen_paths): - text = path.read_text(encoding="utf-8", errors="ignore") - for match in DOCKER_FROM.finditer(text): - image = match.group(1) - if "@sha256:" not in image and (":latest" in image.lower() or ":" not in image): - norm = image.split("@")[0].lower() - if norm in seen_images: - continue - seen_images.add(norm) - findings.append( - _finding( - path, - f"supply-docker-{hash(norm) & 0xFFFF:04x}", - "Docker base image not digest-pinned", - f"FROM {image}", - Severity.HIGH, - "MCTS-T-1015", - evidence={"image": image}, + for path in root.glob("**/Dockerfile*"): + if path.is_file(): + seen_paths.add(path.resolve()) + for path in root.glob("**/Containerfile*"): + if path.is_file(): + seen_paths.add(path.resolve()) + # dedupe by normalized image ref across all files + seen_images: set[str] = set() + for path in sorted(seen_paths): + text = path.read_text(encoding="utf-8", errors="ignore") + for match in DOCKER_FROM.finditer(text): + image = match.group(1) + if "@sha256:" not in image and (":latest" in image.lower() or ":" not in image): + norm = image.split("@")[0].lower() + if norm in seen_images: + continue + seen_images.add(norm) + findings.append( + _finding( + path, + f"supply-docker-{hash(norm) & 0xFFFF:04x}", + "Docker base image not digest-pinned", + f"FROM {image}", + Severity.HIGH, + "MCTS-T-1015", + evidence={"image": image}, + ) ) - ) - return findings + return findings def _find_files(root: Path, name: str) -> list[Path]: diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index 77f3ab1..67e2f5b 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -284,8 +284,7 @@ def scan( int | None, typer.Option("--max-critical", help="Exit 1 if critical finding count exceeds this"), ] = None, - - fail_on_category: Annotated[ + fail_on_category: Annotated[ list[str] | None, typer.Option( "--fail-on-category", @@ -296,7 +295,6 @@ def scan( ), ), ] = None, - theme: Annotated[ str, typer.Option( diff --git a/tests/test_analyzers.py b/tests/test_analyzers.py index 2d1a198..28b7f99 100644 --- a/tests/test_analyzers.py +++ b/tests/test_analyzers.py @@ -31,8 +31,8 @@ def test_docker_dedupe_dockerfile_and_containerfile(tmp_path: Path) -> None: from mcts.analyzers.supply_chain import SupplyChainAnalyzer from mcts.mcp.models import MCPServerInfo - (tmp_path / "Dockerfile").write_text("FROM python:3.11-slim\n") - (tmp_path / "Containerfile").write_text("FROM python:3.11-slim\n") + (tmp_path / "Dockerfile").write_text("FROM python:latest\n") + (tmp_path / "Containerfile").write_text("FROM python:latest\n") findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) docker_highs = [f for f in findings if "Docker base" in f.title] assert len(docker_highs) == 1 @@ -43,7 +43,7 @@ def test_docker_dedupe_same_file_not_scanned_twice(tmp_path: Path) -> None: from mcts.analyzers.supply_chain import SupplyChainAnalyzer from mcts.mcp.models import MCPServerInfo - (tmp_path / "Dockerfile").write_text("FROM python:3.11-slim\n") + (tmp_path / "Dockerfile").write_text("FROM python:latest\n") findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) docker_highs = [f for f in findings if "Docker base" in f.title] assert len(docker_highs) == 1 @@ -54,9 +54,7 @@ def test_docker_dedupe_multistage_same_image(tmp_path: Path) -> None: from mcts.analyzers.supply_chain import SupplyChainAnalyzer from mcts.mcp.models import MCPServerInfo - (tmp_path / "Dockerfile").write_text( - "FROM node:20 AS builder\nFROM node:20 AS runtime\n" - ) + (tmp_path / "Dockerfile").write_text("FROM node:latest AS builder\nFROM node:latest AS runtime\n") findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) docker_highs = [f for f in findings if "Docker base" in f.title] assert len(docker_highs) == 1 @@ -67,9 +65,7 @@ def test_docker_digest_pinned_not_flagged(tmp_path: Path) -> None: from mcts.analyzers.supply_chain import SupplyChainAnalyzer from mcts.mcp.models import MCPServerInfo - (tmp_path / "Dockerfile").write_text( - "FROM python:3.11@sha256:abcdef1234567890\n" - ) + (tmp_path / "Dockerfile").write_text("FROM python:3.11@sha256:abcdef1234567890\n") findings = SupplyChainAnalyzer(tmp_path).analyze(MCPServerInfo(name="x")) docker_highs = [f for f in findings if "Docker base" in f.title] assert len(docker_highs) == 0 diff --git a/tests/test_category_gates.py b/tests/test_category_gates.py index 0e27099..cc240e2 100644 --- a/tests/test_category_gates.py +++ b/tests/test_category_gates.py @@ -48,6 +48,7 @@ def test_category_gate_passes_below_limit() -> None: ] assert not category_gate_failures(findings, {"permissions": 10}) + def test_category_gate_boundary_score_equals_limit() -> None: """score == limit should FAIL with inclusive message.""" failures = category_gate_failures([], {"permissions": 0})