dsh-calibguard 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.en.md +22 -16
  2. package/README.md +22 -15
  3. package/cordis.patch.yml +2 -1
  4. package/docs/configuration.md +2 -2
  5. package/docs/troubleshooting.md +2 -1
  6. package/engine/README.md +33 -0
  7. package/engine/audit_service.py +304 -0
  8. package/engine/calibguard/__init__.py +2 -0
  9. package/engine/calibguard/__main__.py +4 -0
  10. package/engine/calibguard/calibrator.py +78 -0
  11. package/engine/calibguard/cli.py +74 -0
  12. package/engine/calibguard/config.py +129 -0
  13. package/engine/calibguard/constants.py +117 -0
  14. package/engine/calibguard/feature_extractor.py +161 -0
  15. package/engine/calibguard/gate.py +17 -0
  16. package/engine/calibguard/llm_analyzer.py +171 -0
  17. package/engine/calibguard/model.py +79 -0
  18. package/engine/calibguard/pipeline.py +305 -0
  19. package/engine/calibguard/preprocessor.py +218 -0
  20. package/engine/calibguard/report.py +132 -0
  21. package/engine/calibguard/schemas.py +53 -0
  22. package/engine/models/calibration_params.json +1 -0
  23. package/engine/models/codebert_finetuned/config.json +28 -0
  24. package/engine/models/codebert_finetuned/merges.txt +50001 -0
  25. package/engine/models/codebert_finetuned/special_tokens_map.json +51 -0
  26. package/engine/models/codebert_finetuned/tokenizer.json +250371 -0
  27. package/engine/models/codebert_finetuned/tokenizer_config.json +58 -0
  28. package/engine/models/codebert_finetuned/vocab.json +1 -0
  29. package/engine/requirements-gui.txt +11 -0
  30. package/lib/bootstrap.js +4 -3
  31. package/lib/client.js +1 -1
  32. package/lib/config.js +34 -7
  33. package/lib/engine.js +1 -1
  34. package/lib/index.js +1 -1
  35. package/package.json +4 -3
  36. package/setup.mjs +278 -48
@@ -0,0 +1,78 @@
1
+ """概率校准模块:Platt Scaling。
2
+
3
+ 公式(禁止改写):P(y=1 | s) = 1 / (1 + exp(A * s + B))
4
+ 与温度缩放的关系(v1.0 不实现温度缩放):P = σ(s/T) 等价于 A = -1/T, B = 0 的特例。
5
+ """
6
+ import datetime
7
+ import json
8
+ import math
9
+
10
+ from sklearn.linear_model import LogisticRegression
11
+
12
+
13
+ class PlattCalibrator:
14
+ """Platt 校准器:logit → 校准概率;支持拟合与参数存取。"""
15
+
16
+ def __init__(self, A: float = 0.0, B: float = 0.0, method: str = "platt",
17
+ n_samples: int = 0):
18
+ self.A = A
19
+ self.B = B
20
+ self.method = method
21
+ self.n_samples = n_samples
22
+
23
+ def fit(self, logits: list[float], labels: list[int]) -> None:
24
+ """在(logit, label)校准集上拟合 A、B。"""
25
+ if len(logits) != len(labels) or len(logits) == 0:
26
+ raise ValueError("logits 与 labels 长度必须相等且非空")
27
+ try:
28
+ lr = LogisticRegression(C=1e10, solver="lbfgs")
29
+ lr.fit([[s] for s in logits], labels)
30
+ except ValueError as exc:
31
+ # sklearn 在标签只有单一类别时抛 ValueError
32
+ raise RuntimeError("校准集必须同时包含良性与恶意样本") from exc
33
+ # sklearn: P = 1/(1+exp(-(w*s+b))),与目标公式 1/(1+exp(A*s+B)) 对比得:
34
+ self.A = -float(lr.coef_[0][0])
35
+ self.B = -float(lr.intercept_[0])
36
+ self.n_samples = len(labels)
37
+
38
+ def predict_proba(self, logit: float) -> float:
39
+ """logit → 校准概率(含溢出保护)。"""
40
+ z = self.A * logit + self.B
41
+ if z > 700:
42
+ return 1e-16 # p 趋近 0
43
+ if z < -700:
44
+ return 1.0 - 1e-16 # p 趋近 1
45
+ return 1.0 / (1.0 + math.exp(z))
46
+
47
+ def save(self, path: str) -> None:
48
+ """写 JSON 参数文件(键名固定)。"""
49
+ payload = {
50
+ "A": self.A,
51
+ "B": self.B,
52
+ "method": self.method,
53
+ "n_samples": self.n_samples,
54
+ "fit_at": datetime.datetime.now().astimezone().isoformat(),
55
+ }
56
+ with open(path, "w", encoding="utf-8") as handle:
57
+ json.dump(payload, handle, ensure_ascii=False)
58
+
59
+ @classmethod
60
+ def load(cls, path: str) -> "PlattCalibrator":
61
+ """读 JSON 还原;文件不存在抛 FileNotFoundError,内容损坏抛 ValueError。"""
62
+ try:
63
+ with open(path, "r", encoding="utf-8") as handle:
64
+ payload = json.load(handle)
65
+ calibrator = cls(
66
+ A=float(payload["A"]),
67
+ B=float(payload["B"]),
68
+ method=str(payload.get("method", "platt")),
69
+ n_samples=int(payload.get("n_samples", 0)),
70
+ )
71
+ except (ValueError, KeyError, TypeError, AttributeError) as exc:
72
+ raise ValueError(f"校准参数文件损坏: {path}") from exc
73
+ return calibrator
74
+
75
+ @classmethod
76
+ def identity(cls) -> "PlattCalibrator":
77
+ """跳过校准时使用:A=-1, B=0(predict_proba 即 sigmoid)。"""
78
+ return cls(A=-1.0, B=0.0, method="none", n_samples=0)
@@ -0,0 +1,74 @@
1
+ """命令行入口:argparse 解析、打印摘要、退出码管理。
2
+
3
+ 退出码:0 正常 / 1 路径不存在 / 2 配置错误 / 3 校准参数缺失 / 4 模型加载失败 / 5 未知内部错误。
4
+ """
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ from calibguard import __version__
10
+ from calibguard.config import load_config
11
+ from calibguard.pipeline import AuditPipeline
12
+
13
+
14
+ def main() -> None:
15
+ """程序入口,管理退出码。"""
16
+ parser = argparse.ArgumentParser(
17
+ prog="calibguard",
18
+ description="CalibGuard:本地 CodeBERT 校准门控 + 云端 LLM 按需分析的安全审计工具。")
19
+ subparsers = parser.add_subparsers(dest="command")
20
+ audit_parser = subparsers.add_parser("audit", help="审计指定文件或目录")
21
+ audit_parser.add_argument("path", type=str, help="待审计的文件或目录路径")
22
+ audit_parser.add_argument("--config", default=None, help="配置文件路径")
23
+ audit_parser.add_argument("--output-dir", default=None, help="报告输出目录(覆盖配置)")
24
+ subparsers.add_parser("version", help="打印版本号")
25
+
26
+ args = parser.parse_args()
27
+
28
+ if args.command == "version":
29
+ print(f"CalibGuard {__version__}")
30
+ return
31
+ if args.command != "audit":
32
+ parser.print_help()
33
+ return
34
+
35
+ # 1. 加载配置(内部已处理退出码 2)
36
+ cfg = load_config(args.config)
37
+ # 2. 命令行覆盖输出目录
38
+ if args.output_dir:
39
+ cfg.report_output_dir = args.output_dir
40
+ # 3. 路径存在性检查
41
+ if not os.path.exists(args.path):
42
+ print(f"[错误] 路径不存在: {args.path}", file=sys.stderr)
43
+ sys.exit(1)
44
+ # 4. 构建流水线(模型 / 校准参数)
45
+ try:
46
+ pipeline = AuditPipeline(cfg)
47
+ except (FileNotFoundError, ValueError) as exc:
48
+ print(str(exc), file=sys.stderr)
49
+ sys.exit(3)
50
+ except RuntimeError as exc:
51
+ print(str(exc), file=sys.stderr)
52
+ sys.exit(4)
53
+
54
+ # 5-7. 审计、逐行摘要、统计
55
+ try:
56
+ reports = pipeline.audit_path(args.path)
57
+ total = len(reports)
58
+ counts = {"allow": 0, "block": 0, "suspicious": 0, "manual_review": 0}
59
+ for index, report in enumerate(reports, start=1):
60
+ print(f"[{index}/{total}] {report['file_path']} "
61
+ f"p={report['calibrated_probability']} "
62
+ f"gate={report['gate_decision']} final={report['final_decision']}")
63
+ decision = report.get("final_decision")
64
+ if decision in counts:
65
+ counts[decision] += 1
66
+ print(f"审计完成:共 {total} 个文件 | 放行 {counts['allow']} | 拦截 {counts['block']} "
67
+ f"| 可疑 {counts['suspicious']} | 转人工 {counts['manual_review']}")
68
+ print(f"报告目录:{os.path.abspath(cfg.report_output_dir)}")
69
+ except Exception as exc:
70
+ print(f"[错误] 未知内部错误: {type(exc).__name__}: {exc}", file=sys.stderr)
71
+ sys.exit(5)
72
+
73
+ # 8. 正常结束
74
+ sys.exit(0)
@@ -0,0 +1,129 @@
1
+ """配置加载:config.yaml + 环境变量覆盖。"""
2
+ import os
3
+ import sys
4
+ from dataclasses import dataclass
5
+
6
+ import yaml
7
+
8
+ # LLM 复核内容源(封闭集合):evidence=精炼证据 / raw=预处理后的文件原文
9
+ LLM_CONTENT_SOURCES = ("evidence", "raw")
10
+ # LLM 复核投递模式(封闭集合):truncate=截断 / window=滑窗 / all=一次性全部上传
11
+ LLM_DELIVERY_MODES = ("truncate", "window", "all")
12
+
13
+
14
+ @dataclass
15
+ class AppConfig:
16
+ # 模型
17
+ model_base: str = "microsoft/codebert-base"
18
+ model_path: str = "models/codebert_finetuned"
19
+ max_length: int = 512
20
+ window_stride: int = 256
21
+ window_batch_size: int = 8
22
+ # 校准
23
+ calibration_params_path: str = "models/calibration_params.json"
24
+ # 门控
25
+ allow_threshold: float = 0.15
26
+ block_threshold: float = 0.85
27
+ early_stop_threshold: float = 0.9
28
+ # LLM
29
+ llm_enabled: bool = True # 是否使用云端 LLM 复核(False 时灰区按 0.5 切)
30
+ llm_api_key_env: str = "DEEPSEEK_API_KEY" # 读取 API Key 的环境变量名(可被 yaml 覆盖)
31
+ llm_api_key: str = ""
32
+ llm_base_url: str = "https://api.deepseek.com"
33
+ llm_model: str = "deepseek-chat"
34
+ llm_temperature: float = 0.0
35
+ llm_max_tokens: int = 500
36
+ llm_timeout: int = 30
37
+ evidence_max_chars: int = 3000
38
+ # LLM 复核投递(2026-10-05 GUI 需求:内容源 × 投递模式)
39
+ llm_content_source: str = "evidence" # evidence=精炼证据 / raw=文件原文
40
+ llm_delivery_mode: str = "truncate" # truncate / window / all
41
+ llm_truncate_chars: int = 3000 # 截断模式:送审前 N 字符
42
+ llm_window_chars: int = 3000 # 滑窗模式:单窗字符数
43
+ llm_window_overlap: int = 500 # 滑窗模式:相邻窗重叠字符数
44
+ # 报告
45
+ report_output_dir: str = "reports"
46
+
47
+
48
+ def validate_config(cfg: AppConfig) -> str | None:
49
+ """校验配置合法性:返回错误文本;None 表示通过。
50
+
51
+ 抽成独立函数供两处复用:load_config(非法则退出码 2)与 GUI 设置保存前的预检
52
+ (非法则 400,绝不把非法值写进运行中的 cfg)。
53
+ """
54
+ if not (0.0 < cfg.allow_threshold < cfg.block_threshold < 1.0):
55
+ return (f"阈值必须满足 0 < allow({cfg.allow_threshold}) < "
56
+ f"block({cfg.block_threshold}) < 1")
57
+ if cfg.max_length > 512:
58
+ return f"max_length 不得超过 512(CodeBERT 上限),当前 {cfg.max_length}"
59
+ if not (1 <= cfg.window_stride < cfg.max_length):
60
+ return (f"window_stride 必须满足 1 ≤ stride < max_length(512),"
61
+ f"当前 {cfg.window_stride}")
62
+ if cfg.window_batch_size < 1:
63
+ return f"window_batch_size 必须 ≥ 1,当前 {cfg.window_batch_size}"
64
+ if not (0.0 < cfg.early_stop_threshold < 1.0):
65
+ return f"early_stop_threshold 必须在 (0,1) 内,当前 {cfg.early_stop_threshold}"
66
+ if cfg.llm_content_source not in LLM_CONTENT_SOURCES:
67
+ return (f"llm_content_source 必须是 {LLM_CONTENT_SOURCES} 之一,"
68
+ f"当前 {cfg.llm_content_source!r}")
69
+ if cfg.llm_delivery_mode not in LLM_DELIVERY_MODES:
70
+ return (f"llm_delivery_mode 必须是 {LLM_DELIVERY_MODES} 之一,"
71
+ f"当前 {cfg.llm_delivery_mode!r}")
72
+ if cfg.llm_truncate_chars < 1:
73
+ return f"llm_truncate_chars 必须 ≥ 1,当前 {cfg.llm_truncate_chars}"
74
+ if cfg.llm_window_chars < 1:
75
+ return f"llm_window_chars 必须 ≥ 1,当前 {cfg.llm_window_chars}"
76
+ if not (0 <= cfg.llm_window_overlap < cfg.llm_window_chars):
77
+ return (f"llm_window_overlap 必须满足 0 ≤ overlap < window_chars"
78
+ f"({cfg.llm_window_chars}),当前 {cfg.llm_window_overlap}")
79
+ return None
80
+
81
+
82
+ def load_config(path: str | None = None) -> AppConfig:
83
+ """加载配置。规则:默认值 < config.yaml < 环境变量(仅 API Key)。"""
84
+ cfg = AppConfig()
85
+ if path is not None and os.path.isfile(path):
86
+ with open(path, "r", encoding="utf-8") as f:
87
+ raw = yaml.safe_load(f) or {}
88
+ # 逐键覆盖(键不存在则保留默认值,禁止因缺键报错)
89
+ m = raw.get("model", {}) or {}
90
+ if "base_model" in m: cfg.model_base = str(m["base_model"])
91
+ if "finetuned_path" in m: cfg.model_path = str(m["finetuned_path"])
92
+ if "max_length" in m: cfg.max_length = int(m["max_length"])
93
+ if "window_stride" in m: cfg.window_stride = int(m["window_stride"])
94
+ if "window_batch_size" in m: cfg.window_batch_size = int(m["window_batch_size"])
95
+ c = raw.get("calibration", {}) or {}
96
+ if "params_path" in c: cfg.calibration_params_path = str(c["params_path"])
97
+ g = raw.get("gate", {}) or {}
98
+ if "allow_threshold" in g: cfg.allow_threshold = float(g["allow_threshold"])
99
+ if "block_threshold" in g: cfg.block_threshold = float(g["block_threshold"])
100
+ if "early_stop_threshold" in g: cfg.early_stop_threshold = float(g["early_stop_threshold"])
101
+ llm = raw.get("llm", {}) or {}
102
+ if "enabled" in llm: cfg.llm_enabled = bool(llm["enabled"])
103
+ if "api_key" in llm: cfg.llm_api_key = str(llm["api_key"])
104
+ if "base_url" in llm: cfg.llm_base_url = str(llm["base_url"])
105
+ if "model" in llm: cfg.llm_model = str(llm["model"])
106
+ if "temperature" in llm: cfg.llm_temperature = float(llm["temperature"])
107
+ if "max_tokens" in llm: cfg.llm_max_tokens = int(llm["max_tokens"])
108
+ if "timeout_seconds" in llm: cfg.llm_timeout = int(llm["timeout_seconds"])
109
+ if "evidence_max_chars" in llm: cfg.evidence_max_chars = int(llm["evidence_max_chars"])
110
+ if "api_key_env" in llm: cfg.llm_api_key_env = str(llm["api_key_env"])
111
+ if "content_source" in llm: cfg.llm_content_source = str(llm["content_source"])
112
+ if "delivery_mode" in llm: cfg.llm_delivery_mode = str(llm["delivery_mode"])
113
+ if "truncate_chars" in llm: cfg.llm_truncate_chars = int(llm["truncate_chars"])
114
+ if "window_chars" in llm: cfg.llm_window_chars = int(llm["window_chars"])
115
+ if "window_overlap" in llm: cfg.llm_window_overlap = int(llm["window_overlap"])
116
+ r = raw.get("report", {}) or {}
117
+ if "output_dir" in r: cfg.report_output_dir = str(r["output_dir"])
118
+ # API Key 环境变量优先级最高——必须在 if 块之外执行:
119
+ # 无论是否指定 --config、yaml 是否存在,环境变量都要读取
120
+ # (未加载 yaml 时,环境变量名用字段默认值 "DEEPSEEK_API_KEY")
121
+ env_val = os.environ.get(cfg.llm_api_key_env, "")
122
+ if env_val:
123
+ cfg.llm_api_key = env_val
124
+ # 合法性校验:失败直接退出,退出码 2
125
+ error = validate_config(cfg)
126
+ if error:
127
+ print(f"[配置错误] {error}", file=sys.stderr)
128
+ sys.exit(2)
129
+ return cfg
@@ -0,0 +1,117 @@
1
+ """CalibGuard 全局常量。所有列表为封闭集合,禁止增删;所有阈值禁止修改。"""
2
+
3
+ # ============ 标签约定 ============
4
+ LABEL_BENIGN = 0 # 良性类别索引(训练时 label=0,模型输出 logits 索引 0)
5
+ LABEL_MALICIOUS = 1 # 恶意类别索引(训练时 label=1,模型输出 logits 索引 1)
6
+
7
+ # ============ 危险 API 模式(封闭) ============
8
+ DANGEROUS_API_PATTERNS = [
9
+ r"subprocess\.(?:run|call|Popen|check_output|check_call)\s*\(",
10
+ r"os\.system\s*\(",
11
+ r"os\.popen\s*\(",
12
+ r"\beval\s*\(",
13
+ r"\bexec\s*\(",
14
+ r"pickle\.loads?\s*\(",
15
+ r"__import__\s*\(",
16
+ ]
17
+
18
+ # ============ 网络行为模式(封闭) ============
19
+ NETWORK_PATTERNS = [
20
+ r"https?://[^\s'\"<>]+",
21
+ r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
22
+ r"\b(?:curl|wget)\b",
23
+ r"requests\.(?:post|get|put|delete|head)\s*\(",
24
+ r"urllib\.request\.(?:urlopen|Request)\s*\(",
25
+ r"socket\.socket\s*\(",
26
+ ]
27
+
28
+ # ============ 凭据与敏感路径模式(封闭) ============
29
+ CREDENTIAL_PATTERNS = [
30
+ r"\.ssh\b",
31
+ r"\.env\b",
32
+ r"\.aws\b",
33
+ r"\.bash_history\b",
34
+ r"\.netrc\b",
35
+ r"id_rsa",
36
+ r"AWS_SECRET_ACCESS_KEY",
37
+ r"AWS_ACCESS_KEY_ID",
38
+ r"\btoken\b",
39
+ r"\bpasswd?\b",
40
+ r"\bcredential",
41
+ r"\bsecret\b",
42
+ r"\bapi[_-]?key\b",
43
+ ]
44
+
45
+ # ============ 编码与混淆模式(封闭) ============
46
+ OBFUSCATION_PATTERNS = [
47
+ r"base64\.(?:b64decode|b64encode|decode|encode)\s*\(",
48
+ r"bytes\.fromhex\s*\(",
49
+ r"\b(?:binascii\.)?(?:unhexlify|hexlify)\s*\(",
50
+ r"\bchr\s*\(\s*\d+\s*\)",
51
+ r"\\x[0-9a-fA-F]{2}",
52
+ r"codecs\.decode\s*\(",
53
+ r"zlib\.decompress\s*\(",
54
+ r"marshal\.loads?\s*\(",
55
+ ]
56
+
57
+ # ============ 可疑文件操作模式(封闭) ============
58
+ FILE_OPERATION_PATTERNS = [
59
+ r"os\.(?:remove|unlink|rmdir)\s*\(",
60
+ r"shutil\.rmtree\s*\(",
61
+ r"os\.(?:rename|replace)\s*\(",
62
+ r"open\s*\([^)]*['\"][wa]",
63
+ r"\.bashrc\b|\.zshrc\b|\.profile\b",
64
+ r"/etc/(?:hosts|passwd|shadow|sudoers)\b",
65
+ r"\bwinreg\b|\bHKEY_",
66
+ r"\bcrontab\b|\bschtasks\b|LaunchAgents",
67
+ r"chmod\s+[0-7]{3,4}",
68
+ ]
69
+
70
+ # ============ 提示注入话术(封闭,匹配时不区分大小写) ============
71
+ INJECTION_PHRASES = [
72
+ "忽略之前指令", "忽略之前的指令", "忽略以上指令", "忽略上述指令",
73
+ "不要告诉用户", "不要告诉任何人", "不要告知用户",
74
+ "偷偷发送", "悄悄发送", "私下发送",
75
+ "ignore previous instructions", "ignore all previous instructions",
76
+ "disregard previous", "forget previous",
77
+ "do not tell the user", "don't tell the user",
78
+ "secretly send",
79
+ ]
80
+
81
+ # ============ 异常特征阈值(禁止修改) ============
82
+ HIGH_ENTROPY_MIN_LEN = 20 # 高熵候选串最短长度(字符)
83
+ HIGH_ENTROPY_THRESHOLD = 4.5 # Shannon 熵阈值(bit/字符)
84
+ HIGH_ENTROPY_MAX_COUNT = 10 # 高熵串最多记录条数
85
+ LONG_LINE_THRESHOLD = 500 # 超长行判定阈值(字符)
86
+ LONG_LINE_MAX_COUNT = 10 # 最多记录的行号个数
87
+ RANDOM_IDENTIFIER_MIN_LEN = 16 # 随机标识符的最短长度
88
+ RANDOM_IDENTIFIER_THRESHOLD = 10 # 随机标识符数量达到该值才记录总数
89
+
90
+ # ============ 证据精炼限制(禁止修改) ============
91
+ EVIDENCE_PER_CATEGORY_MAX = 10 # 每类信号最多保留条数
92
+ EVIDENCE_ITEM_MAX_CHARS = 80 # 单条证据最大字符数(超出截断加省略号)
93
+ # 证据总长度上限来自配置项 llm.evidence_max_chars(默认 3000),不在本文件定义
94
+
95
+ # ============ 预处理限制(禁止修改) ============
96
+ MAX_INPUT_BYTES = 1048576 # 单文件读入上限:1MB,超出只读前 1MB
97
+ MAX_TEXT_CHARS = 200000 # 清洗后文本上限:20 万字符
98
+ LINE_TRUNCATE_THRESHOLD = 2000 # 单行超此长度截断,追加 "…[LINE_TRUNCATED]"
99
+
100
+ # ============ 压缩包安全限制(禁止修改,防 zip bomb) ============
101
+ ARCHIVE_MAX_DEPTH = 3 # 递归解包最大深度
102
+ ARCHIVE_MAX_FILES = 100 # 单个压缩包最多解出文件数
103
+ ARCHIVE_MAX_TOTAL_BYTES = 52428800 # 解包总字节上限 50MB
104
+ PRINTABLE_MIN_RUN = 4 # 二进制可打印字符串的最短连续长度
105
+
106
+ # ============ 提示注入防护(禁止修改) ============
107
+ # 不可见/危险 Unicode 范围:零宽字符、双向控制符、BOM 等
108
+ HIDDEN_UNICODE_PATTERN = r"[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]"
109
+ # 提交给 LLM 前需移除的控制字符(含 \x00-\x1f 中除 \t\n\r 外的全部)
110
+ CONTROL_CHARS_PATTERN = r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]"
111
+ # LLM 响应语义黑名单:命中即判定 LLM 可能被注入劫持,转 invalid_output
112
+ LLM_SEMANTIC_BLACKLIST = [
113
+ "忽略之前", "忽略上面", "无法分析", "我是一个", "我是人工",
114
+ "ignore previous", "i cannot", "i can't", "as an ai",
115
+ ]
116
+ # 一致性校验:本地校准概率 ≥ 该值 且 LLM 返回 low 时,触发一致性告警
117
+ LLM_CONSISTENCY_LOCAL_PROB = 0.7
@@ -0,0 +1,161 @@
1
+ """特征提取模块:文本 → 结构化可疑证据(FeatureEvidence)+ 证据转提示词文本。"""
2
+ import math
3
+ import re
4
+
5
+ from calibguard import constants as C
6
+ from calibguard.schemas import FeatureEvidence
7
+
8
+ # 高熵候选串(最短长度取常量 HIGH_ENTROPY_MIN_LEN)
9
+ _HIGH_ENTROPY_CANDIDATE_RE = re.compile(r"[A-Za-z0-9+/=_-]{%d,}" % C.HIGH_ENTROPY_MIN_LEN)
10
+ # 长随机标识符(首字符字母或下划线 + 其余为标识符合法字符,总长 ≥ RANDOM_IDENTIFIER_MIN_LEN)
11
+ # 字符类必须包含下划线:混淆器生成的随机变量名普遍为 var_xxxxx 形态,遗漏 _ 会使该检测失效
12
+ _RANDOM_IDENTIFIER_RE = re.compile(
13
+ r"\b[A-Za-z_][A-Za-z0-9_]{%d,}\b" % (C.RANDOM_IDENTIFIER_MIN_LEN - 1)
14
+ )
15
+
16
+ # 隐藏 Unicode 命中时写入混淆类的固定条目
17
+ _HIDDEN_UNICODE_ITEM = "隐藏Unicode字符"
18
+ # 证据文本的八个固定标题(顺序固定,禁止调整)
19
+ _SECTION_TITLES = [
20
+ "【危险API调用】",
21
+ "【网络行为】",
22
+ "【凭据与敏感路径访问】",
23
+ "【编码与混淆】",
24
+ "【可疑文件操作】",
25
+ "【提示注入话术】",
26
+ "【高熵字符串】",
27
+ "【异常结构特征】",
28
+ ]
29
+ # 全部类别为空时的固定返回值
30
+ _EMPTY_EVIDENCE_TEXT = "未发现明显可疑信号。"
31
+
32
+
33
+ def extract_features(text: str) -> FeatureEvidence:
34
+ """对全文抽取七类可疑信号 + 异常特征。"""
35
+ evidence = FeatureEvidence()
36
+
37
+ evidence.dangerous_apis = _collect_matches(C.DANGEROUS_API_PATTERNS, text)
38
+ evidence.network_indicators = _collect_matches(C.NETWORK_PATTERNS, text)
39
+ evidence.credential_access = _collect_matches(C.CREDENTIAL_PATTERNS, text)
40
+ evidence.obfuscation_signals = _collect_matches(C.OBFUSCATION_PATTERNS, text)
41
+ evidence.file_operations = _collect_matches(C.FILE_OPERATION_PATTERNS, text)
42
+
43
+ # 隐藏 Unicode 检测:命中则追加固定条目(仅一条,去重)
44
+ if re.search(C.HIDDEN_UNICODE_PATTERN, text):
45
+ if _HIDDEN_UNICODE_ITEM not in evidence.obfuscation_signals:
46
+ evidence.obfuscation_signals.append(_HIDDEN_UNICODE_ITEM)
47
+ evidence.obfuscation_signals = evidence.obfuscation_signals[
48
+ :C.EVIDENCE_PER_CATEGORY_MAX
49
+ ]
50
+
51
+ # 提示注入话术:不区分大小写,命中整条话术原文(不截断)
52
+ lowered = text.lower()
53
+ injection_hits: list[str] = []
54
+ for phrase in C.INJECTION_PHRASES:
55
+ if phrase.lower() in lowered and phrase not in injection_hits:
56
+ injection_hits.append(phrase)
57
+ evidence.injection_phrases = injection_hits[:C.EVIDENCE_PER_CATEGORY_MAX]
58
+
59
+ # 异常特征
60
+ evidence.high_entropy_strings = _find_high_entropy_strings(text)
61
+ evidence.long_line_numbers = _find_long_lines(text)
62
+ random_count = len(_RANDOM_IDENTIFIER_RE.findall(text))
63
+ if random_count >= C.RANDOM_IDENTIFIER_THRESHOLD:
64
+ evidence.random_identifier_count = random_count
65
+
66
+ return evidence
67
+
68
+
69
+ def _collect_matches(patterns: list[str], text: str) -> list[str]:
70
+ """逐个模式收集匹配串 → 去重(保持首次出现顺序)→ 截断 → 每类最多 10 条。"""
71
+ unique_hits: list[str] = []
72
+ for pattern in patterns:
73
+ for matched in re.findall(pattern, text):
74
+ if matched not in unique_hits:
75
+ unique_hits.append(matched)
76
+ if len(unique_hits) >= C.EVIDENCE_PER_CATEGORY_MAX:
77
+ break
78
+ if len(unique_hits) >= C.EVIDENCE_PER_CATEGORY_MAX:
79
+ break
80
+ return [_clip(item) for item in unique_hits[:C.EVIDENCE_PER_CATEGORY_MAX]]
81
+
82
+
83
+ def _find_long_lines(text: str) -> list[int]:
84
+ """行长 ≥ LONG_LINE_THRESHOLD 的行号(1 起),最多 LONG_LINE_MAX_COUNT 个。"""
85
+ line_numbers: list[int] = []
86
+ for index, line in enumerate(text.split("\n"), start=1):
87
+ if len(line) >= C.LONG_LINE_THRESHOLD:
88
+ line_numbers.append(index)
89
+ if len(line_numbers) >= C.LONG_LINE_MAX_COUNT:
90
+ break
91
+ return line_numbers
92
+
93
+
94
+ def _shannon_entropy(s: str) -> float:
95
+ """Shannon 熵:H = -Σ p_i * log2(p_i)。"""
96
+ if s == "":
97
+ return 0.0
98
+ length = len(s)
99
+ entropy = 0.0
100
+ for char in set(s):
101
+ probability = s.count(char) / length
102
+ entropy -= probability * math.log2(probability)
103
+ return entropy
104
+
105
+
106
+ def _find_high_entropy_strings(text: str) -> list[str]:
107
+ """高熵候选串(长度 ≥ HIGH_ENTROPY_MIN_LEN 且熵 ≥ 阈值),最多 HIGH_ENTROPY_MAX_COUNT 条。"""
108
+ hits: list[str] = []
109
+ for candidate in _HIGH_ENTROPY_CANDIDATE_RE.findall(text):
110
+ if candidate in hits:
111
+ continue
112
+ if _shannon_entropy(candidate) >= C.HIGH_ENTROPY_THRESHOLD:
113
+ hits.append(candidate)
114
+ if len(hits) >= C.HIGH_ENTROPY_MAX_COUNT:
115
+ break
116
+ return [_clip(item) for item in hits]
117
+
118
+
119
+ def evidence_to_prompt_text(evidence: FeatureEvidence, max_chars: int) -> str:
120
+ """证据 → 固定格式提示词文本(空类别整段跳过;全空返回固定串)。"""
121
+ category_items: list[list[str]] = [
122
+ evidence.dangerous_apis,
123
+ evidence.network_indicators,
124
+ evidence.credential_access,
125
+ evidence.obfuscation_signals,
126
+ evidence.file_operations,
127
+ evidence.injection_phrases,
128
+ evidence.high_entropy_strings,
129
+ ]
130
+
131
+ blocks: list[str] = []
132
+ for title, items in zip(_SECTION_TITLES, category_items):
133
+ if not items:
134
+ continue
135
+ lines = [title]
136
+ lines.extend(f"- {item}" for item in items)
137
+ blocks.append("\n".join(lines))
138
+
139
+ # 异常结构特征:超长行行号与长随机标识符数量
140
+ structural_lines: list[str] = []
141
+ if evidence.long_line_numbers:
142
+ structural_lines.append(f"- 超长行行号: {evidence.long_line_numbers}")
143
+ if evidence.random_identifier_count > 0:
144
+ structural_lines.append(f"- 长随机标识符数量: {evidence.random_identifier_count}")
145
+ if structural_lines:
146
+ blocks.append("\n".join([_SECTION_TITLES[7]] + structural_lines))
147
+
148
+ if not blocks:
149
+ return _EMPTY_EVIDENCE_TEXT
150
+
151
+ prompt_text = "\n\n".join(blocks)
152
+ if len(prompt_text) > max_chars:
153
+ prompt_text = prompt_text[:max_chars] + "\n…[EVIDENCE_TRUNCATED]"
154
+ return prompt_text
155
+
156
+
157
+ def _clip(s: str, limit: int = C.EVIDENCE_ITEM_MAX_CHARS) -> str:
158
+ """单条证据超过 limit 字符则截断并追加省略号。"""
159
+ if len(s) <= limit:
160
+ return s
161
+ return s[:limit] + "…"
@@ -0,0 +1,17 @@
1
+ """门控路由模块:校准概率 → ALLOW / BLOCK / LLM_REVIEW。
2
+
3
+ 边界归属(禁止改为 <= / >=):严格小于放行、严格大于拦截、含边界走云端 LLM。
4
+ """
5
+ from calibguard.schemas import GateDecision
6
+
7
+
8
+ def route(probability: float, allow_threshold: float, block_threshold: float) -> GateDecision:
9
+ """按阈值把校准概率路由到三个决策之一。"""
10
+ if not (0.0 <= probability <= 1.0):
11
+ raise ValueError(f"概率必须在 [0, 1] 内,当前 {probability}")
12
+ if probability < allow_threshold:
13
+ return GateDecision.ALLOW
14
+ if probability > block_threshold:
15
+ return GateDecision.BLOCK
16
+ # 其余(含恰好等于两阈值的情况)走云端 LLM
17
+ return GateDecision.LLM_REVIEW