audit

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 13 Imported by: 0

README

audit —— 审计插件(异步、尽力而为)

把每次 MCP 调用的完整上下文(谁、什么工具、入参、结果、耗时、被谁拒了)交给使用方注册的 落地函数Sink)。插件不决定落地方式:日志、Kafka、数仓、第三方 HTTP 审计中心都行。

  • 链上位置:最先安装,位于插件链最外层——既能记录被内层插件(quota / confirm)拒绝的 调用,也能看到内层插件(spill)改写后的最终结果。
  • 投递方式:异步 + 尽力而为。中间件在返回路径上只把事件塞进有界队列,落地由后台单 worker 完成,永不阻塞调用返回、永不改变返回值

为什么没有「审计失败就拒绝返回」这一档

审计判定发生在返回路径上,此刻工具已经执行完了。同步等落地、失败就拒绝返回,挡不住任何 副作用(删除、写入已经生效),只是把结果藏起来、还得劝调用方不要重试。真正需要 fail-closed 的是执行的门:人白名单、二次确认、配额。

代价必须说清楚:「每次调用必有记录」降级为尽力而为——队列打满、进程被 kill -9、机器掉盘 都会丢事件。因此丢弃绝不静默:计数 + 限流告警 + ReadStats() 供宿主接监控。

配置

[audit]
headers          = ["X-Tenant", "User-Agent"]  # 需要采集进事件的请求头;不含凭据类头
queue_size       = 4096                        # 事件队列容量(条);省略或 0 取 4096
flush_timeout    = "3s"                        # 退出时排空队列的预算(时长字符串);省略取 3s
max_args_bytes   = 1024                        # 入参进事件前的截断上限(字节);省略或 0 取 1024
max_result_bytes = 2048                        # 结果进事件前的截断上限(字节);省略或 0 取 2048
字段
  • headers(默认空):采集哪些请求头。数据源只有 Call.Headers(基座已剔除 Authorization / Proxy-Authorization / Cookie / Set-Cookie),配了凭据类头启动失败 ——否则你拿到的是一列恒为 - 的字段却以为采到了。配了但请求里没有的头记 "-",与「没配这个头」 区分开。
  • queue_size(默认 4096):队列容量。越大越能吸收落地抖动,但每条事件占内存(入参/结果已在 快照时截断到 KiB 级,4096 条约几 MiB)。不能为负;0 = 取默认值。刻意没有「无界队列」的 写法——落地方卡住时无界队列就是一条通往 OOM 的路,而丢事件至少是有计数、有告警、能被监控看见的。
  • flush_timeout(默认 3s,duration 字符串):进程退出时排空队列的时间预算,超出的按丢弃 计数。它直接加在服务停止耗时上(发布、重启都要等它),别配长。必须为正。
  • max_args_bytes(默认 1024)/ max_result_bytes(默认 2048):入参与结果进事件前的截断 上限,单位字节。不能为负;0 = 取默认值,没有关闭截断的写法——一次几十兆的原文进事件, 内存与审计后端都会被打满,而「关掉截断」这种开关一旦存在,出事时才会被发现它开着。

写错任何一项都让启动失败,不会静默用默认值(静默的话部署方会以为自己配的策略在生效)。

Sink 契约

audit.OnEvent(func(e audit.Event) error {
    // 落到你自己的后端:日志、MQ、HTTP 审计中心……
    return nil
})
  • 必须在开始接流之前注册Install 之前或之后都行,但要在 Handlers()/Start 之前): 一个都没注册时启动失败。「审计插件在场但一条也没落」是静默失效,而 required_plugins 只校验插件在场。接流之后再把 Sink 清空同样会被计成 Failed 并告警(worker 侧兜底)。
  • 可注册多个,按注册顺序依次调用;一个失败或 panic 不影响其余(逐个 recover)。
  • 返回 error 会计入 ReadStats().Failed 并触发限流告警——不会影响任何调用结果。
  • Sink 在 worker goroutine 上执行,不在请求路径上:慢一点只会挤占队列,不会拖慢调用方。 真正的耗时大户(远端 HTTP、批量攒批)请自己在 Sink 里做超时与重试。
  • Event完全自有的值拷贝(labels 深拷、入参/结果已截断成 string、Meta 拍平成新 map), 交给别的 goroutine 既没有 data race 也不会拖住大对象引用。

