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

563 lines
19 KiB
Python

"""Comprehensive coverage tests for data_sanitizer and log_sanitizer modules.
Module 1 -- data_sanitizer:
DataSanitizer.sanitize, DataSanitizer.redact, sanitize_data, redact_data,
filter_research_metadata, strip_settings_snapshot
Module 2 -- log_sanitizer:
strip_control_chars, sanitize_for_log
"""
import json
import pytest
from local_deep_research.security.data_sanitizer import (
DataSanitizer,
filter_research_metadata,
redact_data,
sanitize_data,
strip_settings_snapshot,
)
from local_deep_research.security.log_sanitizer import (
sanitize_for_log,
strip_control_chars,
)
# ===================================================================
# DataSanitizer.sanitize
# ===================================================================
class TestSanitizeNestedDicts:
"""sanitize removes sensitive keys inside deeply nested dicts."""
def test_two_levels_deep(self):
data = {"outer": {"api_key": "abc", "safe": 1}}
result = DataSanitizer.sanitize(data)
assert result == {"outer": {"safe": 1}}
def test_three_levels_deep(self):
data = {"a": {"b": {"password": "pw", "x": 9}}}
result = DataSanitizer.sanitize(data)
assert result == {"a": {"b": {"x": 9}}}
def test_multiple_sensitive_keys_at_different_depths(self):
data = {
"secret": "top",
"level1": {
"refresh_token": "tok",
"level2": {"private_key": "pk", "ok": True},
},
}
result = DataSanitizer.sanitize(data)
assert result == {"level1": {"level2": {"ok": True}}}
class TestSanitizeLists:
"""sanitize recurses into lists and sanitizes dicts inside them."""
def test_list_of_dicts(self):
data = [{"api_key": "k", "id": 1}, {"password": "p", "id": 2}]
result = DataSanitizer.sanitize(data)
assert result == [{"id": 1}, {"id": 2}]
def test_empty_list(self):
assert DataSanitizer.sanitize([]) == []
def test_list_of_primitives_unchanged(self):
data = [1, "hello", None, True]
assert DataSanitizer.sanitize(data) == [1, "hello", None, True]
class TestSanitizeSensitiveKeyRemoval:
"""Verify all default sensitive keys are removed."""
@pytest.mark.parametrize(
"key", sorted(DataSanitizer.DEFAULT_SENSITIVE_KEYS)
)
def test_each_default_key_removed(self, key):
data = {key: "value", "keep_me": 42}
result = DataSanitizer.sanitize(data)
assert key not in result
assert result["keep_me"] == 42
class TestSanitizeCaseInsensitive:
"""sanitize matches keys case-insensitively."""
def test_uppercase_key(self):
data = {"API_KEY": "secret", "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "API_KEY" not in result
assert result == {"name": "ok"}
def test_mixed_case_key(self):
data = {"PaSsWoRd": "secret", "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "PaSsWoRd" not in result
def test_title_case_key(self):
data = {"Access_Token": "tok", "status": "active"}
result = DataSanitizer.sanitize(data)
assert "Access_Token" not in result
assert result == {"status": "active"}
class TestSanitizeJsonStringInput:
"""sanitize treats a JSON string as a primitive -- it is NOT parsed."""
def test_json_string_returned_as_is(self):
json_str = json.dumps({"api_key": "secret"})
result = DataSanitizer.sanitize(json_str)
assert result == json_str
def test_json_array_string_returned_as_is(self):
json_str = json.dumps([{"password": "pw"}])
assert DataSanitizer.sanitize(json_str) == json_str
class TestSanitizeCustomKeys:
"""sanitize accepts a custom set of sensitive keys."""
def test_custom_keys_only(self):
data = {"my_token": "t", "api_key": "k", "name": "n"}
result = DataSanitizer.sanitize(data, sensitive_keys={"my_token"})
assert "my_token" not in result
assert "api_key" in result # not in custom set
def test_empty_custom_keys_removes_nothing(self):
data = {"api_key": "k", "password": "p"}
result = DataSanitizer.sanitize(data, sensitive_keys=set())
assert result == data
# ===================================================================
# DataSanitizer.redact
# ===================================================================
class TestRedactBasic:
"""redact replaces sensitive values with placeholder text."""
def test_default_redaction_text(self):
data = {"api_key": "secret123", "name": "ok"}
result = DataSanitizer.redact(data)
assert result["api_key"] == "[REDACTED]"
assert result["name"] == "ok"
def test_key_preserved_value_replaced(self):
data = {"password": "mypass"}
result = DataSanitizer.redact(data)
assert "password" in result
assert result["password"] == "[REDACTED]"
class TestRedactCustomText:
"""redact accepts custom redaction placeholder text."""
def test_custom_redaction_text(self):
data = {"api_key": "secret", "name": "ok"}
result = DataSanitizer.redact(data, redaction_text="***")
assert result["api_key"] == "***"
assert result["name"] == "ok"
def test_empty_string_redaction_text(self):
data = {"secret": "val"}
result = DataSanitizer.redact(data, redaction_text="")
assert result["secret"] == ""
def test_long_redaction_text(self):
text = "SENSITIVE DATA REMOVED BY SECURITY FILTER"
data = {"password": "pw"}
result = DataSanitizer.redact(data, redaction_text=text)
assert result["password"] == text
class TestRedactNestedStructures:
"""redact recurses into nested dicts and lists."""
def test_nested_dict(self):
data = {"config": {"auth_token": "tok", "host": "localhost"}}
result = DataSanitizer.redact(data)
assert result["config"]["auth_token"] == "[REDACTED]"
assert result["config"]["host"] == "localhost"
def test_list_of_dicts(self):
data = [{"session_token": "s1"}, {"csrf_token": "c1"}]
result = DataSanitizer.redact(data)
assert result[0]["session_token"] == "[REDACTED]"
assert result[1]["csrf_token"] == "[REDACTED]"
def test_dict_in_list_in_dict(self):
data = {"items": [{"private_key": "pk", "id": 1}]}
result = DataSanitizer.redact(data)
assert result["items"][0]["private_key"] == "[REDACTED]"
assert result["items"][0]["id"] == 1
def test_original_data_not_mutated(self):
data = {"api_key": "original_value", "name": "ok"}
DataSanitizer.redact(data)
assert data["api_key"] == "original_value"
class TestRedactCaseInsensitive:
"""redact matches keys case-insensitively."""
def test_uppercase_key_redacted(self):
data = {"SECRET": "val"}
result = DataSanitizer.redact(data)
assert result["SECRET"] == "[REDACTED]"
def test_mixed_case_key_redacted(self):
data = {"Apikey": "val", "name": "ok"}
result = DataSanitizer.redact(data)
assert result["Apikey"] == "[REDACTED]"
assert result["name"] == "ok"
# ===================================================================
# sanitize_data wrapper
# ===================================================================
class TestSanitizeDataWrapper:
"""sanitize_data convenience function delegates to DataSanitizer.sanitize."""
def test_removes_default_sensitive_keys(self):
data = {"api_key": "k", "name": "n"}
result = sanitize_data(data)
assert result == {"name": "n"}
def test_accepts_custom_sensitive_keys(self):
data = {"custom_field": "v", "api_key": "k"}
result = sanitize_data(data, sensitive_keys={"custom_field"})
assert "custom_field" not in result
assert "api_key" in result
def test_primitive_passthrough(self):
assert sanitize_data(99) == 99
assert sanitize_data(None) is None
# ===================================================================
# redact_data wrapper
# ===================================================================
class TestRedactDataWrapper:
"""redact_data convenience function delegates to DataSanitizer.redact."""
def test_redacts_default_sensitive_keys(self):
data = {"password": "pw", "name": "n"}
result = redact_data(data)
assert result["password"] == "[REDACTED]"
assert result["name"] == "n"
def test_accepts_custom_keys_and_text(self):
data = {"my_key": "v", "password": "pw"}
result = redact_data(
data, sensitive_keys={"my_key"}, redaction_text="XXX"
)
assert result["my_key"] == "XXX"
assert result["password"] == "pw"
def test_primitive_passthrough(self):
assert redact_data("hello") == "hello"
assert redact_data(None) is None
# ===================================================================
# filter_research_metadata
# ===================================================================
class TestFilterResearchMetadata:
"""filter_research_metadata extracts only safe allowlisted fields."""
def test_extracts_is_news_search_true(self):
result = filter_research_metadata(
{"is_news_search": True, "extra": "x"}
)
assert result == {"is_news_search": True}
assert "extra" not in result
def test_extracts_is_news_search_false(self):
result = filter_research_metadata({"is_news_search": False})
assert result == {"is_news_search": False}
def test_missing_is_news_search_defaults_false(self):
result = filter_research_metadata({"some_other_key": 123})
assert result == {"is_news_search": False}
def test_none_input(self):
result = filter_research_metadata(None)
assert result == {"is_news_search": False}
def test_json_string_input_dict(self):
meta_json = json.dumps(
{"is_news_search": True, "settings_snapshot": {}}
)
result = filter_research_metadata(meta_json)
assert result == {"is_news_search": True}
assert "settings_snapshot" not in result
def test_json_string_input_false(self):
meta_json = json.dumps({"is_news_search": False})
result = filter_research_metadata(meta_json)
assert result == {"is_news_search": False}
def test_invalid_json_string(self):
result = filter_research_metadata("{bad json!!!")
assert result == {"is_news_search": False}
def test_integer_input_returns_default(self):
result = filter_research_metadata(42)
assert result == {"is_news_search": False}
def test_is_news_search_truthy_int_coerced(self):
"""Non-zero int is truthy, coerced to True via bool()."""
result = filter_research_metadata({"is_news_search": 1})
assert result["is_news_search"] is True
assert type(result["is_news_search"]) is bool
def test_settings_snapshot_never_leaks(self):
meta = {
"is_news_search": False,
"settings_snapshot": {"api_key": "SUPER_SECRET"},
}
result = filter_research_metadata(meta)
assert "settings_snapshot" not in result
# ===================================================================
# strip_settings_snapshot
# ===================================================================
class TestStripSettingsSnapshot:
"""strip_settings_snapshot removes settings_snapshot, preserves the rest."""
def test_removes_settings_snapshot(self):
meta = {"phase": "done", "settings_snapshot": {"api_key": "k"}}
result = strip_settings_snapshot(meta)
assert "settings_snapshot" not in result
assert result["phase"] == "done"
def test_preserves_all_other_keys(self):
meta = {
"mode": "quick",
"duration": 5.2,
"is_news_search": True,
"settings_snapshot": {},
}
result = strip_settings_snapshot(meta)
assert result == {
"mode": "quick",
"duration": 5.2,
"is_news_search": True,
}
def test_none_input(self):
result = strip_settings_snapshot(None)
assert result == {}
def test_string_metadata_json(self):
meta_json = json.dumps(
{"phase": "complete", "settings_snapshot": {"k": "v"}}
)
result = strip_settings_snapshot(meta_json)
assert result == {"phase": "complete"}
assert "settings_snapshot" not in result
def test_no_settings_snapshot_key_noop(self):
meta = {"phase": "done", "duration": 3.0}
result = strip_settings_snapshot(meta)
assert result == meta
def test_invalid_json_string_returns_empty(self):
result = strip_settings_snapshot("not valid json {{{")
assert result == {}
def test_non_dict_type_returns_empty(self):
assert strip_settings_snapshot(123) == {}
assert strip_settings_snapshot([1, 2]) == {}
# ===================================================================
# strip_control_chars (log_sanitizer)
# ===================================================================
class TestStripControlCharsComprehensive:
"""strip_control_chars removes C0/C1 control, zero-width, and format chars."""
def test_c0_null_and_bell(self):
assert strip_control_chars("a\x00b\x07c") == "abc"
def test_c0_tab_newline_carriage_return(self):
assert strip_control_chars("a\tb\nc\rd") == "abcd"
def test_c1_range(self):
"""C1 control characters (0x80-0x9F) are stripped."""
assert strip_control_chars("a\x80b\x8fc\x9fd") == "abcd"
def test_del_character(self):
"""DEL (0x7F) is stripped."""
assert strip_control_chars("hello\x7fworld") == "helloworld"
def test_zero_width_space(self):
assert strip_control_chars("hello\u200bworld") == "helloworld"
def test_zero_width_non_joiner(self):
assert strip_control_chars("a\u200cb") == "ab"
def test_zero_width_joiner(self):
assert strip_control_chars("a\u200db") == "ab"
def test_ltr_rtl_marks(self):
assert strip_control_chars("a\u200eb\u200fc") == "abc"
def test_unicode_format_chars_rlo(self):
"""Right-to-left override (RLO) is stripped."""
assert strip_control_chars("hello\u202eworld") == "helloworld"
def test_unicode_embedding_chars(self):
"""LRE, RLE, PDF, LRO are stripped."""
assert strip_control_chars("\u202a\u202b\u202c\u202dtext") == "text"
def test_bom_stripped(self):
assert strip_control_chars("\ufeffhello") == "hello"
def test_word_joiner_stripped(self):
assert strip_control_chars("a\u2060b") == "ab"
def test_arabic_letter_mark_stripped(self):
assert strip_control_chars("a\u061cb") == "ab"
def test_isolate_chars_stripped(self):
"""LRI, RLI, FSI, PDI are stripped."""
assert strip_control_chars("\u2066\u2067\u2068\u2069text") == "text"
def test_preserves_valid_unicode(self):
assert (
strip_control_chars("cafe\u0301") == "cafe\u0301"
) # e-acute combining
def test_preserves_emoji(self):
result = strip_control_chars("hello world")
assert result == "hello world"
def test_preserves_cjk(self):
assert strip_control_chars("test") == "test"
def test_empty_string(self):
assert strip_control_chars("") == ""
def test_all_control_chars_yields_empty(self):
assert strip_control_chars("\x00\x01\x02\x7f\x80") == ""
# ===================================================================
# sanitize_for_log (log_sanitizer)
# ===================================================================
class TestSanitizeForLogTruncation:
"""sanitize_for_log truncates with '...' suffix when exceeding max_length."""
def test_long_string_truncated_with_ellipsis(self):
result = sanitize_for_log("a" * 100, max_length=50)
assert result == "a" * 47 + "..."
assert len(result) == 50
def test_default_max_length_50(self):
result = sanitize_for_log("b" * 200)
assert len(result) == 50
assert result.endswith("...")
def test_exact_max_length_no_truncation(self):
result = sanitize_for_log("c" * 50, max_length=50)
assert result == "c" * 50
assert "..." not in result
def test_shorter_than_max_no_truncation(self):
result = sanitize_for_log("short", max_length=50)
assert result == "short"
class TestSanitizeForLogMaxLengthEdgeCases:
"""Edge-case max_length values: 0, 1, 3, 50."""
def test_max_length_zero(self):
"""max_length=0 returns empty string (no room for anything)."""
result = sanitize_for_log("hello", max_length=0)
assert result == ""
def test_max_length_one(self):
"""max_length=1 -- too short for '...' so just truncates."""
result = sanitize_for_log("hello", max_length=1)
assert len(result) <= 1
assert result == "h"
def test_max_length_three(self):
"""max_length=3 -- exactly the length of '...', uses raw truncation."""
result = sanitize_for_log("hello", max_length=3)
assert len(result) <= 3
assert result == "hel"
def test_max_length_four_uses_ellipsis(self):
"""max_length=4 -- enough room for 1 char + '...'."""
result = sanitize_for_log("hello world", max_length=4)
assert result == "h..."
assert len(result) == 4
def test_max_length_50_default(self):
result = sanitize_for_log("x" * 60, max_length=50)
assert len(result) == 50
assert result == "x" * 47 + "..."
class TestSanitizeForLogEmptyString:
"""sanitize_for_log on empty input."""
def test_empty_string_returns_empty(self):
assert sanitize_for_log("") == ""
def test_empty_string_with_explicit_max_length(self):
assert sanitize_for_log("", max_length=10) == ""
class TestSanitizeForLogControlCharsAndTruncation:
"""Control chars are stripped BEFORE truncation is applied."""
def test_control_chars_removed_then_truncated(self):
# 10 visible chars + control chars = still only 10 visible after strip
raw = "abcde\x00\x01\x02fghij"
result = sanitize_for_log(raw, max_length=50)
assert result == "abcdefghij"
def test_control_chars_reduce_length_below_max(self):
"""After stripping, string may become shorter than max_length."""
raw = "\x00\x01\x02abc"
result = sanitize_for_log(raw, max_length=50)
assert result == "abc"
def test_control_chars_stripped_still_needs_truncation(self):
"""Even after stripping, result may still exceed max_length."""
raw = "a" * 40 + "\x00" * 5 + "b" * 40
result = sanitize_for_log(raw, max_length=50)
# After strip: 80 visible chars, needs truncation to 50
assert len(result) == 50
assert result.endswith("...")
def test_all_control_chars_yields_empty(self):
result = sanitize_for_log("\x00\x01\x7f", max_length=50)
assert result == ""
def test_unicode_preserved_controls_stripped(self):
raw = "cafe\u0301\x00\x07 world"
result = sanitize_for_log(raw, max_length=50)
assert result == "cafe\u0301 world"