fix(tools): 审计器补齐 recv/selfCalls 索引与只读白名单;标注器定位修正

## audit_parallel 的四个误判来源

**① indexMethods 没设 recv 和 selfCalls ⇒ 递归跟进空转**
`scanWritesDeep` 靠 recv 组候选键、靠 selfCalls 才知道跟进谁,缺任一个
就静默漏判。handleRestart 只调 p.stopServer/startServer 两个方法,
因此被判成"只读" —— 而 stopServer 里有 `p.server = nil`。
(我先补了闭包那条分支的 selfCalls,误以为问题在闭包上,其实是
 indexMethods 整体缺字段。)

**② writePatterns 写了 ".Set(" 而 callString 收集的是方法名**
`p.sdk.Settings().Set(...)` 是三段链,callString 返回 "Settings.Set",
永远匹配不上 ".Set("。漏判比误判更难发现:结果看着仍然合理。
→ 模式改为不带括号。

**③ looksLikeExternal 用黑名单 ⇒ 纯读标准库调用全被判"可能写"**
Marshal / ReadAll / NewRequest / NewReader 都不带写操作迹象,却被判写,
120 个工具里 119 个判成 SERIAL —— 等于工具没在工作,却**看上去在工作**
(保守方向不会引起怀疑)。
→ 改为 knownReadOnlyCalls 白名单。判定原则:**默认怀疑,明确信任** ——
   写不动的东西要逐个列出来。

**④ 工具名提取打印 AST 内部结构**
`fmt.Sprintf("%v", a.Y)` 输出 `tp&{10500 10515 STRING "manage_social"}`,
名字里混着指针地址,人没法核对。
→ 改用 BasicLit.Value + 归一化去掉运行期前缀。

## annotate_parallel 的插入点定位