SetArgsRedactor(fn) 注册入参脱敏钩子(可选,在截断之前执行)。传入的 args 是本插件拷贝出来的 副本,钩子就地涂改也伤不到真实请求字节。

事件字段要点

  • Subject / Labels进入链时的快照:audit 在最外层,返回路径上 Call.Subject(指针) 已可能被内层插件改过,直接读会记成被改写后的身份。
  • HasIdentity / ActorID():基座默认不信任客户端身份头,Subject.ID 常态为空。ActorID() 在无身份时返回 (anonymous) 而不是空串,避免「匿名调用」与「某个 id 为空的人」混在一起。
  • Args:入参原文(先脱敏、再截断),可能含非法 UTF-8 字节(截断只剥掉尾部那个不完整字符, 中段的坏字节原样保留)。Postgres text、部分 JSON 序列化器会对非法 UTF-8 直接报错,落地前请自行 转义或替换。
  • Resulttools/call 是 JSON 片段(截断后可能不完整),只供人读与全文检索,不要 Unmarshaltools/list 是工具名列表,按个数裁剪,始终是合法 JSON。
  • DeniedBy / DenyReason / MetaMetaCall.Meta 的通用透传(拍平成字符串,键数 ≤ 32、单值 ≤ 256 字节,超出时按键名排序取前 N 个以保证不同进程截出同一批键)。外置插件想把 自己的上下文送进审计流,这是唯一通道。
  • ResultErr:结果序列化失败的原因(不静默丢字段)。

两条覆盖不到的地方(务必知道)

  • 基座 token 准入的拒绝不进审计流:那一层固定装在插件链之前,tools/call 被它拒时直接返回, 本插件根本不会被调用——越权尝试在审计流里是 0 条事件、HTTP 状态还是 200。
  • tools/list 记的是过滤前的全量清单:基座按 token 过滤发生在本插件记录之后,所以 Event.Result 不等于该 token 实际看到的那份。

两者目前的唯一信号是基座打的 [mcp] warning: 准入拒绝 … / tools/list 过滤 … 日志(限流)。 做「谁看见 / 试过哪些工具」这类权限审计时,必须把那两条日志一并采集。

另外:工具入参不得承载密文login(user, password) 这类工具的密文会原样进长期审计存储; 基座不认识字段语义,代替不了你判断——确有需要时用 SetArgsRedactor

可观测性:ReadStats()

s := audit.ReadStats() // Enqueued / Dropped / Delivered / Failed

未安装或已停止时返回零值(监控上看到的是「没在跑」,而不是一组不再增长的旧数)。

  • Dropped 持续增长 = 队列容量或落地速度不够(调 queue_size、或把过滤搬进 Sink)。
  • Failed 增长 = Sink 本身有问题(后端挂了、编码不被接受、panic)。

丢弃与落地失败都会打日志,但按类限流:每类首条立即打,之后每分钟最多一条,汇总行带 「被压制多少条 / 累计多少条」。这两类都是持续性故障,逐条打会按 QPS 刷满磁盘。

启动与退出日志

  • 启动([mcp] audit:):异步投递(尽力而为,不阻塞返回) sinks=N queue_size=… flush_timeout=… max_args_bytes=… max_result_bytes=… headers=[…]sinks真正接流时的条数 (Install 之后注册的也算进去)。
  • 退出:投递收尾 enqueued=… delivered=… failed=… dropped=…,一眼看清这次运行到底丢没丢。

Documentation

Overview

Package audit 是审计插件:把每次 MCP 调用的完整上下文交给使用方注册的落地函数。 基座不参与落地方式,日志、消息队列、数仓都由使用方决定。

安装位置:应最先安装,位于**插件链**最外层——既能记录被内层插件(quota / confirm) 拒绝的调用,也能看到内层插件(如 spill)改写后的最终结果。

