diff --git a/CHANGELOG.md b/CHANGELOG.md index c81d031..ab3a49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/mcts/analyzers/data_leakage.py b/src/mcts/analyzers/data_leakage.py index 488d205..68968b8 100644 --- a/src/mcts/analyzers/data_leakage.py +++ b/src/mcts/analyzers/data_leakage.py @@ -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), @@ -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]") @@ -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.""" @@ -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 @@ -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 diff --git a/tests/test_analyzers.py b/tests/test_analyzers.py index b3a0a86..b06b3d5 100644 --- a/tests/test_analyzers.py +++ b/tests/test_analyzers.py @@ -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 @@ -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