From 20a9f2f46fef49662589cf4b04611cc26145e8bb Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Thu, 19 Mar 2026 16:45:11 +0100 Subject: [PATCH 1/4] guard LITELLM_DEBUG behind an env var Signed-off-by: Tomas Tomecek Assisted-by: Claude --- agents/utils.py | 12 +++++++++++- templates/beeai-agent.env | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/agents/utils.py b/agents/utils.py index 14f5bfc8..89acc2ff 100644 --- a/agents/utils.py +++ b/agents/utils.py @@ -160,7 +160,17 @@ async def mcp_tools( def set_litellm_debug() -> None: - """Set litellm to print collosal amount of debug information. This CAN LEAK TOKENS to the logs.""" + """Set litellm to print debug information. + + WARNING: This CAN LEAK TOKENS to the logs. It is gated behind the + LITELLM_DEBUG environment variable — only enable it in development. + """ + if not os.getenv("LITELLM_DEBUG"): + logger.warning( + "set_litellm_debug() called but LITELLM_DEBUG env var is not set; " + "ignoring to prevent credential leakage in production." + ) + return # the following two modules call `litellm_debug(False)` on import # import them explicitly now to ensure our call to `litellm_debug()` is not negated later import beeai_framework.adapters.litellm.chat diff --git a/templates/beeai-agent.env b/templates/beeai-agent.env index 577dadcf..c2036def 100644 --- a/templates/beeai-agent.env +++ b/templates/beeai-agent.env @@ -43,4 +43,10 @@ GEMINI_API_KEY= BEEAI_MAX_ITERATIONS=255 BEEAI_MAX_RETRIES_PER_STEP=5 BEEAI_TOTAL_MAX_RETRIES=25 + +# LiteLLM Debug Logging +# WARNING: Enabling this will output extremely verbose debug information that +# CAN LEAK API TOKENS AND CREDENTIALS to logs. Only enable in local development. +# Set to any non-empty value to enable (e.g., "true" or "1"). +# Leave empty (default) to disable. LITELLM_DEBUG= From 41643a69e3a514c7b319e5819b563eb551dc35e4 Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Thu, 19 Mar 2026 16:49:25 +0100 Subject: [PATCH 2/4] openshift hardening: set securityContext Signed-off-by: Tomas Tomecek Assisted-by: Claude --- openshift/deployment-backport-agent-c10s.yml | 5 ++++- openshift/deployment-backport-agent-c9s.yml | 5 ++++- openshift/deployment-mcp-gateway.yml | 5 ++++- openshift/deployment-phoenix.yml | 5 ++++- openshift/deployment-rebase-agent-c10s.yml | 5 ++++- openshift/deployment-rebase-agent-c9s.yml | 5 ++++- openshift/deployment-redis-commander.yml | 5 ++++- openshift/deployment-supervisor-processor.yml | 5 ++++- openshift/deployment-triage-agent.yml | 5 ++++- openshift/deployment-valkey.yml | 5 ++++- 10 files changed, 40 insertions(+), 10 deletions(-) diff --git a/openshift/deployment-backport-agent-c10s.yml b/openshift/deployment-backport-agent-c10s.yml index a9c280ee..76069ae4 100644 --- a/openshift/deployment-backport-agent-c10s.yml +++ b/openshift/deployment-backport-agent-c10s.yml @@ -62,7 +62,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault # TODO: this should be reset when we have enough data. terminationGracePeriodSeconds: 30 volumes: diff --git a/openshift/deployment-backport-agent-c9s.yml b/openshift/deployment-backport-agent-c9s.yml index 1ac33b0d..80f02f16 100644 --- a/openshift/deployment-backport-agent-c9s.yml +++ b/openshift/deployment-backport-agent-c9s.yml @@ -62,7 +62,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault # TODO: this should be reset when we have enough data. terminationGracePeriodSeconds: 30 volumes: diff --git a/openshift/deployment-mcp-gateway.yml b/openshift/deployment-mcp-gateway.yml index 00e4c2e4..33be7b93 100644 --- a/openshift/deployment-mcp-gateway.yml +++ b/openshift/deployment-mcp-gateway.yml @@ -69,7 +69,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault terminationGracePeriodSeconds: 30 volumes: - name: mcp-server-git-repos diff --git a/openshift/deployment-phoenix.yml b/openshift/deployment-phoenix.yml index b95c2789..d9cdbad7 100644 --- a/openshift/deployment-phoenix.yml +++ b/openshift/deployment-phoenix.yml @@ -46,7 +46,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault terminationGracePeriodSeconds: 30 volumes: - name: phoenix-data diff --git a/openshift/deployment-rebase-agent-c10s.yml b/openshift/deployment-rebase-agent-c10s.yml index b7b07009..395d9a7f 100644 --- a/openshift/deployment-rebase-agent-c10s.yml +++ b/openshift/deployment-rebase-agent-c10s.yml @@ -63,7 +63,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault # TODO: this should be reset when we have enough data. terminationGracePeriodSeconds: 30 volumes: diff --git a/openshift/deployment-rebase-agent-c9s.yml b/openshift/deployment-rebase-agent-c9s.yml index aa55f383..f9f94330 100644 --- a/openshift/deployment-rebase-agent-c9s.yml +++ b/openshift/deployment-rebase-agent-c9s.yml @@ -63,7 +63,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault # TODO: this should be reset when we have enough data. terminationGracePeriodSeconds: 30 volumes: diff --git a/openshift/deployment-redis-commander.yml b/openshift/deployment-redis-commander.yml index 14a78a50..d4650609 100644 --- a/openshift/deployment-redis-commander.yml +++ b/openshift/deployment-redis-commander.yml @@ -38,5 +38,8 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault terminationGracePeriodSeconds: 30 diff --git a/openshift/deployment-supervisor-processor.yml b/openshift/deployment-supervisor-processor.yml index 7f2f976c..8dccc260 100644 --- a/openshift/deployment-supervisor-processor.yml +++ b/openshift/deployment-supervisor-processor.yml @@ -61,7 +61,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault # TODO: this should be reset when we have enough data. terminationGracePeriodSeconds: 30 volumes: diff --git a/openshift/deployment-triage-agent.yml b/openshift/deployment-triage-agent.yml index 3c4862a6..3d18b5ec 100644 --- a/openshift/deployment-triage-agent.yml +++ b/openshift/deployment-triage-agent.yml @@ -61,7 +61,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault # TODO: this should be reset when we have enough data. terminationGracePeriodSeconds: 30 volumes: diff --git a/openshift/deployment-valkey.yml b/openshift/deployment-valkey.yml index a5faae67..c7a899f9 100644 --- a/openshift/deployment-valkey.yml +++ b/openshift/deployment-valkey.yml @@ -38,7 +38,10 @@ spec: dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler - securityContext: {} + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault terminationGracePeriodSeconds: 30 volumes: - name: valkey-data From 4f3eff2fea095b696777c2baac3541f818d748ff Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Mon, 30 Mar 2026 18:25:34 +0200 Subject: [PATCH 3/4] check for secrets using pre-commit Yelp/detect-secrets Signed-off-by: Tomas Tomecek Assisted-by: Claude --- .pre-commit-config.yaml | 5 + .secrets.baseline | 324 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 .secrets.baseline diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b57a4a4b..868672e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,3 +10,8 @@ repos: - id: check-added-large-files - id: check-merge-conflict - id: mixed-line-ending + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: ['--baseline', '.secrets.baseline'] diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 00000000..6f567d63 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,324 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": { + "goose/templates/goose.env": [ + { + "type": "Secret Keyword", + "filename": "goose/templates/goose.env", + "hashed_secret": "67343f0f7d53428a66c6664e5e671e09754688f2", + "is_verified": false, + "line_number": 2 + } + ], + "goose/templates/mcp-atlassian.env": [ + { + "type": "Basic Auth Credentials", + "filename": "goose/templates/mcp-atlassian.env", + "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", + "is_verified": false, + "line_number": 105 + } + ], + "mcp_server/tests/unit/test_distgit_tools.py": [ + { + "type": "Hex High Entropy String", + "filename": "mcp_server/tests/unit/test_distgit_tools.py", + "hashed_secret": "d4fbef92af33c1789d9130384a56737d181cc6df", + "is_verified": false, + "line_number": 22 + } + ], + "mcp_server/tests/unit/test_gateway.py": [ + { + "type": "GitLab Token", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "f68bd4ab942f012db29f09bbcc991e17768aaafa", + "is_verified": false, + "line_number": 12 + }, + { + "type": "Base64 High Entropy String", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "db33a8c712a01e26f3209fda54abe878e40d574f", + "is_verified": false, + "line_number": 28 + }, + { + "type": "Basic Auth Credentials", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "b015d7de724f9aaf5b147f4076a59d41d032f4e3", + "is_verified": false, + "line_number": 35 + }, + { + "type": "Base64 High Entropy String", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "337dcdf7061ca519f39de8dd4f606f5737f746c9", + "is_verified": false, + "line_number": 44 + }, + { + "type": "Secret Keyword", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "d2f474a79c11ab6b0f222860701c9cb95a809cf9", + "is_verified": false, + "line_number": 46 + }, + { + "type": "Base64 High Entropy String", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "d8c2d22cd8f05317aa6300a3bfbc35a1fe3eabb0", + "is_verified": false, + "line_number": 71 + }, + { + "type": "Base64 High Entropy String", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "8398e9e58a137e35845548126743f3e7bb1b3045", + "is_verified": false, + "line_number": 72 + }, + { + "type": "Base64 High Entropy String", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "d1af3d84986acb596032080abf01216893ccd65e", + "is_verified": false, + "line_number": 73 + }, + { + "type": "Base64 High Entropy String", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "9f771c6ac6b7404172b5f961b8fc7c202bebf1e2", + "is_verified": false, + "line_number": 74 + }, + { + "type": "Basic Auth Credentials", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "923100d19a2d3c043764179cee83d6667696cf19", + "is_verified": false, + "line_number": 109 + }, + { + "type": "Base64 High Entropy String", + "filename": "mcp_server/tests/unit/test_gateway.py", + "hashed_secret": "4aadfe8a2cfc564a0ebb79d694b73abfb28e1c17", + "is_verified": false, + "line_number": 132 + } + ], + "openshift/cronjob-supervisor-collector.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/cronjob-supervisor-collector.yml", + "hashed_secret": "2f19b33175a0177192f368f143936cb1c04a2127", + "is_verified": false, + "line_number": 78 + }, + { + "type": "Secret Keyword", + "filename": "openshift/cronjob-supervisor-collector.yml", + "hashed_secret": "e00a4cd07445154f8d1b589ad5b7152fdf7d88d0", + "is_verified": false, + "line_number": 81 + } + ], + "openshift/deployment-backport-agent-c10s.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/deployment-backport-agent-c10s.yml", + "hashed_secret": "e00a4cd07445154f8d1b589ad5b7152fdf7d88d0", + "is_verified": false, + "line_number": 80 + } + ], + "openshift/deployment-backport-agent-c9s.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/deployment-backport-agent-c9s.yml", + "hashed_secret": "e00a4cd07445154f8d1b589ad5b7152fdf7d88d0", + "is_verified": false, + "line_number": 80 + } + ], + "openshift/deployment-mcp-gateway.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/deployment-mcp-gateway.yml", + "hashed_secret": "2f19b33175a0177192f368f143936cb1c04a2127", + "is_verified": false, + "line_number": 83 + } + ], + "openshift/deployment-rebase-agent-c10s.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/deployment-rebase-agent-c10s.yml", + "hashed_secret": "e00a4cd07445154f8d1b589ad5b7152fdf7d88d0", + "is_verified": false, + "line_number": 81 + } + ], + "openshift/deployment-rebase-agent-c9s.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/deployment-rebase-agent-c9s.yml", + "hashed_secret": "e00a4cd07445154f8d1b589ad5b7152fdf7d88d0", + "is_verified": false, + "line_number": 81 + } + ], + "openshift/deployment-supervisor-processor.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/deployment-supervisor-processor.yml", + "hashed_secret": "2f19b33175a0177192f368f143936cb1c04a2127", + "is_verified": false, + "line_number": 73 + }, + { + "type": "Secret Keyword", + "filename": "openshift/deployment-supervisor-processor.yml", + "hashed_secret": "e00a4cd07445154f8d1b589ad5b7152fdf7d88d0", + "is_verified": false, + "line_number": 76 + } + ], + "openshift/deployment-triage-agent.yml": [ + { + "type": "Secret Keyword", + "filename": "openshift/deployment-triage-agent.yml", + "hashed_secret": "e00a4cd07445154f8d1b589ad5b7152fdf7d88d0", + "is_verified": false, + "line_number": 79 + } + ] + }, + "generated_at": "2026-03-31T08:34:08Z" +} From 5bfd68c71d4f978568b2f21669d376768d895f99 Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Thu, 19 Mar 2026 16:48:29 +0100 Subject: [PATCH 4/4] redact possible api token prints from logs Signed-off-by: Tomas Tomecek Assisted-by: Claude --- mcp_server/distgit_tools.py | 15 ++- mcp_server/gateway.py | 39 +++++- mcp_server/tests/unit/test_gateway.py | 187 ++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 mcp_server/tests/unit/test_gateway.py diff --git a/mcp_server/distgit_tools.py b/mcp_server/distgit_tools.py index cca9af48..0f60d33e 100644 --- a/mcp_server/distgit_tools.py +++ b/mcp_server/distgit_tools.py @@ -1,5 +1,6 @@ import asyncio import os +import re import tempfile import time from typing import Annotated @@ -15,6 +16,11 @@ SYNC_TIMEOUT = 1 * 60 * 60 # seconds +def _sanitize_url(text: str) -> str: + """Remove oauth2:{token}@ credentials from URLs in error messages.""" + return re.sub(r"oauth2:[^@\s]+@", "oauth2:***@", text) + + async def create_zstream_branch( package: Annotated[str, Field(description="Package name")], branch: Annotated[str, Field(description="Name of the branch to create")] @@ -29,8 +35,11 @@ async def create_zstream_branch( username = principal.split("@", maxsplit=1)[0] token = os.environ["GITLAB_TOKEN"] gitlab_repo_url = f"https://oauth2:{token}@gitlab.com/redhat/rhel/rpms/{package}" - if await asyncio.to_thread(git.cmd.Git().ls_remote, gitlab_repo_url, branch, branches=True): - return f"Z-Stream branch {branch} already exists, no need to create it" + try: + if await asyncio.to_thread(git.cmd.Git().ls_remote, gitlab_repo_url, branch, branches=True): + return f"Z-Stream branch {branch} already exists, no need to create it" + except Exception as e: + raise ToolError(f"Failed to check GitLab remote: {_sanitize_url(str(e))}") from e try: with tempfile.TemporaryDirectory() as path: repo = await asyncio.to_thread( @@ -70,4 +79,4 @@ async def create_zstream_branch( await asyncio.sleep(30) raise RuntimeError(f"The {branch} branch wasn't synced to GitLab after {SYNC_TIMEOUT} seconds") except Exception as e: - raise ToolError(f"Failed to create Z-Stream branch: {e}") from e + raise ToolError(f"Failed to create Z-Stream branch: {_sanitize_url(str(e))}") from e diff --git a/mcp_server/gateway.py b/mcp_server/gateway.py index 028fff21..5a978b9d 100644 --- a/mcp_server/gateway.py +++ b/mcp_server/gateway.py @@ -2,6 +2,7 @@ import os import inspect import functools +import re from fastmcp import FastMCP @@ -14,20 +15,52 @@ logger = logging.getLogger(__name__) +# Patterns that match common credential formats in log output +_REDACT_PATTERNS = [ + # GitLab PAT + re.compile(r"glpat-[A-Za-z0-9_-]{20,}"), + # Anthropic API key + re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), + # Google API key + re.compile(r"AIzaSy[A-Za-z0-9_-]{33}"), + # Bearer tokens in URLs or strings + re.compile(r"oauth2:[^@\s]+@"), + # Testing Farm API tokens (UUID format) + re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE), + # Jira Cloud API tokens (ATATT3x... pattern) + re.compile(r"ATATT3x[A-Za-z0-9_-]{20,}"), + # Base64 Authorization headers + re.compile(r"Basic [A-Za-z0-9+/=]{20,}"), + # Generic long hex/base64 tokens (e.g. Jira PATs) + re.compile(r"(?:token|key|password|secret|credential)[\"'=:\s]+[A-Za-z0-9+/=_-]{20,}['\"\s]*", re.IGNORECASE), +] + + +def _redact(text: str) -> str: + """Replace credential-like patterns in text with [REDACTED].""" + for pattern in _REDACT_PATTERNS: + text = pattern.sub("[REDACTED]", text) + return text + def log_tool_call(func): - """Decorator to log tool calls with their arguments.""" + """Decorator to log tool calls with their arguments. + + Sensitive values (tokens, keys, credentials) are redacted from log output. + """ @functools.wraps(func) async def wrapper(*args, **kwargs): tool_name = func.__name__ logger.info(f"Tool called: {tool_name}") - logger.info(f"Tool arguments: args={args}, kwargs={kwargs}") + logger.info("Tool arguments: args=%s, kwargs=%s", + _redact(str(args)), _redact(str(kwargs))) try: result = await func(*args, **kwargs) logger.info(f"Tool {tool_name} completed successfully") return result except Exception as e: - logger.error(f"Tool {tool_name} failed with error: {e}") + logger.error("Tool %s failed with error: %s", + tool_name, _redact(str(e))) raise return wrapper diff --git a/mcp_server/tests/unit/test_gateway.py b/mcp_server/tests/unit/test_gateway.py new file mode 100644 index 00000000..bbb9dbf5 --- /dev/null +++ b/mcp_server/tests/unit/test_gateway.py @@ -0,0 +1,187 @@ +""" Unit tests for mcp_server/gateway.py """ +import pytest + +from gateway import _redact + + +class TestRedactFunction: + """Test the _redact() function for credential pattern matching.""" + + def test_redact_gitlab_pat(self): + """Test redaction of GitLab Personal Access Token.""" + text = "Token: glpat-aBcDeFgHiJkLmNoPqRsTuVwXyZ1234500000" + result = _redact(text) + assert "glpat-" not in result + assert "[REDACTED]" in result + assert result == "Token: [REDACTED]" + + def test_redact_anthropic_api_key(self): + """Test redaction of Anthropic API key.""" + text = "Key: sk-ant-api03-BDbG2jaStLaS_yflKC9aEuAUWsPR8fLGir3rnUYptbp34Vxj80000Pq5azVXQ6LzeXYM--yDbNVZeaY6uAqVXQ-XVDKQgAA" + result = _redact(text) + assert "sk-ant-" not in result + assert "[REDACTED]" in result + assert result == "Key: [REDACTED]" + + def test_redact_google_api_key(self): + """Test redaction of Google API key.""" + text = "GOOGLE_API_KEY=AIzaSyCrbXLEWFA45Jn00006XI0DwBF2p7_94Mo" + result = _redact(text) + assert "AIzaSy" not in result + assert "[REDACTED]" in result + + def test_redact_oauth2_token_in_url(self): + """Test redaction of oauth2 token embedded in URL.""" + text = "https://oauth2:glpat-sometoken123456@gitlab.com/repo" + result = _redact(text) + assert "glpat-sometoken123456" not in result + assert "[REDACTED]" in result + assert result == "https://[REDACTED]gitlab.com/repo" + + @pytest.mark.parametrize( + "text", + [ + "token=abc123def456ghi789jkl012mno345pqr678", + "key: xyz123abc456def789ghi012jkl345mno678pqr901stu234", + 'password="secret123456789012345678901234567890"', + "secret = longsecretvalue1234567890abcdefghijklmnop", + "credential:value1234567890abcdefghijklmnopqrstuvwxyz", + ], + ) + def test_redact_generic_token_patterns(self, text: str): + """Test redaction of generic token/key/password patterns.""" + result = _redact(text) + assert "[REDACTED]" == result, f"Failed to redact: {text}" + + def test_redact_multiple_credentials(self): + """Test redaction of multiple credentials in the same text.""" + text = ( + "Using token glpat-abc123456789012345678901234 " + "and API key sk-ant-api03-xyz789012345678901234567890123456789012345678901234567890123456789012345678901234567890 " + "to access gitlab.com" + ) + result = _redact(text) + assert "glpat-" not in result + assert "sk-ant-" not in result + assert result.count("[REDACTED]") == 2 + + def test_redact_case_insensitive(self): + """Test that generic patterns are case-insensitive.""" + test_cases = [ + "TOKEN=abcdefghijklmnopqrstuvwxyz1234567890", + "Token=abcdefghijklmnopqrstuvwxyz1234567890", + "PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890", + "Password=abcdefghijklmnopqrstuvwxyz1234567890", + ] + for text in test_cases: + result = _redact(text) + assert "[REDACTED]" in result, f"Failed to redact: {text}" + + def test_redact_no_false_positives(self): + """Test that normal text is not redacted.""" + text = "This is a normal log message with no credentials" + result = _redact(text) + assert result == text + assert "[REDACTED]" not in result + + def test_redact_short_tokens_not_matched(self): + """Test that short tokens (< 20 chars) are not redacted to avoid false positives.""" + text = "token=short123" + result = _redact(text) + # Short tokens should not be redacted + assert result == text + + def test_redact_empty_string(self): + """Test redaction of empty string.""" + result = _redact("") + assert result == "" + + def test_redact_preserves_structure(self): + """Test that redaction preserves the overall structure of the text.""" + text = "Config: {token: 'glpat-abc123456789012345678901234', url: 'https://example.com'}" + result = _redact(text) + assert "[REDACTED]" in result + assert "url: 'https://example.com'" in result + assert "glpat-" not in result + + def test_redact_real_world_example_git_url(self): + """Test redaction in a real-world git command output scenario.""" + text = "Fetching from https://oauth2:glpat-xyz123456789012345678901234@gitlab.com/redhat/rhel/rpms/bash" + result = _redact(text) + assert "glpat-" not in result + assert "[REDACTED]" in result + assert "gitlab.com/redhat/rhel/rpms/bash" in result + + def test_redact_real_world_example_error_message(self): + """Test redaction in error message containing a token.""" + text = "Failed to authenticate with token glpat-abc123456789012345678901234: 401 Unauthorized" + result = _redact(text) + assert "glpat-" not in result + assert "[REDACTED]" in result + assert "401 Unauthorized" in result + + def test_redact_real_world_example_dict_str(self): + """Test redaction in string representation of a dict containing credentials.""" + text = "{'url': 'https://gitlab.com', 'token': 'glpat-xyz123456789012345678901234'}" + result = _redact(text) + assert "glpat-" not in result + assert "[REDACTED]" in result + + def test_redact_jira_personal_token(self): + """Test redaction of Jira Personal Access Token (base64-like pattern).""" + text = "JIRA_TOKEN=NTMxMTc4N00000k5OiNdRf7iO/YZvg7uUwczDkh8iLfR" + result = _redact(text) + # This should match the generic token pattern + assert "[REDACTED]" in result + + def test_redact_multiple_patterns_same_line(self): + """Test redaction when multiple different credential patterns appear in one line.""" + text = ( + "Authenticating with gitlab token glpat-abc123456789012345678901234 " + "and anthropic key sk-ant-api03-xyz789012345678901234567890123456789012345678901234567890123456789012345678901234567890 " + "and google key AIzaSyCrbXLEWFA45Jnl1500000DwBF2p7_94Mo" + ) + result = _redact(text) + assert "glpat-" not in result + assert "sk-ant-" not in result + assert "AIzaSy" not in result + assert result.count("[REDACTED]") == 3 + + def test_redact_testing_farm_token(self): + """Test redaction of Testing Farm API token (UUID format).""" + text = "TESTING_FARM_API_TOKEN=d1f2e3a4-b5c6-7890-abcd-ef1234567890" + result = _redact(text) + assert "d1f2e3a4-b5c6-7890-abcd-ef1234567890" not in result + assert "[REDACTED]" in result + + def test_redact_jira_cloud_token(self): + """Test redaction of Jira Cloud API token (ATATT3x... pattern).""" + text = "JIRA_API_TOKEN=ATATT3xFfGF0Z123456788888888YjRhMC1hZGY5MjYxNzQ5OTk" # pragma: allowlist secret + result = _redact(text) + assert "ATATT3x" not in result + assert "[REDACTED]" in result + + def test_redact_base64_authorization_header(self): + """Test redaction of Base64 Authorization header.""" + secret = "dXNlcm44444444444444444xMjM0NTY3ODkw" # pragma: allowlist secret + text = f"Authorization: Basic {secret}" + result = _redact(text) + assert secret not in result + assert "[REDACTED]" in result + assert result == "Authorization: [REDACTED]" + + def test_redact_does_not_affect_safe_content(self): + """Test that redaction doesn't affect legitimate non-credential content.""" + safe_texts = [ + "Processing package bash version 5.2.15", + "Build succeeded in 42 seconds", + "Merging PR #12345", + "Error: file not found", + "token is missing", # Missing actual value + "key=", # Empty value + "password:", # No actual password + ] + for text in safe_texts: + result = _redact(text) + assert result == text, f"Safe text was incorrectly modified: {text}" + assert "[REDACTED]" not in result