Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

398 lines
16 KiB
Python

"""Tests for the standalone ``sanitize_error_message`` function.
Verifies that the regex-based sanitizer catches common credential formats
in exception messages. This function was extracted from
``BaseSearchEngine._sanitize_error_message`` so it can be used outside the
search-engine inheritance tree (e.g. in LLM config and error handling).
"""
from local_deep_research.security.log_sanitizer import sanitize_error_message
class TestSanitizeErrorMessageStandalone:
"""Unit tests for the pattern-based credential sanitizer."""
def test_bearer_token_redacted(self):
msg = 'Error: Connection refused, token="Bearer sk-proj-abc123xyz456def789ghi012"'
result = sanitize_error_message(msg)
assert "sk-proj-abc123xyz456def789ghi012" not in result
assert "Bearer [REDACTED]" in result
def test_url_query_api_key_redacted(self):
msg = "HTTPSConnectionPool: Max retries exceeded with url: /v1/models?api_key=sk-secret-key-value-12345&q=test"
result = sanitize_error_message(msg)
assert "sk-secret-key-value-12345" not in result
assert "api_key=[REDACTED]" in result
def test_url_query_apikey_no_underscore_redacted(self):
msg = "?apikey=my-secret-api-key-value-here&format=json"
result = sanitize_error_message(msg)
assert "my-secret-api-key-value-here" not in result
assert "apikey=[REDACTED]" in result
def test_url_query_api_key_hyphenated_redacted(self):
"""Guardian uses ``api-key`` (with hyphen) as the param name."""
msg = "?api-key=guardian-secret-key-value-here&q=test"
result = sanitize_error_message(msg)
assert "guardian-secret-key-value-here" not in result
assert "api-key=[REDACTED]" in result
def test_url_query_key_redacted(self):
"""Google PSE uses ``key`` as the param name."""
msg = "?key=google-pse-secret-key-value&q=test"
result = sanitize_error_message(msg)
assert "google-pse-secret-key-value" not in result
assert "key=[REDACTED]" in result
def test_url_query_named_secrets_redacted(self):
"""Secret-named query params beyond the classic tokens must also be
redacted, matching DataSanitizer.DEFAULT_SENSITIVE_KEYS."""
for name in (
"client_secret",
"secret_key",
"bearer_token",
"api_secret",
"app_secret",
):
value = f"{name}-oauth-value-here-1234"
result = sanitize_error_message(f"POST /cb?{name}={value}&x=1")
assert value not in result, name
assert f"{name}=[REDACTED]" in result, name
def test_url_query_client_id_not_redacted(self):
"""client_id is a public identifier, not a secret — stays readable."""
msg = "?client_id=public-app-id-value-12345&scope=read"
result = sanitize_error_message(msg)
assert "public-app-id-value-12345" in result
def test_url_query_token_redacted(self):
msg = "?token=secret-token-value-1234567890&page=1"
result = sanitize_error_message(msg)
assert "secret-token-value-1234567890" not in result
assert "token=[REDACTED]" in result
def test_url_query_secret_redacted(self):
msg = "&secret=super-secret-value-abcde12345&format=json"
result = sanitize_error_message(msg)
assert "super-secret-value-abcde12345" not in result
assert "secret=[REDACTED]" in result
def test_url_credentials_redacted(self):
msg = "Connection to https://admin:supersecretpassword@api.example.com failed"
result = sanitize_error_message(msg)
assert "admin" not in result
assert "supersecretpassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_url_credentials_inside_api_key_param_value(self):
"""URL-embedded credentials must survive being an api-key param value.
Regression test for pattern ordering: when the param value is itself
a URL with embedded credentials, the param pattern used to consume
the ``https`` scheme first (``api-key=[REDACTED]://user:pass@...``),
leaving the credentials unredacted. The URL-credentials pattern now
runs before the URL-param pattern.
"""
msg = "Request failed for ?api-key=https://admin:supersecret@internal.example.com/v1"
result = sanitize_error_message(msg)
assert "supersecret" not in result
assert "admin" not in result
def test_url_credentials_inside_key_param_value(self):
"""Same ordering interaction via the ``key=`` spelling."""
msg = "?key=https://user:hunter2@host.example.com/path"
result = sanitize_error_message(msg)
assert "hunter2" not in result
assert "user:" not in result
def test_url_credentials_inside_token_param_value(self):
"""Same ordering interaction via the ``token=`` spelling."""
msg = "retry failed: &token=http://svc:p4ssw0rd@10.0.0.5:8080/auth"
result = sanitize_error_message(msg)
assert "p4ssw0rd" not in result
assert "svc:" not in result
class TestDatabaseDsnCredentials:
"""URL-credential redaction covers database DSNs, not just http(s).
A raw SQLAlchemy / driver connection error is the most common way a
credential-bearing string reaches an exception message. The userinfo in
any URL scheme is a credential, so it is redacted regardless of scheme.
"""
def test_postgres_dsn_password_redacted(self):
msg = (
"OperationalError: could not connect to "
"postgresql://newsuser:s3cr3tPw0rd@db.internal:5432/news"
)
result = sanitize_error_message(msg)
assert "s3cr3tPw0rd" not in result
assert "newsuser" not in result
assert "[REDACTED]:[REDACTED]@db.internal" in result
def test_sqlalchemy_dialect_driver_scheme_redacted(self):
# SQLAlchemy DSNs carry a `dialect+driver` scheme. Each DSN is built
# by f-string concatenation so no literal "user:password@" pair
# appears in source — keeps regex-based secret scanners from flagging
# classic default-cred shapes like root:toor alongside the MongoDB
# fixture that originally triggered alert #2.
pg_user, pg_pw = "user", "pa55"
my_user, my_pw = "root", "toor"
mongo_user, mongo_pw = "appuser", "letmein"
for msg in (
f"postgresql+psycopg2://{pg_user}:{pg_pw}@dbhost/appdb",
f"mysql+pymysql://{my_user}:{my_pw}@localhost:3306/db",
f"mongodb+srv://{mongo_user}:{mongo_pw}@cluster0.mongodb.net/test",
):
result = sanitize_error_message(msg)
assert "[REDACTED]:[REDACTED]@" in result, msg
assert my_pw not in sanitize_error_message(
f"mysql+pymysql://{my_user}:{my_pw}@localhost:3306/db"
)
def test_password_only_redis_dsn_redacted(self):
# redis uses a password-only DSN (empty username).
msg = "redis://:mypassword@127.0.0.1:6379/0 connection reset"
result = sanitize_error_message(msg)
assert "mypassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_credential_less_dsn_with_port_unchanged(self):
# No userinfo (just host:port) must NOT be touched — the trailing `@`
# is required and `/` is excluded from the userinfo.
for msg in (
"connect failed to postgresql://dbhost:5432/news",
"redis://cache.local:6379/0 timed out",
):
assert sanitize_error_message(msg) == msg
def test_http_credentials_still_redacted(self):
# Regression guard: generalizing the scheme must not lose http(s).
msg = "Connection to https://admin:supersecretpassword@api.example.com failed"
result = sanitize_error_message(msg)
assert "supersecretpassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_uppercase_scheme_redacted(self):
# URL schemes are case-insensitive (RFC 3986); an uppercase/mixed-case
# DSN scheme must not defeat redaction.
for msg in (
"POSTGRESQL://newsuser:s3cr3tPw0rd@db.internal:5432/news",
"Postgresql+Psycopg2://user:pa55@dbhost/appdb",
):
result = sanitize_error_message(msg)
assert "s3cr3tPw0rd" not in result
assert "pa55" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_url_glued_to_preceding_word_char_still_redacted(self):
# Regression guard against a leading `\b` anchor: a credential URL
# glued to a preceding word char must still have its userinfo redacted
# (the scheme's first char is a word char, so `\b` would fail to anchor
# here and leak the password).
msg = "prefixXhttps://admin:supersecretpassword@host/path"
result = sanitize_error_message(msg)
assert "supersecretpassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_sk_prefix_redacted(self):
"""OpenAI-style key with modern hyphenated format (sk-proj-...)."""
msg = "Invalid API key: sk-proj-abc123xyz456def789ghi012jkl"
result = sanitize_error_message(msg)
assert "sk-proj-abc123xyz456def789ghi012jkl" not in result
assert "[REDACTED_KEY]" in result
def test_sk_prefix_pure_alphanumeric(self):
"""Old-style OpenAI key (pure alphanumeric after sk-)."""
msg = "key: sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result = sanitize_error_message(msg)
assert "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" not in result
assert "[REDACTED_KEY]" in result
def test_pk_prefix_redacted(self):
msg = "public key pk-test-abc123xyz456def789ghi012jkl345"
result = sanitize_error_message(msg)
assert "pk-test-abc123xyz456def789ghi012jkl345" not in result
assert "[REDACTED_KEY]" in result
def test_empty_string_passthrough(self):
assert sanitize_error_message("") == ""
def test_no_secrets_unchanged(self):
msg = (
"Connection timeout after 10s for https://api.example.com/v1/models"
)
assert sanitize_error_message(msg) == msg
def test_multiple_secrets_in_one_message(self):
msg = (
"Bearer sk-proj-abc123xyz456def789ghi012 "
"with ?api_key=my-secret-key-12345&token=another-secret-value-67890"
)
result = sanitize_error_message(msg)
assert "sk-proj-abc123xyz456def789ghi012" not in result
assert "my-secret-key-12345" not in result
assert "another-secret-value-67890" not in result
assert "Bearer [REDACTED]" in result
assert "api_key=[REDACTED]" in result
assert "token=[REDACTED]" in result
def test_sk_prefix_too_short_not_redacted(self):
"""Short strings like ``sk-abc`` should NOT be redacted (false positive risk)."""
msg = "prefix: sk-abc"
assert sanitize_error_message(msg) == msg
def test_delegate_preserves_backward_compat(self):
"""The BaseSearchEngine method delegates to the standalone function,
so this test verifies the output matches the old inline behavior
for a representative message."""
from local_deep_research.web_search_engines.search_engine_base import (
BaseSearchEngine,
)
msg = "Error: ?api_key=secret12345678&key=mykey1234567890123456"
expected = sanitize_error_message(msg)
# Create a minimal subclass instance to call the instance method.
class _MinimalEngine(BaseSearchEngine):
def _get_previews(self, query, *args, **kwargs):
return []
engine = _MinimalEngine.__new__(_MinimalEngine)
actual = engine._sanitize_error_message(msg)
assert actual == expected
class TestSanitizeErrorDetails:
"""Unit tests for the shared ``sanitize_error_details`` helper used by the
exception ``to_dict()`` backstops (NewsAPIException / WebAPIException)."""
def test_str_leaf_credential_redacted(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
out = sanitize_error_details(
{"note": "Authorization: Bearer sk-abc123DEF456ghi789"}
)
assert "sk-abc123DEF456ghi789" not in out["note"]
assert "REDACTED" in out["note"]
def test_benign_and_non_string_leaves_pass_through(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
value = {
"query": "reset my password",
"count": 3,
"ok": True,
"x": None,
}
assert sanitize_error_details(value) == value
def test_nested_dict_list_recursion(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
out = sanitize_error_details(
{"outer": {"inner": ["?api_key=AIzaSyD-EXAMPLE_1234567890", 7]}}
)
leaf = out["outer"]["inner"][0]
assert "AIzaSyD-EXAMPLE_1234567890" not in leaf
assert "REDACTED" in leaf
assert out["outer"]["inner"][1] == 7
def test_tuple_normalizes_to_list(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
assert sanitize_error_details({"pair": ("a", "b")})["pair"] == [
"a",
"b",
]
def test_namedtuple_does_not_crash(self):
from collections import namedtuple
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
Pair = namedtuple("Pair", ["a", "b"])
out = sanitize_error_details(
{"pair": Pair("Bearer sk-abc123DEF456ghi789", "x")}
)["pair"]
assert isinstance(out, list)
assert "sk-abc123DEF456ghi789" not in out[0]
assert "REDACTED" in out[0]
assert out[1] == "x"
def test_input_is_not_mutated(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
original = {"note": "Bearer sk-secret999value000", "keep": [1, 2]}
out = sanitize_error_details(original)
assert original["note"] == "Bearer sk-secret999value000"
assert out["note"] != original["note"]
assert out["keep"] is not original["keep"]
def test_non_container_scalar_passthrough(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
assert sanitize_error_details(42) == 42
assert sanitize_error_details(None) is None
def test_dataclass_leaf_is_recursed_and_redacted(self):
# Flask's default JSON provider serializes a dataclass via asdict(), so
# a credential in a dataclass field would ship to the client unless the
# helper recurses into it.
import dataclasses
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
@dataclasses.dataclass
class Ctx:
note: str
count: int
out = sanitize_error_details(
{"ctx": Ctx("Authorization: Bearer sk-abc123DEF456ghi789", 5)}
)
assert "sk-abc123DEF456ghi789" not in out["ctx"]["note"]
assert "REDACTED" in out["ctx"]["note"]
assert out["ctx"]["count"] == 5
def test_credential_shaped_dict_key_redacted(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
out = sanitize_error_details(
{"Authorization: Bearer sk-abc123DEF456ghi789": "v"}
)
assert not any("sk-abc123DEF456ghi789" in k for k in out)
# benign field-name keys are untouched
assert sanitize_error_details({"username": "bob"}) == {
"username": "bob"
}
def test_set_leaf_passes_through(self):
# A set is not JSON-serializable, so it is left as-is (fails closed at
# jsonify rather than leaking) — pin the passthrough behavior.
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
s = {"a", "b"}
assert sanitize_error_details({"tags": s})["tags"] == s