mcpField
T
triage

triage

github.com/anthropics安全7.7k1.1k 次安装12 周前更新
代码仓库

关于 triage

triage 是一项安全技能,用于结合源码审查原始扫描发现。它会验证并去重发现,按可利用性排序,并分配组件负责人。

主要功能

  1. 01结合源码验证发现
  2. 02跨扫描任务与扫描器去重
  3. 03按推导出的可利用性排序
  4. 04将发现分配给组件负责人
  5. 05支持交互与自动模式

适用场景

  1. 01审查原始安全扫描输出
  2. 02整理并排序多轮扫描的发现
  3. 03生成带负责人的修复清单

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/anthropics/defending-code-reference-harness --skill triage

安装后在 Claude Code 中输入/triage确认已加载。

附带文件fixtures/canary-findings.jsonREADME.md

详细介绍

triage

对原始安全扫描器输出进行对抗式分诊。完成四项工作: 验证每项发现是否属实,去重不同运行和扫描器报告的重复项, 根据推导出的可利用性而非扫描器声称的严重性排序 剩余项,并将每项分配给相应的组件负责人。输出简短、排序且 已分配负责人的列表,而不是原始报告。

使用 /triage <findings-path> [--auto] [--votes N] [--repo PATH] [--fp-rules FILE] 调用。

参数(从 $ARGUMENTS 解析;不同运行时对 $1/$2 的位置参数展开 并不稳定):

  • 发现文件路径(第一个位置参数,必填):JSON 文件、包含 JSON 文件的目录、 VULN-FINDINGS.json、INCIDENTS.json、流水线的 results/<target>/<ts>/ 目录,或 Markdown 报告。
  • --auto:跳过访谈并使用默认值。默认模式为 交互式。
  • --votes N:每项发现的验证者投票数(默认值为 3;快速检查时使用 1, 高风险批次使用 5)。
  • --repo PATH:目标代码库的路径,只读(默认值为当前工作目录)。 验证需要访问源代码;如果无法访问所引用的文件,该技能会报错并停止。
  • --fp-rules FILE:将 FILE 的内容追加到验证者的排除规则列表中(第 3a 阶段)。 可用于组织特定的先例:“我们到处使用 Prisma ORM——仅关注原始查询中的 SQLi”、 “k8s 资源限制可防止 DoS” 等。纯文本,每行或每段一条规则。
  • --fresh:忽略 ./.triage-state/ 中已有的检查点,并从第 0 阶段开始。 如果存在检查点且未指定此标志,该技能会从上次完成的阶段恢复。

工具: Read、Glob、Grep、Write、Task、AskUserQuestion。Bash 仅允许用于 git、find、wc、ls、jq 和 python3 .claude/skills/_lib/checkpoint.py(检查点 I/O)。

不要执行目标代码。 不得构建、运行、安装依赖项或发送请求。 意外对真实目标生效的概念验证不可接受;“无法编写出可用的 PoC”也不是证明漏洞不可利用的有力证据。每项结论都必须基于 阅读源代码得出。此要求适用于主协调 Agent 和所有子 Agent; 在每个 Task 提示中都要包含此约束。对于高置信度的 HIGH 级 发现,应建议后续由人工编写 PoC。

不要访问网络。 不得查询软件包注册表、CVE 数据库或获取上游提交。

检查点(在 Phase 0 前及每个阶段后运行)

处理大量发现项时,完整运行可能会耗尽上下文或在中途触及速率限制,尤其是会生成 candidates × votes 个验证器的 Phase 3。阶段状态会持久化到 ./.triage-state/,这样新的 /triage 会话就能恢复运行,无需重新进行访谈或再次生成验证器。

所有检查点 I/O 都通过 python3 .claude/skills/_lib/checkpoint.py 完成(原子写入,并经过 JSON 验证)。绝不要直接使用 Write 工具写入 progress.json。绝不要通过 heredoc 或 stdin 传递数据;从目标内容派生的字符串可能与 heredoc 分隔符冲突,导致内容逸出到 shell。Write→--from 模式可确保从仓库派生的字节不会进入 Bash argv。

./.triage-state/ 中的状态文件:

  • progress.json — 恢复位置的唯一真实来源: {"status": "running"|"complete", "phase_done": N, "shards_done": [...]}。 恢复决策只读取此文件,绝不扫描 phase*.json 或分片文件(不得信任先前运行留下的过期文件)。
  • phaseN.json — 阶段 N 的数据载荷(架构见下文各阶段部分的末尾)。
  • _chunk.tmp — 临时载荷缓冲区;每次调用 save/shard/append 前都会覆盖。

运行开始时 — 恢复检查。 Bash: python3 .claude/skills/_lib/checkpoint.py load ./.triage-state

  • status == "absent" 或 "complete",或 $ARGUMENTS 中包含 --fresh → 从头开始。 Bash: python3 .claude/skills/_lib/checkpoint.py reset ./.triage-state, 然后进入 Phase 0。
  • status == "running" 且 phase_done == N → 恢复运行。 按顺序读取 ./.triage-state/phase0.json 至 phaseN.json(以及 shards_done 中列出的所有 shard_*.json 文件),将键合并到工作状态中(后面的文件覆盖前面的文件——检查点可能只包含增量)。输出 Resuming from checkpoint: Phase N complete (./.triage-state/phaseN.json), 并直接跳到 Phase N+1。

每个阶段 N 结束时。 调用两项工具:

  1. Write 工具 → 将该阶段的输出 JSON 写入 ./.triage-state/_chunk.tmp(架构见下文各阶段部分的末尾)。
  2. Bash → python3 .claude/skills/_lib/checkpoint.py save ./.triage-state <N> <name> --from ./.triage-state/_chunk.tmp

