diff --git a/src/mcts/analyzers/supply_chain.py b/src/mcts/analyzers/supply_chain.py index 5b0cd51..8d6de5a 100644 --- a/src/mcts/analyzers/supply_chain.py +++ b/src/mcts/analyzers/supply_chain.py @@ -138,21 +138,36 @@ def _scan_requirements(self, root: Path) -> list[Finding]: 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 + # 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(image) & 0xFFFF}", + 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 diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index 69546d9..67e2f5b 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -288,7 +288,11 @@ def scan( 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[ diff --git a/src/mcts/report/data.py b/src/mcts/report/data.py index e84106d..ad127dd 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 diff --git a/tests/test_analyzers.py b/tests/test_analyzers.py index cb7e787..b3a0a86 100644 --- a/tests/test_analyzers.py +++ b/tests/test_analyzers.py @@ -48,3 +48,44 @@ def test_data_leakage_ignores_loopback_urls_in_log_messages() -> None: assert len(findings) == 1 assert findings[0].location assert findings[0].location.line == 4 + + +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 + + (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 + + +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 + + (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 + + +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 + + (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 + + +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 + + (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 64eb08e..cc240e2 100644 --- a/tests/test_category_gates.py +++ b/tests/test_category_gates.py @@ -47,3 +47,27 @@ 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