From f7819dbabd5e0e9cc7e6b53e53bead7c6234d56d Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Thu, 6 Aug 2026 15:45:48 +0000 Subject: [PATCH 1/5] SNOW-2912540: decouple LocalTestOOBTelemetryService from connector.telemetry_oob The UD's telemetry_oob.TelemetryService is a no-op stub (BD#45) that lacks batch_size, causing an AttributeError at runtime. Make LocalTestOOBTelemetryService standalone: add its own singleton, queue, batch_size, _enabled, and close(). Also fixes the pre-existing _enable typo (should have been _enabled). Co-Authored-By: Claude Sonnet 4.6 --- src/snowflake/snowpark/mock/_telemetry.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/snowflake/snowpark/mock/_telemetry.py b/src/snowflake/snowpark/mock/_telemetry.py index 2c090d0617..c4b33d7264 100644 --- a/src/snowflake/snowpark/mock/_telemetry.py +++ b/src/snowflake/snowpark/mock/_telemetry.py @@ -5,6 +5,7 @@ import json import logging import os +import queue as _queue import threading import uuid from datetime import datetime @@ -13,7 +14,6 @@ from typing import Optional from snowflake.connector.secret_detector import SecretDetector -from snowflake.connector.telemetry_oob import TelemetryService from snowflake.snowpark._internal.utils import ( get_os_name, get_python_version, @@ -83,17 +83,29 @@ class LocalTestTelemetryEventType(Enum): SESSION_CONNECTION = "session" -class LocalTestOOBTelemetryService(TelemetryService): +class LocalTestOOBTelemetryService: PROD = "https://client-telemetry.snowflakecomputing.com/enqueue" + _instance: "LocalTestOOBTelemetryService | None" = None + _instance_lock: threading.Lock = threading.Lock() + + @classmethod + def get_instance(cls) -> "LocalTestOOBTelemetryService": + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + def __init__(self) -> None: - super().__init__() self._is_internal_usage = bool( os.getenv("SNOWPARK_LOCAL_TESTING_INTERNAL_TELEMETRY", False) ) self._deployment_url = self.PROD - self._enable = True + self._enabled = True self._lock = threading.RLock() + self.queue: _queue.Queue = _queue.Queue() + self.batch_size: int = 100 def _upload_payload(self, payload) -> None: if not REQUESTS_AVAILABLE: @@ -189,6 +201,9 @@ def export_queue_to_string(self): _, masked_text, _ = SecretDetector.mask_secrets(payload) return masked_text + def close(self) -> None: + self.flush() + def log_session_creation(self, connection_uuid: Optional[str] = None): try: telemetry_data = generate_base_oob_telemetry_data_dict( From e4551d6c488f8d0e580fd26e80cde715b921071e Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Thu, 20 Aug 2026 17:12:48 +0000 Subject: [PATCH 2/5] SNOW-2912540: add missing size() to LocalTestOOBTelemetryService The decoupling from connector.telemetry_oob ported add()/flush()/enable()/ disable() but missed size() (connector/telemetry_oob.py:542, `return self.queue.qsize()`), which several tests (tests/mock/test_oob_telemetry.py, tests/mock/test_multithreading.py) call directly on the service instance. --- src/snowflake/snowpark/mock/_telemetry.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/snowflake/snowpark/mock/_telemetry.py b/src/snowflake/snowpark/mock/_telemetry.py index c4b33d7264..5a06b1cde4 100644 --- a/src/snowflake/snowpark/mock/_telemetry.py +++ b/src/snowflake/snowpark/mock/_telemetry.py @@ -170,6 +170,10 @@ def flush(self) -> None: return self._upload_payload(payload) + def size(self) -> int: + """Returns the size of the queue.""" + return self.queue.qsize() + @property def enabled(self) -> bool: """Whether the Telemetry service is enabled or not.""" From a84576af06ee725a7ef2291d4ba2ff9ea5f85806 Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 09:13:06 +0000 Subject: [PATCH 3/5] SNOW-2912540: move SecretDetector masking into snowpark-python mock/_telemetry.py imported snowflake.connector.secret_detector.SecretDetector unconditionally -- the only reason UD PR #598 ports SecretDetector into the Universal Driver at all is to satisfy this one Snowpark import (SecretDetector is not otherwise used or maintained by UD). Per BehaviorDifferences.yaml #45, OOB telemetry is disabled on the backend anyway, so this masking is defense-in-depth on the outbound payload, not a response to active exploitation -- but export_queue_to_string() feeds a real requests.Session().post() to a live analytics endpoint, so it's not a safe no-op either. Ports only what _telemetry.py actually calls (mask_secrets -> masked string), from UD's actual _common/secret_detector.py source (PR #598) rather than the older, less complete real v4 connector's version -- verified UD's copy has 3 additional maskers (OAuth tokens, OAuth client secrets, passcodes) plus a PASSWORD_PATTERN false-positive fix and a wire-format fix to CONNECTION_TOKEN_PATTERN, all needed for correctness. Drops everything that exists solely for UD's backward-compat contract: @backward_compatibility, MaskedMessageData's 3-tuple return (only masked_text is ever read), logging.Formatter inheritance, and the classmethod-vs-staticmethod workaround. Fixes the exception-handling branch rather than porting it straight: the legacy/UD code does masked_text = str(ex) on failure, which can leak exception-reflected input. Returns a static sentinel instead -- this was already flagged as a real finding by the security bot on UD #598. After this, mock/_telemetry.py has zero remaining imports from snowflake.connector. Test coverage: ported every behavioral test from UD PR #598's test_secret_detector.py TestMaskSecrets class (27 cases via pytest count), adapted from the 3-tuple/class-method API to the plain-function API here, plus one exception-handling test adapted to assert the static sentinel instead of the leaked exception text. Dropped TestFormatter and the logging.Formatter-specific exception tests -- not applicable, since mask_secrets is a plain function here, not a logging.Formatter subclass. --- .../snowpark/mock/_secret_detector.py | 138 ++++++ src/snowflake/snowpark/mock/_telemetry.py | 4 +- tests/mock/test_secret_detector.py | 393 ++++++++++++++++++ 3 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 src/snowflake/snowpark/mock/_secret_detector.py create mode 100644 tests/mock/test_secret_detector.py diff --git a/src/snowflake/snowpark/mock/_secret_detector.py b/src/snowflake/snowpark/mock/_secret_detector.py new file mode 100644 index 0000000000..dd626ae837 --- /dev/null +++ b/src/snowflake/snowpark/mock/_secret_detector.py @@ -0,0 +1,138 @@ +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# +"""Masks secrets that might otherwise leak via out-of-band telemetry. + +Ported from the Universal Driver's ``connector._common.secret_detector.SecretDetector`` +backward-compat shim -- only the masking behavior LocalTestOOBTelemetryService actually +calls, not the full legacy surface (NamedTuple return, logging.Formatter integration, +backward-compat shims, classmethod workaround). Every masking regex and the order +they're applied in is kept verbatim from the original -- this is a security control, +not a cosmetic detail. +""" +import re +from typing import Optional + +_MASKING_FAILURE_SENTINEL = "****" + +_AWS_KEY_PATTERN = re.compile( + r"(aws_key_id|aws_secret_key|access_key_id|secret_access_key)\s*=\s*'([^']+)'", + flags=re.IGNORECASE, +) +_AWS_TOKEN_PATTERN = re.compile( + r'(accessToken|tempToken|keySecret)"\s*:\s*"([a-z0-9/+]{32,}={0,2})"', + flags=re.IGNORECASE, +) +# Detects OAuth access/refresh tokens in serialized JSON (e.g. the OAuth +# token-exchange response), matching legacy JDBC's OAUTH_JSON_PATTERN. +_OAUTH_TOKEN_PATTERN = re.compile( + r'(access_token|refresh_token)"\s*:\s*"([a-z0-9!"#\$%&\'\(\)\*\+\,\-\./:;<=>\?\@\[\]\^_`\{\|\}~]{3,})"', + flags=re.IGNORECASE, +) +_SAS_TOKEN_PATTERN = re.compile( + r"(sig|signature|AWSAccessKeyId|password|passcode)=(?P[a-z0-9%/+]{16,})", + flags=re.IGNORECASE, +) +_PRIVATE_KEY_PATTERN = re.compile( + r"-{3,}BEGIN [A-Z ]*PRIVATE KEY-{3,}\n([\s\S]*?)\n-{3,}END [A-Z ]*PRIVATE KEY-{3,}", + flags=re.MULTILINE | re.IGNORECASE, +) +_PRIVATE_KEY_DATA_PATTERN = re.compile( + r'"privateKeyData": "([a-z0-9/+=\\n]{10,})"', + flags=re.MULTILINE | re.IGNORECASE, +) +# ':' and '%' are in the value class so a version/hint-prefixed session token +# (e.g. "token=ver:1-hint:1036-", Snowflake's actual wire format) masks +# in full instead of stopping at the first ':' -- matches legacy Node.js's fix. +_CONNECTION_TOKEN_PATTERN = re.compile( + r"(token|assertion content)" r"([\'\"\s:=]+)" r"([a-z0-9=/_\-\+\.:%]{8,})", + flags=re.IGNORECASE, +) +# Matches legacy Node.js's OAUTH_CLIENT_SECRET_PATTERN. +_OAUTH_CLIENT_SECRET_PATTERN = re.compile( + r"(oauthClientId|oauthClientSecret|clientSecret)" + r"([\'\"\s:=]+)" + r"([a-z0-9!\"#\$%&\\\'\(\)\*\+\,-\./:;<=>\?\@\[\]\^_`\{\|\}~]{8,})", + flags=re.IGNORECASE, +) +# Matches legacy Node.js's PASSCODE_PATTERN. +_PASSCODE_PATTERN = re.compile( + r"(passcode|otp|pin|otac)\s*([:=])\s*([0-9]{4,6})", + flags=re.IGNORECASE, +) +# Value quantifier is {6,} (not {1,}) so short common words after a bare +# "password"/"pwd" keyword -- e.g. "...no ID password was not given" -- are +# not mistaken for the secret value itself; matches legacy .NET/JDBC's floor. +_PASSWORD_PATTERN = re.compile( + r"(password" + r"|pwd)" + r"([\'\"\s:=]+)" + r"([a-z0-9!\"#\$%&\\\'\(\)\*\+\,-\./:;<=>\?\@\[\]\^_`\{\|\}~]{6,})", + flags=re.IGNORECASE, +) + +_SECRET_STARRED_MASK_STR = "****" + + +def _mask_aws_keys(text: str) -> str: + return _AWS_KEY_PATTERN.sub(r"\1=" + f"'{_SECRET_STARRED_MASK_STR}'", text) + + +def _mask_sas_tokens(text: str) -> str: + return _SAS_TOKEN_PATTERN.sub(r"\1=" + _SECRET_STARRED_MASK_STR, text) + + +def _mask_aws_tokens(text: str) -> str: + return _AWS_TOKEN_PATTERN.sub(r'\1":"XXXX"', text) + + +def _mask_oauth_tokens(text: str) -> str: + return _OAUTH_TOKEN_PATTERN.sub(r'\1":"XXXX"', text) + + +def _mask_private_key(text: str) -> str: + return _PRIVATE_KEY_PATTERN.sub( + "-----BEGIN PRIVATE KEY-----\\\\nXXXX\\\\n-----END PRIVATE KEY-----", text + ) + + +def _mask_private_key_data(text: str) -> str: + return _PRIVATE_KEY_DATA_PATTERN.sub('"privateKeyData": "XXXX"', text) + + +def _mask_oauth_client_secrets(text: str) -> str: + return _OAUTH_CLIENT_SECRET_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) + + +def _mask_passcodes(text: str) -> str: + return _PASSCODE_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) + + +def _mask_password(text: str) -> str: + return _PASSWORD_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) + + +def _mask_connection_token(text: str) -> str: + return _CONNECTION_TOKEN_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) + + +def mask_secrets(text: Optional[str]) -> Optional[str]: + """Return text with any detected secrets masked. On masking failure, + returns a static placeholder rather than the exception message, to + avoid reflecting secret-containing input back into the caller.""" + if text is None: + return None + try: + masked_text = _mask_aws_keys(text) + masked_text = _mask_sas_tokens(masked_text) + masked_text = _mask_aws_tokens(masked_text) + masked_text = _mask_oauth_tokens(masked_text) + masked_text = _mask_private_key(masked_text) + masked_text = _mask_private_key_data(masked_text) + masked_text = _mask_oauth_client_secrets(masked_text) + masked_text = _mask_passcodes(masked_text) + masked_text = _mask_password(masked_text) + masked_text = _mask_connection_token(masked_text) + return masked_text + except Exception: + return _MASKING_FAILURE_SENTINEL diff --git a/src/snowflake/snowpark/mock/_telemetry.py b/src/snowflake/snowpark/mock/_telemetry.py index 5a06b1cde4..a0fb6eef69 100644 --- a/src/snowflake/snowpark/mock/_telemetry.py +++ b/src/snowflake/snowpark/mock/_telemetry.py @@ -13,13 +13,13 @@ from http.client import OK from typing import Optional -from snowflake.connector.secret_detector import SecretDetector from snowflake.snowpark._internal.utils import ( get_os_name, get_python_version, get_version, ) +from ._secret_detector import mask_secrets from .exceptions import SnowparkLocalTestingException REQUESTS_AVAILABLE = True @@ -202,7 +202,7 @@ def export_queue_to_string(self): exc_info=True, ) payload = None - _, masked_text, _ = SecretDetector.mask_secrets(payload) + masked_text = mask_secrets(payload) return masked_text def close(self) -> None: diff --git a/tests/mock/test_secret_detector.py b/tests/mock/test_secret_detector.py new file mode 100644 index 0000000000..04e279bbd0 --- /dev/null +++ b/tests/mock/test_secret_detector.py @@ -0,0 +1,393 @@ +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# +"""Behavioral tests for mock/_secret_detector.py, ported from the Universal +Driver's SecretDetector backward-compat shim test suite -- masking-behavior +assertions only, not the logging.Formatter/backward-compat-specific tests +that don't apply here (mask_secrets is a plain function, not a class). +""" +import random +import string +from unittest import mock + +import pytest + +from snowflake.snowpark.mock import _secret_detector +from snowflake.snowpark.mock._secret_detector import mask_secrets + + +def test_clean_text_is_not_masked(): + text = "select 1 from dual" + assert mask_secrets(text) == text + + +def test_none_text_returns_none(): + assert mask_secrets(None) is None + + +def test_empty_string_is_not_masked(): + assert mask_secrets("") == "" + + +def test_password_is_masked(): + random_password = "Fh[+2J~AcqeqW%?" + + assert mask_secrets("password:" + random_password) == "password:****" + assert mask_secrets("PASSWORD:" + random_password) == "PASSWORD:****" + assert mask_secrets("PassWorD:" + random_password) == "PassWorD:****" + assert mask_secrets("password = " + random_password) == "password = ****" + assert mask_secrets("pwd:" + random_password) == "pwd:****" + assert mask_secrets("password=Afs...") == "password=****" + + +@pytest.mark.parametrize( + "text", + [ + "2020-04-30 23:06:04,069 - MainThread auth.py:397" + " - write_temporary_credential() - DEBUG - no ID password was not given", + "2020-04-30 23:06:04,069 - MainThread auth.py:397" + " - write_temporary_credential() - DEBUG - no ID proxyPassword was not given", + ], +) +def test_password_false_positives_are_not_masked(text): + """PASSWORD_PATTERN's value floor is 6 chars (not 1) so a short common word + right after a bare password/pwd keyword isn't mistaken for the secret + itself.""" + assert mask_secrets(text) == text + + +def test_aws_key_is_masked(): + sql = ( + "copy into 's3://xxxx/test' from \n" + "(select seq1(), random()\n" + ", random(), random(), random(), random()\n" + ", random(), random(), random(), random()\n" + ", random() , random(), random(), random()\n" + "\tfrom table(generator(rowcount => 10000)))\n" + "credentials=(\n" + " aws_key_id='xxdsdfsafds'\n" + " aws_secret_key='safas+asfsad+safasf'\n" + " )\n" + "OVERWRITE = TRUE \n" + "MAX_FILE_SIZE = 500000000 \n" + "HEADER = TRUE \n" + "FILE_FORMAT = (TYPE = PARQUET SNAPPY_COMPRESSION = TRUE )\n" + ";" + ) + correct = ( + "copy into 's3://xxxx/test' from \n" + "(select seq1(), random()\n" + ", random(), random(), random(), random()\n" + ", random(), random(), random(), random()\n" + ", random() , random(), random(), random()\n" + "\tfrom table(generator(rowcount => 10000)))\n" + "credentials=(\n" + " aws_key_id='****'\n" + " aws_secret_key='****'\n" + " )\n" + "OVERWRITE = TRUE \n" + "MAX_FILE_SIZE = 500000000 \n" + "HEADER = TRUE \n" + "FILE_FORMAT = (TYPE = PARQUET SNAPPY_COMPRESSION = TRUE )\n" + ";" + ) + assert mask_secrets(sql) == correct + + +@pytest.mark.parametrize( + "text,expected", + [ + ('accessToken":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'accessToken":"XXXX"'), + ('tempToken":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'tempToken":"XXXX"'), + ('keySecret":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'keySecret":"XXXX"'), + ( + 'accessToken" : "aB1aaaaaaaaaaZaaaaaaaaaaa9aaaaaaa="', + 'accessToken":"XXXX"', + ), + ( + 'accessToken" : "aB1aaaaaaaaaaZaaaaaaaa56aaaaaaaaaa=="', + 'accessToken":"XXXX"', + ), + ], +) +def test_aws_tokens_are_masked(text, expected): + assert mask_secrets(text) == expected + + +def test_sas_tokens_are_masked(): + azure_sas_token = ( + "https://someaccounts.blob.core.windows.net/results/018b90ab-0033-" + "5f8e-0000-14f1000bd376_0/main/data_0_0_1?sv=2015-07-08&" + "sig=iCvQmdZngZNW%2F4vw43j6%2BVz6fndHF5LI639QJba4r8o%3D&" + "spr=https&st=2016-04-12T03%3A24%3A31Z&" + "se=2016-04-13T03%3A29%3A31Z&srt=s&ss=bf&sp=rwl" + ) + masked_azure_sas_token = ( + "https://someaccounts.blob.core.windows.net/results/018b90ab-0033-" + "5f8e-0000-14f1000bd376_0/main/data_0_0_1?sv=2015-07-08&" + "sig=****&" + "spr=https&st=2016-04-12T03%3A24%3A31Z&" + "se=2016-04-13T03%3A29%3A31Z&srt=s&ss=bf&sp=rwl" + ) + s3_sas_token = ( + "https://somebucket.s3.amazonaws.com/vzy1-s-va_demo0/results/018b92f3" + "-01c2-02dd-0000-03d5000c8066_0/main/data_0_0_1?" + "x-amz-server-side-encryption-customer-algorithm=AES256&" + "response-content-encoding=gzip&AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE" + "&Expires=1555481960&Signature=zFiRkdB9RtRRYomppVes4fQ%2ByWw%3D" + ) + masked_s3_sas_token = ( + "https://somebucket.s3.amazonaws.com/vzy1-s-va_demo0/results/018b92f3" + "-01c2-02dd-0000-03d5000c8066_0/main/data_0_0_1?" + "x-amz-server-side-encryption-customer-algorithm=AES256&" + "response-content-encoding=gzip&AWSAccessKeyId=****" + "&Expires=1555481960&Signature=****" + ) + + assert mask_secrets(azure_sas_token) == masked_azure_sas_token + assert mask_secrets(s3_sas_token) == masked_s3_sas_token + + text = "".join(random.choice(string.ascii_lowercase) for _ in range(200)) + assert mask_secrets(text) == text + + assert ( + mask_secrets(azure_sas_token + "\n" + azure_sas_token) + == masked_azure_sas_token + "\n" + masked_azure_sas_token + ) + assert ( + mask_secrets(s3_sas_token + "\n" + s3_sas_token) + == masked_s3_sas_token + "\n" + masked_s3_sas_token + ) + assert ( + mask_secrets(azure_sas_token + "\n" + s3_sas_token) + == masked_azure_sas_token + "\n" + masked_s3_sas_token + ) + + +def test_combined_secrets_in_create_stage_sql(): + sql = ( + "create stage mystage " + "URL = 's3://mybucket/mypath/' " + "credentials = (aws_key_id = 'AKIAIOSFODNN7EXAMPLE' " + "aws_secret_key = 'frJIUN8DYpKDtOLCwo//yllqDzg='); " + "create stage mystage2 " + "URL = 'azure//mystorage.blob.core.windows.net/cont' " + "credentials = (azure_sas_token = " + "'?sv=2016-05-31&ss=b&srt=sco&sp=rwdl&se=2018-06-27T10:05:50Z&" + "st=2017-06-27T02:05:50Z&spr=https,http&" + "sig=bgqQwoXwxzuD2GJfagRg7VOS8hzNr3QLT7rhS8OFRLQ%3D')" + ) + masked_sql = ( + "create stage mystage " + "URL = 's3://mybucket/mypath/' " + "credentials = (aws_key_id='****' " + "aws_secret_key='****'); " + "create stage mystage2 " + "URL = 'azure//mystorage.blob.core.windows.net/cont' " + "credentials = (azure_sas_token = " + "'?sv=2016-05-31&ss=b&srt=sco&sp=rwdl&se=2018-06-27T10:05:50Z&" + "st=2017-06-27T02:05:50Z&spr=https,http&" + "sig=****')" + ) + assert mask_secrets(sql) == masked_sql + + text = "".join(random.choice(string.ascii_lowercase) for _ in range(500)) + assert mask_secrets(text) == text + + +def test_connection_token_is_masked(): + long_token = ( + "_Y1ZNETTn5/qfUWj3Jedby7gipDzQs=U" + "KyJH9DS=nFzzWnfZKGV+C7GopWCGD4Lj" + "OLLFZKOE26LXHDt3pTi4iI1qwKuSpf/F" + "mClCMBSissVsU3Ei590FP0lPQQhcSGcD" + "u69ZL_1X6e9h5z62t/iY7ZkII28n2qU=" + "nrBJUgPRCIbtJQkVJXIuOHjX4G5yUEKj" + "ZBAx4w6=_lqtt67bIA=o7D=oUSjfywsR" + "FoloNIkBPXCwFTv+1RVUHgVA2g8A9Lw5" + "XdJYuI8vhg=f0bKSq7AhQ2Bh" + ) + json_token = ( + "{'TOKEN': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFt" + "ZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'}" + ) + + assert mask_secrets("Token =" + long_token) == "Token =****" + assert mask_secrets("idToken : " + long_token) == "idToken : ****" + assert mask_secrets("sessionToken : " + long_token) == "sessionToken : ****" + assert mask_secrets("masterToken : " + long_token) == "masterToken : ****" + assert mask_secrets("assertion content:" + long_token) == "assertion content:****" + assert mask_secrets(json_token) == "{'TOKEN': '****'}" + + +def test_token_false_positives(): + false_positive_token_str = ( + "2020-04-30 23:06:04,069 - MainThread auth.py:397" + " - write_temporary_credential() - DEBUG - no ID " + "token is given when try to store temporary credential" + ) + assert mask_secrets(false_positive_token_str) == false_positive_token_str + + +def test_multiple_secrets_are_all_masked(): + long_token = ( + "_Y1ZNETTn5/qfUWj3Jedby7gipDzQs=U" + "KyJH9DS=nFzzWnfZKGV+C7GopWCGD4Lj" + "OLLFZKOE26LXHDt3pTi4iI1qwKuSpf/F" + "mClCMBSissVsU3Ei590FP0lPQQhcSGcD" + "u69ZL_1X6e9h5z62t/iY7ZkII28n2qU=" + "nrBJUgPRCIbtJQkVJXIuOHjX4G5yUEKj" + "ZBAx4w6=_lqtt67bIA=o7D=oUSjfywsR" + "FoloNIkBPXCwFTv+1RVUHgVA2g8A9Lw5" + "XdJYuI8vhg=f0bKSq7AhQ2Bh" + ) + long_token2 = ( + "ktL57KJemuq4-M+Q0pdRjCIMcf1mzcr" + "MwKteDS5DRE/Pb+5MzvWjDH7LFPV5b_" + "/tX/yoLG3b4TuC6Q5qNzsARPPn_zs/j" + "BbDOEg1-IfPpdsbwX6ETeEnhxkHIL4H" + "sP-V" + ) + random_pwd = "Fh[+2J~AcqeqW%?" + random_pwd2 = random_pwd + "vdkav13" + + assert ( + mask_secrets( + "token=" + long_token + " random giberish " + "password:" + random_pwd + ) + == "token=****" + " random giberish " + "password:****" + ) + + # order reversed + assert ( + mask_secrets( + "password:" + random_pwd + " random giberish " + "token=" + long_token + ) + == "password:****" + " random giberish " + "token=****" + ) + + # multiple tokens and password + assert ( + mask_secrets( + "token=" + + long_token + + " random giberish " + + "password:" + + random_pwd + + " random giberish " + + "idToken:" + + long_token2 + ) + == "token=****" + + " random giberish " + + "password:****" + + " random giberish " + + "idToken:****" + ) + + # multiple passwords + assert ( + mask_secrets( + "password=" + random_pwd + " random giberish " + "pwd:" + random_pwd2 + ) + == "password=****" + " random giberish " + "pwd:****" + ) + + assert ( + mask_secrets( + "password=" + + random_pwd + + " random giberish " + + "password=" + + random_pwd2 + + " random giberish " + + "password=" + + random_pwd + ) + == "password=****" + + " random giberish " + + "password=****" + + " random giberish " + + "password=****" + ) + + +def test_private_key_body_is_masked(): + rsa_key = ( + "-----BEGIN RSA PRIVATE KEY-----\n" + "MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEA0pCa0rw1n4GBjylx\n" + "sBJPVCrsKO7SowkgJ52Lc8K3hMHNKXvYiqwgizbXFBQA27kvpEVSeRQVC3FAPRU5\n" + "gjtLRwIDAQABAkBHZbz5o9PS6AjUUEs6VpsLgRpersxBeACtLiBw+h9cJfUerR//\n" + "tTmNsQ9LlamMu2lOlfbO3R2J45ybF7z94A+hAiEA8piucvAlo9YJ4VViQGRTVvr+\n" + "xZKekSEYRJBn2czeP+kCIQDeMt1PVk/p0NEcNvQMbO0vJ3+U+lITJRwmtJ9Fs1Lj\n" + "rwIgJeTdkwyaBI6BepY4w7AoKHUKaNgvNqJBxSv9XNMYgEkCIG2rl1YgWOMkAQI3\n" + "EW/Ml6jtiugiQT5X07Q69F33q5LbAiEArZM7htafpt0RVia+nC9aY+73wpW0Be9e\n" + "pDz0yVv8s/Q=\n" + "-----END RSA PRIVATE KEY-----\n" + ) + assert ( + mask_secrets(rsa_key) + == "-----BEGIN PRIVATE KEY-----\\nXXXX\\n-----END PRIVATE KEY-----\n" + ) + + +def test_private_key_data_is_masked(): + text = '"privateKeyData": "aslkjdflasjf"' + filtered_text = '"privateKeyData": "XXXX"' + assert mask_secrets(text) == filtered_text + + +def test_session_token_wire_format_is_masked(): + """CONNECTION_TOKEN_PATTERN's value class includes ':' and '%' so a + version/hint-prefixed session token -- Snowflake's actual wire format -- + masks in full instead of stopping at the first ':'.""" + assert mask_secrets("token=ver:1-hint:1036-abcd1234efgh5678") == "token=****" + + +@pytest.mark.parametrize( + "text,expected", + [ + ('"access_token" : "some:FAKE_token123"', '"access_token":"XXXX"'), + ('"refresh_token" : "some:FAKE_token123"', '"refresh_token":"XXXX"'), + ], +) +def test_oauth_tokens_are_masked(text, expected): + assert mask_secrets(text) == expected + + +@pytest.mark.parametrize( + "text,expected", + [ + ("oauthClientSecret: aVeryLongSecretValue123", "oauthClientSecret: ****"), + ("clientSecret=anotherLongSecretValue456", "clientSecret=****"), + ], +) +def test_oauth_client_secrets_are_masked(text, expected): + assert mask_secrets(text) == expected + + +@pytest.mark.parametrize( + "text,expected", + [ + ("passcode: 123456", "passcode:****"), + ("otp=987654", "otp=****"), + ("pin = 4321", "pin=****"), + ], +) +def test_passcodes_are_masked(text, expected): + assert mask_secrets(text) == expected + + +def test_masking_failure_returns_static_sentinel_not_exception_text(): + """Masking failures must fail closed: the exception message (which may + itself reflect secret-containing input) is never returned -- only the + static sentinel.""" + with mock.patch.object( + _secret_detector, + "_mask_connection_token", + side_effect=Exception("Test exception, would leak: password=hunter2"), + ): + result = mask_secrets("some text") + assert result == "****" + assert "hunter2" not in result + assert "Test exception" not in result From f53b5c499b635f49277be7c260964a936a1d16fe Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 09:31:47 +0000 Subject: [PATCH 4/5] SNOW-2912540: make SecretDetector move a literal 1:1 port, not a rewrite Corrects the previous commit: this is now an exact copy of UD's _common/secret_detector.py (drivers#598), not a collapsed rewrite. Keeps the SecretDetector class, MaskedMessageData, the (is_masked, masked_text, err_str) 3-tuple return, classmethods, logging.Formatter inheritance, format(), create_formatting_error_log(), and masked_text = str(ex) on failure -- unchanged, even though the str(ex) behavior is a known real finding (flagged by the security bot on UD #598); fixing it is a separate call for whoever reviews this, not bundled into a move. Only removed what's physically impossible to keep: @backward_compatibility, install_backward_compatibility_getattr and its import, and the trailing install_backward_compatibility_getattr(__name__) call -- none of that mechanism exists in snowpark-python. The mask_secrets() docstring explaining why the maskers are classmethods (to survive @backward_compatibility stashing the class out of module globals) is left as-is even though that rationale no longer applies here -- not silently deleted or rewritten as part of this move. _telemetry.py's call site goes back to unpacking the 3-tuple unmodified; test_secret_detector.py is now a full copy of UD's test suite (TestMaskSecrets, TestMaskSecretsExceptionHandling, TestFormatter), not just the masking-behavior subset -- format()/logging.Formatter are kept, so their tests are too. --- .../snowpark/mock/_secret_detector.py | 326 ++++--- src/snowflake/snowpark/mock/_telemetry.py | 4 +- tests/mock/test_secret_detector.py | 846 +++++++++++------- 3 files changed, 709 insertions(+), 467 deletions(-) diff --git a/src/snowflake/snowpark/mock/_secret_detector.py b/src/snowflake/snowpark/mock/_secret_detector.py index dd626ae837..2b10d5ab85 100644 --- a/src/snowflake/snowpark/mock/_secret_detector.py +++ b/src/snowflake/snowpark/mock/_secret_detector.py @@ -1,138 +1,216 @@ # # Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. # -"""Masks secrets that might otherwise leak via out-of-band telemetry. - -Ported from the Universal Driver's ``connector._common.secret_detector.SecretDetector`` -backward-compat shim -- only the masking behavior LocalTestOOBTelemetryService actually -calls, not the full legacy surface (NamedTuple return, logging.Formatter integration, -backward-compat shims, classmethod workaround). Every masking regex and the order -they're applied in is kept verbatim from the original -- this is a security control, -not a cosmetic detail. -""" -import re -from typing import Optional - -_MASKING_FAILURE_SENTINEL = "****" - -_AWS_KEY_PATTERN = re.compile( - r"(aws_key_id|aws_secret_key|access_key_id|secret_access_key)\s*=\s*'([^']+)'", - flags=re.IGNORECASE, -) -_AWS_TOKEN_PATTERN = re.compile( - r'(accessToken|tempToken|keySecret)"\s*:\s*"([a-z0-9/+]{32,}={0,2})"', - flags=re.IGNORECASE, -) -# Detects OAuth access/refresh tokens in serialized JSON (e.g. the OAuth -# token-exchange response), matching legacy JDBC's OAUTH_JSON_PATTERN. -_OAUTH_TOKEN_PATTERN = re.compile( - r'(access_token|refresh_token)"\s*:\s*"([a-z0-9!"#\$%&\'\(\)\*\+\,\-\./:;<=>\?\@\[\]\^_`\{\|\}~]{3,})"', - flags=re.IGNORECASE, -) -_SAS_TOKEN_PATTERN = re.compile( - r"(sig|signature|AWSAccessKeyId|password|passcode)=(?P[a-z0-9%/+]{16,})", - flags=re.IGNORECASE, -) -_PRIVATE_KEY_PATTERN = re.compile( - r"-{3,}BEGIN [A-Z ]*PRIVATE KEY-{3,}\n([\s\S]*?)\n-{3,}END [A-Z ]*PRIVATE KEY-{3,}", - flags=re.MULTILINE | re.IGNORECASE, -) -_PRIVATE_KEY_DATA_PATTERN = re.compile( - r'"privateKeyData": "([a-z0-9/+=\\n]{10,})"', - flags=re.MULTILINE | re.IGNORECASE, -) -# ':' and '%' are in the value class so a version/hint-prefixed session token -# (e.g. "token=ver:1-hint:1036-", Snowflake's actual wire format) masks -# in full instead of stopping at the first ':' -- matches legacy Node.js's fix. -_CONNECTION_TOKEN_PATTERN = re.compile( - r"(token|assertion content)" r"([\'\"\s:=]+)" r"([a-z0-9=/_\-\+\.:%]{8,})", - flags=re.IGNORECASE, -) -# Matches legacy Node.js's OAUTH_CLIENT_SECRET_PATTERN. -_OAUTH_CLIENT_SECRET_PATTERN = re.compile( - r"(oauthClientId|oauthClientSecret|clientSecret)" - r"([\'\"\s:=]+)" - r"([a-z0-9!\"#\$%&\\\'\(\)\*\+\,-\./:;<=>\?\@\[\]\^_`\{\|\}~]{8,})", - flags=re.IGNORECASE, -) -# Matches legacy Node.js's PASSCODE_PATTERN. -_PASSCODE_PATTERN = re.compile( - r"(passcode|otp|pin|otac)\s*([:=])\s*([0-9]{4,6})", - flags=re.IGNORECASE, -) -# Value quantifier is {6,} (not {1,}) so short common words after a bare -# "password"/"pwd" keyword -- e.g. "...no ID password was not given" -- are -# not mistaken for the secret value itself; matches legacy .NET/JDBC's floor. -_PASSWORD_PATTERN = re.compile( - r"(password" - r"|pwd)" - r"([\'\"\s:=]+)" - r"([a-z0-9!\"#\$%&\\\'\(\)\*\+\,-\./:;<=>\?\@\[\]\^_`\{\|\}~]{6,})", - flags=re.IGNORECASE, -) - -_SECRET_STARRED_MASK_STR = "****" - - -def _mask_aws_keys(text: str) -> str: - return _AWS_KEY_PATTERN.sub(r"\1=" + f"'{_SECRET_STARRED_MASK_STR}'", text) - - -def _mask_sas_tokens(text: str) -> str: - return _SAS_TOKEN_PATTERN.sub(r"\1=" + _SECRET_STARRED_MASK_STR, text) - - -def _mask_aws_tokens(text: str) -> str: - return _AWS_TOKEN_PATTERN.sub(r'\1":"XXXX"', text) - - -def _mask_oauth_tokens(text: str) -> str: - return _OAUTH_TOKEN_PATTERN.sub(r'\1":"XXXX"', text) - - -def _mask_private_key(text: str) -> str: - return _PRIVATE_KEY_PATTERN.sub( - "-----BEGIN PRIVATE KEY-----\\\\nXXXX\\\\n-----END PRIVATE KEY-----", text - ) - +"""The secret detector detects sensitive information. -def _mask_private_key_data(text: str) -> str: - return _PRIVATE_KEY_DATA_PATTERN.sub('"privateKeyData": "XXXX"', text) +It masks secrets that might be leaked from two potential avenues + 1. Out of Band Telemetry + 2. Logging +Ported from snowflake-connector-python's legacy SecretDetector via the +Universal Driver's backward-compat shim (drivers#598). +""" +from __future__ import annotations -def _mask_oauth_client_secrets(text: str) -> str: - return _OAUTH_CLIENT_SECRET_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) +import logging +import os +import re +from typing import NamedTuple -def _mask_passcodes(text: str) -> str: - return _PASSCODE_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) +MIN_TOKEN_LEN = os.getenv("MIN_TOKEN_LEN", 32) +MIN_PWD_LEN = os.getenv("MIN_PWD_LEN", 8) -def _mask_password(text: str) -> str: - return _PASSWORD_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) +class MaskedMessageData(NamedTuple): + is_masked: bool = False + masked_text: str | None = None + error_str: str | None = None -def _mask_connection_token(text: str) -> str: - return _CONNECTION_TOKEN_PATTERN.sub(r"\1\2" + _SECRET_STARRED_MASK_STR, text) +class SecretDetector(logging.Formatter): + AWS_KEY_PATTERN = re.compile( + r"(aws_key_id|aws_secret_key|access_key_id|secret_access_key)\s*=\s*'([^']+)'", + flags=re.IGNORECASE, + ) + AWS_TOKEN_PATTERN = re.compile( + r'(accessToken|tempToken|keySecret)"\s*:\s*"([a-z0-9/+]{32,}={0,2})"', + flags=re.IGNORECASE, + ) + # Detects OAuth access/refresh tokens in serialized JSON (e.g. the OAuth + # token-exchange response), matching legacy JDBC's OAUTH_JSON_PATTERN. + OAUTH_TOKEN_PATTERN = re.compile( + r'(access_token|refresh_token)"\s*:\s*"([a-z0-9!"#\$%&\'\(\)\*\+\,\-\./:;<=>\?\@\[\]\^_`\{\|\}~]{3,})"', + flags=re.IGNORECASE, + ) + SAS_TOKEN_PATTERN = re.compile( + r"(sig|signature|AWSAccessKeyId|password|passcode)=(?P[a-z0-9%/+]{16,})", + flags=re.IGNORECASE, + ) + PRIVATE_KEY_PATTERN = re.compile( + r"-{3,}BEGIN [A-Z ]*PRIVATE KEY-{3,}\n([\s\S]*?)\n-{3,}END [A-Z ]*PRIVATE KEY-{3,}", + flags=re.MULTILINE | re.IGNORECASE, + ) + PRIVATE_KEY_DATA_PATTERN = re.compile( + r'"privateKeyData": "([a-z0-9/+=\\n]{10,})"', flags=re.MULTILINE | re.IGNORECASE + ) + # ':' and '%' are in the value class so a version/hint-prefixed session token + # (e.g. "token=ver:1-hint:1036-", Snowflake's actual wire format) masks + # in full instead of stopping at the first ':' -- matches legacy Node.js's fix. + CONNECTION_TOKEN_PATTERN = re.compile( + r"(token|assertion content)" r"([\'\"\s:=]+)" r"([a-z0-9=/_\-\+\.:%]{8,})", + flags=re.IGNORECASE, + ) + # Matches legacy Node.js's OAUTH_CLIENT_SECRET_PATTERN. + OAUTH_CLIENT_SECRET_PATTERN = re.compile( + r"(oauthClientId|oauthClientSecret|clientSecret)" + r"([\'\"\s:=]+)" + r"([a-z0-9!\"#\$%&\\\'\(\)\*\+\,-\./:;<=>\?\@\[\]\^_`\{\|\}~]{8,})", + flags=re.IGNORECASE, + ) + # Matches legacy Node.js's PASSCODE_PATTERN. + PASSCODE_PATTERN = re.compile( + r"(passcode|otp|pin|otac)\s*([:=])\s*([0-9]{4,6})", + flags=re.IGNORECASE, + ) + # Value quantifier is {6,} (not {1,}) so short common words after a bare + # "password"/"pwd" keyword -- e.g. "...no ID password was not given" -- are + # not mistaken for the secret value itself; matches legacy .NET/JDBC's floor. + PASSWORD_PATTERN = re.compile( + r"(password" + r"|pwd)" + r"([\'\"\s:=]+)" + r"([a-z0-9!\"#\$%&\\\'\(\)\*\+\,-\./:;<=>\?\@\[\]\^_`\{\|\}~]{6,})", + flags=re.IGNORECASE, + ) -def mask_secrets(text: Optional[str]) -> Optional[str]: - """Return text with any detected secrets masked. On masking failure, - returns a static placeholder rather than the exception message, to - avoid reflecting secret-containing input back into the caller.""" - if text is None: - return None - try: - masked_text = _mask_aws_keys(text) - masked_text = _mask_sas_tokens(masked_text) - masked_text = _mask_aws_tokens(masked_text) - masked_text = _mask_oauth_tokens(masked_text) - masked_text = _mask_private_key(masked_text) - masked_text = _mask_private_key_data(masked_text) - masked_text = _mask_oauth_client_secrets(masked_text) - masked_text = _mask_passcodes(masked_text) - masked_text = _mask_password(masked_text) - masked_text = _mask_connection_token(masked_text) - return masked_text - except Exception: - return _MASKING_FAILURE_SENTINEL + SECRET_STARRED_MASK_STR = "****" + + @classmethod + def mask_connection_token(cls, text: str) -> str: + return cls.CONNECTION_TOKEN_PATTERN.sub( + r"\1\2" + f"{cls.SECRET_STARRED_MASK_STR}", text + ) + + @classmethod + def mask_password(cls, text: str) -> str: + return cls.PASSWORD_PATTERN.sub( + r"\1\2" + f"{cls.SECRET_STARRED_MASK_STR}", text + ) + + @classmethod + def mask_aws_keys(cls, text: str) -> str: + return cls.AWS_KEY_PATTERN.sub( + r"\1=" + f"'{cls.SECRET_STARRED_MASK_STR}'", text + ) + + @classmethod + def mask_sas_tokens(cls, text: str) -> str: + return cls.SAS_TOKEN_PATTERN.sub( + r"\1=" + f"{cls.SECRET_STARRED_MASK_STR}", text + ) + + @classmethod + def mask_aws_tokens(cls, text: str) -> str: + return cls.AWS_TOKEN_PATTERN.sub(r'\1":"XXXX"', text) + + @classmethod + def mask_oauth_tokens(cls, text: str) -> str: + return cls.OAUTH_TOKEN_PATTERN.sub(r'\1":"XXXX"', text) + + @classmethod + def mask_oauth_client_secrets(cls, text: str) -> str: + return cls.OAUTH_CLIENT_SECRET_PATTERN.sub( + r"\1\2" + f"{cls.SECRET_STARRED_MASK_STR}", text + ) + + @classmethod + def mask_passcodes(cls, text: str) -> str: + return cls.PASSCODE_PATTERN.sub( + r"\1\2" + f"{cls.SECRET_STARRED_MASK_STR}", text + ) + + @classmethod + def mask_private_key(cls, text: str) -> str: + return cls.PRIVATE_KEY_PATTERN.sub( + "-----BEGIN PRIVATE KEY-----\\\\nXXXX\\\\n-----END PRIVATE KEY-----", text + ) + + @classmethod + def mask_private_key_data(cls, text: str) -> str: + return cls.PRIVATE_KEY_DATA_PATTERN.sub('"privateKeyData": "XXXX"', text) + + @classmethod + def mask_secrets(cls, text: str | None) -> MaskedMessageData: + """Return ``text`` with any detected secrets masked (the public entry point). + + These maskers are ``classmethod``s (not the legacy ``staticmethod``s) so + their ``cls.`` self-references resolve even though + ``@backward_compatibility`` stashes ``SecretDetector`` out of the module + globals; a bare ``SecretDetector.`` reference would raise ``NameError``. + """ + if text is None: + return MaskedMessageData() + + masked = False + err_str = None + try: + masked_text = cls.mask_aws_keys(text) + masked_text = cls.mask_sas_tokens(masked_text) + masked_text = cls.mask_aws_tokens(masked_text) + masked_text = cls.mask_oauth_tokens(masked_text) + masked_text = cls.mask_private_key(masked_text) + masked_text = cls.mask_private_key_data(masked_text) + masked_text = cls.mask_oauth_client_secrets(masked_text) + masked_text = cls.mask_passcodes(masked_text) + masked_text = cls.mask_password(masked_text) + masked_text = cls.mask_connection_token(masked_text) + if masked_text != text: + masked = True + except Exception as ex: + # We'll assume that the exception was raised during masking + # to be safe consider that the log has sensitive information + # and do not raise an exception. + masked = True + masked_text = str(ex) + err_str = str(ex) + + return MaskedMessageData(masked, masked_text, err_str) + + @staticmethod + def create_formatting_error_log( + original_record: logging.LogRecord, error_message: str + ) -> str: + return "{} - {} {} - {} - {} - {}".format( + original_record.asctime, + original_record.threadName, + "secret_detector.py", + "sanitize_log_str", + original_record.levelname, + error_message, + ) + + def format(self, record: logging.LogRecord) -> str: + """Format ``record`` via ``logging.Formatter``, masking any secrets. + + Ensures the formatted message is free from sensitive credentials before + it reaches a log handler. + """ + try: + unsanitized_log = super().format(record) + masked, optional_sanitized_log, err_str = type(self).mask_secrets( + unsanitized_log + ) + # Added to comply with type hints (Optional[str] is not accepted for str) + sanitized_log = optional_sanitized_log or "" + + if masked and err_str is not None: + sanitized_log = self.create_formatting_error_log(record, err_str) + + except Exception as ex: + sanitized_log = self.create_formatting_error_log( + record, "EXCEPTION - " + str(ex) + ) + + return sanitized_log diff --git a/src/snowflake/snowpark/mock/_telemetry.py b/src/snowflake/snowpark/mock/_telemetry.py index a0fb6eef69..ce66071234 100644 --- a/src/snowflake/snowpark/mock/_telemetry.py +++ b/src/snowflake/snowpark/mock/_telemetry.py @@ -19,7 +19,7 @@ get_version, ) -from ._secret_detector import mask_secrets +from ._secret_detector import SecretDetector from .exceptions import SnowparkLocalTestingException REQUESTS_AVAILABLE = True @@ -202,7 +202,7 @@ def export_queue_to_string(self): exc_info=True, ) payload = None - masked_text = mask_secrets(payload) + _, masked_text, _ = SecretDetector.mask_secrets(payload) return masked_text def close(self) -> None: diff --git a/tests/mock/test_secret_detector.py b/tests/mock/test_secret_detector.py index 04e279bbd0..de2bcde4bd 100644 --- a/tests/mock/test_secret_detector.py +++ b/tests/mock/test_secret_detector.py @@ -1,274 +1,355 @@ -# -# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. -# -"""Behavioral tests for mock/_secret_detector.py, ported from the Universal -Driver's SecretDetector backward-compat shim test suite -- masking-behavior -assertions only, not the logging.Formatter/backward-compat-specific tests -that don't apply here (mask_secrets is a plain function, not a class). +"""Behavioral tests for the ``SecretDetector`` shim, ported from the Universal +Driver's backward-compat test suite (drivers#598), which itself ports the full +legacy ``snowflake-connector-python`` masking-behavior suite +(``test_log_secret_detector.py`` and ``test_oob_secret_detector.py``) verbatim, +plus cases ported from other legacy drivers for masking gaps that legacy +Python itself never covered: AWS-token masking (from .NET's ``TestAWSTokens``), +OAuth-token JSON masking (from JDBC's ``OAUTH_JSON_PATTERN``), +OAuth-client-secret and passcode/OTP/PIN masking (from Node.js), the +version/hint-prefixed session-token wire format, and the ``PASSWORD_PATTERN`` +false-positive fix (from .NET/JDBC's ``{6,}`` floor). """ + +import logging import random import string + from unittest import mock import pytest -from snowflake.snowpark.mock import _secret_detector -from snowflake.snowpark.mock._secret_detector import mask_secrets - - -def test_clean_text_is_not_masked(): - text = "select 1 from dual" - assert mask_secrets(text) == text - - -def test_none_text_returns_none(): - assert mask_secrets(None) is None - - -def test_empty_string_is_not_masked(): - assert mask_secrets("") == "" - - -def test_password_is_masked(): - random_password = "Fh[+2J~AcqeqW%?" - - assert mask_secrets("password:" + random_password) == "password:****" - assert mask_secrets("PASSWORD:" + random_password) == "PASSWORD:****" - assert mask_secrets("PassWorD:" + random_password) == "PassWorD:****" - assert mask_secrets("password = " + random_password) == "password = ****" - assert mask_secrets("pwd:" + random_password) == "pwd:****" - assert mask_secrets("password=Afs...") == "password=****" - - -@pytest.mark.parametrize( - "text", - [ - "2020-04-30 23:06:04,069 - MainThread auth.py:397" - " - write_temporary_credential() - DEBUG - no ID password was not given", - "2020-04-30 23:06:04,069 - MainThread auth.py:397" - " - write_temporary_credential() - DEBUG - no ID proxyPassword was not given", - ], -) -def test_password_false_positives_are_not_masked(text): - """PASSWORD_PATTERN's value floor is 6 chars (not 1) so a short common word - right after a bare password/pwd keyword isn't mistaken for the secret - itself.""" - assert mask_secrets(text) == text - - -def test_aws_key_is_masked(): - sql = ( - "copy into 's3://xxxx/test' from \n" - "(select seq1(), random()\n" - ", random(), random(), random(), random()\n" - ", random(), random(), random(), random()\n" - ", random() , random(), random(), random()\n" - "\tfrom table(generator(rowcount => 10000)))\n" - "credentials=(\n" - " aws_key_id='xxdsdfsafds'\n" - " aws_secret_key='safas+asfsad+safasf'\n" - " )\n" - "OVERWRITE = TRUE \n" - "MAX_FILE_SIZE = 500000000 \n" - "HEADER = TRUE \n" - "FILE_FORMAT = (TYPE = PARQUET SNAPPY_COMPRESSION = TRUE )\n" - ";" - ) - correct = ( - "copy into 's3://xxxx/test' from \n" - "(select seq1(), random()\n" - ", random(), random(), random(), random()\n" - ", random(), random(), random(), random()\n" - ", random() , random(), random(), random()\n" - "\tfrom table(generator(rowcount => 10000)))\n" - "credentials=(\n" - " aws_key_id='****'\n" - " aws_secret_key='****'\n" - " )\n" - "OVERWRITE = TRUE \n" - "MAX_FILE_SIZE = 500000000 \n" - "HEADER = TRUE \n" - "FILE_FORMAT = (TYPE = PARQUET SNAPPY_COMPRESSION = TRUE )\n" - ";" - ) - assert mask_secrets(sql) == correct - - -@pytest.mark.parametrize( - "text,expected", - [ - ('accessToken":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'accessToken":"XXXX"'), - ('tempToken":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'tempToken":"XXXX"'), - ('keySecret":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'keySecret":"XXXX"'), - ( - 'accessToken" : "aB1aaaaaaaaaaZaaaaaaaaaaa9aaaaaaa="', - 'accessToken":"XXXX"', - ), - ( - 'accessToken" : "aB1aaaaaaaaaaZaaaaaaaa56aaaaaaaaaa=="', - 'accessToken":"XXXX"', - ), - ], -) -def test_aws_tokens_are_masked(text, expected): - assert mask_secrets(text) == expected - - -def test_sas_tokens_are_masked(): - azure_sas_token = ( - "https://someaccounts.blob.core.windows.net/results/018b90ab-0033-" - "5f8e-0000-14f1000bd376_0/main/data_0_0_1?sv=2015-07-08&" - "sig=iCvQmdZngZNW%2F4vw43j6%2BVz6fndHF5LI639QJba4r8o%3D&" - "spr=https&st=2016-04-12T03%3A24%3A31Z&" - "se=2016-04-13T03%3A29%3A31Z&srt=s&ss=bf&sp=rwl" - ) - masked_azure_sas_token = ( - "https://someaccounts.blob.core.windows.net/results/018b90ab-0033-" - "5f8e-0000-14f1000bd376_0/main/data_0_0_1?sv=2015-07-08&" - "sig=****&" - "spr=https&st=2016-04-12T03%3A24%3A31Z&" - "se=2016-04-13T03%3A29%3A31Z&srt=s&ss=bf&sp=rwl" - ) - s3_sas_token = ( - "https://somebucket.s3.amazonaws.com/vzy1-s-va_demo0/results/018b92f3" - "-01c2-02dd-0000-03d5000c8066_0/main/data_0_0_1?" - "x-amz-server-side-encryption-customer-algorithm=AES256&" - "response-content-encoding=gzip&AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE" - "&Expires=1555481960&Signature=zFiRkdB9RtRRYomppVes4fQ%2ByWw%3D" - ) - masked_s3_sas_token = ( - "https://somebucket.s3.amazonaws.com/vzy1-s-va_demo0/results/018b92f3" - "-01c2-02dd-0000-03d5000c8066_0/main/data_0_0_1?" - "x-amz-server-side-encryption-customer-algorithm=AES256&" - "response-content-encoding=gzip&AWSAccessKeyId=****" - "&Expires=1555481960&Signature=****" - ) +from snowflake.snowpark.mock._secret_detector import MaskedMessageData, SecretDetector - assert mask_secrets(azure_sas_token) == masked_azure_sas_token - assert mask_secrets(s3_sas_token) == masked_s3_sas_token - text = "".join(random.choice(string.ascii_lowercase) for _ in range(200)) - assert mask_secrets(text) == text +def _assert_not_masked(text): + masked, masked_text, err_str = SecretDetector.mask_secrets(text) + assert not masked + assert err_str is None + assert masked_text == text - assert ( - mask_secrets(azure_sas_token + "\n" + azure_sas_token) - == masked_azure_sas_token + "\n" + masked_azure_sas_token - ) - assert ( - mask_secrets(s3_sas_token + "\n" + s3_sas_token) - == masked_s3_sas_token + "\n" + masked_s3_sas_token - ) - assert ( - mask_secrets(azure_sas_token + "\n" + s3_sas_token) - == masked_azure_sas_token + "\n" + masked_s3_sas_token - ) +class TestMaskSecrets: + def test_clean_text_is_not_masked(self): + _assert_not_masked("select 1 from dual") -def test_combined_secrets_in_create_stage_sql(): - sql = ( - "create stage mystage " - "URL = 's3://mybucket/mypath/' " - "credentials = (aws_key_id = 'AKIAIOSFODNN7EXAMPLE' " - "aws_secret_key = 'frJIUN8DYpKDtOLCwo//yllqDzg='); " - "create stage mystage2 " - "URL = 'azure//mystorage.blob.core.windows.net/cont' " - "credentials = (azure_sas_token = " - "'?sv=2016-05-31&ss=b&srt=sco&sp=rwdl&se=2018-06-27T10:05:50Z&" - "st=2017-06-27T02:05:50Z&spr=https,http&" - "sig=bgqQwoXwxzuD2GJfagRg7VOS8hzNr3QLT7rhS8OFRLQ%3D')" - ) - masked_sql = ( - "create stage mystage " - "URL = 's3://mybucket/mypath/' " - "credentials = (aws_key_id='****' " - "aws_secret_key='****'); " - "create stage mystage2 " - "URL = 'azure//mystorage.blob.core.windows.net/cont' " - "credentials = (azure_sas_token = " - "'?sv=2016-05-31&ss=b&srt=sco&sp=rwdl&se=2018-06-27T10:05:50Z&" - "st=2017-06-27T02:05:50Z&spr=https,http&" - "sig=****')" - ) - assert mask_secrets(sql) == masked_sql - - text = "".join(random.choice(string.ascii_lowercase) for _ in range(500)) - assert mask_secrets(text) == text - - -def test_connection_token_is_masked(): - long_token = ( - "_Y1ZNETTn5/qfUWj3Jedby7gipDzQs=U" - "KyJH9DS=nFzzWnfZKGV+C7GopWCGD4Lj" - "OLLFZKOE26LXHDt3pTi4iI1qwKuSpf/F" - "mClCMBSissVsU3Ei590FP0lPQQhcSGcD" - "u69ZL_1X6e9h5z62t/iY7ZkII28n2qU=" - "nrBJUgPRCIbtJQkVJXIuOHjX4G5yUEKj" - "ZBAx4w6=_lqtt67bIA=o7D=oUSjfywsR" - "FoloNIkBPXCwFTv+1RVUHgVA2g8A9Lw5" - "XdJYuI8vhg=f0bKSq7AhQ2Bh" - ) - json_token = ( - "{'TOKEN': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFt" - "ZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'}" - ) + def test_none_text_returns_empty_result(self): + result = SecretDetector.mask_secrets(None) + assert result == MaskedMessageData() - assert mask_secrets("Token =" + long_token) == "Token =****" - assert mask_secrets("idToken : " + long_token) == "idToken : ****" - assert mask_secrets("sessionToken : " + long_token) == "sessionToken : ****" - assert mask_secrets("masterToken : " + long_token) == "masterToken : ****" - assert mask_secrets("assertion content:" + long_token) == "assertion content:****" - assert mask_secrets(json_token) == "{'TOKEN': '****'}" + def test_empty_string_is_not_masked(self): + _assert_not_masked("") + def test_password_is_masked(self): + random_password = "Fh[+2J~AcqeqW%?" -def test_token_false_positives(): - false_positive_token_str = ( - "2020-04-30 23:06:04,069 - MainThread auth.py:397" - " - write_temporary_credential() - DEBUG - no ID " - "token is given when try to store temporary credential" - ) - assert mask_secrets(false_positive_token_str) == false_positive_token_str - - -def test_multiple_secrets_are_all_masked(): - long_token = ( - "_Y1ZNETTn5/qfUWj3Jedby7gipDzQs=U" - "KyJH9DS=nFzzWnfZKGV+C7GopWCGD4Lj" - "OLLFZKOE26LXHDt3pTi4iI1qwKuSpf/F" - "mClCMBSissVsU3Ei590FP0lPQQhcSGcD" - "u69ZL_1X6e9h5z62t/iY7ZkII28n2qU=" - "nrBJUgPRCIbtJQkVJXIuOHjX4G5yUEKj" - "ZBAx4w6=_lqtt67bIA=o7D=oUSjfywsR" - "FoloNIkBPXCwFTv+1RVUHgVA2g8A9Lw5" - "XdJYuI8vhg=f0bKSq7AhQ2Bh" + masked, masked_text, err_str = SecretDetector.mask_secrets( + "password:" + random_password + ) + assert masked + assert err_str is None + assert masked_text == "password:****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "PASSWORD:" + random_password + ) + assert masked + assert err_str is None + assert masked_text == "PASSWORD:****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "PassWorD:" + random_password + ) + assert masked + assert err_str is None + assert masked_text == "PassWorD:****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "password = " + random_password + ) + assert masked + assert err_str is None + assert masked_text == "password = ****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "pwd:" + random_password + ) + assert masked + assert err_str is None + assert masked_text == "pwd:****" + + masked, masked_text, err_str = SecretDetector.mask_secrets("password=Afs...") + assert masked + assert err_str is None + assert masked_text == "password=****" + + @pytest.mark.parametrize( + "text", + [ + "2020-04-30 23:06:04,069 - MainThread auth.py:397" + " - write_temporary_credential() - DEBUG - no ID password was not given", + "2020-04-30 23:06:04,069 - MainThread auth.py:397" + " - write_temporary_credential() - DEBUG - no ID proxyPassword was not given", + ], ) - long_token2 = ( - "ktL57KJemuq4-M+Q0pdRjCIMcf1mzcr" - "MwKteDS5DRE/Pb+5MzvWjDH7LFPV5b_" - "/tX/yoLG3b4TuC6Q5qNzsARPPn_zs/j" - "BbDOEg1-IfPpdsbwX6ETeEnhxkHIL4H" - "sP-V" + def test_password_false_positives_are_not_masked(self, text): + """``PASSWORD_PATTERN``'s value floor is 6 chars (not 1) so a short common word + right after a bare ``password``/``pwd`` keyword isn't mistaken for the secret + itself; matches legacy .NET/JDBC's floor and ``TestPasswordFalsePositive``.""" + _assert_not_masked(text) + + def test_aws_key_is_masked(self): + sql = ( + "copy into 's3://xxxx/test' from \n" + "(select seq1(), random()\n" + ", random(), random(), random(), random()\n" + ", random(), random(), random(), random()\n" + ", random() , random(), random(), random()\n" + "\tfrom table(generator(rowcount => 10000)))\n" + "credentials=(\n" + " aws_key_id='xxdsdfsafds'\n" + " aws_secret_key='safas+asfsad+safasf'\n" + " )\n" + "OVERWRITE = TRUE \n" + "MAX_FILE_SIZE = 500000000 \n" + "HEADER = TRUE \n" + "FILE_FORMAT = (TYPE = PARQUET SNAPPY_COMPRESSION = TRUE )\n" + ";" + ) + correct = ( + "copy into 's3://xxxx/test' from \n" + "(select seq1(), random()\n" + ", random(), random(), random(), random()\n" + ", random(), random(), random(), random()\n" + ", random() , random(), random(), random()\n" + "\tfrom table(generator(rowcount => 10000)))\n" + "credentials=(\n" + " aws_key_id='****'\n" + " aws_secret_key='****'\n" + " )\n" + "OVERWRITE = TRUE \n" + "MAX_FILE_SIZE = 500000000 \n" + "HEADER = TRUE \n" + "FILE_FORMAT = (TYPE = PARQUET SNAPPY_COMPRESSION = TRUE )\n" + ";" + ) + _, masked_text, _ = SecretDetector.mask_secrets(sql) + assert masked_text == correct + + @pytest.mark.parametrize( + "text,expected", + [ + ('accessToken":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'accessToken":"XXXX"'), + ('tempToken":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'tempToken":"XXXX"'), + ('keySecret":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', 'keySecret":"XXXX"'), + ( + 'accessToken" : "aB1aaaaaaaaaaZaaaaaaaaaaa9aaaaaaa="', + 'accessToken":"XXXX"', + ), + ( + 'accessToken" : "aB1aaaaaaaaaaZaaaaaaaa56aaaaaaaaaa=="', + 'accessToken":"XXXX"', + ), + ], ) - random_pwd = "Fh[+2J~AcqeqW%?" - random_pwd2 = random_pwd + "vdkav13" + def test_aws_tokens_are_masked(self, text, expected): + """``AWS_TOKEN_PATTERN`` (``accessToken``/``tempToken``/``keySecret``) has no legacy + Python test at all; ported from the legacy .NET driver's ``TestAWSTokens``.""" + masked, masked_text, err_str = SecretDetector.mask_secrets(text) + assert masked + assert err_str is None + assert masked_text == expected + + def test_sas_tokens_are_masked(self): + azure_sas_token = ( + "https://someaccounts.blob.core.windows.net/results/018b90ab-0033-" + "5f8e-0000-14f1000bd376_0/main/data_0_0_1?sv=2015-07-08&" + "sig=iCvQmdZngZNW%2F4vw43j6%2BVz6fndHF5LI639QJba4r8o%3D&" + "spr=https&st=2016-04-12T03%3A24%3A31Z&" + "se=2016-04-13T03%3A29%3A31Z&srt=s&ss=bf&sp=rwl" + ) + masked_azure_sas_token = ( + "https://someaccounts.blob.core.windows.net/results/018b90ab-0033-" + "5f8e-0000-14f1000bd376_0/main/data_0_0_1?sv=2015-07-08&" + "sig=****&" + "spr=https&st=2016-04-12T03%3A24%3A31Z&" + "se=2016-04-13T03%3A29%3A31Z&srt=s&ss=bf&sp=rwl" + ) + s3_sas_token = ( + "https://somebucket.s3.amazonaws.com/vzy1-s-va_demo0/results/018b92f3" + "-01c2-02dd-0000-03d5000c8066_0/main/data_0_0_1?" + "x-amz-server-side-encryption-customer-algorithm=AES256&" + "response-content-encoding=gzip&AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE" + "&Expires=1555481960&Signature=zFiRkdB9RtRRYomppVes4fQ%2ByWw%3D" + ) + masked_s3_sas_token = ( + "https://somebucket.s3.amazonaws.com/vzy1-s-va_demo0/results/018b92f3" + "-01c2-02dd-0000-03d5000c8066_0/main/data_0_0_1?" + "x-amz-server-side-encryption-customer-algorithm=AES256&" + "response-content-encoding=gzip&AWSAccessKeyId=****" + "&Expires=1555481960&Signature=****" + ) + + _, masked_text, _ = SecretDetector.mask_secrets(azure_sas_token) + assert masked_text == masked_azure_sas_token + + _, masked_text, _ = SecretDetector.mask_secrets(s3_sas_token) + assert masked_text == masked_s3_sas_token - assert ( - mask_secrets( + text = "".join(random.choice(string.ascii_lowercase) for _ in range(200)) + _, masked_text, _ = SecretDetector.mask_secrets(text) + assert masked_text == text + + _, masked_text, _ = SecretDetector.mask_secrets( + azure_sas_token + "\n" + azure_sas_token + ) + assert masked_text == masked_azure_sas_token + "\n" + masked_azure_sas_token + + _, masked_text, _ = SecretDetector.mask_secrets( + s3_sas_token + "\n" + s3_sas_token + ) + assert masked_text == masked_s3_sas_token + "\n" + masked_s3_sas_token + + _, masked_text, _ = SecretDetector.mask_secrets( + azure_sas_token + "\n" + s3_sas_token + ) + assert masked_text == masked_azure_sas_token + "\n" + masked_s3_sas_token + + def test_combined_secrets_in_create_stage_sql(self): + sql = ( + "create stage mystage " + "URL = 's3://mybucket/mypath/' " + "credentials = (aws_key_id = 'AKIAIOSFODNN7EXAMPLE' " + "aws_secret_key = 'frJIUN8DYpKDtOLCwo//yllqDzg='); " + "create stage mystage2 " + "URL = 'azure//mystorage.blob.core.windows.net/cont' " + "credentials = (azure_sas_token = " + "'?sv=2016-05-31&ss=b&srt=sco&sp=rwdl&se=2018-06-27T10:05:50Z&" + "st=2017-06-27T02:05:50Z&spr=https,http&" + "sig=bgqQwoXwxzuD2GJfagRg7VOS8hzNr3QLT7rhS8OFRLQ%3D')" + ) + masked_sql = ( + "create stage mystage " + "URL = 's3://mybucket/mypath/' " + "credentials = (aws_key_id='****' " + "aws_secret_key='****'); " + "create stage mystage2 " + "URL = 'azure//mystorage.blob.core.windows.net/cont' " + "credentials = (azure_sas_token = " + "'?sv=2016-05-31&ss=b&srt=sco&sp=rwdl&se=2018-06-27T10:05:50Z&" + "st=2017-06-27T02:05:50Z&spr=https,http&" + "sig=****')" + ) + _, masked_text, _ = SecretDetector.mask_secrets(sql) + assert masked_text == masked_sql + + text = "".join(random.choice(string.ascii_lowercase) for _ in range(500)) + _, masked_text, _ = SecretDetector.mask_secrets(text) + assert masked_text == text + + def test_connection_token_is_masked(self): + long_token = ( + "_Y1ZNETTn5/qfUWj3Jedby7gipDzQs=U" + "KyJH9DS=nFzzWnfZKGV+C7GopWCGD4Lj" + "OLLFZKOE26LXHDt3pTi4iI1qwKuSpf/F" + "mClCMBSissVsU3Ei590FP0lPQQhcSGcD" + "u69ZL_1X6e9h5z62t/iY7ZkII28n2qU=" + "nrBJUgPRCIbtJQkVJXIuOHjX4G5yUEKj" + "ZBAx4w6=_lqtt67bIA=o7D=oUSjfywsR" + "FoloNIkBPXCwFTv+1RVUHgVA2g8A9Lw5" + "XdJYuI8vhg=f0bKSq7AhQ2Bh" + ) + json_token = ( + "{'TOKEN': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFt" + "ZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'}" + ) + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "Token =" + long_token + ) + assert masked + assert err_str is None + assert masked_text == "Token =****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "idToken : " + long_token + ) + assert masked + assert err_str is None + assert masked_text == "idToken : ****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "sessionToken : " + long_token + ) + assert masked + assert err_str is None + assert masked_text == "sessionToken : ****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "masterToken : " + long_token + ) + assert masked + assert err_str is None + assert masked_text == "masterToken : ****" + + masked, masked_text, err_str = SecretDetector.mask_secrets( + "assertion content:" + long_token + ) + assert masked + assert err_str is None + assert masked_text == "assertion content:****" + + masked, masked_text, err_str = SecretDetector.mask_secrets(json_token) + assert masked + assert err_str is None + assert masked_text == "{'TOKEN': '****'}" + + def test_token_false_positives(self): + false_positive_token_str = ( + "2020-04-30 23:06:04,069 - MainThread auth.py:397" + " - write_temporary_credential() - DEBUG - no ID " + "token is given when try to store temporary credential" + ) + _assert_not_masked(false_positive_token_str) + + def test_multiple_secrets_are_all_masked(self): + long_token = ( + "_Y1ZNETTn5/qfUWj3Jedby7gipDzQs=U" + "KyJH9DS=nFzzWnfZKGV+C7GopWCGD4Lj" + "OLLFZKOE26LXHDt3pTi4iI1qwKuSpf/F" + "mClCMBSissVsU3Ei590FP0lPQQhcSGcD" + "u69ZL_1X6e9h5z62t/iY7ZkII28n2qU=" + "nrBJUgPRCIbtJQkVJXIuOHjX4G5yUEKj" + "ZBAx4w6=_lqtt67bIA=o7D=oUSjfywsR" + "FoloNIkBPXCwFTv+1RVUHgVA2g8A9Lw5" + "XdJYuI8vhg=f0bKSq7AhQ2Bh" + ) + long_token2 = ( + "ktL57KJemuq4-M+Q0pdRjCIMcf1mzcr" + "MwKteDS5DRE/Pb+5MzvWjDH7LFPV5b_" + "/tX/yoLG3b4TuC6Q5qNzsARPPn_zs/j" + "BbDOEg1-IfPpdsbwX6ETeEnhxkHIL4H" + "sP-V" + ) + random_pwd = "Fh[+2J~AcqeqW%?" + random_pwd2 = random_pwd + "vdkav13" + + masked, masked_text, err_str = SecretDetector.mask_secrets( "token=" + long_token + " random giberish " + "password:" + random_pwd ) - == "token=****" + " random giberish " + "password:****" - ) + assert masked + assert err_str is None + assert masked_text == "token=****" + " random giberish " + "password:****" - # order reversed - assert ( - mask_secrets( + # order reversed + masked, masked_text, err_str = SecretDetector.mask_secrets( "password:" + random_pwd + " random giberish " + "token=" + long_token ) - == "password:****" + " random giberish " + "token=****" - ) + assert masked + assert err_str is None + assert masked_text == "password:****" + " random giberish " + "token=****" - # multiple tokens and password - assert ( - mask_secrets( + # multiple tokens and password + masked, masked_text, err_str = SecretDetector.mask_secrets( "token=" + long_token + " random giberish " @@ -278,23 +359,26 @@ def test_multiple_secrets_are_all_masked(): + "idToken:" + long_token2 ) - == "token=****" - + " random giberish " - + "password:****" - + " random giberish " - + "idToken:****" - ) + assert masked + assert err_str is None + assert ( + masked_text + == "token=****" + + " random giberish " + + "password:****" + + " random giberish " + + "idToken:****" + ) - # multiple passwords - assert ( - mask_secrets( + # multiple passwords + masked, masked_text, err_str = SecretDetector.mask_secrets( "password=" + random_pwd + " random giberish " + "pwd:" + random_pwd2 ) - == "password=****" + " random giberish " + "pwd:****" - ) + assert masked + assert err_str is None + assert masked_text == "password=****" + " random giberish " + "pwd:****" - assert ( - mask_secrets( + masked, masked_text, err_str = SecretDetector.mask_secrets( "password=" + random_pwd + " random giberish " @@ -304,90 +388,170 @@ def test_multiple_secrets_are_all_masked(): + "password=" + random_pwd ) - == "password=****" - + " random giberish " - + "password=****" - + " random giberish " - + "password=****" - ) + assert masked + assert err_str is None + assert ( + masked_text + == "password=****" + + " random giberish " + + "password=****" + + " random giberish " + + "password=****" + ) + def test_private_key_body_is_masked(self): + rsa_key = ( + "-----BEGIN RSA PRIVATE KEY-----\n" + "MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEA0pCa0rw1n4GBjylx\n" + "sBJPVCrsKO7SowkgJ52Lc8K3hMHNKXvYiqwgizbXFBQA27kvpEVSeRQVC3FAPRU5\n" + "gjtLRwIDAQABAkBHZbz5o9PS6AjUUEs6VpsLgRpersxBeACtLiBw+h9cJfUerR//\n" + "tTmNsQ9LlamMu2lOlfbO3R2J45ybF7z94A+hAiEA8piucvAlo9YJ4VViQGRTVvr+\n" + "xZKekSEYRJBn2czeP+kCIQDeMt1PVk/p0NEcNvQMbO0vJ3+U+lITJRwmtJ9Fs1Lj\n" + "rwIgJeTdkwyaBI6BepY4w7AoKHUKaNgvNqJBxSv9XNMYgEkCIG2rl1YgWOMkAQI3\n" + "EW/Ml6jtiugiQT5X07Q69F33q5LbAiEArZM7htafpt0RVia+nC9aY+73wpW0Be9e\n" + "pDz0yVv8s/Q=\n" + "-----END RSA PRIVATE KEY-----\n" + ) + masked, masked_text, err_str = SecretDetector.mask_secrets(rsa_key) + assert masked + assert err_str is None + assert ( + masked_text + == "-----BEGIN PRIVATE KEY-----\\nXXXX\\n-----END PRIVATE KEY-----\n" + ) -def test_private_key_body_is_masked(): - rsa_key = ( - "-----BEGIN RSA PRIVATE KEY-----\n" - "MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEA0pCa0rw1n4GBjylx\n" - "sBJPVCrsKO7SowkgJ52Lc8K3hMHNKXvYiqwgizbXFBQA27kvpEVSeRQVC3FAPRU5\n" - "gjtLRwIDAQABAkBHZbz5o9PS6AjUUEs6VpsLgRpersxBeACtLiBw+h9cJfUerR//\n" - "tTmNsQ9LlamMu2lOlfbO3R2J45ybF7z94A+hAiEA8piucvAlo9YJ4VViQGRTVvr+\n" - "xZKekSEYRJBn2czeP+kCIQDeMt1PVk/p0NEcNvQMbO0vJ3+U+lITJRwmtJ9Fs1Lj\n" - "rwIgJeTdkwyaBI6BepY4w7AoKHUKaNgvNqJBxSv9XNMYgEkCIG2rl1YgWOMkAQI3\n" - "EW/Ml6jtiugiQT5X07Q69F33q5LbAiEArZM7htafpt0RVia+nC9aY+73wpW0Be9e\n" - "pDz0yVv8s/Q=\n" - "-----END RSA PRIVATE KEY-----\n" + def test_private_key_data_is_masked(self): + text = '"privateKeyData": "aslkjdflasjf"' + filtered_text = '"privateKeyData": "XXXX"' + _, result, _ = SecretDetector.mask_secrets(text) + assert result == filtered_text + + def test_session_token_wire_format_is_masked(self): + """``CONNECTION_TOKEN_PATTERN``'s value class includes ':' and '%' so a + version/hint-prefixed session token -- Snowflake's actual wire format -- + masks in full instead of stopping at the first ':'; matches legacy + Node.js's fix for the same gap.""" + masked, masked_text, err_str = SecretDetector.mask_secrets( + "token=ver:1-hint:1036-abcd1234efgh5678" + ) + assert masked + assert err_str is None + assert masked_text == "token=****" + + @pytest.mark.parametrize( + "text,expected", + [ + ('"access_token" : "some:FAKE_token123"', '"access_token":"XXXX"'), + ('"refresh_token" : "some:FAKE_token123"', '"refresh_token":"XXXX"'), + ], + ) + def test_oauth_tokens_are_masked(self, text, expected): + """``OAUTH_TOKEN_PATTERN`` has no legacy Python equivalent; ported from + legacy JDBC's ``OAUTH_JSON_PATTERN`` / ``testMaskOAuthSecrets``.""" + masked, masked_text, err_str = SecretDetector.mask_secrets(text) + assert masked + assert err_str is None + assert masked_text == expected + + @pytest.mark.parametrize( + "text,expected", + [ + ("oauthClientSecret: aVeryLongSecretValue123", "oauthClientSecret: ****"), + ("clientSecret=anotherLongSecretValue456", "clientSecret=****"), + ], + ) + def test_oauth_client_secrets_are_masked(self, text, expected): + """``OAUTH_CLIENT_SECRET_PATTERN`` has no legacy Python equivalent; ported + from legacy Node.js's ``OAUTH_CLIENT_SECRET_PATTERN``.""" + masked, masked_text, err_str = SecretDetector.mask_secrets(text) + assert masked + assert err_str is None + assert masked_text == expected + + @pytest.mark.parametrize( + "text,expected", + [ + ("passcode: 123456", "passcode:****"), + ("otp=987654", "otp=****"), + ("pin = 4321", "pin=****"), + ], ) - assert ( - mask_secrets(rsa_key) - == "-----BEGIN PRIVATE KEY-----\\nXXXX\\n-----END PRIVATE KEY-----\n" + def test_passcodes_are_masked(self, text, expected): + """``PASSCODE_PATTERN`` has no legacy Python equivalent; ported from legacy + Node.js's ``PASSCODE_PATTERN`` (covers passcode/otp/pin/otac, 4-6 digits).""" + masked, masked_text, err_str = SecretDetector.mask_secrets(text) + assert masked + assert err_str is None + assert masked_text == expected + + +class TestMaskSecretsExceptionHandling: + """Masking failures must fail closed: the original (unmasked) text is never + returned, even if a masker raises.""" + + @mock.patch.object( + SecretDetector, + "mask_connection_token", + mock.Mock(side_effect=Exception("Test exception")), ) + def test_exception_in_masking_is_fail_closed(self): + test_str = "This string will raise an exception" + masked, masked_text, err_str = SecretDetector.mask_secrets(test_str) + assert masked + assert err_str == "Test exception" + assert masked_text == "Test exception" + + @staticmethod + def _format_with_masking_exception(): + test_str = "This string will raise an exception" + log_record = logging.LogRecord( + SecretDetector.__name__, + logging.DEBUG, + "test_secret_detector.py", + 45, + test_str, + [], + None, + ) + log_record.asctime = "2003-07-08 16:49:45,896" + sanitized_log = SecretDetector().format(log_record) + assert "Test exception" in sanitized_log + assert "secret_detector.py" in sanitized_log + assert "sanitize_log_str" in sanitized_log + assert test_str not in sanitized_log + + @mock.patch.object( + SecretDetector, + "mask_connection_token", + mock.Mock(side_effect=Exception("Test exception")), + ) + def test_exception_in_secret_detector_while_log_masking(self): + self._format_with_masking_exception() + @mock.patch.object( + SecretDetector, + "mask_secrets", + mock.Mock(side_effect=Exception("Test exception")), + ) + def test_exception_while_log_masking(self): + self._format_with_masking_exception() + + +class TestFormatter: + def test_format_sanitizes_log_record(self): + formatter = SecretDetector("%(message)s") + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="connecting with password=hunter2", + args=None, + exc_info=None, + ) + formatted = formatter.format(record) + assert "hunter2" not in formatted + assert SecretDetector.SECRET_STARRED_MASK_STR in formatted -def test_private_key_data_is_masked(): - text = '"privateKeyData": "aslkjdflasjf"' - filtered_text = '"privateKeyData": "XXXX"' - assert mask_secrets(text) == filtered_text - - -def test_session_token_wire_format_is_masked(): - """CONNECTION_TOKEN_PATTERN's value class includes ':' and '%' so a - version/hint-prefixed session token -- Snowflake's actual wire format -- - masks in full instead of stopping at the first ':'.""" - assert mask_secrets("token=ver:1-hint:1036-abcd1234efgh5678") == "token=****" - - -@pytest.mark.parametrize( - "text,expected", - [ - ('"access_token" : "some:FAKE_token123"', '"access_token":"XXXX"'), - ('"refresh_token" : "some:FAKE_token123"', '"refresh_token":"XXXX"'), - ], -) -def test_oauth_tokens_are_masked(text, expected): - assert mask_secrets(text) == expected - - -@pytest.mark.parametrize( - "text,expected", - [ - ("oauthClientSecret: aVeryLongSecretValue123", "oauthClientSecret: ****"), - ("clientSecret=anotherLongSecretValue456", "clientSecret=****"), - ], -) -def test_oauth_client_secrets_are_masked(text, expected): - assert mask_secrets(text) == expected - - -@pytest.mark.parametrize( - "text,expected", - [ - ("passcode: 123456", "passcode:****"), - ("otp=987654", "otp=****"), - ("pin = 4321", "pin=****"), - ], -) -def test_passcodes_are_masked(text, expected): - assert mask_secrets(text) == expected - - -def test_masking_failure_returns_static_sentinel_not_exception_text(): - """Masking failures must fail closed: the exception message (which may - itself reflect secret-containing input) is never returned -- only the - static sentinel.""" - with mock.patch.object( - _secret_detector, - "_mask_connection_token", - side_effect=Exception("Test exception, would leak: password=hunter2"), - ): - result = mask_secrets("some text") - assert result == "****" - assert "hunter2" not in result - assert "Test exception" not in result + def test_is_a_logging_formatter(self): + assert issubclass(SecretDetector, logging.Formatter) From 8dcd947d167a4819eae719b5eb59ea440d121c9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 17:51:59 +0000 Subject: [PATCH 5/5] SNOW-2912540: add missing license header to test_secret_detector.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre-commit insert-license failed Check linting from this PR through #4313. Co-authored-by: Filip Pawłowski --- tests/mock/test_secret_detector.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/mock/test_secret_detector.py b/tests/mock/test_secret_detector.py index de2bcde4bd..3c65d84a73 100644 --- a/tests/mock/test_secret_detector.py +++ b/tests/mock/test_secret_detector.py @@ -1,3 +1,6 @@ +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# """Behavioral tests for the ``SecretDetector`` shim, ported from the Universal Driver's backward-compat test suite (drivers#598), which itself ports the full legacy ``snowflake-connector-python`` masking-behavior suite