运行结束时。 写入 TRIAGE.json 和 TRIAGE.md 后,运行 Bash: python3 .claude/skills/_lib/checkpoint.py done ./.triage-state 6


Phase 0:模式选择与访谈

0a. 解析参数

从 $ARGUMENTS 中提取发现项路径(第一个位置参数)、--auto 标志、--votes N(默认值为 3)、--repo PATH(默认值为 .)、--fp-rules FILE(默认值为 none)。如果未提供发现项路径,请用户提供并停止。 如果提供了 --fp-rules,现在读取该文件,并将其内容作为 context.extra_fp_rules,以便注入 Phase 3a 验证器提示词。

0b. 交互模式(默认):访谈用户

除非传入了 --auto,否则使用 AskUserQuestion 收集会影响验证和排序的上下文。将问题合并为一到两次调用,每次最多四个问题。通过“Other”选项接收自由文本回答;多选选项仅作提示,不构成限制。

第 1 轮(单次 AskUserQuestion 调用):

  1. 环境与信任边界(标题 Environment,单选) 这些发现来自哪类系统?不可信输入从何处进入系统? 选项:面向互联网的 Web 服务(HTTP 不可信)、 内部服务(调用方是经过身份验证的对等方)、 库 / SDK(调用方是信任边界)、 CLI / 批处理工具(操作者输入可信,文件输入不可信)、 嵌入式 / 固件(将物理访问纳入范围)。 可达性会根据此边界判定;在多租户 Web 服务中,“来自环境变量的命令注入”是真阳性,而在操作者使用的 CLI 中则是规则 8 判定的假阳性。

  2. 威胁模型(标题 Threat model,多选) 对于此系统,最坏情况下的攻击者是什么样的?哪些情况绝不能发生?最好使用自由文本回答。 选项:未认证的远程代码执行、 租户间数据泄露、 权限提升至管理员、 下游用户遭遇供应链攻击、 针对付费 SLA 的拒绝服务、 合规范围内的数据暴露(PII / PCI / PHI)。 第 4 阶段会提高与已声明威胁相对应的发现项的优先级。

  3. 评分标准(标题 Scoring,单选) 输出中应如何表示严重性? 选项:根据前置条件推导 HIGH/MEDIUM/LOW(默认)、 CVSS v3.1 向量 + 基础分数、CVSS v4.0 向量 + 基础分数、 OWASP 风险评级(可能性 x 影响)、 组织缺陷门槛(在“Other”中描述)。 始终会计算前置条件规则;此选项控制 severity_label 是否额外显示其他信息。

  4. 噪声容忍度(标题 Noise tolerance,单选) 验证者意见不一致时,平票应如何处理? 选项: 精确率:丢弃未获多数确认的内容(减少误报,但可能漏掉真实缺陷)、 召回率:将意见不一的结果保留为 needs_manual_test(需要审查的内容更多,漏报更少)、 遇到时逐项询问我。

第 2 轮(有条件):如果威胁模型的回答为空或过于笼统,或者评分答案为 Organization bug-bar,则提出一个有针对性的后续问题。

将回答记录为一个贯穿各阶段并在输出中以 triage_context 字段回显的 context 字典。

0c. 自动模式默认值

设置了 --auto 时,不调用 AskUserQuestion。使用:

  • Environment:Unknown. Treat any externally-reachable entry point as untrusted; flag trust-boundary assumptions explicitly in rationale.
  • Threat model:空(不提高优先级)。
  • Scoring:推导 HIGH/MEDIUM/LOW。
  • Noise tolerance:精确率。

**检查点:**写入工具 → ./.triage-state/_chunk.tmp:

{"phase": 0, "context": {mode, environment, threat_model, scoring, noise_tolerance, votes_per_finding, repo, findings_path}}

然后运行 Bash: python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 0 interview --from ./.triage-state/_chunk.tmp 从第 0 阶段之后恢复时,不会重新询问访谈问题;context 会从此文件恢复。


第 1 阶段:摄取并规范化

无论输入格式如何,都将输入转换为包含稳定 ID 的扁平 findings[] 列表。

1a. 检测输入形态

检查 findings 路径:

  • 目录:对 **/*.json 和 **/*.jsonl 执行 Glob。按以下优先级识别容器:
    • VULN-FINDINGS.json({findings: [...]} 容器):读取 .findings[]。
    • INCIDENTS.json(/dnr-hunt / /dnr-respond 的输出):读取 .incidents[];每个 incident 都是一条 finding。映射嵌套字段 — vuln.file → file、vuln.line → line、vuln.type → category、 vuln.function → function、description ← vuln.mechanism 加上 impact — 并保留 id、title、verdict 和 poc;1b 中的别名会将 poc 路由到 exploit_scenario,而 confirmed_exploited verdict 表示该 incident 已经过执行验证,并非静态 猜测 — 验证器应对此给予更高权重。顶层的 ruled_out 数组不是 findings — 不要摄取。没有 vuln.file 的 incident 遵循 1b 中与其他 finding 相同的 file 缺失规则。
    • reports/bug_*/report.json 或 reports/manifest.jsonl(此仓库的 流水线输出):每个 bug_NN 对应一条 finding。将 crash.crash_type → category、verdict.severity_rating → severity、文本形式的 report → description,以及 ASAN 顶部帧中的崩溃文件映射到 file/line。
    • found_bugs.jsonl:每行对应一条 finding。
    • 任何其他顶层为对象列表,或包含 findings/results/issues/vulnerabilities 数组的 *.json:使用该数组。
  • 单个 .json / .jsonl 文件:按上述规则识别。
  • Markdown / 文本:按二级/三级标题或 --- 分隔线拆分;对于每个 部分,通过模式(File:、Line:、Severity: 标签或 path:NN 片段) 提取 file、line、category、severity、description。 尽力解析;将 source_format 标记为 "markdown_heuristic"。

如果没有找到可解析内容,则停止并报告检测到的内容。

1b. 规范化字段

对于每条原始记录,构建一个 finding 字典。**提取已有字段;绝不猜测缺失字段。**字段映射(源键别名 → 规范字段):

规范字段也接受
filepath、location.file、filename、ASAN 顶部帧文件
lineline_number、location.line、lineno
categorytype、cwe、rule_id、crash_type、vulnerability_class
severityseverity_rating、level、priority、risk
titlename、summary、message
descriptiondetails、report、body、evidence
exploit_scenarioattack_scenario、poc、reproduction
preconditionsrequirements、assumptions
recommendationfix、remediation、mitigation
scanner_confidenceconfidence、score、certainty(规范化为 0.0-1.0)

为每条 finding 附加:

  • id:按摄取顺序依次为 f001、f002、...。如果大多数 findings 都包含 scanner_confidence,则按其降序排列摄取顺序,以便优先验证高信号 findings(并优先显示在部分输出中);否则保持源顺序。这仅作为调度优先级 — 不会影响 verdict。
  • source:来源文件的相对路径,以及源格式。
  • missing_fields:缺失的规范字段列表。如果缺少 file,或该文件无法在 --repo 下解析,则该 finding 无法定位:跳过去重和验证,直接以以下内容输出:verdict: false_positive、 verify_verdict: needs_manual_test、confidence: 0、refute_reasons: ["doesnt_exist"]、 rationale: "no source location in input; cannot verify statically; human review required"。绝不要对无法定位的 finding 给出置信 verdict,也绝不要让它参与去重吸收或被其他 finding 吸收。
1c. 定位目标代码库

解析 --repo(默认值为当前工作目录)。对于前 5 条带有 file 的发现,检查其路径是否解析到代码库目录内。按以下顺序尝试:(a) 按原样使用 repo/file;(b) 将 file 作为绝对路径或相对于当前工作目录的路径;(c) 从 file 中剥除常见前缀(src/、app/、./ 或代码库自身的目录名,例如 --repo harness 时的 harness/grade.py),再使用 repo/file。记录成功的解析方式,并将其应用于每条发现。如果都无法解析,停止:告知用户验证需要访问源代码,而引用的文件无法访问,并根据可见内容中最长的公共后缀建议一个 --repo 值。

检查点: 将 tool 写入 ./.triage-state/_chunk.tmp:

{"phase": 1, "context": {...}, "findings": [ {normalized finding dicts with id/source/file/line/category/...} ], "path_resolution": "<which of a/b/c worked>"}

然后运行 Bash: python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 1 ingest --from ./.triage-state/_chunk.tmp


第 2 阶段:去重(验证前)

合并重复项,避免重复发现各自消耗 N 个验证者。

2a. 确定性处理(内联执行,不使用子 Agent)

将满足以下所有条件的发现归为一组:

  • file 相同(路径规范化后),并且
  • category 相同(忽略大小写并去除标点),并且
  • line 行号相差不超过 10。两者都缺失时视为匹配;仅一方缺失时不匹配(没有行号的记录不得合并有行号的记录)。

每组中,missing_fields 最少的记录作为规范记录;若并列,则选择 id 最小的记录。其他每条记录都标记为 verdict: duplicate、duplicate_of: <canonical id>,并从工作集移除。在规范记录上以 absorbed: [...] 记录重复项的 ID。

2b. 语义处理(仅当剩余组数大于 1 时,使用一个子 Agent)

启动一个 Task,设置 subagent_type: "general-purpose",并使用以下提示词:

You are deduplicating security findings before expensive verification. Two
findings are DUPLICATES if fixing one would also fix the other. Two findings
are DISTINCT if they have genuinely independent root causes, even if they
share a category or file.

Treat as DUPLICATE:
- Same root cause described with different wording or by different scanners
- A shared vulnerable helper function reported once per call site
- A missing global protection (auth check, output encoding) reported once
  per endpoint that lacks it
- A cause ("missing input validation on `name`") and its consequence
  ("SQL injection via `name`") in the same code path

Treat as DISTINCT:
- Different categories in the same file region (an "ssrf" near a
  "buffer_overflow" is not a duplicate just because the lines are close)
- Same file, same category, but different tainted variables reaching
  different sinks
- Same helper, but two independent bugs inside it
- Two endpoints missing the same check, where the fix is per-endpoint
  rather than a shared gate

Below are the candidate findings (one per line: id | file:line | category |
title). Group them. Respond with ONLY lines of the form:

  GROUP: <canonical_id> <- <dup_id>, <dup_id>, ...

One line per group that has duplicates. Omit singletons. Pick the most
specific / best-described finding as canonical. No prose.

CANDIDATES:
{one line per surviving finding: "f003 | src/auth.py:112 | sql_injection | User lookup concatenates name into query"}

解析 GROUP: 行。对于每行,将列出的重复项 ID 标记为 verdict: duplicate、duplicate_of: <canonical>,将其追加到规范记录的 absorbed 中,并从工作集中移除。

保留 candidates[] = 剩余的规范记录。

检查点: 将 tool 写入 ./.triage-state/_chunk.tmp:

{"phase": 2, "context": {...}, "findings": [ {all findings; duplicates carry verdict/duplicate_of} ], "candidates": ["f001", "f003", "..."]}

然后运行 Bash: python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 2 dedup --from ./.triage-state/_chunk.tmp


第 3 阶段:验证

对于每个候选项,N 个相互独立的对抗式验证者从代码中重新推导声明并投票。每个验证者的立场都是“找出任何能证明其错误的理由”。每位验证者都从引用位置的代码入手,而不是从扫描器的描述入手,并且不会看到其他验证者的推理(共享上下文会传播盲点)。

3a. 验证者提示词(组装一次,供每次启动复用)
You are a skeptical security engineer adversarially verifying ONE finding
from an automated scanner. Your default assumption is that the scanner is
WRONG. Your job is to re-derive the claim from the source code yourself and
decide TRUE_POSITIVE or FALSE_POSITIVE.

You have read-only access to the target codebase at: {REPO_PATH}
You may use Read, Glob, and Grep, but ONLY on paths inside {REPO_PATH}.
Do NOT read, grep, or glob outside that root: anything outside it (the
triage pipeline itself, scanner outputs, fixtures, other repos on disk) is
out of scope and citing it contaminates your verdict. If a finding's
`file` resolves outside {REPO_PATH}, return CANNOT_VERIFY with
REFUTE_REASON: doesnt_exist. You may NOT build, run, or test the target,
install dependencies, or reach the network. Every conclusion must come
from reading source under {REPO_PATH}.

ENVIRONMENT (from the operator; this defines the trust boundary):
{context.environment or "Unknown. Treat any externally-reachable entry point as untrusted."}

────────────────────────────────────────────────────────────────────────
PROCEDURE: follow all four steps. Each exists because skipping it lets a
specific false-positive class through.

1. READ THE CODE AT THE CITED LOCATION YOURSELF.
   Open {file} at line {line}. Understand what the code actually does. Do
   NOT trust the scanner's description: scanners misread code surprisingly
   often, and if you start from the summary you inherit the misreading.

2. TRACE REACHABILITY BACKWARDS FROM THE SINK.
   Grep for callers of this function/method. Follow imports. Establish
   whether attacker-controlled input (per the ENVIRONMENT above) can
   actually reach this line. A plausible-sounding chain is NOT enough: for
   at least the FIRST link in the chain, READ the actual call site and
   QUOTE the file:line in your rationale. Unreachable code is the single
   largest false-positive source.

3. HUNT FOR PROTECTIONS.
   Actively look for reasons the finding is WRONG:
   - Input validation / sanitization upstream of the sink
   - Framework auto-escaping, parameterized queries, prepared statements
   - Type constraints (the value is an int, an enum, a fixed-length token)
   - Authentication / authorization gates before this path
   - Configuration that limits exposure (feature flag off, debug-only)
   - Dead code, test-only code, example/fixture code

4. STRESS-TEST EACH PROTECTION.
   For each protection you found: is it applied on EVERY path to the sink,
   or only the one the scanner happened to trace? Are there encodings,
   edge cases, or alternate entry points that bypass it?

────────────────────────────────────────────────────────────────────────
EXCLUSION RULES: if the finding matches any of these, it is FALSE_POSITIVE
even if technically accurate. Cite the rule number in your verdict.

  1. Volumetric DoS or missing rate-limiting (handled at infrastructure
     layer). ReDoS, algorithmic complexity, and unbounded recursion ARE
     still valid findings.
  2. Test-only code, dead code, example/fixture code, or a crash with no
     security impact.
  3. Behavior that is the intended design (compression middleware, a
     backward-compatible weak algorithm offered alongside a strong one).
  4. Memory-safety concerns in memory-safe languages outside `unsafe` /
     FFI blocks.
  5. SSRF where the attacker controls only the path, not the host or
     protocol.
  6. User input flowing into an AI/LLM prompt (prompt injection is not a
     code vulnerability in the target).
  7. Path traversal in object storage (S3/GCS) where `../` does not escape
     a trust boundary.
  8. Trusted inputs used as the attack vector (env vars, CLI flags set by
     the operator), UNLESS the ENVIRONMENT above marks them untrusted.
  9. Client-side code flagged for server-side vulnerability classes.
 10. Outdated dependency versions (managed by a separate process).
 11. Weak random used for non-security purposes (jitter, shuffling,
     dev-only fallbacks).
 12. Low-impact nuisance issues (log spoofing, CSRF on logout, self-XSS,
     tabnabbing, open redirect, regex injection).
 13. Missing hardening or best-practice gap with no concrete exploit path
     (missing security headers, no audit logging, permissive config that
     isn't actually reached by untrusted input).
 14. XSS in a framework with default auto-escaping (React, Angular, Vue,
     Jinja2 autoescape=on) UNLESS the sink is a raw-HTML escape hatch
     (dangerouslySetInnerHTML, bypassSecurityTrustHtml, v-html, |safe).
 15. Identifiers that are unguessable by construction (UUIDv4, 128-bit+
     random tokens) flagged as "predictable" or "needs validation".
 16. Race conditions or TOCTOU that are theoretical only — no realistic
     window, or no security-relevant state changes between check and use.

{if context.extra_fp_rules: append here verbatim under an
 "ORG-SPECIFIC RULES:" heading}

────────────────────────────────────────────────────────────────────────
VERDICT: your response MUST end with EXACTLY this block:

  VERDICT: TRUE_POSITIVE | FALSE_POSITIVE | CANNOT_VERIFY
  CONFIDENCE: <0-10>
  REFUTE_REASON: <one of: doesnt_exist, already_handled,
    implausible_trigger, intentional_behavior, misread_code, duplicate,
    not_actionable, n/a>
  EXCLUSION_RULE: <1-16, org rule, or none>
  FIRST_LINK: <file:line of the first call site you read, or "none found">
  RATIONALE: <2-5 sentences citing specific file:line evidence for
    reachability, protections found/absent, and why each held or didn't>

TRUE_POSITIVE requires ALL of: path is reachable from untrusted input per
the ENVIRONMENT; protections are insufficient or bypassable; real-world
exploitation is feasible.

FALSE_POSITIVE requires ANY of: unreachable from untrusted input;
adequately protected on all paths; scanner misread the code; an exclusion
rule applies.

CANNOT_VERIFY: static reasoning genuinely hit its limit (e.g. behavior
depends on runtime configuration you cannot read, or the code path crosses
into a binary you cannot inspect). Use sparingly; it must not become the
default.
3b. 为每个候选项生成 N 个验证器,并放在同一条消息中

对于 candidates[] 中的每个发现项,构建 N 个 Task 调用(N = --votes, 默认值为 3),并设置 subagent_type: "general-purpose" 和 description: "verify {id} vote {k}/{N}"。

务必设置 subagent_type;切勿 fork。 省略 subagent_type 会 fork 协调器,而 fork 会继承完整的对话上下文:其他所有发现项的描述、扫描器的说明,以及此前验证器的结果。这会破坏验证器的独立性,并重新引入本阶段旨在避免的继承框架失效模式。每个验证器都必须从全新、空白的上下文开始,并且只能接收 3a 提示词以及正在审核的单个发现项。4a 中的排名子 Agent 也遵循同样的要求。

每个提示词都是 3a 中的验证器提示词,并在末尾附上此代码块:

────────────────────────────────────────────────────────────────────────
FINDING UNDER REVIEW (from the scanner; treat as a CLAIM, not a fact):

  id:        {id}
  file:      {file}
  line:      {line}
  category:  {category}
  severity (claimed): {severity}
  title:     {title}

  description:
  {description}

  exploit_scenario:
  {exploit_scenario or "(not provided)"}

  preconditions (claimed):
  {preconditions as bullets or "(not provided)"}

You are vote {k} of {N}. You have NOT seen the other verifiers' reasoning
and you must NOT try to find it. Work independently from the code.

将所有验证器 Task 调用放在同一条 assistant 消息中,以便它们并发运行。不要设置 run_in_background;你需要最终文本,而不是异步句柄。如果 len(candidates) * N 超过约 40,则分成多个依次执行的批次,每批约 40 个,但每个批次仍须放在单条消息中。

大规模处理提示词长度。 3a 提示词约有 1200 个单词。当 candidates * votes > ~50 时,改用此精简形式(流程和输出约定相同,删去说明性文字):

Adversarially verify ONE scanner finding. Default: scanner is WRONG.
Read-only access scoped to {REPO_PATH} ONLY. No exec, no network.
ENVIRONMENT: {context.environment}

Steps: (1) Read {file}:{line} yourself; don't trust the description.
(2) Trace callers backwards; quote the first call-site file:line.
(3) Hunt for protections: validation, escaping, type bounds, auth gates,
dead/test code. (4) Stress-test each protection on every path.

Exclusion rules (FALSE_POSITIVE if matched): 1 volumetric DoS;
2 test/dead/fixture code; 3 intended design; 4 memory-safety in safe
lang outside unsafe/FFI; 5 SSRF path-only; 6 LLM prompt input;
7 object-storage traversal; 8 trusted operator env/CLI inputs;
9 client code, server vuln class; 10 outdated deps; 11 weak random
non-security; 12 low-impact nuisance (log spoof, open redirect, regex
inject); 13 missing-hardening-only, no concrete exploit; 14 XSS in
auto-escape framework w/o raw-HTML escape hatch; 15 unguessable
UUID/token flagged predictable; 16 theoretical-only race/TOCTOU.
{+ org rules from --fp-rules if any}

End with EXACTLY:
  VERDICT: TRUE_POSITIVE | FALSE_POSITIVE | CANNOT_VERIFY
  CONFIDENCE: <0-10>
  REFUTE_REASON: <doesnt_exist|already_handled|implausible_trigger|
    intentional_behavior|misread_code|duplicate|not_actionable|n/a>
  EXCLUSION_RULE: <1-16, org rule, or none>
  FIRST_LINK: <file:line or "none found">
  RATIONALE: <2-5 sentences, file:line cited>

FINDING: {id} {file}:{line} {category} (claimed {severity})
{title}
{description}
Vote {k}/{N}. Independent; do not seek other votes.

有 file 但没有 line 的发现项,无论 --votes 为何,都只分配一个验证器投票(扫描整个文件开销较大,且无法从投票中获益)。

如果任何 Task 调用返回 status: "async_launched" 而非验证器文本,说明运行时已将其置于后台(部分运行时会对大型并行批次自动执行此操作)。选择一种恢复方式,并对整个批次统一使用:

  • 如果你的对话中会收到完成通知:通知到达时,从其 result 中解析每个验证器的 VERDICT 块。 在所有投票都已统计完毕之前,不要结束本轮。
  • 如果收不到通知:不要轮询 transcript 文件。请在新的 Task 批次中重新启动缺失的验证器(缩小分片大小,例如 10),并使用同步结果。 2b 中的去重子 Agent 和 4a 中的排名子 Agent 也使用同样的恢复方式。
3c. 统计投票

对于每个候选项,解析其 N 个验证器各自输出的末尾区块 (兼容代码围栏和空白字符)。如果验证器出错、超时,或未生成可解析的 VERDICT 区块,则重新启动一次。如果重试仍失败,将该票计为 cannot_verify,并设 confidence: 0,同时在 refute_reasons 中 注明 "verifier_error"。其余 N-1 票仍然决定结果。

构建:

  • vote_breakdown:{"true_positive": x, "false_positive": y, "cannot_verify": z}
  • confidence:同意多数意见的投票中 CONFIDENCE 的平均值, 四舍五入到一位小数。
  • exclusion_rule:FALSE_POSITIVE 投票中出现次数最多的 EXCLUSION_RULE, 否则为 null。
  • refute_reasons:FALSE_POSITIVE 投票中的 REFUTE_REASON 值,去重并排序。
  • first_links:所有投票中的 FIRST_LINK 值,去重(可审计可达性的记录)。
  • rationale:胜出方中置信度最高的投票所给出的 RATIONALE,原文照录。

判定 verdict:

  • TRUE_POSITIVE 占多数 → verdict: true_positive。进入阶段 4。
  • FALSE_POSITIVE 占多数 → verdict: false_positive。跳过阶段 4。
  • 没有多数票(平票,或 CANNOT_VERIFY 占多数):
    • 噪声容忍度为 precision → verdict: false_positive;在 rationale 后追加 "(split vote, dropped under precision policy)"。
    • 噪声容忍度为 recall → verdict: true_positive,并设 verify_verdict: needs_manual_test。进入阶段 4。
    • 噪声容忍度为 ask → 收集所有存在分歧的发现,并在阶段 3 末尾通过一次 AskUserQuestion 调用展示(标题:id + title;选项:keep / drop),然后应用用户的选择。

构建 confirmed[] = verdict == true_positive 的候选项。

检查点: 使用 Write 工具写入 → ./.triage-state/_chunk.tmp:

{"phase": 3, "context": {...}, "findings": [ {all findings with verdict/vote_breakdown/confidence/refute_reasons/first_links/rationale/exclusion_rule} ], "confirmed": ["f001", "..."]}

然后使用 Bash: python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 3 verify --from ./.triage-state/_chunk.tmp

这是开销最大的检查点。当 len(candidates) * votes 超过 约 40,且验证器以分批顺序方式启动时,在统计每个候选项的投票时还需 逐个候选项设置检查点:

  1. 使用 Write 工具写入 → ./.triage-state/_chunk.tmp = 该发现统计投票后的字典。
  2. 使用 Bash: python3 .claude/skills/_lib/checkpoint.py shard ./.triage-state <id> --from ./.triage-state/_chunk.tmp

恢复时若 phase_done == 2,阶段 3 的入口会读取 progress.json:shards_done(默认为 [] —— 不要在磁盘上 glob 分片文件;之前运行留下的过期分片可能仍然存在),加载对应的 shard_{id}.json 文件,并且只为 phase2.json 中 candidates[] 里 不在 shards_done 中的 id 启动验证器。当所有候选项都已加入 shards_done 后,按上述方式写入合并后的 phase3.json 检查点。


阶段 4:按可利用性排序(仅限已确认的发现)

根据前提条件和可达性重新评定严重程度,而不是依据类别名称,并单独判断 扫描器声称的严重程度。验证和严重程度是彼此独立的判断;“这是真的” 不应被夸大成“这是严重问题”。

4a. 排序提示词

每个已确认的发现启动一个 Task(subagent_type: "general-purpose", 全部放在同一条消息中),内容如下:

You are assigning severity to a CONFIRMED security finding. Verification
already happened; assume the finding is real. Your only job is to derive
how bad it is, independently of what the scanner claimed.

You may Read/Grep the codebase at {REPO_PATH} to check preconditions. Do
NOT execute code.

ENVIRONMENT: {context.environment}
THREAT MODEL (operator-stated, may be empty):
{context.threat_model as bullets, or "(none provided)"}
SCORING STANDARD: {context.scoring}

FINDING:
  id:        {id}
  file:      {file}:{line}
  category:  {category}
  claimed severity: {severity}
  reachability evidence: {first_links from Phase 3}
  verifier rationale: {rationale from Phase 3}

────────────────────────────────────────────────────────────────────────
STEP 1: Enumerate EVERY precondition that must hold for exploitation.
Be concrete: required auth state, configuration, prior request, race
window, attacker position. Then state the minimum ACCESS LEVEL required
(unauthenticated remote / authenticated / local / physical).

STEP 2: Derive severity from the precondition count and access level:

  | Preconditions | Access required          | Severity |
  |---------------|--------------------------|----------|
  | 0             | Unauthenticated remote   | HIGH     |
  | 1-2           | Authenticated            | MEDIUM   |
  | 3+            | Local-only / no demo path| LOW      |

  Evaluate each column independently and take the LOWER result. Example:
  0 preconditions but authenticated-only is MEDIUM, not HIGH; 1
  precondition but local-only is LOW. Cross-check: if your preconditions
  list has 3+ items, HIGH is almost certainly wrong.

STEP 3: Threat-model match. If the THREAT MODEL is non-empty and this
finding maps onto one of its entries, note which one. A match may raise
severity by ONE step (LOW to MEDIUM or MEDIUM to HIGH), never two. If the
threat model is empty, skip this step.

STEP 4: Judge the scanner's claimed severity. From the perspective of an
engineer who has reviewed two hundred scanner findings this week and is
allergic to inflation: would the CLAIMED severity contribute to alert
fatigue? Is it comparable to a real CVE at that level? Is the code in test
fixtures or dev-only config? Score in -5..+5:
  +3..+5  claimed severity is justified or understated
   0..+2  roughly right
  -1..-3  inflated by one level
  -4..-5  badly inflated (LOW dressed as HIGH)

STEP 5: verify_verdict. Exactly one of:
  exploitable        preconditions are realistically satisfiable
  mitigated          real, but a deployed control reduces it below the
                     derived severity (name the control)
  needs_manual_test  severity hinges on something only a runtime test can
                     settle; recommend a human build a PoC

STEP 6: If SCORING STANDARD is a CVSS or OWASP variant, emit a
`severity_label` in that format (vector string + base score for CVSS;
likelihood x impact for OWASP). Otherwise set it equal to the derived
HIGH/MEDIUM/LOW.

────────────────────────────────────────────────────────────────────────
Respond with ONLY this block:

  PRECONDITIONS:
  - <one per line>
  ACCESS_LEVEL: <unauthenticated_remote|authenticated|local|physical>
  SEVERITY: <HIGH|MEDIUM|LOW>
  SEVERITY_LABEL: <per scoring standard>
  THREAT_MATCH: <matched threat-model entry, or none>
  SEVERITY_ALIGNMENT: <-5..+5>
  VERIFY_VERDICT: <exploitable|mitigated|needs_manual_test>
  RANK_RATIONALE: <2-4 sentences>
4b. 合并

对于每个已确认的发现,解析区块并附加 preconditions (替换扫描器提供的任何列表)、access_level、severity (重新评定)、severity_label、threat_match、severity_alignment、 verify_verdict,并将 RANK_RATIONALE 追加到 rationale 中(与阶段 3 的 rationale 之间以空行分隔)。

对于未进入阶段 4 的发现(false_positive、duplicate、 无法定位):设置 severity: null、verify_verdict: null、 severity_alignment: null、preconditions: []。

检查点: 使用 Write 工具写入 → ./.triage-state/_chunk.tmp:

{"phase": 4, "context": {...}, "findings": [ {all findings with severity/severity_label/preconditions/access_level/threat_match/severity_alignment/verify_verdict} ]}

然后使用 Bash: python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 4 rank --from ./.triage-state/_chunk.tmp


阶段 5:分配负责人

为每个已确认的 true-positive 标注可推断出的最具体组件或负责人。对于 confirmed[] 中的每个发现,按以下顺序检查,命中后即停止:

  1. CODEOWNERS / OWNERS。 在 --repo 中搜索 CODEOWNERS、OWNERS、.github/CODEOWNERS、docs/CODEOWNERS。如果找到,根据其中的模式匹配发现项的 file(最后匹配项优先)。提示: "CODEOWNERS:<pattern> -> <owner(s)>"。
  2. git log。 如果 --repo 是 git 检出目录,运行 git -C {REPO} log --format='%an' -n 50 -- "{file}" | sort | uniq -c | sort -rn | head -3。 提示:"主要提交者:<name>(近期提交中 <n>/<total> 次);无 CODEOWNERS 条目"。
  3. 模块回退方案。 提示:"组件:<文件的顶层目录>/;无 CODEOWNERS 或 git 历史记录"。

将结果附加为 owner_hint。注明来源,以便明确置信度;单独的用户名不如 "组件:auth/;无 CODEOWNERS 条目;主要提交者 jsmith(近期提交中 14/20 次)" 有用。对于非 true-positive 的发现,设置 owner_hint: null。

检查点: 使用 Write 工具 → ./.triage-state/_chunk.tmp:

{"phase": 5, "context": {...}, "findings": [ {all findings with owner_hint} ]}

然后运行 Bash: python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 5 route --from ./.triage-state/_chunk.tmp


阶段 6:输出

6a. 排序

按以下顺序排列所有发现:

  1. verdict:先 true_positive,再 duplicate,最后 false_positive。
  2. 对于 true-positive:按 severity 从高到低排序(HIGH > MEDIUM > LOW),然后按 confidence 降序,最后按 severity_alignment 降序。
  3. 对于其他项:按原始 id 排序。
6b. 写入 ./TRIAGE.json
{
  "triage_completed": true,
  "triage_context": {
    "mode": "interactive|auto",
    "environment": "...",
    "threat_model": ["..."],
    "scoring": "...",
    "noise_tolerance": "...",
    "votes_per_finding": 3,
    "repo": "..."
  },
  "summary": {
    "input_count": 0,
    "duplicates": 0,
    "false_positives": 0,
    "true_positives": 0,
    "needs_manual_test": 0,
    "by_severity": {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
  },
  "findings": [
    {
      "id": "f001",
      "source": "VULN-FINDINGS.json#0",
      "title": "...",
      "file": "...",
      "line": 0,
      "category": "...",
      "claimed_severity": "HIGH",
      "verdict": "true_positive|false_positive|duplicate",
      "verify_verdict": "exploitable|mitigated|needs_manual_test|null",
      "confidence": 0.0,
      "severity": "HIGH|MEDIUM|LOW|null",
      "severity_label": "...",
      "severity_alignment": 0,
      "preconditions": ["..."],
      "access_level": "...",
      "threat_match": "...|null",
      "rationale": "file:line-cited prose: reachability, protections, why each held or didn't; then ranking rationale",
      "vote_breakdown": {"true_positive": 0, "false_positive": 0, "cannot_verify": 0},
      "refute_reasons": ["..."],
      "exclusion_rule": null,
      "first_links": ["file:line", "..."],
      "duplicate_of": null,
      "absorbed": ["..."],
      "owner_hint": "...",
      "missing_fields": ["..."]
    }
  ]
}

每个输入发现都必须且仅出现一次(重复项通过 duplicate_of 引用其规范项)。不要悄悄丢弃任何内容。不要将此 JSON 打印到终端;仅写入文件。

6c. 写入 ./TRIAGE.md

面向审核者的报告。逐步构建。不要一次性写入整个文件。每个发现写入一个区块;即使某个区块卡住,也只会丢失该区块,而不会影响整个文件。

步骤 1 — 标题。 使用 Write 工具 → ./TRIAGE.md(覆盖任何已有文件),仅包含标题区块、摘要和 ## 需要处理 标题:

# Triage Report

{summary line: N in -> D duplicates, F false positives, T confirmed (H high / M med / L low), X need manual test}

Context: {mode}; environment = {environment}; scoring = {scoring}; {votes}-vote verification.

## Act on these

步骤 2 — 每个发现。 按严重程度顺序处理每个 true_positive:

  1. 使用 Write 工具 → ./.triage-state/_chunk.tmp,其中包含一个发现的区块:
### [{severity}] {title}  ({id})
`{file}:{line}` | {category} | claimed {claimed_severity} (alignment {severity_alignment:+d}) | confidence {confidence}/10
**Owner:** {owner_hint}
**Verdict:** {verify_verdict}, votes {vote_breakdown}
**Preconditions ({n}):** {bulleted}
**Threat-model match:** {threat_match or "none"}
**Why:** {rationale}
**Reachability evidence:** {first_links}
{if verify_verdict == needs_manual_test:}
> Recommend a human build a PoC; static reasoning hit its limit.
  1. 运行 Bash: python3 .claude/skills/_lib/checkpoint.py append ./TRIAGE.md --from ./.triage-state/_chunk.tmp

对每个 true_positive 重复此操作。

步骤 3 — 页脚。 使用 Write 工具 → ./.triage-state/_chunk.tmp,其中包含 Dropped 表,然后以相同方式运行 checkpoint.py append:

## Dropped

| id | title | file:line | why dropped |
{false_positives: refute_reasons + exclusion_rule}
{duplicates: "duplicate of {duplicate_of}"}
{unlocatable: "no source location in input"}

检查点(最终): 运行 Bash: python3 .claude/skills/_lib/checkpoint.py done ./.triage-state 6 下次调用时,恢复检查会看到 status == "complete" 并从头开始。

6d. 终端摘要

约 12 行以内:

Triage complete: {N} findings -> {T} confirmed, {F} false positives, {D} duplicates.

  HIGH:   {n}   {title of top HIGH, owner_hint}
  MEDIUM: {n}
  LOW:    {n}
  Needs manual test: {n}

  Top refute reasons: {top 3 refute_reasons with counts}

Wrote ./TRIAGE.md and ./TRIAGE.json

测试此技能

冒烟测试(包含五个发现的 fixture:2 个真实问题、1 个重复项、2 个 FP):

/triage .claude/skills/triage/fixtures/canary-findings.json --auto --repo targets/canary

预期结果:确认 f001 和 f003;f002 是 f001 的重复项;丢弃 f004 (misread_code:这是一个读取缓冲区,而非随机数来源);丢弃 f005 (already_handled:第 68 行有空值检查)。

或者使用流水线输出:

vuln-pipeline run drlibs --runs 3 --parallel --stream
/triage results/drlibs/<ts>/ --repo targets/drlibs

人工检查部分 TRUE_POSITIVE/HIGH 结果(first_links 应指向真实的调用位置)以及部分 FALSE_POSITIVE 拒绝项(exclusion_rule 或 refute_reasons 应有充分依据)。


设计说明

  • 检查点是按阶段保存的 JSON,不是对话状态。管道的 --resume <session_id>(docs/pipeline.md)会恢复对话记录,但 无法解决编排器自身的上下文窗口已满的问题; 基于文件的检查点可让全新会话从上一个已完成阶段继续。 ./.triage-state/ 是临时目录——请将其添加到 .gitignore。
  • 先去重,再验证,以重复率为倍数降低验证器的开销(多扫描器输入时通常可降低 2–4 倍),代价是多调用一个廉价的 子 Agent。
  • 语义去重由一个 Agent 完成,它只能获取 id/file/line/category/title: 这些信息足以进行聚类,但不足以让某个扫描器的推理泄露到 其他发现的验证过程中。
  • 仅在有限范围内允许使用 Bash,用于执行 git log(所有者提示)、jq/find (导入)以及 python3 .claude/skills/_lib/checkpoint.py(状态 I/O)。 实际的安全保障是“不执行目标代码”,这一点仍然成立。
  • CANNOT_VERIFY 的存在是为了避免强迫验证器做出错误的二元判断。根据召回策略,它会映射为 needs_manual_test;根据精确率策略,则会被舍弃。
  • 威胁模型带来的提升最多为一个级别,这样已声明的威胁就不会让 LOW 重新升回 HIGH,从而绕过前置条件规则。
  • severity_label 与 severity 分开处理。 排序始终使用由前置条件推导出的 HIGH/MEDIUM/LOW;标签仅用于展示,遵循审查者工具所需的标准。
  • 导入管道的 report.json 时尽力而为。 这些报告描述的是 ASAN 崩溃,并附有利用可能性的文字分析,而不是静态验证器所需的文件/行号/类别结构。与静态扫描器 JSON 相比,这类输入产生的 needs_manual_test 判定会更多。
  • 分片时并行 Task 数量约为 40,这是针对典型 Agent 派生限制所设的保守上限;如果运行环境允许,可以调高。
  • 刻意不使用网络。 补充 CVE 数据库信息和检查上游修复有助于排序,但会破坏隔离环境审查这一特性。

评论

支持 Markdown