改文本匹配改结构体字面量这条路走了三次弯:
- 正则找"最后一个顶层字段"被嵌套 map 里的同形文本骗到,823 处重排改坏文件
- 变量前缀匹配没要求 RegisterTool( 在**同一行**,命中函数体里散落的字面量,
  起点错到 switch case 中间,报错指向一处看起来完全无辜的分支
- 只按行末**净**深度判断:Parameters 写在单行时(进出同一行,净变化 0)
  永远察觉不到曾进入深度 3,追踪一路跑到 1305 行才"收敛"
→ 最终按**行内峰值深度** + 记录进入深度 3 的行号判定。

## 幂等

未标注过的工具重复跑会插入第二份 ParallelSafe(duplicate field 编译错误)。
→ 加 blockHasDecl 预检。
This commit is contained in:
JianFeeeee
2026-09-27 15:59:58 +08:00
parent deeda22650
commit 1b8218afa9
2 changed files with 181 additions and 27 deletions

View File

@ -51,6 +51,14 @@ func main() {
fmt.Fprintf(os.Stderr, " 跳过 %s: %v\n", j.tool, err)
continue
}
// 幂等:块内已有并发声明就跳过。
//
// 不加这条时,重跑会在已标注的工具上**再插一份** —— 而
// duplicate field name 是编译期错误,跨文件批量跑时定位成本很高。
if blockHasDecl(lines, j.tool) {
fmt.Printf(" %s 已有声明,跳过\n", j.tool)
continue
}
field := "ParallelSafe: true,"
if j.kind == "serial" {
field = "Serial: true,"
@ -67,7 +75,12 @@ func main() {
// findInsertPoint 找到该 RegisterTool 字面量中,Parameters 闭合之后的位置。
func findInsertPoint(lines []string, tool string) (int, error) {
// 匹配两种注册形式:
// RegisterTool("get_article", ...) 字面量
// RegisterTool(tp+"get_article", ...) 变量前缀 + 字面量
// 只认字面量会漏掉后者 —— example 里绝大多数是变量前缀形式。
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
alt := fmt.Sprintf(`+"%s"`, tool)
start := -1
for i, l := range lines {
if strings.Contains(l, head) {
@ -75,6 +88,23 @@ func findInsertPoint(lines []string, tool string) (int, error) {
break
}
}
if start < 0 {
// ★ 必须 RegisterTool( 与字面量在**同一行**。
//
// 我第一版只找含 `+"name"` 的行,命中了函数体里的散落字面量
// (vanblog 的 handleAuth 里满是 "restore"/"update" 这类 case 分支),
// 起点错到函数体中间,深度追踪再也回不到 2 ⇒ 插入点落在 1300+ 行,
// 把文件改坏。
//
// 症状离原因很远:报错说"expected 1 expression",指向的是一处
// 看起来完全正常的 case 分支。
for i, l := range lines {
if strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(") {
start = i
break
}
}
}
if start < 0 {
return 0, fmt.Errorf("找不到 RegisterTool(%q)", tool)
}
@ -85,6 +115,11 @@ func findInsertPoint(lines []string, tool string) (int, error) {
inStr := false
esc := false
for i := start; i < len(lines); i++ {
// peak = 本行内的峰值深度。
//
// 每行重置:它表示"这一行曾深入到多深",不是全程最大值 ——
// 全程最大值一旦到过 3 就永远是 3,"曾进入 Parameters"判据随之失效。
peak := depth
for _, ch := range lines[i] {
if esc {
esc = false
@ -105,6 +140,9 @@ func findInsertPoint(lines []string, tool string) (int, error) {
case '(', '{', '[':
depth++
started = true
if depth > peak {
peak = depth
}
case ')', '}', ']':
depth--
}
@ -133,16 +171,53 @@ func findInsertPoint(lines []string, tool string) (int, error) {
// 2. Name:/Description: 本来就在深度 2,早于 Parameters。
// 只判 depth==2 会在 Name 行就返回,插入点跑到 RegisterTool 之前,
// 编译报 "expected 1 expression"。
if started && depth >= 3 {
// 判定"进入过 Parameters"要按**行内峰值深度**,不能只看行末净深度。
//
// get_meta 的 Parameters 全在一行:
// Parameters: map[string]interface{}{"type":"object","properties":map[string]interface{}{}},
// 这行净深度变化是 0(进去又出来)—— 只看净深就永远察觉不到曾进入
// 深度 3 ⇒ 追踪一路跑到 1305 行才"收敛",插入点落在某个 case 分支
// 中间,文件改坏。症状离原因很远:报错指向一处看起来完全正常的
// switch case。
if started && peak >= 3 {
enteredAt = i
}
if enteredAt >= 0 && i > enteredAt && depth <= 2 {
// 闭合判定:进入过 Parameters(enteredAt)之后,深度回到 2 的那一行
// **就是** Parameters 的闭合行;插入点取它的**下一行**。
//
// ★ 不能要求 i > enteredAt:Parameters 写在单行时(get_meta 就是)
// enteredAt 与闭合行是**同一行**,加上这个条件会跳到再下一行,
// 插到 log.Printf 之前 —— 不报错,但声明落在了字面量外面。
if enteredAt >= 0 && i >= enteredAt && depth <= 2 {
return i + 1, nil
}
}
return 0, fmt.Errorf("括号深度追踪未收敛")
}
// blockHasDecl 报告该工具的字面量里是否已有并发声明。
func blockHasDecl(lines []string, tool string) bool {
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
alt := fmt.Sprintf(`+"%s"`, tool)
start := -1
for i, l := range lines {
if strings.Contains(l, head) || (strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(")) {
start = i
break
}
}
if start < 0 {
return false
}
// 从注册行往后找 30 行(工具定义不会更长)
for i := start; i < len(lines) && i <= start+30; i++ {
if strings.Contains(lines[i], "ParallelSafe:") || strings.Contains(lines[i], "Serial:") {
return true
}
}
return false
}
func readLines(p string) []string {
f, err := os.Open(p)
if err != nil {

View File

@ -300,18 +300,35 @@ func indexMethods(fset *token.FileSet, files map[string]*ast.File) map[string]*m
continue
}
name := fd.Name.Name
recv := ""
if fd.Recv != nil && len(fd.Recv.List) > 0 {
if t, ok := fd.Recv.List[0].Type.(*ast.StarExpr); ok {
rt := fd.Recv.List[0].Type
if t, ok := rt.(*ast.StarExpr); ok {
if id, ok := t.X.(*ast.Ident); ok {
name = "*" + id.Name + "." + name
recv = id.Name
}
} else if id, ok := rt.(*ast.Ident); ok {
recv = id.Name
}
if recv != "" {
name = "*" + recv + "." + name
}
}
out[name] = &methodImpl{
file: path,
line: fset.Position(fd.Pos()).Line,
body: fd.Body,
locked: hasMutexIn(fd.Body),
// ★ recv 与 selfCalls 都必须填:scanWritesDeep 用 recv 组候选键去
// 跟进下层方法,用 selfCalls 才知道要跟进谁。缺任何一个,
// 递归跟进就是空转 —— handlerRestart → p.stopServer/startServer
// 这类"只调两个方法"的写法会静默漏判成只读。
mi := &methodImpl{
file: path,
line: fset.Position(fd.Pos()).Line,
body: fd.Body,
recv: recv,
locked: hasMutexIn(fd.Body),
selfCalls: selfCallsOf(fd.Body),
}
out[name] = mi
if recv != "" {
out[recv+"."+fd.Name.Name] = mi
}
}
}
@ -325,7 +342,11 @@ func handlerImplOf(call *ast.CallExpr, fset *token.FileSet, methods map[string]*
}
switch a := call.Args[2].(type) {
case *ast.FuncLit: // 闭包
return &methodImpl{file: "<closure>", line: fset.Position(a.Pos()).Line, body: a.Body, locked: hasMutexIn(a.Body)}
// ⚠️ recv 留空是有代价的:scanWritesDeep 跟进 selfCalls 时用
// "*"+recv+"."+name 找方法实现,recv 为空就永远找不到 ⇒
// 闭包里对 p.xxx(...) 的调用全部追不下去,write 被静默漏掉。
// 症状是"看起来有扫描、实际漏判"。
return &methodImpl{file: "<closure>", line: fset.Position(a.Pos()).Line, body: a.Body, locked: hasMutexIn(a.Body), selfCalls: selfCallsOf(a.Body)}
case *ast.SelectorExpr: // p.handleRead
//
// ★ 这里踩过一个隐蔽的坑:调用点的接收者是**变量名**(p),
@ -380,7 +401,15 @@ func toolNameOf(call *ast.CallExpr, fset *token.FileSet) string {
if lit, ok := a.Y.(*ast.BasicLit); ok {
rhs = strings.Trim(lit.Value, `"`)
}
return lhs + "_" + rhs
// 归一化:变量前缀(p.name / tp / p.tp)在运行期才确定,
// 静态只保留字面量那一段,前缀分隔符一并去掉。
if _, isIdent := a.X.(*ast.Ident); isIdent {
return rhs
}
if i := strings.Index(lhs, "."); i >= 0 {
lhs = lhs[i+1:]
}
return lhs + rhs
case *ast.Ident:
return a.Name
case *ast.SelectorExpr:
@ -447,19 +476,25 @@ var writePatterns = []struct{ what, pat string }{
{"mkdir", "os.MkdirAll"},
{"http-post", "http.Post"},
{"http-do", "client.Do"},
{"sdk-set", ".Set("},
{"sdk-save", ".Save("},
{"sdk-update", ".Update("},
{"sdk-delete", ".Delete("},
{"sdk-add", ".Add("},
{"sdk-install", ".Install("},
{"sdk-restart", ".Restart("},
{"sdk-shutdown", ".Shutdown("},
{"write", ".Write("},
{"write", ".WriteString("},
{"start", ".Start("},
{"stop", ".Stop("},
{"kill", ".Kill("},
// ⚠️ 这些模式**不带括号**:callString 收集的是链上方法名并用点连起来
// ("Settings.Set"),不是完整调用文本。我第一版写成 ".Set(",
// 于是永远匹配不上 —— handleConfigure(写配置 + 启停服务)被判只读。
// 症状是"漏判",比误判更难发现:结果看起来仍然合理。
{"sdk-set", ".Set"},
{"sdk-save", ".Save"},
{"sdk-update", ".Update"},
{"sdk-delete", ".Delete"},
{"sdk-add", ".Add"},
{"sdk-install", ".Install"},
{"sdk-restart", ".Restart"},
{"sdk-shutdown", ".Shutdown"},
{"write", ".Write"},
{"start", ".Start"},
{"stop", ".Stop"},
{"kill", ".Kill"},
{"os-write", "os.WriteFile"},
{"os-remove", "os.Remove"},
{"exec", "exec.Command"},
}
func scanWrites(body *ast.BlockStmt) []finding {
@ -588,12 +623,56 @@ func diagOf(call *ast.CallExpr) string {
return fmt.Sprintf("%T", call.Args[2])
}
// knownReadOnlyCalls 是**确认无副作用**的调用/构造器。
//
// ⚠️ 这张表必须显式列出,不能靠"名字不像写操作"来猜:我第一版用
// looksLikeExternal(黑名单),结果 Marshal / ReadAll / NewRequest /
// NewReader 这些**纯读**的标准库调用全被判"可能写" ⇒ 只读的
// get_article 变成 SERIAL,120 个工具里 119 个被判串行 ——
// 等于工具没在工作,却看上去在工作(保守方向不会引起怀疑)。
//
// 判定原则:**默认怀疑,明确信任**。写不动的东西要逐个列出来。
var knownReadOnlyCalls = map[string]bool{
// 格式化
"Sprintf": true, "Fprintf": true, "Errorf": true, "Fatalf": true,
"Printf": true, "Sprintln": true, "Sprint": true, "Sscanf": true,
// 字符串(纯函数)
"String": true, "TrimSpace": true, "Trim": true, "TrimPrefix": true,
"TrimSuffix": true, "Split": true, "SplitN": true, "Join": true,
"Replace": true, "ReplaceAll": true, "ToLower": true, "ToUpper": true,
"Contains": true, "HasPrefix": true, "HasSuffix": true, "Fields": true,
"Repeat": true, "EqualFold": true, "Title": true,
// 数值
"Min": true, "Max": true, "Abs": true, "Round": true, "Floor": true, "Ceil": true,
// 编解码(纯变换)
"Marshal": true, "Unmarshal": true, "NewDecoder": true, "NewEncoder": true,
// JSON 读取(只读文件/流,不写)
"NewReader": true, "ReadAll": true, "Read": true, "Decode": true,
// HTTP 只读侧
"NewRequest": true, "NewRequestWithContext": true, "Parse": true, "ParseForm": true,
// 时间
"Now": true, "Unix": true, "ParseDuration": true, "After": true,
// 容器
"New": true, "NewMap": true, "Keys": true, "Values": true,
"Len": true, "Cap": true, "Copy": true, "Append": true,
// 容器查
"Get": true, "Load": true, "Exists": true, "List": true, "Query": true,
// 文件只读
"Stat": true, "ReadDir": true, "ReadFile": true, "Glob": true, "Walk": true,
// 错误
"Is": true, "As": true, "Unwrap": true, "Error": true,
}
func looksLikeExternal(name string) bool {
switch name {
case "Errorf", "Sprintf", "Fatalf", "Printf", "String", "TrimSpace",
"Split", "Join", "New", "Now", "Unix", "Abs", "Min", "Max", "Len", "Cap":
if knownReadOnlyCalls[name] {
return false
}
// 指针/包前缀形式(strings.TrimSpace)取最后一段
if i := strings.LastIndex(name, "."); i >= 0 {
if knownReadOnlyCalls[name[i+1:]] {
return false
}
}
return true
}