mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 20:27:13 +00:00
fix(cntfilter): allow legacy sensitive-word lists over 64 patterns (#2467)
* fix(cntfilter): allow legacy sensitive-word lists over 64 patterns Legacy sensitive-words.json files shipped ~70 rules. After v4.10.7, BanWordFilter treated the 64-pattern safe_regex cap as a hard failure and blocked every message. Raise the cap only on the sensitive-word path, keep the 50ms CPU budget, and truncate oversized lists with a one-time warning. Fixes #2443 * fix(cntfilter): reject oversized sensitive-word lists --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""BanWordFilter regression tests for legacy sensitive-word lists.
|
||||
|
||||
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
|
||||
the previous default list (~70 patterns). The filter must keep applying those
|
||||
rules instead of blocking every message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories import FakeApp
|
||||
|
||||
|
||||
def _load_banwords():
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
|
||||
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||
safe_regex = import_module('langbot.pkg.utils.safe_regex')
|
||||
return banwords, entities, safe_regex
|
||||
|
||||
|
||||
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
|
||||
banwords, entities, _ = _load_banwords()
|
||||
app = FakeApp()
|
||||
app.sensitive_meta = Mock()
|
||||
app.sensitive_meta.data = {
|
||||
'words': words,
|
||||
'mask': mask,
|
||||
'mask_word': mask_word,
|
||||
}
|
||||
return banwords.BanWordFilter(app), entities, app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
|
||||
"""A pre-v4.10.7 word list must not fail closed on every message."""
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
|
||||
filt, entities, _ = _filter_with_words(words)
|
||||
|
||||
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||
|
||||
assert result.level == entities.ResultLevel.PASS
|
||||
assert result.replacement == 'hello there, nothing banned'
|
||||
assert result.user_notice == ''
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
|
||||
"""Words past the first 64-pattern batch must still be applied."""
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'please hide secret-token now')
|
||||
|
||||
assert result.level == entities.ResultLevel.MASKED
|
||||
assert 'secret-token' not in result.replacement
|
||||
assert '[hidden]' in result.replacement
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_masks_match_in_first_batch():
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'alpha-secret is here')
|
||||
|
||||
assert result.level == entities.ResultLevel.MASKED
|
||||
assert result.replacement == '[hidden] is here'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_sensitive_word_regex_still_blocks():
|
||||
filt, entities, _ = _filter_with_words(['(unclosed'])
|
||||
|
||||
result = await filt.process(Mock(), 'any message')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_word_list_is_blocked():
|
||||
"""Configured rules must never be silently skipped when the list is oversized."""
|
||||
banwords, _, _ = _load_banwords()
|
||||
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
|
||||
filt, entities, _ = _filter_with_words(words)
|
||||
|
||||
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.replacement == ''
|
||||
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_beyond_total_cap_cannot_bypass_filter():
|
||||
banwords, _, _ = _load_banwords()
|
||||
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'please hide late-secret now')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.replacement == ''
|
||||
@@ -53,6 +53,45 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mask_patterns_honors_explicit_pattern_count_cap():
|
||||
patterns = ['a'] * (safe_regex.MAX_PATTERN_COUNT + 6)
|
||||
found, masked = await safe_regex.mask_patterns(
|
||||
patterns,
|
||||
'hello',
|
||||
mask='*',
|
||||
mask_word='',
|
||||
max_pattern_count=len(patterns),
|
||||
)
|
||||
assert found is False
|
||||
assert masked == 'hello'
|
||||
|
||||
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||
await safe_regex.mask_patterns(
|
||||
patterns,
|
||||
'hello',
|
||||
mask='*',
|
||||
mask_word='',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mask_patterns_rejects_oversized_sequence_before_copying_it():
|
||||
class OversizedPatterns(list):
|
||||
def __iter__(self):
|
||||
raise AssertionError('oversized patterns must not be materialized')
|
||||
|
||||
patterns = OversizedPatterns(['a'] * (safe_regex.MAX_PATTERN_COUNT + 1))
|
||||
|
||||
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||
await safe_regex.mask_patterns(
|
||||
patterns,
|
||||
'hello',
|
||||
mask='*',
|
||||
mask_word='',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||
found, masked = await safe_regex.mask_patterns(
|
||||
|
||||
Reference in New Issue
Block a user