Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- Broaden secret detection for AI/provider tokens, modern GitHub tokens, package registry tokens, private keys, and common JSON/YAML assignments; redact matched credentials from finding evidence.

## [0.1.4] - 2026-06-12

### Security
Expand Down
44 changes: 40 additions & 4 deletions src/mcts/analyzers/data_leakage.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,33 @@

SECRET_PATTERNS: list[tuple[str, re.Pattern[str], Severity]] = [
("OpenAI API Key", re.compile(r"sk-[A-Za-z0-9]{20,}"), Severity.CRITICAL),
("Anthropic API Key", re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), Severity.CRITICAL),
("Hugging Face Token", re.compile(r"hf_[A-Za-z0-9]{20,}"), Severity.CRITICAL),
("AWS Access Key", re.compile(r"AKIA[0-9A-Z]{16}"), Severity.CRITICAL),
("Google API Key", re.compile(r"AIza[0-9A-Za-z\-_]{35}"), Severity.CRITICAL),
("Google OAuth Token", re.compile(r"ya29\.[0-9A-Za-z\-_]+"), Severity.CRITICAL),
("GitHub PAT", re.compile(r"ghp_[a-zA-Z0-9]{36}"), Severity.CRITICAL),
(
"GitHub PAT",
re.compile(r"(?:ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9_]{40,})"),
Severity.CRITICAL,
),
("GitLab PAT", re.compile(r"glpat-[a-zA-Z0-9\-_]{20,}"), Severity.CRITICAL),
("Slack Token", re.compile(r"xox[baprs]-[0-9A-Za-z\-]{10,}"), Severity.CRITICAL),
("npm Access Token", re.compile(r"npm_[A-Za-z0-9]{20,}"), Severity.CRITICAL),
("PyPI API Token", re.compile(r"pypi-[A-Za-z0-9_-]{20,}"), Severity.CRITICAL),
(
"Private Key",
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
Severity.CRITICAL,
),
("JWT", re.compile(r"eyJ[a-zA-Z0-9\-_]+\.eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+"), Severity.HIGH),
(
"Generic Secret Assignment",
re.compile(r"(?i)(api_key|secret|password|token)\s*=\s*['\"][^'\"]+['\"]"),
re.compile(
r"(?i)[\"']?(?:api[_-]?key|client[_-]?secret|access[_-]?token|auth[_-]?token|"
r"database[_-]?password|db[_-]?password|password|secret|token)[\"']?\s*[:=]\s*"
r"[\"']?[A-Za-z0-9_./+=:@-]{8,}"
),
Severity.HIGH,
),
("Database URL", re.compile(r"(?i)(postgres|mysql|mongodb)://\S+"), Severity.HIGH),
Expand All @@ -37,6 +54,16 @@
"DATABASE_URL",
"GITHUB_TOKEN",
"ANTHROPIC_API_KEY",
"AZURE_OPENAI_API_KEY",
"COHERE_API_KEY",
"HUGGINGFACE_TOKEN",
"LANGFUSE_SECRET_KEY",
"LANGSMITH_API_KEY",
"MISTRAL_API_KEY",
"NPM_TOKEN",
"PINECONE_API_KEY",
"PYPI_API_TOKEN",
"WANDB_API_KEY",
)

HIDDEN_CHAR_PATTERN = re.compile(r"[\u200b-\u200f\ufeff\u202a-\u202e]")
Expand All @@ -60,6 +87,14 @@ def _is_logging_statement(line: str) -> bool:
return bool(LOGGING_CALL_PATTERN.search(line))


def _redact_secrets(line: str) -> str:
"""Return bounded context with every recognized credential removed."""
redacted = line
for _, pattern, _ in SECRET_PATTERNS:
redacted = pattern.sub("[REDACTED]", redacted)
return redacted.strip()[:120]


class DataLeakageAnalyzer(BaseAnalyzer):
"""Scans tool metadata and source files for exposed secrets."""

Expand Down Expand Up @@ -118,7 +153,8 @@ def _scan_source_files(self, server: MCPServerInfo) -> list[Finding]:
for file_path, content in server.source_files.items():
for line_no, line in enumerate(content.splitlines(), start=1):
for label, pattern, severity in SECRET_PATTERNS:
if not pattern.search(line):
match = pattern.search(line)
if not match:
continue
if label == "Internal URL" and _is_logging_statement(line):
continue
Expand All @@ -137,7 +173,7 @@ def _scan_source_files(self, server: MCPServerInfo) -> list[Finding]:
technique_id="MCTS-T-1004",
confidence=0.7,
location=SourceLocation(file=file_path, line=line_no),
evidence={"pattern": pattern.pattern, "line": line.strip()[:120]},
evidence={"pattern": pattern.pattern, "line": _redact_secrets(line)},
)
)
return findings
41 changes: 41 additions & 0 deletions tests/test_analyzers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Tests for source-aware analyzers."""

import json
from pathlib import Path

import pytest

from mcts.analyzers.command_execution import CommandExecutionAnalyzer
from mcts.analyzers.data_leakage import DataLeakageAnalyzer
from mcts.core.config import ScanConfig
Expand Down Expand Up @@ -50,6 +53,44 @@ def test_data_leakage_ignores_loopback_urls_in_log_messages() -> None:
assert findings[0].location.line == 4


@pytest.mark.parametrize(
("source", "secret"),
[
("ANTHROPIC_API_KEY='sk-ant-" + "a" * 30 + "'", "sk-ant-" + "a" * 30),
("token = 'hf_" + "b" * 30 + "'", "hf_" + "b" * 30),
('{"apiKey": "super-secret-value"}', "super-secret-value"),
("DB_PASSWORD: database-password", "database-password"),
("-----BEGIN PRIVATE KEY-----", "-----BEGIN PRIVATE KEY-----"),
],
)
def test_data_leakage_detects_and_redacts_common_secret_formats(
source: str, secret: str
) -> None:
server = MCPServerInfo(name="secret-fixture", source_files={"config.txt": source})

findings = DataLeakageAnalyzer().analyze(server)

assert findings
evidence = json.dumps([finding.evidence for finding in findings])
assert secret not in evidence
assert "[REDACTED]" in evidence


def test_data_leakage_redacts_multiple_secrets_from_the_same_line() -> None:
anthropic_key = "sk-ant-" + "a" * 30
huggingface_token = "hf_" + "b" * 30
server = MCPServerInfo(
name="two-secrets",
source_files={"config.txt": f"primary={anthropic_key} backup={huggingface_token}"},
)

findings = DataLeakageAnalyzer().analyze(server)

evidence = json.dumps([finding.evidence for finding in findings])
assert anthropic_key not in evidence
assert huggingface_token not in evidence


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
Expand Down