Skip to content
Merged
23 changes: 19 additions & 4 deletions src/mcts/analyzers/supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/mcts/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
5 changes: 4 additions & 1 deletion src/mcts/report/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
41 changes: 41 additions & 0 deletions tests/test_analyzers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions tests/test_category_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading