SDK:漏洞检测 Agent

自动扫描代码中的安全漏洞

🌐 查看英文原文 | 源码 Notebook


漏洞检测 Agent

安全团队希望在攻击者之前发现内存安全 bug,但现有工具让这变得困难:静态分析器产生如此多的误报,以至于审查者不再阅读,而 fuzzer 在发现任何问题之前需要为每个入口点手写一个 harness。本 cookbook 展示了如何使用 Claude Agent SDK 构建一个漏洞发现 Agent,它使用 Claude Code 的内置 Read、Grep 和 Glob 工具读取源代码,推理哪些输入可能破坏内存,并写出审查者可以采取行动的发现。

通过本 cookbook,你将能够:

  • 运行一个先引导后访谈的威胁模型,作为写入 THREAT_MODEL.md 的多轮 ClaudeSDKClient 会话
  • 使用内置的 Read/Grep/Glob 工具驱动 Agent 发现循环,而不是手动实现的文件访问
  • 将发现、分类和报告作为独立的 query() 调用串联起来,输出符合 schema 的 JSON

前提条件

所需知识: - Python 基础知识,包括 async/await - 足够的 C 语言知识来阅读 45 行文件并识别 memcpy

所需工具: - Python 3.11+ - Node.js 18+ 和 Claude Code CLI:npm install -g @anthropic-ai/claude-code - Anthropic API 密钥(在此获取)

对于任何真实目标所需: 获得评估你所指向代码的授权。本 notebook 附带一个微小的自包含 canary.c,其中植入了 bug,因此你可以端到端运行所有内容而无需触及生产代码。

步骤 1:设置环境和参与上下文

我们定义一个 ENGAGEMENT_CONTEXT 块,作为每个 Agent 的 system_prompt 传递。它记录了此评估的范围(由代码所有者授权、隔离的只读沙盒、发现结果用于负责任的披露),因此管道中的每个步骤都针对相同的已记录基本规则运行。对于任何真实目标,保持这三个声明为真。

关于网络安全的说明: Claude 在 API 层应用实时网络安全保障。如果你在真实代码库上的工作触发了这些保障,请通过该页面申请网络安全验证计划(CVP):这是一个免费的基于申请的计划,让专业人士以最小的中断继续合法的双用途安全工作。

%%capture
%pip install -U claude-agent-sdk python-dotenv
import json
from collections.abc import AsyncIterator
from pathlib import Path

from dotenv import load_dotenv

from claude_agent_sdk import (
    AssistantMessage,
    ClaudeAgentOptions,
    ClaudeSDKClient,
    Message,
    ResultMessage,
    TextBlock,
    ToolUseBlock,
    query,
)

load_dotenv()

MODEL_NAME = "claude-opus-4-7"
# This notebook expects to be run from the claude_agent_sdk/ directory
# (Jupyter's default when you open the file from there). The assert makes
# the failure explicit if the kernel was started elsewhere.
TARGET_DIR = Path("vulnerability_detection_agent/canary").resolve()
assert TARGET_DIR.is_dir(), f"run this notebook from claude_agent_sdk/ (got cwd={Path.cwd()})"

ENGAGEMENT_CONTEXT = """\
## Engagement context

This is authorized security research conducted as a defensive security
assessment on a self-contained canary target vendored in this notebook. The
target is read-only source (no execution). Findings are collected for
demonstration and responsible-disclosure workflow testing.
"""


async def collect(stream: AsyncIterator[Message]) -> str:
    """Consume an Agent SDK message stream; print tool calls; return final text.

    Both ``query()`` and ``ClaudeSDKClient.receive_response()`` return an
    ``AsyncIterator[Message]`` that terminates after a ``ResultMessage``.
    This is the same ``async for msg in ...`` loop the other notebooks in this
    series write inline; it is factored out here because this notebook runs
    the loop four times (TM bootstrap, TM interview, find, triage) and the
    ``isinstance`` ladder would otherwise repeat verbatim.
    """
    final = ""
    async for msg in stream:
        if isinstance(msg, AssistantMessage):
            for block in msg.content:
                if isinstance(block, ToolUseBlock):
                    args = str(block.input)
                    args = args if len(args) <= 120 else args[:120] + "...}"
                    print(f"  [tool] {block.name} {args}")
                elif isinstance(block, TextBlock) and block.text.strip():
                    final += block.text
        elif isinstance(msg, ResultMessage) and msg.is_error:
            raise RuntimeError(msg.result)
    return final


print(f"Model: {MODEL_NAME}")
Model: claude-opus-4-7

步骤 2:加载金丝雀目标

vulnerability_detection_agent/canary/canary.c 是一个约 45 行的 C 程序,包含三个故意植入的内存安全 bug(堆缓冲区溢出、栈缓冲区溢出和释放后使用),每个都可以通过输入开头的不同”魔法字节”到达。bug 没有标记;步骤 4 中的发现 Agent 必须通过阅读逻辑来定位它们,就像在真实代码中一样。当你准备好尝试自己的代码时,将 TARGET_DIR 指向你的检出。

print((TARGET_DIR / "canary.c").read_text())
// canary.c
// Entry: ./canary <input_file>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void parse_alpha(const unsigned char *data, size_t len) {
    unsigned char *buf = malloc(32);
    memcpy(buf, data, len);
    printf("alpha: %02x\n", buf[0]);
    free(buf);
}

static void parse_bravo(const unsigned char *data, size_t len) {
    char name[16];
    memcpy(name, data, len);
    name[15] = 0;
    printf("bravo: %s\n", name);
}

static void parse_charlie(const unsigned char *data, size_t len) {
    char *p = malloc(64);
    if (len > 0 && data[0] == 0xff) {
        free(p);
    }
    memcpy(p, data, len < 64 ? len : 64);
    printf("charlie: %p\n", (void *)p);
}

int main(int argc, char **argv) {
    if (argc < 2) return 1;
    FILE *f = fopen(argv[1], "rb");
    if (!f) return 1;
    unsigned char buf[4096];
    size_t n = fread(buf, 1, sizeof buf, f);
    fclose(f);
    if (n < 1) return 1;
    switch (buf[0]) {
        case 'A': parse_alpha(buf + 1, n - 1); break;
        case 'B': parse_bravo(buf + 1, n - 1); break;
        case 'C': parse_charlie(buf + 1, n - 1); break;
        default: printf("unknown format\n");
    }
    return 0;
}

步骤 3:对目标进行威胁建模(先引导,后访谈)

威胁模型回答”这个系统可能出什么问题、谁会这样做、哪些结果重要?“,独立于任何具体的 bug。威胁(”攻击者通过不可信文件解析实现内存破坏”)在补丁后仍然存在;漏洞(“第 31 行没有对 len 进行边界检查”)则不会。发现循环寻找漏洞;威胁模型告诉它去哪里寻找,并告诉分类如何评分。

我们在一个 ClaudeSDKClient 会话的两轮中构建它:

  1. 引导。 Claude 使用内置的 Read 工具读取代码并起草模型(上下文、资产、入口点和信任边界、威胁,以及代码无法回答的开放问题)。
  2. 访谈。 应用所有者回答开放问题,Claude 细化可能性和影响,然后在目标旁边写入 THREAT_MODEL.md。

将两轮保留在一个客户端会话中意味着访谈轮次可以看到引导的工具结果,而无需我们重新发送源代码。在 45 行的金丝雀上,两轮都很轻量;这里的重点是输出形状:入口点表(你在真实仓库上会跨并行发现 Agent 分区的内容)和开放问题列表(从引导到访谈的交接)。

(TARGET_DIR / "THREAT_MODEL.md").unlink(missing_ok=True)

TM_SCHEMA = """\
# Threat Model: <system name>
## 1. System context
## 2. Assets
| asset | description | sensitivity |
## 3. Entry points & trust boundaries
| entry_point | description | trust_boundary | reachable_assets |
## 4. Threats
| id | threat | surface | asset | impact | likelihood |
## 5. Open questions
- (things the code alone cannot answer: deployment context, which inputs are
  attacker-controlled in practice, blast radius)
"""

BOOTSTRAP_PROMPT = f"""\
You are bootstrapping a threat model from source code alone; no application
owner is available yet. Read `canary.c` in this directory and emit a draft
threat model in the schema below. Be explicit in section 5 about what you could
NOT determine from the code: those open questions are the agenda for the owner
interview. Do not write any files yet.

## Schema

{TM_SCHEMA}
"""

OWNER_ANSWERS = """\
- Deployment: `canary` is a local CLI that reads a file path from argv; it is
  not network-facing.
- Attacker control: the input file is fully attacker-controlled (think email
  attachment or downloaded file opened by the user).
- Blast radius: the process runs as the invoking user with no sandboxing;
  memory corruption is code execution as that user.
"""

INTERVIEW_PROMPT = f"""\
The application owner has now answered your open questions:

{OWNER_ANSWERS}

Refine the threat model: update likelihood and impact in section 4 using the
owner's answers, resolve every item in section 5 that the answers cover, and
add any new threats the deployment context implies. Keep the same schema, then
write the refined model to `THREAT_MODEL.md` in this directory.
"""

tm_options = ClaudeAgentOptions(
    model=MODEL_NAME,
    cwd=str(TARGET_DIR),
    system_prompt={"type": "preset", "preset": "claude_code", "append": ENGAGEMENT_CONTEXT},
    allowed_tools=["Read", "Write", "Edit"],
    disallowed_tools=["Bash"],
    permission_mode="acceptEdits",
)

async with ClaudeSDKClient(options=tm_options) as tm_agent:
    # collect() fully drains receive_response() through its terminating
    # ResultMessage, so the second query() sees a clean stream.
    await tm_agent.query(BOOTSTRAP_PROMPT)
    draft_tm = await collect(tm_agent.receive_response())
    print("--- bootstrap draft ---\n" + draft_tm + "\n")

    await tm_agent.query(INTERVIEW_PROMPT)
    await collect(tm_agent.receive_response())

tm_path = TARGET_DIR / "THREAT_MODEL.md"
if not tm_path.exists():
    raise RuntimeError("interview agent did not write THREAT_MODEL.md; check the trace above")
threat_model = tm_path.read_text()
print("--- refined THREAT_MODEL.md ---\n" + threat_model)
  [tool] Read {'file_path': 'vulnerability_detection_agent/canary/cana...}
--- bootstrap draft ---
`★ Insight ─────────────────────────────────────`
- The file dispatches on `buf[0]` into three parsers, each with a distinct memory-safety bug class: heap overflow, stack overflow, and use-after-free. This is a canonical "one bug per parser" canary.
- The `len` passed to each parser is `n - 1` (up to 4095), but buffers are sized 32/16/64, so every path is reachable with attacker-controlled overflow length from a single input file.
- Threat modeling from source alone can enumerate the *bug classes* and *entry points*, but it cannot tell you who supplies `argv[1]` in production — that determines whether these are local footguns or remote RCE primitives.
`─────────────────────────────────────────────────`

# Threat Model: canary (file-format parser CLI)

## 1. System context
A small C command-line utility invoked as `./canary <input_file>`. It opens the supplied path, reads up to 4096 bytes, and dispatches on the first byte (`A`/`B`/`C`) to one of three parsers. No network, IPC, or privilege-management code is present in the source. Deployment context (who runs it, who supplies the file, whether it is wrapped by a service) is unknown from the code alone.

## 2. Assets
| asset | description | sensitivity |
| --- | --- | --- |
| process memory / control flow | heap and stack of the `canary` process, including return addresses and heap metadata | high — corruption yields arbitrary code execution in the process's security context |
| input file contents | bytes read from `argv[1]`; format selector plus parser payload | low–medium (content itself); high as an attack vector |
| host execution context | whatever uid/role/container the binary runs as; file-system reach of that context | unknown — depends on deployment |

## 3. Entry points & trust boundaries
| entry_point | description | trust_boundary | reachable_assets |
| --- | --- | --- | --- |
| `argv[1]` path | caller-supplied filesystem path opened with `fopen(..., "rb")` | caller → process (path-traversal / symlink exposure depends on caller privilege) | any file readable by the process |
| file contents (first 4096 bytes) | `fread` into `buf`, dispatched by `buf[0]` | file producer → parser | process memory via all three parsers |
| `parse_alpha` payload (`A` prefix) | bytes 1..n copied via `memcpy` into a 32-byte heap buffer | untrusted file → heap | heap adjacent to 32-byte allocation |
| `parse_bravo` payload (`B` prefix) | bytes 1..n copied into a 16-byte stack buffer | untrusted file → stack | return address, saved frame pointer, stack canary (if enabled) |
| `parse_charlie` payload (`C` prefix) | bytes 1..n copied into 64-byte heap buffer; first byte may trigger early `free` | untrusted file → heap | freed chunk metadata, tcache/fastbin state |

## 4. Threats
| id | threat | surface | asset | impact | likelihood |
| --- | --- | --- | --- | --- | --- |
| T1 | Heap buffer overflow: `parse_alpha` copies up to `n-1` (≤4095) bytes into a 32-byte `malloc` without bounds check (canary.c:8-9) | `A`-prefixed input | process memory | heap corruption → potential RCE | high given a malicious file |
| T2 | Stack buffer overflow: `parse_bravo` copies up to `n-1` bytes into a 16-byte stack array (canary.c:15-16); the trailing `name[15]=0` does not prevent the overflow, only truncates the printed string | `B`-prefixed input | saved return address / stack | classic stack smash → RCE if no/bypassed stack protector | high |
| T3 | Use-after-free / double-free vector: `parse_charlie` frees `p` when `data[0]==0xff` then immediately `memcpy`s into the freed chunk (canary.c:23-26); the subsequent `printf` also leaks the freed pointer | `C`-prefixed input with first payload byte `0xff` | heap allocator metadata | UAF write → heap grooming, info leak of freed address | medium–high |
| T4 | Unchecked `malloc` return in `parse_alpha` and `parse_charlie` (canary.c:8, 22) | any `A` or `C` input under memory pressure | process stability | NULL-deref DoS | low in normal ops |
| T5 | Pointer disclosure via `printf("%p", p)` in `parse_charlie` (canary.c:27) | any `C` input | ASLR secret | leaks heap address, aids exploitation of T1/T3 | high when combined with T1/T3 |
| T6 | Path handling of `argv[1]`: no canonicalization or allow-list; process will open any readable path including symlinks and device nodes | caller-controlled argv | host files | info disclosure or hang (e.g., `/dev/zero`) depending on who runs it | unknown — depends on invoker privilege |
| T7 | Silent truncation: only the first 4096 bytes are read; parsers operate on truncated data, which can mask malformed-file detection upstream | any input | integrity of downstream decisions | logic bug, not memory-safety | low |

## 5. Open questions
- **Who supplies `argv[1]`?** Local user only, a setuid wrapper, a web upload handler, an MTA, a sandbox runner? This determines whether T1–T3 are local-only footguns or remote code-execution primitives.
- **What uid / capabilities / container does the binary run as?** Blast radius of successful RCE hinges on this (root vs. nobody vs. seccomp-confined).
- **Compiler and link flags in production builds:** is `-fstack-protector-strong`, `-D_FORTIFY_SOURCE=2`, PIE, RELRO, ASLR, or CFI enabled? These materially change exploitability of T2 and the usefulness of T5's leak.
- **Allocator in use** (glibc ptmalloc, musl, jemalloc, hardened_malloc). T3's UAF exploit primitives differ by allocator.
- **Is `canary` invoked directly or via a wrapper that validates the file first?** A front-end parser / size cap / magic-byte allow-list upstream could neutralize T1–T3.
- **Are crashes monitored?** Unchecked `malloc` (T4) and T6 DoS matter only if uptime is a requirement; source alone does not say.
- **Intended threat model for the format itself:** are `A`/`B`/`C` formats specified anywhere (docs, RFC, sibling files) so we can tell "malformed" from "adversarial"?
- **Expected lifetime and distribution** of the binary: is this a demo/test fixture (the name "canary" suggests so) or shipped to customers? Affects remediation priority and disclosure path.

  [tool] Write {'file_path': 'vulnerability_detection_agent/canary/THRE...}
--- refined THREAT_MODEL.md ---
# Threat Model: canary (local file-format parser CLI)

## 1. System context
`canary` is a local command-line utility invoked as `./canary <input_file>`. It
opens the supplied path, reads up to 4096 bytes, and dispatches on the first
byte (`A`/`B`/`C`) into one of three parsers. It is **not network-facing**. The
input file is **fully attacker-controlled** in the intended threat model —
typical delivery is a file received by email or downloaded from the web and
then opened by the local user. The process runs as the **invoking user with no
sandboxing**, so any memory-safety bug that yields control flow yields code
execution in that user's security context (home directory, SSH keys, browser
profile, cloud credentials, any writable path the user has).

## 2. Assets
| asset | description | sensitivity |
| --- | --- | --- |
| invoking user's account | uid, home directory, shell history, SSH keys, browser profile, cloud tokens, dotfiles, writable mounts | high — compromise equals full user takeover and a foothold for lateral movement |
| process memory / control flow | heap and stack of the `canary` process, return addresses, heap metadata | high — corruption is the exploitation primitive for the user-account asset |
| input file contents | bytes read from `argv[1]`; format selector plus parser payload | low as data; the primary *attack vector* |
| host integrity | persistence locations writable as the user (crontab, `~/.bashrc`, `~/.config/systemd/user`, login items) | high — trivially reachable post-exploitation; no sandbox to contain it |

## 3. Entry points & trust boundaries
| entry_point | description | trust_boundary | reachable_assets |
| --- | --- | --- | --- |
| `argv[1]` path | caller-supplied filesystem path opened with `fopen(..., "rb")` | local user → process (same uid; the boundary is between the untrusted *file content* and the parser, not between the caller and process) | any file readable by the user |
| file contents (first 4096 bytes) | `fread` into `buf`; dispatch on `buf[0]` | untrusted attacker (file author) → parser | process memory via all three parsers |
| `parse_alpha` payload (`A` prefix) | bytes 1..n copied via `memcpy` into a 32-byte heap buffer | untrusted file → heap | heap adjacent to 32-byte allocation |
| `parse_bravo` payload (`B` prefix) | bytes 1..n copied into a 16-byte stack buffer | untrusted file → stack | return address, saved frame pointer, stack canary (if enabled) |
| `parse_charlie` payload (`C` prefix) | bytes 1..n copied into a 64-byte heap buffer; first byte may trigger early `free` | untrusted file → heap | freed-chunk metadata, tcache/fastbin state |

## 4. Threats
| id | threat | surface | asset | impact | likelihood |
| --- | --- | --- | --- | --- | --- |
| T1 | Heap buffer overflow: `parse_alpha` copies up to `n-1` (≤4095) bytes into a 32-byte `malloc` without bounds check (canary.c:8-9) | `A`-prefixed attacker file | process memory → user account | critical — RCE as the invoking user; full account takeover, no sandbox containment | high — trivially reachable with a single crafted file |
| T2 | Stack buffer overflow: `parse_bravo` copies up to `n-1` bytes into a 16-byte stack array (canary.c:15-16); trailing `name[15]=0` only truncates the print, does not prevent the overflow | `B`-prefixed attacker file | saved return address → user account | critical — classic stack smash to RCE as the user; exploitability depends on stack-protector / PIE / ASLR in the shipped build | high |
| T3 | Use-after-free / double-free: `parse_charlie` frees `p` when `data[0]==0xff` then immediately `memcpy`s into the freed chunk (canary.c:23-26) | `C`-prefixed attacker file with first payload byte `0xff` | heap allocator metadata → user account | critical — heap-grooming primitive for RCE as the user | medium–high — reliability varies by allocator but the primitive is clean |
| T4 | Unchecked `malloc` return in `parse_alpha` and `parse_charlie` (canary.c:8, 22) | any `A` or `C` input under memory pressure | process stability | low — NULL-deref crash of a user-invoked CLI; annoyance, not compromise | low |
| T5 | Pointer disclosure via `printf("%p", p)` in `parse_charlie` (canary.c:27) | any `C` input | ASLR secret | high — directly hands the attacker a heap address, making T1/T3 reliable even with ASLR | high — unconditional on the `C` path |
| T6 | Path handling of `argv[1]`: no canonicalization or allow-list; `canary` opens any path the user can read, including symlinks and device nodes (e.g., `/dev/zero` hang) | caller-supplied argv | process stability / caller expectations | low — the caller is already the user, so there is no privilege boundary to cross via path tricks | low |
| T7 | Silent truncation: only the first 4096 bytes are read; malformed-file detection upstream can be bypassed by padding the exploit into the first 4 KB | any input | integrity of any upstream "scan then open" pipeline | low on its own; relevant if a scanner is put in front | low |
| T8 | **(new)** Social-engineering delivery: the attack surface is "user opens a file." Typical vectors are email attachments, messenger drops, and browser downloads. Any of T1/T2/T3 becomes a one-click RCE given a convincing lure | attacker-authored file delivered to the user | user account | critical — same as T1–T3; this threat names the delivery mechanism | high — the dominant real-world path to triggering T1–T3 |
| T9 | **(new)** File-association / handler registration: if `canary` is (or becomes) the registered handler for a file extension or MIME type, double-clicking a download auto-invokes it, removing the need to coach the user into a shell command | OS file-association layer | user account | critical — converts T8 from "run this binary on this file" to "open the attachment" | unknown without deployment config; flagged for owner |
| T10 | **(new)** Post-exploitation blast radius: no sandbox, so RCE in `canary` immediately has the user's full ambient authority — SSH keys, browser cookies/session tokens, cloud CLI credentials (`~/.aws`, `~/.config/gcloud`), persistence via `~/.bashrc`, user-level cron, user systemd units, login items | any of T1/T2/T3 succeeding | user account, connected systems | critical — lateral movement into email, cloud, source control is trivial from here | high conditional on T1/T2/T3 |
| T11 | **(new)** Corpus / fuzzing exposure: with three obvious memory bugs and a one-byte dispatch, the first hour of `afl-fuzz` or `libFuzzer` against `canary` will produce crashing inputs. If the binary is distributed, researchers and attackers will find these quickly | any attacker with the binary | disclosure timeline | high — forces a short remediation window | high |

## 5. Open questions
All prior open questions are resolved by the owner's answers, except the
following residual items, which are narrower and still code- or
build-dependent:

- **Shipped compiler / linker hardening:** is the distributed build compiled
  with `-fstack-protector-strong`, `-D_FORTIFY_SOURCE=2`, `-fPIE`/`-pie`, full
  RELRO, and with ASLR enabled on the target OS? Changes exploit reliability of
  T2 and the value of T5's leak, but not the severity class.
- **Allocator in the shipped build** (glibc ptmalloc vs. musl vs. hardened
  allocator): determines which T3 exploitation primitives are practical.
- **File-association registration (T9):** does any installer or desktop entry
  register `canary` as a handler for an extension or MIME type? If yes, T8
  collapses into a pure double-click RCE.
- **Signed / notarized distribution:** is the binary shipped in a way that OS
  gatekeepers (macOS Gatekeeper, Windows SmartScreen, Linux desktop "executable
  bit" prompts) would warn the user before first run? Affects the friction on
  T8 but not the final impact.
- **Telemetry / crash reporting:** are crashes from T1–T4 reported anywhere the
  defender would see them, or does a failed exploit attempt go unnoticed?

步骤 4:运行 Agent 发现循环

使用原始 Messages API,这一步将是一个手写的 while stop_reason == "tool_use" 循环和自定义文件工具。Agent SDK 处理所有这些:我们调用 query() 一次,设置 allowed_tools=["Read", "Grep", "Glob"] 和 disallowed_tools=["Bash"],Claude Code 自行运行探索-读取-推理循环。cwd=str(TARGET_DIR) 将 Agent 指向金丝雀,system_prompt={"type": "preset", "preset": "claude_code", "append": ...} 保留 Claude Code 的默认系统提示(已经告诉 Agent 其工作目录),同时追加我们的参与上下文,因此 Agent 永远不必猜测自己的位置。在 Bash/Write/Edit 被保留的情况下,Agent 保持只读。

提示中最重要的部分仍然是质量层级标准。 没有它,LLM 漏洞搜索者会报告他们能找到的每一个空指针解引用和失败的断言,这些是真实的崩溃但几乎不可利用。标准告诉 Agent 哪些崩溃类别需要提交(堆/栈溢出、释放后使用、受控地址写入),哪些是需要继续阅读的路标。这一个块是安全工程师会采取行动的报告与他们忽略的报告之间的大部分区别。

生产版本会将 "Bash" 添加到 allowed_tools,以便 Agent 可以使用 -fsanitize=address 编译并确认每个崩溃;这属于锁定容器内的操作,因此本 notebook 保持只读。

FIND_PROMPT = f"""\
Find memory-safety bugs in the target source tree using the file tools
available to you.

## Threat model

Focus on the entry points and threats identified here; you do not need to
re-derive them.

{threat_model}

## Quality tiers: what to report

**HIGH VALUE (report these):**
- heap-buffer-overflow (especially WRITE)
- heap-use-after-free / double-free
- stack-buffer-overflow
- global-buffer-overflow

**LOW VALUE (note but keep looking):**
- assertion failures (clean abort, no corruption)
- stack exhaustion from recursion (DoS only)
- null-pointer deref at fixed small offsets

## Output

For each HIGH VALUE finding emit a block:

<finding>
<id>F-NN</id>
<file>path:line</file>
<category>heap-buffer-overflow | stack-buffer-overflow | use-after-free | ...</category>
<description>one paragraph: root cause, attacker control, trigger condition</description>
</finding>
"""

find_options = ClaudeAgentOptions(
    model=MODEL_NAME,
    cwd=str(TARGET_DIR),
    system_prompt={"type": "preset", "preset": "claude_code", "append": ENGAGEMENT_CONTEXT},
    allowed_tools=["Read", "Grep", "Glob"],
    disallowed_tools=["Bash"],
)

findings_text = await collect(query(prompt=FIND_PROMPT, options=find_options))
print("\n" + findings_text)
  [tool] Glob {'pattern': '**/*'}
  [tool] Read {'file_path': 'vulnerability_detection_agent/canary/cana...}

`★ Insight ─────────────────────────────────────`
- The one-byte dispatch at line 38 makes each parser independently reachable with a trivial file prefix (`A`/`B`/`C`), so each bug is a standalone attack surface.
- `parse_bravo`'s `name[15]=0` is a common false-safety pattern: it null-terminates for the `printf`, but the `memcpy` on line 16 has already written past the 16-byte frame before that line runs.
- `parse_charlie` composes two primitives (conditional free then unconditional write) into a clean UAF whose trigger byte is attacker-chosen, which is rarer than an accidental UAF and nastier to fix.
`─────────────────────────────────────────────────`

<finding>
<id>F-01</id>
<file>canary.c:8-9</file>
<category>heap-buffer-overflow</category>
<description>`parse_alpha` allocates a fixed 32-byte heap buffer and then `memcpy`s `len` attacker-controlled bytes into it with no bounds check. `len` is `n-1` where `n` is up to 4096, so an `A`-prefixed file delivers up to 4095 bytes into the 32-byte chunk, overwriting adjacent heap metadata and neighboring allocations. Trigger: any file whose first byte is `A` and whose total size exceeds 33 bytes.</description>
</finding>

<finding>
<id>F-02</id>
<file>canary.c:15-16</file>
<category>stack-buffer-overflow</category>
<description>`parse_bravo` copies up to `n-1` attacker-controlled bytes into a 16-byte stack array `name[16]` without bounds-checking `len`. The subsequent `name[15]=0` only truncates the `printf` output; the out-of-bounds write in `memcpy` has already clobbered the saved frame pointer, return address, and any stack canary. Trigger: any file whose first byte is `B` and whose payload exceeds 16 bytes.</description>
</finding>

<finding>
<id>F-03</id>
<file>canary.c:22-26</file>
<category>use-after-free</category>
<description>`parse_charlie` allocates 64 bytes, conditionally frees `p` when the first payload byte equals `0xff`, and then unconditionally `memcpy`s into `p`. When the trigger byte is present, the `memcpy` writes into a freed chunk, corrupting allocator freelist metadata (tcache/fastbin on glibc) and yielding a standard heap-grooming primitive toward arbitrary write / control-flow hijack. The subsequent `printf("%p", p)` also leaks the heap pointer, making exploitation reliable under ASLR. Trigger: a file starting with `C` followed by byte `0xff`.</description>
</finding>

Three high-value findings (F-01 heap overflow, F-02 stack overflow, F-03 UAF with pointer disclosure) match threats T1/T2/T3+T5 in the threat model. No additional high-value memory-safety bugs in the 45-line source beyond these.

步骤 5:分类原始发现

发现 Agent 针对召回率进行了调优,因此其输出通常包含重复项(一个根因通过两条路径到达),偶尔有一个不成立的发现。分类是过滤器:一个全新的 query() 重新读取代码,验证每个发现与实际行的对应关系,按根因合并重复项,并根据威胁模型中信任边界的可达性重新推导严重性。我们故意不让分类继承发现 Agent 的严重性分数;独立重新推导它们是捕捉过度自信的廉价方式。

TRIAGE_PROMPT = f"""\
Triage these findings against the source in this directory and the threat model
below. For each: verify it is real (cite the line), derive severity from
reachability across the trust boundaries in the threat model, and collapse
duplicates by root cause.

## Threat model

{threat_model}

## Raw findings

{findings_text}
"""

triage_options = ClaudeAgentOptions(
    model=MODEL_NAME,
    cwd=str(TARGET_DIR),
    system_prompt={"type": "preset", "preset": "claude_code", "append": ENGAGEMENT_CONTEXT},
    allowed_tools=["Read", "Grep"],
    disallowed_tools=["Bash"],
)

triaged_text = await collect(query(prompt=TRIAGE_PROMPT, options=triage_options))
print("\n" + triaged_text)
  [tool] Read {'file_path': 'vulnerability_detection_agent/canary/cana...}

`★ Insight ─────────────────────────────────────`
- All three bugs sit behind the same trust boundary (attacker-authored file → parser), reached by a single-byte dispatch in `main`. No auth, no sandbox, same-uid blast radius, so reachability is identical for all three and severity is driven by the memory-corruption primitive itself.
- F-03 is really two bugs fused at one call site: the UAF (T3) and the `printf("%p")` heap-pointer disclosure (T5). Collapsing them under F-03 is correct by root cause (both are in `parse_charlie`'s 6 lines), but the leak is what upgrades the UAF from "unreliable under ASLR" to "one-shot".
`─────────────────────────────────────────────────`

## Triage

| id | verdict | line(s) | threat | severity | notes |
| --- | --- | --- | --- | --- | --- |
| F-01 | real | canary.c:8-9 | T1 | **Critical** | `malloc(32)` then `memcpy(buf, data, len)` with `len` up to 4095 (from `main` `n-1`, line 39). Attacker file `A` + ≥33 bytes overflows heap; no sandbox → RCE as invoking user. |
| F-02 | real | canary.c:15-16 | T2 | **Critical** | `char name[16]` then `memcpy(name, data, len)`; `name[15]=0` on line 17 runs *after* the OOB write and only truncates the `printf`. Classic stack smash; exploit reliability depends on shipped hardening (open question in threat model) but severity class unchanged. |
| F-03 | real | canary.c:22-27 | T3 + T5 | **Critical** | `malloc(64)` on line 22, conditional `free(p)` on line 24 when `data[0]==0xff`, unconditional `memcpy` on line 26 → UAF into freelist metadata. Line 27 `printf("charlie: %p", p)` leaks the heap pointer unconditionally, collapsing ASLR for both T1 and T3. Keep as one finding (root cause = `parse_charlie`), but call out the leak explicitly. |

**Duplicates:** none to collapse. Three distinct root causes in three distinct parsers.

**Coverage check against threat model:** F-01/F-02/F-03 cover T1/T2/T3/T5. T4 (NULL-`malloc`) and T6–T7 (path handling, 4 KB truncation) are intentionally out of scope as memory-safety findings and the threat model already rates them low. T8–T11 are delivery/deployment/program concerns, not source bugs — not expected in this pass.

步骤 6:生成结构化报告

下游系统(问题跟踪器、仪表板、SIEM)需要结构化数据。一个最终的无工具 query() 将分类后的发现转换为符合显式 schema 的 JSON。我们将每个字段标记为必填,并使用 null 表示”不适用”,这样模型就不必猜测可选性;在生产环境中,你应该使用 jsonschema 验证并在失败时重试。

# Every key is in `required` so the model never silently drops a field; values
# may be null when a field is not applicable (e.g., no recommendation yet).
REPORT_SCHEMA = {
    "type": "object",
    "properties": {
        "findings": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {"type": ["string", "null"]},
                    "category": {"type": ["string", "null"]},
                    "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"]},
                    "file": {"type": ["string", "null"]},
                    "description": {"type": ["string", "null"]},
                    "recommendation": {"type": ["string", "null"]},
                },
                "required": ["id", "category", "severity", "file", "description", "recommendation"],
            },
        }
    },
    "required": ["findings"],
}

REPORT_PROMPT = f"""\
Convert the triaged findings below into strict JSON conforming to this schema.
Every field is required; use null for not-applicable. Respond with JSON only,
no surrounding prose or code fences.

## Schema

{json.dumps(REPORT_SCHEMA, indent=2)}

## Triaged findings

{triaged_text}
"""

report_options = ClaudeAgentOptions(
    model=MODEL_NAME,
    system_prompt={"type": "preset", "preset": "claude_code", "append": ENGAGEMENT_CONTEXT},
    allowed_tools=[],
)

report_json = await collect(query(prompt=REPORT_PROMPT, options=report_options))
raw = report_json.strip()
if raw.startswith("```"):
    raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
try:
    report = json.loads(raw)
except json.JSONDecodeError as e:
    print(f"[report agent did not return clean JSON: {e}]\n")
    print(raw)
else:
    print(json.dumps(report, indent=2))
{
  "findings": [
    {
      "id": "F-01",
      "category": "Heap buffer overflow",
      "severity": "critical",
      "file": "canary.c",
      "description": "parse_alpha allocates malloc(32) then memcpys attacker-controlled data of length up to 4095 bytes (from main's n-1 at line 39). An attacker-authored file beginning with 'A' followed by >=33 bytes overflows the heap allocation. With no sandbox, this yields RCE as the invoking user.",
      "recommendation": "Validate len against the allocation size before memcpy (e.g., require len <= 32) or size the allocation from len. Reject oversized inputs at the parser boundary and add bounds-checked copy helpers."
    },
    {
      "id": "F-02",
      "category": "Stack buffer overflow",
      "severity": "critical",
      "file": "canary.c",
      "description": "parse_bravo declares char name[16] then memcpys attacker-controlled data of length up to 4095 bytes into it. The name[15]=0 null-termination on line 17 executes after the out-of-bounds write and only truncates the subsequent printf; it does not prevent the stack smash. Exploit reliability depends on compile-time hardening but the vulnerability class is unchanged.",
      "recommendation": "Bound len to sizeof(name)-1 before memcpy, or use strncpy/snprintf with explicit size. Ensure stack protectors (-fstack-protector-strong), FORTIFY_SOURCE, and PIE are enabled in the build."
    },
    {
      "id": "F-03",
      "category": "Use-after-free with heap address disclosure",
      "severity": "critical",
      "file": "canary.c",
      "description": "parse_charlie mallocs 64 bytes at line 22, conditionally frees p at line 24 when data[0]==0xff, then unconditionally memcpys into p at line 26, corrupting freelist metadata. Line 27's printf(\"charlie: %p\", p) unconditionally leaks the heap pointer, defeating ASLR and upgrading the UAF (and the F-01 heap overflow) from unreliable to one-shot exploitable.",
      "recommendation": "Set p=NULL after free and guard subsequent uses; restructure so the free and the write cannot both execute on the same pointer. Remove the %p disclosure (or gate it behind a debug build) to preserve ASLR."
    }
  ]
}

总结和后续步骤

你已经使用 Agent SDK 构建了完整的威胁建模、发现、分类、报告管道:一个多轮 ClaudeSDKClient 会话用于威胁建模,三个一次性 query() 调用用于其余部分,Claude Code 的内置文件工具负责探索。你需要带走的关键模式:

  • ClaudeSDKClient 用于对话,query() 用于一次性调用。 威胁建模访谈需要上下文中的引导轮次;发现、分类和报告不依赖于彼此的工具记录,因此无状态调用更简单。
  • cwd + allowed_tools 替换手动实现的工具。 限定到目标目录的 Read/Grep/Glob 是整个发现 Agent 的支架。
  • 分类是独立的步骤。 独立于发现 Agent 重新验证和重新评分可以廉价地捕捉过度自信。

进一步探索

  • 使用托管版本。 Claude Code Security 将相同的发现和分类能力作为托管产品运行,因此你只需将它指向一个仓库,Anthropic 负责沙盒和扩展。
  • 扩展到真实仓库。 将 cwd 指向真实检出,在沙盒容器内将 "Bash" 添加到 allowed_tools,以便 Agent 可以使用 -fsanitize=address 编译并确认崩溃,然后使用 asyncio.gather 为威胁模型中的每个入口点生成一个 query()。
  • 将报告连接到你的跟踪器。 使用 jsonschema 验证步骤 6 的 JSON,将其映射到 SARIF 或你的工单 schema,然后 POST。

Tip

中文读者提示:本章节的代码和输出为英文原文。如需理解具体实现细节,可参考上方中文导读和代码注释。如有疑问,欢迎在 GitHub Issues 讨论。