216 lines
8.3 KiB
Python
216 lines
8.3 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""DLP 敏感数据拦截引擎(dlp)单元测试:规则匹配 / 出站拦截 / 审计 / 模板加载。"""
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import unittest
|
|||
|
|
|
|||
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||
|
|
import _bootstrap # noqa: F401
|
|||
|
|
|
|||
|
|
from llm_gateway.dlp import ( # noqa: E402
|
|||
|
|
DLP_DEFAULT_RULES,
|
|||
|
|
DlpEngine,
|
|||
|
|
DlpRule,
|
|||
|
|
DlpRuleKind,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
CONFIG_PATH = os.path.join(
|
|||
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|||
|
|
"config", "dlp.template.yaml",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BuiltinRulesTest(unittest.TestCase):
|
|||
|
|
"""内置保底规则(无任何配置时通用 PII 也默认拦截)。"""
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
self.engine = DlpEngine()
|
|||
|
|
|
|||
|
|
def test_default_rules_present(self):
|
|||
|
|
self.assertGreater(len(DLP_DEFAULT_RULES), 0)
|
|||
|
|
self.assertGreaterEqual(self.engine.rule_count, len(DLP_DEFAULT_RULES))
|
|||
|
|
|
|||
|
|
def test_id_card_blocked(self):
|
|||
|
|
result = self.engine.check_outbound({"query": "员工身份证 110101199003071234 入职"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertEqual(result.reason, "hit")
|
|||
|
|
self.assertTrue(any(h.category == "pii" for h in result.hits))
|
|||
|
|
|
|||
|
|
def test_mobile_blocked(self):
|
|||
|
|
result = self.engine.check_outbound({"query": "联系 13812345678"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertIn("pii_mobile", {h.rule for h in result.hits})
|
|||
|
|
|
|||
|
|
def test_cloud_ak_blocked(self):
|
|||
|
|
result = self.engine.check_outbound({"output": "ak = AKIAIOSFODNN7EXAMPLE"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertIn("credential_ak", {h.rule for h in result.hits})
|
|||
|
|
|
|||
|
|
def test_clean_content_allowed(self):
|
|||
|
|
result = self.engine.check_outbound({
|
|||
|
|
"query": "今天炉况怎么样",
|
|||
|
|
"context": "炉温控制在 850-950°C 范围内。",
|
|||
|
|
"output": "当前运行平稳。",
|
|||
|
|
})
|
|||
|
|
self.assertFalse(result.blocked)
|
|||
|
|
self.assertEqual(result.decision, "allow")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class KeywordRuleTest(unittest.TestCase):
|
|||
|
|
def test_cn_keyword_hit(self):
|
|||
|
|
engine = DlpEngine(rules=[
|
|||
|
|
DlpRule(name="proc_formula", category="process-keyword",
|
|||
|
|
kind=DlpRuleKind.KEYWORD, pattern="配方"),
|
|||
|
|
])
|
|||
|
|
result = engine.check_outbound({"output": "已按配方调整投料"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertIn("proc_formula", {h.rule for h in result.hits})
|
|||
|
|
hit = next(h for h in result.hits if h.rule == "proc_formula")
|
|||
|
|
self.assertEqual(hit.matched, "配方")
|
|||
|
|
|
|||
|
|
def test_en_keyword_case_insensitive(self):
|
|||
|
|
engine = DlpEngine(rules=[
|
|||
|
|
DlpRule(name="secret_word", category="credential",
|
|||
|
|
kind=DlpRuleKind.KEYWORD, pattern="SecretKey"),
|
|||
|
|
])
|
|||
|
|
self.assertTrue(engine.check_outbound({"output": "the secretkey is hidden"}).blocked)
|
|||
|
|
self.assertTrue(engine.check_outbound({"output": "the SECRETKEY is hidden"}).blocked)
|
|||
|
|
|
|||
|
|
def test_no_match_allowed(self):
|
|||
|
|
engine = DlpEngine(rules=[
|
|||
|
|
DlpRule(name="proc_formula", category="process-keyword",
|
|||
|
|
kind=DlpRuleKind.KEYWORD, pattern="配方"),
|
|||
|
|
])
|
|||
|
|
result = engine.check_outbound({"output": "炉温正常"})
|
|||
|
|
self.assertFalse(result.blocked)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RegexRuleTest(unittest.TestCase):
|
|||
|
|
def test_structured_formula_ratio(self):
|
|||
|
|
engine = DlpEngine(rules=[
|
|||
|
|
DlpRule(name="proc_ratio", category="process-parameter",
|
|||
|
|
kind=DlpRuleKind.REGEX, pattern=r"配比\s*[::]?\s*[\d.]+%?"),
|
|||
|
|
])
|
|||
|
|
result = engine.check_outbound({"output": "当前配比:0.35 保持不变"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertEqual(result.hits[0].matched, "配比:0.35")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class OutboundCheckTest(unittest.TestCase):
|
|||
|
|
"""出站检查:多 part 聚合 / 命中定位 / 拦截原因。"""
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
self.engine = DlpEngine()
|
|||
|
|
|
|||
|
|
def test_hit_in_context_blocks_whole_packet(self):
|
|||
|
|
result = self.engine.check_outbound({
|
|||
|
|
"query": "请问这个员工怎么样",
|
|||
|
|
"context": "档案:13900000000",
|
|||
|
|
"output": "该员工表现良好。",
|
|||
|
|
})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
# 命中定位在 context part
|
|||
|
|
self.assertEqual(result.hits[0].part, "context")
|
|||
|
|
|
|||
|
|
def test_empty_parts_allowed(self):
|
|||
|
|
self.assertFalse(self.engine.check_outbound({}).blocked)
|
|||
|
|
self.assertFalse(self.engine.check_outbound({"query": ""}).blocked)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FailClosedTest(unittest.TestCase):
|
|||
|
|
def test_no_rules_fail_closed_blocks(self):
|
|||
|
|
engine = DlpEngine(rules=[], include_builtins=False, fail_closed=True)
|
|||
|
|
result = engine.check_outbound({"query": "任意内容"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertEqual(result.reason, "no_rules")
|
|||
|
|
|
|||
|
|
def test_fail_open_without_rules_allows(self):
|
|||
|
|
engine = DlpEngine(rules=[], include_builtins=False, fail_closed=False)
|
|||
|
|
self.assertFalse(engine.check_outbound({"query": "任意内容"}).blocked)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MaskTest(unittest.TestCase):
|
|||
|
|
def test_mask_redacts_hits(self):
|
|||
|
|
engine = DlpEngine()
|
|||
|
|
masked = engine.mask("联系 13812345678 或 13900000000")
|
|||
|
|
self.assertNotIn("13812345678", masked)
|
|||
|
|
self.assertNotIn("13900000000", masked)
|
|||
|
|
self.assertIn("<pii>", masked)
|
|||
|
|
|
|||
|
|
def test_mask_clean_text_unchanged(self):
|
|||
|
|
engine = DlpEngine()
|
|||
|
|
text = "炉温正常"
|
|||
|
|
self.assertEqual(engine.mask(text), text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AuditTest(unittest.TestCase):
|
|||
|
|
def test_audit_record_fields(self):
|
|||
|
|
engine = DlpEngine()
|
|||
|
|
result = engine.check_outbound({"query": "身份证 110101199003071234"})
|
|||
|
|
audit = result.audit
|
|||
|
|
self.assertEqual(audit["channel"], "outbound")
|
|||
|
|
self.assertEqual(audit["decision"], "block")
|
|||
|
|
self.assertEqual(audit["reason"], "hit")
|
|||
|
|
self.assertGreaterEqual(audit["hit_count"], 1)
|
|||
|
|
# 审计不落明文敏感内容,只记脱敏占位
|
|||
|
|
serialized = str(audit)
|
|||
|
|
self.assertNotIn("110101199003071234", serialized)
|
|||
|
|
self.assertIn("<pii>", serialized)
|
|||
|
|
|
|||
|
|
def test_audit_buffer_and_hook(self):
|
|||
|
|
seen = []
|
|||
|
|
engine = DlpEngine(audit_hook=seen.append)
|
|||
|
|
engine.check_outbound({"query": "13812345678"})
|
|||
|
|
engine.check_outbound({"query": "炉温正常"})
|
|||
|
|
self.assertEqual(len(seen), 2)
|
|||
|
|
self.assertEqual(len(engine.drain_audit()), 2)
|
|||
|
|
self.assertEqual(engine.drain_audit(), [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TemplateConfigTest(unittest.TestCase):
|
|||
|
|
"""从真实模板资产(config/dlp.template.yaml)加载。"""
|
|||
|
|
|
|||
|
|
def test_load_example_assets(self):
|
|||
|
|
engine = DlpEngine.from_template_config(CONFIG_PATH)
|
|||
|
|
self.assertEqual(engine.template, "ti-cl4")
|
|||
|
|
self.assertEqual(engine.version, "1.0.0")
|
|||
|
|
self.assertTrue(engine.fail_closed)
|
|||
|
|
# 模板规则 + 内置保底规则
|
|||
|
|
names = set(engine.rule_names())
|
|||
|
|
self.assertIn("proc_cl2_flow", names)
|
|||
|
|
self.assertIn("proc_furnace_temp", names)
|
|||
|
|
self.assertIn("pii_mobile", names) # 内置保底仍在
|
|||
|
|
|
|||
|
|
def test_template_industry_keyword_blocks(self):
|
|||
|
|
engine = DlpEngine.from_template_config(CONFIG_PATH)
|
|||
|
|
result = engine.check_outbound({"output": "今日氯气流量为 5.2 t/h"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertIn("proc_cl2_flow", {h.rule for h in result.hits})
|
|||
|
|
|
|||
|
|
def test_template_regex_blocks(self):
|
|||
|
|
engine = DlpEngine.from_template_config(CONFIG_PATH)
|
|||
|
|
result = engine.check_outbound({"output": "配比:0.42 已确认"})
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
self.assertIn("proc_formula_detail", {h.rule for h in result.hits})
|
|||
|
|
|
|||
|
|
def test_template_rule_overrides_builtin(self):
|
|||
|
|
# 同名规则(pii_mobile)在模板中自定义 pattern 后覆盖内置
|
|||
|
|
engine = DlpEngine.from_template_config(CONFIG_PATH)
|
|||
|
|
result = engine.check_outbound({"query": "联系 13812345678"})
|
|||
|
|
# 内置手机号规则仍在(模板未覆盖),应照常命中
|
|||
|
|
self.assertTrue(result.blocked)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TemplateOverrideTest(unittest.TestCase):
|
|||
|
|
def test_same_name_overrides_builtin(self):
|
|||
|
|
engine = DlpEngine(rules=[
|
|||
|
|
DlpRule(name="pii_mobile", category="pii",
|
|||
|
|
kind=DlpRuleKind.REGEX, pattern=r"\b1[3-9]\d{9}\b"),
|
|||
|
|
])
|
|||
|
|
names = [r.name for r in engine._rules]
|
|||
|
|
self.assertEqual(names.count("pii_mobile"), 1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
unittest.main()
|