但它不是整条链的最外层:**基座的 token 准入固定装在所有插件之前**,见第 6 条。

使用方必须知道的七条:

  1. **投递是异步的、尽力而为的,永远不阻塞调用返回。** 中间件在返回路径上只把事件 塞进有界队列,落地由后台单 worker 做(见 async.go)。刻意**没有**「阻塞直到落地」 或「没审计就不返回结果」这类档位:审计判定发生在返回路径上、工具此刻已经执行完, fail-closed 挡不住任何副作用,只是把结果藏起来、还得劝调用方不要重试;真正需要 fail-closed 的是执行**前**的门(人白名单、二次确认、配额)。 代价是「每次调用必有记录」降级为尽力而为:队列打满、进程被 kill -9、机器掉盘 都会丢事件。因此丢弃绝不静默——计数 + 限流告警 + ReadStats() 供宿主接监控。
  2. 落地函数要在 Install 之前注册:一个都没注册时启动直接失败。 「审计插件在场但一条也没落」是静默失效,而 required_plugins 只校验插件在场。 接流之后再把 Sink 清空同样会被计成失败并告警(worker 侧兜底)。
  3. 每个 MCP 方法都会产生事件,包括 initialize、ping 与各类 notifications。 远端 Sink 请按 Event.Method 自行过滤。落地慢不再影响调用方,但会挤占队列—— 过滤放在 Sink 里,队列容量按 queue_size 调。
  4. 事件里没有凭据。Call.Headers 已由基座剔除 Authorization/Proxy-Authorization/ Cookie/Set-Cookie,本插件也拒绝把这些头名配进 headers(见 normalize),且不去读 *http.Request 绕过这层剔除。Subject.Token 按基座契约是 token 的**用途名**。
  5. 工具入参不得承载密文。Event.Args 是入参原文,`login(user, password)` 这类工具 的密文会原样进长期审计存储;基座不认识字段语义、代替不了你判断。确有需要时用 SetArgsRedactor 注册脱敏钩子(在截断之前执行)。这条同样适用于其它插件。
  6. **基座 token 准入的拒绝不进审计流**(终审实测,务必知道):那一层固定装在插件链 之前,`tools/call` 被它拒时直接返回,本插件根本不会被调用——越权尝试在审计流里 是 0 条事件、HTTP 状态还是 200。同理 `tools/list` 的按 token 过滤发生在本插件 记录**之后**,所以 Event.Result 里是过滤**前**的全量清单,不等于该 token 实际 看到的那份。两者目前的唯一信号是基座打的 `[mcp] warning: 准入拒绝 …` / `tools/list 过滤 …` 日志(限流,见 runtime/tokenauthz.go)。做「谁看见/试过哪些 工具」这类权限审计时,必须把那两条日志一并采集,不能只看审计流。
  7. Event.Meta 是 Call.Meta 的通用透传(拍平成字符串、键数与值长都有上限)。 它是外置插件把自己的上下文送进审计流的唯一通道——本插件不认识任何具体插件, 所以键名的含义由写入方负责,落地方按需过滤。

Event 是**完全自有的值拷贝**(labels 深拷、入参/结果已截断成 string、Meta 拍平成新 map),交给 worker goroutine 既没有 data race 也不会拖住大对象引用——异步化不需要 额外拷贝,但**新增字段时必须保持这条性质**(不要往 Event 里塞指针或共享 map)。

Index

Constants

View Source
const AnonymousActor = "(anonymous)"

AnonymousActor 是 Subject.ID 为空时 ActorID 返回的占位标识。

基座默认不信任客户端送来的身份头,因此 Subject.ID 常态为空串。落地方若直接把空串 当用户名写进审计,「匿名调用」与「某个 id 为空的人」就再也分不开了,所以这里给一个 一眼能看出不是人名的占位值,并另配 Event.HasIdentity 供程序判定。

View Source
const Name = "audit"

Name 是插件名,与 required_plugins 里的写法一致。

Variables

This section is empty.

Functions

func Install

func Install(r *runtime.Registry) error

