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
398 lines
16 KiB
Python
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
|