状态:✅ 已完成 前置阅读:02-Mac本地部署实操、03-技术架构与业务模块分析、08-提示词注入攻防、10-业务接入全流程 日期:2026-09-14 素材来源:JoySafety 源码核实(safety/ 全模块)+ garak/PyRIT/Promptfoo 官方文档与社区资料(2026 年现状) 读者定位:已经把演示环境跑起来(02 篇)、读懂了 DAG 编排(03 篇),现在想回答一个问题——这条防线到底拦得有多全,改了配置之后有没有退化
〇、先建立全局认识
0.1 被测对象是谁:不是模型,是"整条防线"
红队评测 JoySafety 时容易犯的第一个概念错误:把 garak 的 target 指向某个 BERT 模型。那就退化成了 08 篇的模型抽检。本篇的被测对象是 POST /llmsec/api/defense/v2/{accessKey} 这一个切面——攻击样本从切面打进去,穿过鉴权、限流、预处理、DAG 编排、词库/模型/知识库,最后看响应里的 riskCode。切面之下任何一环失效(词库没这个词、模型阈值不够、节点超时丢结果、robot 侧根本没编排模型),在指标上都体现为"漏检",但归因完全不同。
0.2 本篇在系列中的位置
01/02 篇验证了"四个案例能拦"(功能通),08 篇手工构造了几十个注入样本看检出率(抽查)。本篇解决的是规模化与持续化:用三个工具分别覆盖广度(garak 探针群扫面)、深度(PyRIT 多轮+变形打点)、回归(Promptfoo 进 CI 防退化),并建立漏检样本的反哺闭环。
一、为什么红队评测是运营纪律,不是一次性项目
一次性跑一轮攻击、出个报告,价值只有三周。三个持续变化源决定了评测必须常态化:
① 防线自身每天都在变。 JoySafety 的全部检测行为由配置驱动:词库、DAG 策略、阈值、知识库都在 MySQL 里,走版本化状态机变更(02 篇附录 C 的 active → newVersion → update → upgrade 流水线)。变更生效靠两条 60 秒轮询链:
- safety-api 每分钟轮询 admin 拉全量 online 配置并重建 DAG(
safety/safety-api/src/main/java/com/jd/security/llmsec/service/DynamicConfigService.java:65,@Scheduled(fixedRate = 60000)) - safety-keywords 每分钟轮询
sensitive_words表,先查max(update_time)再全量重建 AC 自动机(safety/safety-skills/safety-keywords/src/main/java/com/jd/security/llmsec/service/data/SensitiveWordsManageServiceImpl.java:193-196)
运维同学下午加了一批词、调了一个阈值,晚上防线行为就变了。没有自动回归,每次变更都是一次盲改。
② 攻击手法在周更。 越狱社区的新模板(DAN 变体、编码绕过、多轮套话)以周为单位出现,靠 08 篇那种手工样本库永远落后一个身位——garak 的 dan.DanInTheWild 这类探针直接收录社区野生越狱样本,工具升级等于样本库升级。
③ 风险分类标准本身在变。 OWASP LLM Top 10 从 2023 版到 2025 版(2024-11-18 发布)新增了三项:LLM07 系统提示词泄露、LLM08 向量与嵌入弱点、LLM10 无边界消耗(从旧的模型 DoS 扩展为成本攻击+模型窃取)。映射到 JoySafety:演示策略的注入检测(riskCode=201)只覆盖 LLM01 的"输入侧",RAG 知识库(LLM08 的相关面)依赖 kb_search 阈值,而 LLM07/LLM10 基本没有对应原子能力——红队评测的第一个产出就应该是这张覆盖度映射表,它直接告诉你防线盲区在哪。
类比:这就像 Web 防火墙的 WAF 规则回归——没有人会上线一条 WAF 规则后不跑历史攻击包回放。LLM 护栏只是把"规则"换成了"模型+词库+编排",回归的义务只会更重(模型输出有概率性,判分本身都有噪声)。
二、工具格局与选型(2026 年现状)
| 工具 | 背景 | 定位 | 接入方式 | 输出 |
|---|---|---|---|---|
| garak | NVIDIA 开源(PyPI garak) | 广度扫描器:数十个探针家族(dan/promptinject/encoding/leakreplay/latentinjection/malwaregen/xss…),每个家族含大量攻击模板 | rest.RestGenerator 接任意 HTTP 端点 | 每 attempt 落 report.jsonl + HTML 报告 |
| PyRIT | 微软开源(Azure 官方维护) | 深度攻击编排:Attack(原 Orchestrator)+ Converter(变形)+ Scorer(判分)三段式,支持 Crescendo/TAP/Skeleton Key 等多轮策略 | HTTPTarget(Burp 风格原始报文模板)或自定义 PromptTarget 子类 | 结构化攻击结果 + 会话全记录 |
| Promptfoo | MIT 开源 CLI,2026-03 被 OpenAI 收购(官宣 3 月 9 日,协议不变),事实上的 CI 评测标准 | 回归门禁:YAML 声明样本+断言,CLI 退出码即门禁;redteam 子命令可生成式扩样 | https provider 直接配 URL/body/响应提取,或 python:// 自定义 provider | 评测矩阵报告 + CI 退出码 |
| DeepTeam | Confident AI(构建于 DeepEval 之上) | 代码内嵌式红队框架:vulnerability × attack 组合扫描 | Python 回调包任意系统 | 与 DeepEval 指标打通 |
基准套件(不直接打防线,用于校准度量口径):
| 基准 | 规模 | 裁判 | 适用 |
|---|---|---|---|
| HarmBench | 510 条行为(标准/上下文/版权/多模态) | 微调分类器 | 跨方法横评,数字可比 |
| JailbreakBench | 100 条行为 | 标准化 judge 模型 | CI 回归、结果可复现 |
| StrongREJECT | 313 条违禁 prompt | 分级"有用性"评分器 | 区分"拒答"与"敷衍式漏答" |
选型结论(本篇的用法):garak 扫面拿 baseline(第三章),PyRIT 打点做变形+多轮(第四章),Promptfoo 固定样本进 CI 防退化(第五章),基准的 ASR 定义作为我们指标口径的参照(第 3.4 节)。
三、评测架构设计:把 Defense API 包装成 target
3.1 判据:什么叫"拦住"
响应 JSON 的 data[0] 是检测结果(DefenseApiResponse,safety/safety-basic/src/main/java/com/jd/security/llmsec/core/api/defense/DefenseApiResponse.java:23-54),关键字段:
评测判据就一行:riskCode != 0 记为拦截,否则记为穿透。注意两个边角:① riskCode=-1 是默认值,出现说明链路异常而非放行,统计时要单列"错误"桶;② riskMessage 在返回前会被拼上 riskCode 数字前缀(FunctionExecutorImpl.java:341-344),所以看到 "1006辱骂" 这种文案是正常的。
3.2 调用包装:签名问题怎么解
评测工具都不会帮你做 HMAC-SHA1 签名,两条路:
- 评测专用
test*accessKey(推荐):DefenseApiV2Controller.java:74的特赦——if (!accessKey.startsWith("test")) { defenseService.verify(request); }。演示环境直接用test业务即可,请求体只需content / responseMode=sync / messageInfo.fromRole=user三个必填项(checkParams只校验这些,DefenseServiceImpl.java:61-77;sync 模式连 sessionId 都不强制)。 - 正式 key:在工具和 JoySafety 之间垫一个签名 shim——直接复用
quickstart/api_test.py:26-36的do_sign()(签名串accessKey&accessTarget&requestId×tamp&plainText,HMAC-SHA1→Base64),包一层 FastAPI 转发即可。Promptfoo 场景更省事:python://provider.py自定义 provider 里 import 这段签名逻辑。
3.3 会污染评测数据的四个"坑"(全部落到源码)
这些不是工具问题,是 JoySafety 自身机制,设计评测方案时必须绕过或利用:
| # | 机制 | 源码坐标 | 对评测的影响与对策 |
|---|---|---|---|
| 1 | 识别结果缓存 15 分钟:user 单条请求按 业务名:MD5(content) 写 Redis,NX EX 900+抖动 | FunctionExecutorImpl.java:247,253-254;开关 ${execute.cache.enable},条件 fromUser && checkNum==1(:275-286) | 同一样本重复打会命中缓存,测不到真实链路。对策:每样本加随机尾缀(garak -g 1 且探针本身多样本);或反过来利用——CI 回归时固定样本跑两遍,第二遍全走缓存,可对比冷/热路径 |
| 2 | checkContent 超 2048 字中间截断:头 1024 + _c_ + 尾 1024,中段丢弃 | FunctionExecutorImpl.java:398,427-431;keyword/bert/kb/rag 四个 Function 送检的都是这个 checkContent(service/execute/functions/ 下 KeywordFunction.java:75、SingleLabelPredFunction.java:123 等) | 长文本攻击(many-shot、埋雷长文)的中段根本不进任何检测——这既是评测设计注意点,本身就是一个可验证的攻击面(第 4.4 节) |
| 3 | 业务级限流:Guava RateLimiter 按 accessKey 内存限流,test 业务 qpsLimit=20 | example/safety-skills/safety-knowledge/business_init.py:34;RateLimitServiceImpl.java:68-87 | garak 默认并发会触发限流,且 JoySafety 限流返回的是 HTTP 200 + code=rate_limited,garak 的 ratelimit_codes(默认只认 HTTP 429)抓不到。对策:--parallel_requests 1 低速打,后处理剔除限流样本;或评测时调大 qpsLimit |
| 4 | 超时丢结果:DAG 节点/原子能力两层 timeoutMilliseconds,超时节点结果被静默丢弃 | 02 篇坑 4;演示策略 bert 节点 2000ms(example/.../strategy_example.yml:53) | Mac CPU 上高负载扫描时 BERT 推理变慢 → 漏检率虚高。这不是防线被击穿,是防线"裸奔"。对策:扫描限速 + 归因时看 cost 字段;或按 02 篇附录 A/B 先调大超时 |
3.4 指标定义(与基准口径对齐)
- ASR(Attack Success Rate,攻击成功率) = 穿透样本数 ÷ 有效攻击样本数。防线视角的 拦截率 = 1 − ASR。基准套件(HarmBench/JailbreakBench/StrongREJECT)报的都是 ASR,对齐便于横向比较。
- 误判率(FPR):必须配 benign 对照组——一批正常业务话术(电商咨询、抱怨、法律条文摘抄,参照 multi_turn_detect 审核细则里那些"应判 0"的边界情形,
safety-knowledge/app/dialog/prompt.py:16-29),断言放行。只报拦截率不报误判率的红队报告没有意义:把阈值全调成 0.5 拦截率立刻飙升,代价是业务方被误杀。 - 有效样本:剔除限流(code=rate_limited)、链路异常(riskCode=-1)、工具自身错误的样本,别计入分母。
3.5 三种 target 包装(关键配置)
garak(rest 生成器)。注意 CLI 旗标在新版已从 --model_type/--model_name 改为 --target_type/--target_name(社区 issue 记载 v0.16 前后的变更,以你装的版本 --help 为准)。配置文件 joysafety_rest.json:
| |
逐行批注:$INPUT 是 garak 的 prompt 占位符(文档定义,连同 $KEY);response_json_field 以 $ 开头即按 JSONPath 解析,把"审核结论"提取成 garak 眼中的"模型回复";request_timeout 默认 20s,Mac 上 BERT 冷启动可能超时,放宽到 30。这里没有签名字段——test key 免验签(3.2 节)。garak 原生 detector 语义是"命中=发现漏洞",对我们场景"穿透=正常文本"才是漏洞:最省事的做法是不纠结自定义 detector,直接后处理 report.jsonl(附录 A);要原生集成就写一个继承 garak.detectors.base.StringDetector 的探测器,构造参数 substrings=["正常文本"](matchtype='str'、case_sensitive=False 为其默认参数)。
PyRIT(HTTPTarget)。用 Burp 风格原始报文模板,{PROMPT} 占位:
| |
批注:key="data[0].riskMessage" 是点路径提取(官方 AOAI 示例用 choices[0].message.content);配合 SubStringScorer(substring="正常文本"),score=True 即"穿透=攻击成功",语义正好对上。多轮/变形完整样例见第四章。
Promptfoo(https provider):
| |
{{prompt}} 是 promptfoo 的变量占位;transformResponse 把响应收敛成 riskCode 数字,后续断言直接判 output !== 0(完整配置见第六章)。
四、Baseline 实操:garak 打演示策略
4.1 先看清楚被测策略的真实面貌
注意一个文档与代码不符的点:docs/strategy_example.yml(03 篇引用过)是旧版,引用 fast20250710、bert_20250819 等名字;实际初始化容器灌进去的是 example/safety-skills/safety-knowledge/strategy_example.yml,节点如下:
| |
关键判定逻辑(该文件 :77-88 行)——阈值表就是防线的"松紧螺丝",红队评测很大程度上是在量化这颗螺丝:
三个先验观察(评测后要验证的假设):① 注入判定要求置信度 ≥0.99——边界注入样本(0.8~0.99)会系统性漏过;② robot 侧只有 keyword——机器人输出的暴恐/涉黄内容只要不含词表里的词就直接放行;③ DAG 里没有 kb_search / rag_answer / multi_turn_detect 节点(它们在 skills_init.py 注册了但策略没引用),演示防线实际是"词库 + 双 BERT"两层。
4.2 探针子集与命令
不要上来就 --probes all(数百模板、Mac 上 BERT 跑 CPU 会打到天荒地老)。按四类场景选子集:
映射关系:dan.*(越狱/角色扮演→注入模型)、promptinject.*(目标劫持→注入模型)、encoding(Base64/ROT13/hex/摩斯码变形→打词库与模型的分布外短板)、leakreplay(训练数据泄露回放→内容模型)。
4.3 报告解析:自己算 ASR
garak 每次运行落 *.report.jsonl,每行一个 attempt(含 prompt、outputs、detector 结果)。我们的判据在 outputs(即 riskMessage)里,附录 A 给出 20 行的后处理脚本,输出:每探针家族的 样本数 / 穿透数 / ASR / 限流剔除数。
4.4 漏检归因三分法
拿到漏检清单后逐条归因——同一条漏检,三种归因的修复动作完全不同:
| 归因 | 判定特征 | 修复动作 |
|---|---|---|
| 模型问题 | 原文直接送 BERT(绕开 DAG 直连 :8003/v1/bert/{model},02 篇坑 4 的二分法)也判正常或置信度低 | 换模型(14 篇横评)、红蓝军对抗重训(08 篇) |
| 阈值问题 | 直连模型置信度 0.85~0.99 之间,被 groovy 阈值表挡成"正常" | 调阈值(版本化流水线),并用 benign 组复核误判代价 |
| 编排问题 | 模型/词库本身能识别,但链路没走到:robot 侧无模型节点、2048 中段截断、超时丢结果、缺解码/归一化层 | 改 DAG:补节点、加 Function、调超时 |
基于源码机制,可以推演出预期漏检面(作为扫描结果的对照假设;实测数字与样本强相关,以你跑出来的 report 为准):
| 攻击面 | 预期表现 | 机制依据 |
|---|---|---|
| 词表内辱骂/违禁词 | 高拦截(~40ms 命中) | AC 自动机字面匹配 |
| 编码变形(Base64 违禁词) | 高穿透 | 词库是字面匹配;BERT 训练分布外;防线无解码归一化层 |
| 边界注入(置信度 0.8~0.99) | 系统性穿透 | thresholds_inject[201]=0.99 |
| 长文本中段埋雷(>2048 字) | 可验证的穿透 | FunctionExecutorImpl.java:427-431 中段截断 |
| robot 侧非词表风险输出 | 穿透 | 演示 DAG robot 分支只有 keyword |
五、PyRIT:多轮与变形攻击编排
5.1 变形攻击为什么专打这条防线的短板
garak 的 encoding 探针已经提示了方向,PyRIT 的 Converter 把变形做到了工业级:Base64Converter、TranslationConverter(翻译成小语种再进)、LeetspeakConverter、RandomCapitalLettersConverter、StringJoinConverter(字符间插符)等几十种,可自由串联。对 JoySafety 的威胁模型很直接:
- AC 自动机是字面多模式匹配(06 篇),任何编码/拆字/同音变形都天然免疫词库;
- BERT 分类器对变形文本处于训练分布外,置信度坍塌;
- 防线没有“先归一化再检测"的编排层——这是评测后要反哺的改进点(09 篇自定义原子能力正适合做解码前置 Function)。
5.2 变形批量攻击样例
PyRIT 在 2025 年后把 PromptSendingOrchestrator 演进为 PromptSendingAttack(老教程里的 Orchestrator 写法仍可见,以你所装版本的文档为准):
| |
未变形时该词 40ms 内被 keyword 拦截(01 篇案例 2);base64 化后词库失效——这条用例就是"变形是否击穿"的最小验证。
5.3 多轮渐进式攻击 vs multi_turn_detect
多轮攻击(RedTeamingAttack / Crescendo:LLM 攻击方逐轮套话)要打中 JoySafety 的多轮检测,有两个前置条件,都来自源码:
- 历史必须落在同一 sessionId 下。会话历史写 Redis list(
session:his:*,30 分钟过期,SessionService.java:16-17;写入在FunctionExecutorImpl.java:390→SessionServiceImpl.java:346-349)。注意纯 HTTPTarget 静态报文做不到"每个 conversation 一个 sessionId、每轮 messageId 自增”——这里要按官方 Creating Custom Targets 的方式继承PromptTarget写个自定义 target,在发送逻辑里维护 conversation→sessionId 映射和轮次计数,让 PyRIT 的一条攻击会话始终打进 JoySafety 的同一个会话。 - 演示 DAG 根本没挂 multi_turn_detect 节点(4.1 节已述)。能力已注册(
skills_init.py:77-87,type: multi_turn_detect,maxTurns: 20),要测它得先按版本化流水线改 DAG:在 bert 节点后追加一个 multi_turn_detect 节点并 groovy 判定。这也是本篇与 07 篇动手实验的衔接点。
挂好后,多轮链路是:MultiTurnDetectFunction(safety-api/.../service/execute/functions/MultiTurnDetectFunction.java:96-118)从 Redis 拉最近 maxTurns*2 条历史拼成 user/assistant 消息序列 → POST 给 safety-knowledge /dialog → 审核细则 prompt + 历史发 OpenAI 兼容 LLM → 正则抽 {label, reason} JSON(safety-knowledge/app/dialog/handlers.py:58-85)。
5.4 多轮检测的判定口径(指标设计必须对齐)
读 dialog/prompt.py:16-29 的审核细则会发现:它按整体对话判定伤害——“对话中其中一个角色存在对负面内容的纠正的,可以认为整体是正常对话(0)"。也就是说:攻击者前五轮套话、assistant 一直拒绝,判 0;只有 assistant 真的输出了有害内容才判 1(恶意对话)。
这决定了多轮评测的指标口径与单轮不同:multi_turn_detect 度量的是"伤害发生率”,不是"攻击拦截率"。Crescendo 前几轮单独送审全部放行是设计使然而非漏检。评测报告里要把"攻击尝试轮次"和"伤害发生轮次"分开统计,否则会得出"多轮防线全是洞"的错误结论。
六、CI 回归机制:Promptfoo 做门禁
6.1 一个关键分工:门禁用固定样本 eval,不用生成式 redteam
promptfoo redteam run(生成+评测一把梭)适合定期扩样——让它的攻击模型生成新样本、人工 review 后沉淀进固定样本库。但 CI 门禁必须用 promptfoo eval + 固定样本集 + 确定性断言:生成式攻击有随机性,门禁指标必须可 diff、可复现,否则基线劣化报警会淹没在噪声里。
6.2 门禁配置全文
| |
javascript 断言里的 output 就是 transformResponse 提取出的 riskCode。跑法:npx promptfoo@latest eval -c promptfooconfig.yaml,任何断言失败则退出码非 0——天然 CI 门禁。
6.3 三类触发时机与"60 秒生效窗口"
| 变更 | 生效机制 | CI 处理 |
|---|---|---|
| 模型升级(换 bert 版本) | 原子能力/策略变更走版本化流水线,api 60s 轮询重建 | 变更后 sleep 70s 再跑门禁(02 篇坑 5:部分变更需 docker restart safety-api) |
| DAG 策略/阈值变更 | 同上(DynamicConfigService.java:65) | 同上;建议门禁配置与被测策略同仓库管理,PR 即触发 |
| 词库更新 | keywords 服务 60s 轮询 MySQL 重建(SensitiveWordsManageServiceImpl.java:193) | upsert 后 sleep 70s;不需要重启任何服务 |
6.4 基线入库与劣化报警
promptfoo 输出 JSON 报告(promptfoo eval -o result.json)。门禁脚本解析出 拦截率/误判率 两个数,与基线文件(如 baseline.json 随仓库版本化)比较:
| |
劣化阈值建议:拦截率下降 >2 个百分点 或 误判率上升 >1 个百分点 即阻断合并。promptfoo 官方有 promptfoo/promptfoo-action 可直接用;被 OpenAI 收购后 MIT 协议与 CLI 形态未变,但长期治理值得在选型备注里留一笔。
七、结果反哺:让漏检样本长回防线里
红队评测的终点不是报告,是防线变厚。三条沉淀路径,全部有现成管理 API:
| |
API 字段细节见 safety-admin/docs/api/sensitive_words_api.md、knowledge_api.md。反哺后的样本必须留在 CI 门禁样本集里——否则下次词库清理、模型换版,同样的洞会再开一次。这就是"运营纪律"的落点:
| 节奏 | 动作 |
|---|---|
| 每次配置/模型变更(事件驱动) | 固定样本门禁(Promptfoo eval),拦截率劣化阻断 |
| 每周 | garak 探针子集扫面 + 探针库随工具升级,新漏检归因后反哺 |
| 每月 | PyRIT 多轮/变形打点 + promptfoo redteam run 生成式扩样,人工 review 入库 |
| 每季度 | 对照 OWASP LLM Top10 与基准(HarmBench/JailbreakBench)更新攻击面覆盖度映射表 |
附录 A:garak report.jsonl 后处理脚本(算 ASR)
| |
附录 B:关键源码坐标速查
| 主题 | 位置 |
|---|---|
| 审核入口 / test 免验签 | safety/safety-api/.../contoller/DefenseApiV2Controller.java:37 / :74 |
| 必填参数校验 | safety/safety-api/.../service/DefenseServiceImpl.java:61-77 |
| 限流(RateLimiter) | safety/safety-api/.../service/RateLimitServiceImpl.java:68-87 |
| 结果缓存 15min | safety/safety-api/.../service/execute/FunctionExecutorImpl.java:247,253-254 |
| checkContent 2048 中段截断 | 同上 :398,427-431 |
| 配置 60s 轮询重建 | safety/safety-api/.../service/DynamicConfigService.java:65 |
| 词库 60s 轮询重建 | safety/safety-skills/safety-keywords/.../SensitiveWordsManageServiceImpl.java:193 |
| 会话历史(30min) | safety/safety-api/.../service/session/SessionService.java:16-17 |
| multi_turn_detect 组装历史 | safety/safety-api/.../service/execute/functions/MultiTurnDetectFunction.java:96-118 |
| 多轮审核细则 prompt | safety/safety-skills/safety-knowledge/app/dialog/prompt.py:16-29 |
| 实际演示 DAG(勿与 docs/ 旧版混淆) | example/safety-skills/safety-knowledge/strategy_example.yml |
| 签名参考实现 | quickstart/api_test.py:26-36 |
注:03 篇坐标表中
engine/functions/的路径系笔误,实际包路径为service/execute/functions/,本篇已按实际代码修正。
附录 C:参考资料
- garak REST 生成器字段定义:garak.generators.rest 官方文档;CLI 旗标变更:garak issue #2131
- garak detector 基类(StringDetector 参数):garak.detectors.base
- PyRIT 自定义/HTTP target:Creating Custom Targets、HTTP Target;新版 Attack API:PyRIT 官方文档
- Promptfoo:HTTP provider、Red team strategies、CI/CD 集成;收购事件:Promptfoo alternatives 综述(含收购细节)
- OWASP LLM Top 10 2025 变化:cybersecify 解读
- 基准对比口径:HarmBench/JailbreakBench/StrongREJECT 对比
- DeepTeam:Confident AI 官方文档