Install 安装审计插件。应最先安装,使其位于洋葱最外层。

func OnEvent

func OnEvent(fn Sink)

OnEvent 注册一个审计落地函数,可注册多个(按注册顺序依次调用)。 必须在 Install 之前(或同时)调用,理由见包注释第 1 条。

func SetArgsRedactor

func SetArgsRedactor(fn Redactor)

SetArgsRedactor 注册入参脱敏钩子(可选,传 nil 清除)。未注册时入参原样进事件。

Types

type Config

type Config struct {
	Audit Section `toml:"audit"`
}

Config 对应配置文件的 audit 段。

刻意用具名字段结构体、且字段集与文档一致(基座的配置认领制要求):用 map 兜底解码 会让段内拼错的键被判为「已解码」,于是 `[audit] on_eror = ...` 被完全吞掉、插件按 默认值上线;少声明一个文档承诺的字段,则部署方照文档写全反而启动失败。

type Event

type Event struct {
	// At 是调用进入 audit 的时刻。
	At time.Time
	// LogID 与 HTTP 层回写的同名响应头一致,用于和宿主 access log 对账。
	LogID string
	// Method 是 MCP 方法名,tools/call、tools/list 之外还包括 initialize、ping、
	// notifications/*,落地方可据此过滤。
	Method string
	Tool   string // 非 tools/call 时为空
	// Labels 是该工具的完整 labels(含基座投影的 name/pkg),进入时的快照。
	Labels map[string]string
	// Subject 是调用主体的**进入时快照**:audit 在最外层,返回路径上 Call.Subject
	// (指针)已可能被内层插件改过,直接读会记成被改写后的身份。
	Subject runtime.Subject
	// HasIdentity 区分「有真实身份」与「匿名/未配置身份」,见 AnonymousActor。
	HasIdentity bool
	// Args 是入参 JSON(先脱敏、再按上限截断),ArgsTruncated 标记是否截断。
	//
	// **可能含非法 UTF-8 字节**:它是调用方送来的原始 JSON 字节,审计不替它改写
	// (截断只剥掉尾部那个不完整字符,中段的坏字节原样保留)。落地前请按后端要求
	// 自行转义或替换——Postgres text、部分 JSON 序列化器会对非法 UTF-8 直接报错,
	// 那会让这条事件在你的 Sink 里落地失败(计入 ReadStats().Failed,不影响调用方)。
	Args          string
	ArgsTruncated bool
	// Result 是最终结果的摘要——**限于插件链之内**的最终结果。
	//
	// tools/list 是例外:基座的按 token 过滤在本插件记录之后才发生,所以这里是过滤
	// **前**的全量清单,不是该 token 实际看到的那份(见包注释第 6 条)。
	//
	// tools/call:JSON **片段**——截断后可能不完整(例如 `[{"type":"text",`),
	// 只供人读与全文检索,**不要 Unmarshal**。任意工具的结果形状不可控,
	// 想保证合法 JSON 就只能整份留下,那正是截断要避免的事。
	// tools/list:可见工具名列表,按**个数**裁剪,始终是合法 JSON。
	Result          string
	ResultTruncated bool
	// ResultErr 记录结果序列化失败的原因(不静默丢字段)。
	ResultErr string
	Cost      time.Duration
	// IsError 为真表示结果是错误态(业务级拒绝或工具报错)。
	IsError bool
	// Err 是链上抛出的协议级错误文案,空表示没有。
	Err string
	// DeniedBy / DenyReason 来自 Call.Meta,空表示未被任何插件拒绝。
	DeniedBy   string
	DenyReason string
	// Headers 是按配置采集的请求头,缺失的头值为 "-"。
	Headers map[string]string
	// Meta 是 Call.Meta 在链返回后的快照:任何插件写进 Meta 的内容都会到这里。
	//
	// 刻意做成通用透传而不是逐个插件开字段:audit 不认识 confirm、quota 这些插件,
	// 加特化字段等于把插件语义搬进 audit,外置插件就再也用不上这条路。
	//
	// 值一律拍平成字符串(按 fmt.Sprintf("%v"))——审计后端要能直接写库,
	// 任意嵌套结构会让落地方在序列化时才爆。单个值按 maxMetaValueBytes 截断;
	// 键数超过 maxMetaKeys 时按键名排序取前 N 个(排序保证同一次调用在不同进程里
	// 截出同一批键,否则对账时会各说各话)。
	Meta map[string]string
}

Event 是一次调用的审计事件。字段在进入链时快照,返回后补齐结果类字段。

func (Event) ActorID

func (e Event) ActorID() string

ActorID 返回可直接写进审计的执行人标识:无身份时返回 AnonymousActor 而不是空串。

type Redactor

type Redactor func(tool string, args []byte) []byte

Redactor 是入参脱敏钩子:入参进事件之前先过它一遍。

契约:返回改写后的切片,返回 nil 视为空入参。 传入的 args 是**本插件拷贝出来的副本**,钩子就地涂改也伤不到真实请求字节—— 这层防御不是可省的:Call.Args 与 MCP SDK 的 CallToolParamsRaw.Arguments 共享同一 底层数组,而工具入参的反序列化发生在链终点(钩子之后),一个写错的钩子否则会让 线上工具收到被涂改的参数。脱敏本就是少数部署才开的开关,这份拷贝不影响默认路径。

type Section

type Section struct {
	// Headers 是需要采集进事件的请求头名;不含凭据类头(配了即启动失败)。
	Headers []string `toml:"headers"`
	// QueueSize 是异步事件队列的容量(条),省略或填 0 取 4096;不能为负。
	//
	// 刻意**没有**「无界队列」的写法:落地方卡住时无界队列就是一条通往 OOM 的路,
	// 而丢事件至少是有计数、有告警、能被监控看见的。
	QueueSize int `toml:"queue_size"`
	// FlushTimeout 是进程退出时排空队列的时间预算(duration 串,如 "3s"),
	// 省略取 3s。它直接加在服务停止耗时上,别配太长。
	FlushTimeout string `toml:"flush_timeout"`
	// MaxArgsBytes 是入参进事件前的截断上限(字节),省略或填 0 取 1024;无法关闭。
	MaxArgsBytes int `toml:"max_args_bytes"`
	// MaxResultBytes 是结果进事件前的截断上限(字节),省略或填 0 取 2048;无法关闭。
	MaxResultBytes int `toml:"max_result_bytes"`
	// contains filtered or unexported fields
}

Section 是 audit 段的字段集,即本插件的对外配置契约。

type Sink

type Sink func(Event) error

Sink 是使用方注册的审计落地函数。

返回 error 而非无返回值:落地方是唯一知道「这条有没有写成功」的一方。 投递是异步的,返回 error 不会影响任何调用结果,但它会被计入 ReadStats().Failed 并触发限流告警——不返回错误,落地失败就只有落地方自己知道。

type Stats

type Stats struct {
	// Enqueued 是成功入队的事件数。
	Enqueued int64
	// Dropped 是被丢弃的事件数:入队时队列已满,或进程已进入停止流程。
	Dropped int64
	// Delivered 是已交给全部 Sink 且无一报错的事件数。
	Delivered int64
	// Failed 是至少有一个 Sink 报错或 panic 的事件数。
	Failed int64
}

Stats 是审计投递的运行期计数,供宿主接监控用。

有了它,「异步会丢事件」才是一件可观测的事而不是一句免责声明: Dropped 持续增长说明队列容量或落地速度不够,Failed 增长说明 Sink 本身有问题。

func ReadStats

func ReadStats() Stats

ReadStats 返回审计投递的运行期计数,供宿主接监控。未安装或已停止时返回零值。

名字带 Read 而不是直接叫 Stats:包里已经有 Stats 这个类型, 而计数类型比访问函数更值得占用这个名字(落地方要在自己的代码里声明它)。

为什么必须给出来:异步投递会丢事件(队列满、进程被强杀),不暴露计数的话 「尽力而为」就只是一句免责声明,运维看不见自己到底丢了多少。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL