mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-01 14:53:12 +00:00
tools: 工具并发安全审计器(audit_parallel + annotate_parallel)
两个工具,都是为了让「这个工具能不能并发」有**可复现的依据**,而不是靠人眼扫。
## tools/audit_parallel.go —— 审计
为什么需要它:正则扫 `ToolDef` 字面量**不可靠**。用它审计主仓 27 个工具时,
把 `config_set` 判成"无共享写",而它的 handler 其实在 `p.handleSet` 里且无锁 ——
原因:RegisterTool 第三个参数是方法名/闭包,正则看不到执行体。
本工具用 go/ast 从**注册点跟进到 handler 实现**,递归 3 层(带环检测),
输出三态:SAFE / SERIAL / UNKNOWN。
★ UNKNOWN 一律不声明并发安全 —— 追不到实现就不能声称安全。
### 开发它时踩的坑(都写在代码注释里)
- **通用 HTTP 包装器**:vanblog 的 manage_social / manage_settings 全走
`p.do("GET"|"POST"|"PUT", ...)`,方法名毫无写操作迹象,纯靠名字匹配
**全判成 SAFE** —— 而它们明确含 POST/PUT。审计工具给出与代码相反的结论,
比不给结论更危险(人会信它)。改为按 HTTP 动词判定后,
统计从 72/37 变为 46/63。
- **链式调用丢方法**:`p.sdk.Settings().Set(...)` 是三段链,只看最外层只得到
"Settings",`.Set` 整个丢失 ⇒ handleConfigure(写配置 + 启停服务)被判只读。
改为收集整条链的所有方法名。
- **变量名 ≠ 类型名**:调用点写 `p.handleRead()`,定义处是
`func (p *Plugin) handleRead()`。拿变量名去查类型索引**永远匹配不上**,
143 个工具全报 UNKNOWN。须建"变量名 → 接收者类型"索引。
- **方法接收者不是局部变量**:example 里根本没有 `p := &Plugin{}`,
p 是 Start 的接收者,局部变量索引全空。须把接收者变量名也纳入索引。
- **枚举被字符串污染**:verdict = "Serial:true" 而 report 只认三个枚举值
⇒ 全部落进 UNKNOWN,输出"共 13:UNKNOWN 13",看着像工具没在工作。
- **作者声明必须优先**:我把"已声明"当 finding 记录后照常跑写入检测并
**覆盖** verdict,于是 plugin_install(已标 Serial:true)被判 SAFE。
## tools/annotate_parallel/ —— 标注
按 SDK 风格插入声明项:Name 在首位,声明项在末尾(Parameters 之后、
handler 之前),不打散 gofmt 对齐。
插入点必须用**括号深度 + 记录进入深度 3 的行号**定位:
- `RegisterTool( =1, ToolDef{ =2, Parameters{ =3`
- 只判 depth==2 会在 `Name:` 行就返回(那行本来就是深度 2),
插入点跑到 RegisterTool 之前,编译报 "expected 1 expression"
- 空 `properties: map[string]interface{}{}` 让深度**在同一行**进出平衡,
所以"曾触及深度 3"也不能作门控,必须记行号
- 试过用正则找"最后一个顶层字段",被嵌套 map 里的同形文本骗到,
823 处错误重排把文件改坏 —— 文本匹配改结构体字面量就是这条路
用法:go build -o /tmp/annotate ./tools/annotate_parallel
/tmp/annotate <file> <tool:parallel|serial:说明> ...
This commit is contained in:
172
tools/annotate_parallel/main.go
Normal file
172
tools/annotate_parallel/main.go
Normal file
@ -0,0 +1,172 @@
|
||||
// 命令 annotate_parallel 按 SDK 声明风格为工具加并发安全声明。
|
||||
//
|
||||
// 风格要求(照 SDK 的 NoMemory 走,不自创):
|
||||
//
|
||||
// · 声明项是**结构体字段**(ParallelSafe / Serial),不是注释标记;
|
||||
// · 插在 Parameters 之后、handler 之前 —— 即字面量的**末尾**,
|
||||
// 与 SDK 里 NoMemory/ContextPolicy/RecallPolicy 的位置一致;
|
||||
// · Name 保持在首位,不打散 gofmt 对齐。
|
||||
//
|
||||
// 为什么用括号深度定位插入点:之前用正则找"最后一个顶层字段",
|
||||
// 会被嵌套 map 里的同形文本骗到,结果把声明插到 Parameters 中间,
|
||||
// 甚至把文件改坏(823 处重排)。深度计数是唯一可靠的。
|
||||
//
|
||||
// 用法:annotate_parallel <file> <tool:kind:note> ...
|
||||
//
|
||||
// kind: parallel | serial
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "用法: annotate_parallel <file> <tool:kind:note>...")
|
||||
os.Exit(2)
|
||||
}
|
||||
path := os.Args[1]
|
||||
lines := readLines(path)
|
||||
// 从后往前改,避免行号漂移
|
||||
type job struct {
|
||||
tool, kind, note string
|
||||
}
|
||||
var jobs []job
|
||||
for _, arg := range os.Args[2:] {
|
||||
p := strings.SplitN(arg, ":", 3)
|
||||
if len(p) != 3 {
|
||||
fmt.Fprintf(os.Stderr, "参数格式错: %q\n", arg)
|
||||
os.Exit(2)
|
||||
}
|
||||
jobs = append(jobs, job{p[0], p[1], p[2]})
|
||||
}
|
||||
// 反序处理(同一文件里多个工具,位置互不影响,但保守起见从后往前)
|
||||
for i := len(jobs) - 1; i >= 0; i-- {
|
||||
j := jobs[i]
|
||||
at, err := findInsertPoint(lines, j.tool)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " 跳过 %s: %v\n", j.tool, err)
|
||||
continue
|
||||
}
|
||||
field := "ParallelSafe: true,"
|
||||
if j.kind == "serial" {
|
||||
field = "Serial: true,"
|
||||
}
|
||||
ins := []string{"\t\t// " + j.note, "\t\t" + field}
|
||||
out := append([]string{}, lines[:at]...)
|
||||
out = append(out, ins...)
|
||||
out = append(out, lines[at:]...)
|
||||
lines = out
|
||||
fmt.Printf(" %s @line %d (%s)\n", j.tool, at+1, j.kind)
|
||||
}
|
||||
writeLines(path, lines)
|
||||
}
|
||||
|
||||
// findInsertPoint 找到该 RegisterTool 字面量中,Parameters 闭合之后的位置。
|
||||
func findInsertPoint(lines []string, tool string) (int, error) {
|
||||
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
|
||||
start := -1
|
||||
for i, l := range lines {
|
||||
if strings.Contains(l, head) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return 0, fmt.Errorf("找不到 RegisterTool(%q)", tool)
|
||||
}
|
||||
// 从 RegisterTool( 开始做括号深度追踪,找到 ToolDef 字面量的闭合 "}," 行
|
||||
depth := 0
|
||||
started := false
|
||||
enteredAt := -1
|
||||
inStr := false
|
||||
esc := false
|
||||
for i := start; i < len(lines); i++ {
|
||||
for _, ch := range lines[i] {
|
||||
if esc {
|
||||
esc = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' && inStr {
|
||||
esc = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inStr = !inStr
|
||||
continue
|
||||
}
|
||||
if inStr {
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '(', '{', '[':
|
||||
depth++
|
||||
started = true
|
||||
case ')', '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
// 插入点 = Parameters 字段的**闭合之后**。
|
||||
//
|
||||
// 精确定位法:Parameters 起始处的深度是 3(RegisterTool( → ToolDef{ →
|
||||
// Parameters{);它的闭合就是深度**首次从 3 回到 2** 的那一行。
|
||||
//
|
||||
// ⚠️ 我第一版没有这样做,而是"找 required 行的下一行,没有就返回字面量
|
||||
// 闭合行"。对于没有 required 的工具(如 config_list_plugins),
|
||||
// 后者落在 properties{} 内部 —— 生成的代码是
|
||||
// "properties": map[string]interface{}{},
|
||||
// ParallelSafe: true, ← 跑到 map 里去了
|
||||
// 编译报 undefined: ParallelSafe,症状离原因很远。
|
||||
// 插入点 = Parameters 字段闭合的**下一行**。
|
||||
//
|
||||
// 深度:RegisterTool( =1, ToolDef{ =2, Parameters{ =3。
|
||||
//
|
||||
// ★ 两个坑都是"同一行内深度进出平衡"造成的:
|
||||
//
|
||||
// 1. 空 properties:`"properties": map[string]interface{}{},`
|
||||
// 深度 3→2 在**同一行**完成。所以不能用"曾触及 3"作门控,
|
||||
// 必须记住**行号**:进入 3 的那行之后,首个回到 2 的行才是闭合行。
|
||||
//
|
||||
// 2. Name:/Description: 本来就在深度 2,早于 Parameters。
|
||||
// 只判 depth==2 会在 Name 行就返回,插入点跑到 RegisterTool 之前,
|
||||
// 编译报 "expected 1 expression"。
|
||||
if started && depth >= 3 {
|
||||
enteredAt = i
|
||||
}
|
||||
if enteredAt >= 0 && i > enteredAt && depth <= 2 {
|
||||
return i + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("括号深度追踪未收敛")
|
||||
}
|
||||
|
||||
func readLines(p string) []string {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||
for sc.Scan() {
|
||||
out = append(out, sc.Text())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeLines(p string, lines []string) {
|
||||
var sb strings.Builder
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(sb.String()), 0644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
736
tools/audit_parallel.go
Normal file
736
tools/audit_parallel.go
Normal file
@ -0,0 +1,736 @@
|
||||
//go:build ignore
|
||||
|
||||
// 命令 audit_parallel 审计插件工具的并发安全性。
|
||||
//
|
||||
// 存在理由:正则扫 ToolDef 字面量**不可靠** —— 我用它审计主仓 27 个工具时,
|
||||
// 把 config_set 判成"无共享写",而它的 handler 其实在 p.handleSet 里且无锁。
|
||||
// 原因:RegisterTool 的第三个参数是方法名/闭包,正则看不到执行体。
|
||||
//
|
||||
// 本工具用 go/ast 跟进**注册点到 handler 实现**,对每个工具判定:
|
||||
// - 找到所有可达的写操作(字段赋值、append、map 写入、文件写、exec、SDK Set)
|
||||
// - 找到所有互斥保护(Lock/RLock 覆盖该写,还是写发生在锁外)
|
||||
//
|
||||
// 输出三态:SAFE(只读)/ SERIAL(有写或顺序约束)/ UNKNOWN(追不到实现)。
|
||||
// UNKNOWN 一律不标 ParallelSafe —— 追不到就不能声称安全。
|
||||
//
|
||||
// 用法:go run ./tools/audit_parallel.go <dir>...
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// exprString 把表达式打印回源码文本。
|
||||
func exprString(fset *token.FileSet, e ast.Expr) string {
|
||||
var sb strings.Builder
|
||||
if err := printer.Fprint(&sb, fset, e); err != nil {
|
||||
return ""
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// verdict 是单个工具的审计结论。
|
||||
type verdict string
|
||||
|
||||
const (
|
||||
verdictSafe verdict = "SAFE" // 只读,可并发
|
||||
verdictSerial verdict = "SERIAL" // 有写或顺序约束,必须串行
|
||||
verdictUnknown verdict = "UNKNOWN" // 追不到实现,不声明
|
||||
)
|
||||
|
||||
// finding 是审计中命中的一个风险点。
|
||||
type finding struct {
|
||||
line int
|
||||
what string
|
||||
}
|
||||
|
||||
// toolAudit 是一个工具的审计结果。
|
||||
type toolAudit struct {
|
||||
name string
|
||||
file string
|
||||
verdict verdict
|
||||
findings []finding
|
||||
// implFile/implLine 指向追到的 handler 实现位置
|
||||
implFile string
|
||||
implLine int
|
||||
}
|
||||
|
||||
func main() {
|
||||
dirs := os.Args[1:]
|
||||
if len(dirs) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法: audit_parallel <dir>...")
|
||||
os.Exit(2)
|
||||
}
|
||||
all := map[string]*toolAudit{}
|
||||
for _, dir := range dirs {
|
||||
auditDir(dir, all)
|
||||
}
|
||||
report(all)
|
||||
}
|
||||
|
||||
func auditDir(dir string, out map[string]*toolAudit) {
|
||||
// 收集该目录下所有 .go 源码(用于跨文件跟进方法体)
|
||||
files := map[string]*ast.File{}
|
||||
fset := token.NewFileSet()
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
f, perr := parser.ParseFile(fset, path, nil, 0)
|
||||
if perr != nil {
|
||||
return nil
|
||||
}
|
||||
files[path] = f
|
||||
return nil
|
||||
})
|
||||
// 索引:方法名 -> 实现((file, receiver, method))
|
||||
methods := indexMethods(fset, files)
|
||||
// 索引:**变量名** -> 可能的接收者类型
|
||||
//
|
||||
// 为何需要:调用点写的是 p.handleRead(),定义处是 func (p *Plugin) handleRead()。
|
||||
// 变量名 p 与类型名 Plugin 是两个名字空间,必须显式连起来。
|
||||
recvTypes := indexRecvTypes(fset, files)
|
||||
|
||||
for path, f := range files {
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || !isRegisterTool(call) {
|
||||
return true
|
||||
}
|
||||
name := toolNameOf(call, fset)
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
ta := &toolAudit{name: name, file: path, verdict: verdictUnknown}
|
||||
// 作者已声明的**优先**,且不被后续判定覆盖。
|
||||
//
|
||||
// ⚠️ 我第一版把"已声明"当作一条 finding 记录,然后照常跑
|
||||
// 写入检测并**覆盖** verdict —— 结果 plugin_install(已标
|
||||
// Serial:true,实现里 exec.Command 也确实在跑)被判成 SAFE。
|
||||
// 审计工具自己给出与代码相反的结论,比没有工具更糟。
|
||||
if decl := declaredFlags(call); decl != "" {
|
||||
// 映射到三态之一。
|
||||
//
|
||||
// ⚠️ 我第一版直接 verdict = decl(值是 "Serial:true"),
|
||||
// 而 report 只认 SAFE/SERIAL/UNKNOWN 三个枚举值 ⇒ 全部落进
|
||||
// UNKNOWN 分支,输出"共 13:UNKNOWN 13",看着像工具没在工作。
|
||||
// 一枚举值被字符串污染时,症状离原因很远。
|
||||
if strings.Contains(decl, "Serial") {
|
||||
ta.verdict = verdictSerial
|
||||
} else {
|
||||
ta.verdict = verdictSafe
|
||||
}
|
||||
ta.findings = append(ta.findings, finding{0, "已声明 " + decl + "(作者判断,采信)"})
|
||||
out[name] = ta
|
||||
return true
|
||||
}
|
||||
// 跟进 handler 实现
|
||||
impl := handlerImplOf(call, fset, methods, recvTypes)
|
||||
if impl == nil {
|
||||
ta.findings = append(ta.findings, finding{0, "追不到 handler 实现(保守:不声明并发安全) diag=" + diagOf(call)})
|
||||
} else {
|
||||
ta.implFile, ta.implLine = impl.file, impl.line
|
||||
// ★ 递归跟进:写操作常常**不在** handler 体内。
|
||||
// 手工核实 plugin_install 时发现,闭包体只写了两行
|
||||
// `return p.installFromPath(...)`,真正的写在那个方法里 ——
|
||||
// 只扫一层会把它判成 SAFE(第一版就是这个 bug)。
|
||||
fs := scanWritesDeep(impl, methods, map[string]bool{}, 0)
|
||||
ta.findings = append(ta.findings, fs...)
|
||||
hasWrite := false
|
||||
for _, x := range fs {
|
||||
if isWriteKind(x.what) {
|
||||
hasWrite = true
|
||||
}
|
||||
}
|
||||
if hasWrite {
|
||||
ta.verdict = verdictSerial
|
||||
} else {
|
||||
ta.verdict = verdictSafe
|
||||
}
|
||||
}
|
||||
if old, exists := out[name]; !exists || old.verdict == verdictUnknown {
|
||||
out[name] = ta
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// indexRecvTypes 建立 "变量名 -> 接收者类型名列表" 的索引。
|
||||
func indexRecvTypes(fset *token.FileSet, files map[string]*ast.File) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, f := range files {
|
||||
// ★ 方法接收者变量名 → 接收者类型。
|
||||
//
|
||||
// example/files 里根本没有 `p := &Plugin{}` 这类语句 —— p 是
|
||||
// Start 的方法接收者。于是局部变量索引全空,143 个工具跟着全 UNKNOWN。
|
||||
// 症状("追不到实现")完全看不出真因是"p 从来不是局部变量"。
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fd.Recv == nil || len(fd.Recv.List) == 0 {
|
||||
continue
|
||||
}
|
||||
var recvType string
|
||||
if st, ok := fd.Recv.List[0].Type.(*ast.StarExpr); ok {
|
||||
if id, ok := st.X.(*ast.Ident); ok {
|
||||
recvType = id.Name
|
||||
}
|
||||
} else if id, ok := fd.Recv.List[0].Type.(*ast.Ident); ok {
|
||||
recvType = id.Name
|
||||
}
|
||||
if recvType == "" {
|
||||
continue
|
||||
}
|
||||
for _, nm := range fd.Recv.List[0].Names {
|
||||
out[nm.Name] = appendUnique(out[nm.Name], recvType)
|
||||
}
|
||||
}
|
||||
// 局部变量 p := &Plugin{} / var p *Plugin / p := New(...)
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.AssignStmt:
|
||||
if len(x.Lhs) == 1 && len(x.Rhs) == 1 {
|
||||
if id, ok := x.Lhs[0].(*ast.Ident); ok {
|
||||
for _, t := range typeNamesOf(x.Rhs[0]) {
|
||||
out[id.Name] = appendUnique(out[id.Name], t)
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.ValueSpec:
|
||||
// ⚠️ ValueSpec.Type 是 ast.Expr(**单个**类型表达式),
|
||||
// 不是切片 —— 我按 FieldList 的形状写了 x.Type[i],编译不过。
|
||||
// 有显式类型才建索引;`p := New(...)` 走上面 AssignStmt 分支。
|
||||
if x.Type != nil {
|
||||
for _, id := range x.Names {
|
||||
for _, t := range typeNamesOf(x.Type) {
|
||||
out[id.Name] = appendUnique(out[id.Name], t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
// 结构体字段 p.sdk / p.xxx 指向含 Plugin 的类型:并入该结构体名
|
||||
for _, d := range f.Decls {
|
||||
gd, ok := d.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, sp := range gd.Specs {
|
||||
ts, ok := sp.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, fld := range st.Fields.List {
|
||||
for _, t := range typeNamesOf(fld.Type) {
|
||||
out[ts.Name.Name] = appendUnique(out[ts.Name.Name], t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUnique(xs []string, x string) []string {
|
||||
for _, y := range xs {
|
||||
if y == x {
|
||||
return xs
|
||||
}
|
||||
}
|
||||
return append(xs, x)
|
||||
}
|
||||
|
||||
// typeNamesOf 从表达式/类型里抽出可能的类型名(含 New(T{}) 这类构造)。
|
||||
func typeNamesOf(e ast.Expr) []string {
|
||||
var out []string
|
||||
switch x := e.(type) {
|
||||
case *ast.StarExpr:
|
||||
return typeNamesOf(x.X)
|
||||
case *ast.Ident:
|
||||
return []string{x.Name}
|
||||
case *ast.SelectorExpr:
|
||||
return []string{x.Sel.Name}
|
||||
case *ast.CompositeLit:
|
||||
return typeNamesOf(x.Type)
|
||||
case *ast.CallExpr:
|
||||
// New(Plugin{}) → Plugin;也支持 f(&Plugin{}) / NewPlugin()
|
||||
if id, ok := x.Fun.(*ast.Ident); ok && id.Name == "New" && len(x.Args) > 0 {
|
||||
return typeNamesOf(x.Args[0])
|
||||
}
|
||||
if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
|
||||
return []string{strings.TrimPrefix(sel.Sel.Name, "New")}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type methodImpl struct {
|
||||
file string
|
||||
line int
|
||||
body *ast.BlockStmt
|
||||
recv string // 接收者类型名(大写化),如 "Plugin"
|
||||
// locked 记录是否在函数内出现 Lock/RLock
|
||||
locked bool
|
||||
// selfCall 记录本方法内调用的 p.xxx(...) 形式,便于继续跟进
|
||||
selfCalls []string
|
||||
}
|
||||
|
||||
func indexMethods(fset *token.FileSet, files map[string]*ast.File) map[string]*methodImpl {
|
||||
out := map[string]*methodImpl{}
|
||||
for path, f := range files {
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fd.Body == nil {
|
||||
continue
|
||||
}
|
||||
name := fd.Name.Name
|
||||
if fd.Recv != nil && len(fd.Recv.List) > 0 {
|
||||
if t, ok := fd.Recv.List[0].Type.(*ast.StarExpr); ok {
|
||||
if id, ok := t.X.(*ast.Ident); ok {
|
||||
name = "*" + id.Name + "." + name
|
||||
}
|
||||
}
|
||||
}
|
||||
out[name] = &methodImpl{
|
||||
file: path,
|
||||
line: fset.Position(fd.Pos()).Line,
|
||||
body: fd.Body,
|
||||
locked: hasMutexIn(fd.Body),
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handlerImplOf 从 RegisterTool 的第三个参数跟到实现。
|
||||
func handlerImplOf(call *ast.CallExpr, fset *token.FileSet, methods map[string]*methodImpl, recvTypes map[string][]string) *methodImpl {
|
||||
if len(call.Args) < 3 {
|
||||
return nil
|
||||
}
|
||||
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)}
|
||||
case *ast.SelectorExpr: // p.handleRead
|
||||
//
|
||||
// ★ 这里踩过一个隐蔽的坑:调用点的接收者是**变量名**(p),
|
||||
// 而方法定义的接收者是**类型名**(Plugin)。我第一版拿变量名
|
||||
// 直接去查类型索引,**永远匹配不上**,于是 143 个工具全报 UNKNOWN ——
|
||||
// 症状(全是未知)完全看不出是名字空间搞错了。
|
||||
// 修法:用变量名 + 方法名,跨 recvTypes 找出所有可能的类型。
|
||||
if recv, ok := a.X.(*ast.Ident); ok {
|
||||
if recvTypes != nil {
|
||||
if types, ok := recvTypes[recv.Name]; ok {
|
||||
for _, rt := range types {
|
||||
for _, k := range []string{"*" + rt + "." + a.Sel.Name, rt + "." + a.Sel.Name} {
|
||||
if m, ok := methods[k]; ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 退化:直接按名字试(无类型信息时)
|
||||
if m, ok := methods["*"+recv.Name+"."+a.Sel.Name]; ok {
|
||||
return m
|
||||
}
|
||||
if m, ok := methods[a.Sel.Name]; ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
case *ast.Ident: // 已声明的 handler 变量
|
||||
key := a.Name
|
||||
if m, ok := methods[key]; ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toolNameOf(call *ast.CallExpr, fset *token.FileSet) string {
|
||||
if len(call.Args) == 0 {
|
||||
return ""
|
||||
}
|
||||
switch a := call.Args[0].(type) {
|
||||
case *ast.BasicLit:
|
||||
return strings.Trim(a.Value, `"`)
|
||||
case *ast.BinaryExpr: // tp+"read" 之类
|
||||
//
|
||||
// ⚠️ 我第一版用 fmt.Sprintf("%v", a.Y) 拼名字,而 a.Y 是 *ast.BasicLit,
|
||||
// %v 打印的是 token 内部结构 —— 输出长这样:
|
||||
// tp&{10500 10515 STRING "manage_social"}
|
||||
// 名字里混着指针地址,人根本没法核对。必须取 BasicLit.Value。
|
||||
lhs := exprString(fset, a.X)
|
||||
rhs := ""
|
||||
if lit, ok := a.Y.(*ast.BasicLit); ok {
|
||||
rhs = strings.Trim(lit.Value, `"`)
|
||||
}
|
||||
return lhs + "_" + rhs
|
||||
case *ast.Ident:
|
||||
return a.Name
|
||||
case *ast.SelectorExpr:
|
||||
return a.Sel.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isRegisterTool(call *ast.CallExpr) bool {
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch sel.Sel.Name {
|
||||
case "RegisterTool":
|
||||
return true
|
||||
}
|
||||
// mockSDK / shim.RegisterTool 之类
|
||||
if strings.Contains(sel.Sel.Name, "RegisterTool") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func declaredFlags(call *ast.CallExpr) string {
|
||||
var sb strings.Builder
|
||||
ast.Inspect(call, func(n ast.Node) bool {
|
||||
kv, ok := n.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
k, ok1 := kv.Key.(*ast.Ident)
|
||||
v, ok2 := kv.Value.(*ast.Ident)
|
||||
if ok1 && ok2 && (k.Name == "ParallelSafe" || k.Name == "Serial") {
|
||||
fmt.Fprintf(&sb, "%s:%s ", k.Name, v.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// writePatterns 是需要视为"写"的调用与赋值形态。
|
||||
// httpWriteVerbs 是"写"语义的 HTTP 动词。
|
||||
//
|
||||
// ★ 这条是被手工核对逼出来的:vanblog 的 manage_social / manage_settings
|
||||
// 全走 `p.do("GET"|"POST"|"PUT"|..., ...)` 这个通用包装器,方法名里没有
|
||||
// 任何 write/delete 迹象,纯靠方法名匹配**全部漏判成 SAFE** ——
|
||||
// 而它们明确含 POST/PUT,是写操作。工具给出与代码相反的结论,
|
||||
// 比不给结论更危险(人会信它)。
|
||||
var httpWriteVerbs = []string{"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
// httpReadVerbs 只读的动词(用于在"混合"工具上区分)。
|
||||
var httpReadVerbs = []string{"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
var writePatterns = []struct{ what, pat string }{
|
||||
{"http-write-verb", ""}, // 占位:走专门的检查
|
||||
{"exec", "exec.Command"},
|
||||
{"exec", "exec.CommandContext"},
|
||||
{"file-write", "os.WriteFile"},
|
||||
{"file-remove", "os.Remove"},
|
||||
{"file-remove", "os.RemoveAll"},
|
||||
{"file-rename", "os.Rename"},
|
||||
{"mkdir", "os.Mkdir"},
|
||||
{"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("},
|
||||
}
|
||||
|
||||
func scanWrites(body *ast.BlockStmt) []finding {
|
||||
var out []finding
|
||||
// 字段赋值
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.AssignStmt:
|
||||
for _, lhs := range x.Lhs {
|
||||
if isSelector(lhs) {
|
||||
out = append(out, finding{0, "assign"})
|
||||
}
|
||||
}
|
||||
case *ast.IncDecStmt:
|
||||
if isSelector(x.X) {
|
||||
out = append(out, finding{0, "incdec"})
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
s := callString(x)
|
||||
for _, p := range writePatterns {
|
||||
if p.pat == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(s, p.pat) {
|
||||
out = append(out, finding{0, p.what})
|
||||
}
|
||||
}
|
||||
// HTTP 动词检查(覆盖通用包装器)
|
||||
if v := httpVerbOf(x); v != "" {
|
||||
for _, w := range httpWriteVerbs {
|
||||
if v == w {
|
||||
out = append(out, finding{0, "http-" + v})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
// append
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "append" {
|
||||
out = append(out, finding{0, "append"})
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
// 去重
|
||||
seen := map[string]bool{}
|
||||
var uniq []finding
|
||||
for _, f := range out {
|
||||
if seen[f.what] {
|
||||
continue
|
||||
}
|
||||
seen[f.what] = true
|
||||
uniq = append(uniq, f)
|
||||
}
|
||||
return uniq
|
||||
}
|
||||
|
||||
// maxFollowDepth 是跟进深度的上界。
|
||||
//
|
||||
// 定 3 层不是随便取的:example 插件里最深的链是
|
||||
// handler → 业务方法 → 存储/请求方法,三层已覆盖;再深的链说明该工具
|
||||
// 复杂到**人工读**比自动判更可靠,达到上界时按 UNKNOWN 处理(不标并发安全)。
|
||||
const maxFollowDepth = 3
|
||||
|
||||
// scanWritesDeep 递归跟进方法调用,收集写操作。
|
||||
func scanWritesDeep(impl *methodImpl, methods map[string]*methodImpl, seen map[string]bool, depth int) []finding {
|
||||
if impl == nil {
|
||||
return nil
|
||||
}
|
||||
key := fmt.Sprintf("%s:%d", impl.file, impl.line)
|
||||
if seen[key] {
|
||||
return nil // 环
|
||||
}
|
||||
seen[key] = true
|
||||
defer delete(seen, key)
|
||||
|
||||
var out []finding
|
||||
out = append(out, scanWrites(impl.body)...)
|
||||
|
||||
if depth >= maxFollowDepth {
|
||||
// 到达上界:还有未跟进的自调用就不能声称只读
|
||||
if len(impl.selfCalls) > 0 {
|
||||
out = append(out, finding{0, fmt.Sprintf("跟进到深度上界 %d,仍有 %d 个下层调用未展开", maxFollowDepth, len(impl.selfCalls))})
|
||||
}
|
||||
return out
|
||||
}
|
||||
// 跟进本方法的 selfCalls
|
||||
for _, c := range impl.selfCalls {
|
||||
// 在同一接收者类型下找;找不到就跨接收者找
|
||||
cands := []string{"*" + impl.recv + "." + c, impl.recv + "." + c, c}
|
||||
found := false
|
||||
for _, k := range cands {
|
||||
if impl.recv == "" && k == "*."+c {
|
||||
continue
|
||||
}
|
||||
if m, ok := methods[k]; ok && m != impl {
|
||||
out = append(out, scanWritesDeep(m, methods, seen, depth+1)...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && looksLikeExternal(c) {
|
||||
out = append(out, finding{0, "外部调用 " + c + "()(可能写,需人工确认)"})
|
||||
}
|
||||
}
|
||||
return dedupeFindings(out)
|
||||
}
|
||||
|
||||
// diagOf 输出第三个参数的形态,用于诊断 handlerImplOf 为何失败。
|
||||
func diagOf(call *ast.CallExpr) string {
|
||||
if len(call.Args) < 3 {
|
||||
return fmt.Sprintf("args=%d", len(call.Args))
|
||||
}
|
||||
switch a := call.Args[2].(type) {
|
||||
case *ast.FuncLit:
|
||||
return "closure"
|
||||
case *ast.SelectorExpr:
|
||||
return "selector:" + selReceiver(a) + "." + a.Sel.Name
|
||||
case *ast.Ident:
|
||||
return "ident:" + a.Name
|
||||
}
|
||||
return fmt.Sprintf("%T", call.Args[2])
|
||||
}
|
||||
|
||||
func looksLikeExternal(name string) bool {
|
||||
switch name {
|
||||
case "Errorf", "Sprintf", "Fatalf", "Printf", "String", "TrimSpace",
|
||||
"Split", "Join", "New", "Now", "Unix", "Abs", "Min", "Max", "Len", "Cap":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func dedupeFindings(in []finding) []finding {
|
||||
seen := map[string]bool{}
|
||||
var out []finding
|
||||
for _, f := range in {
|
||||
if seen[f.what] {
|
||||
continue
|
||||
}
|
||||
seen[f.what] = true
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isWriteKind(w string) bool {
|
||||
switch w {
|
||||
case "assign", "incdec", "append":
|
||||
return true
|
||||
}
|
||||
return true // 保守:所有命中都算写
|
||||
}
|
||||
|
||||
// httpVerbOf 从调用实参里取 HTTP 动词(如 p.do("POST", ...))。
|
||||
func httpVerbOf(c *ast.CallExpr) string {
|
||||
for _, a := range c.Args {
|
||||
if lit, ok := a.(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
v := strings.ToUpper(strings.Trim(lit.Value, `"`))
|
||||
for _, x := range append(append([]string{}, httpWriteVerbs...), httpReadVerbs...) {
|
||||
if v == x {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isSelector(e ast.Expr) bool { _, ok := e.(*ast.SelectorExpr); return ok }
|
||||
|
||||
// callString 收集整条方法链上的**所有**方法名。
|
||||
//
|
||||
// ★ 误判根因:`p.sdk.Settings().Set("listen", ...)` 是三段链式调用,
|
||||
//
|
||||
// 只看最外层只会得到 "Settings",于是 .Set 这个写操作**整个丢失**,
|
||||
// handleConfigure(写配置 + 启停服务)被判成只读。
|
||||
// 链式调用在 Go 里极常见,只看最外层等于漏掉大半写操作。
|
||||
func callString(c *ast.CallExpr) string {
|
||||
parts := callChain(c)
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// callChain 自内向外收集链上的方法名/标识符。
|
||||
func callChain(c *ast.CallExpr) []string {
|
||||
switch f := c.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if inner, ok := f.X.(*ast.CallExpr); ok {
|
||||
return append(callChain(inner), f.Sel.Name)
|
||||
}
|
||||
return []string{f.Sel.Name}
|
||||
case *ast.Ident:
|
||||
return []string{f.Name}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selReceiver(s *ast.SelectorExpr) string {
|
||||
switch x := s.X.(type) {
|
||||
case *ast.Ident:
|
||||
return x.Name
|
||||
case *ast.SelectorExpr:
|
||||
return selReceiver(x) + "." + x.Sel.Name
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// selfCallsOf 收集形如 x.method(...) 的调用(x 通常是接收者变量名)。
|
||||
func selfCallsOf(body *ast.BlockStmt) []string {
|
||||
var out []string
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
c, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
|
||||
if _, isIdent := sel.X.(*ast.Ident); isIdent {
|
||||
out = append(out, sel.Sel.Name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func hasMutexIn(body *ast.BlockStmt) bool {
|
||||
found := false
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
s := callString(c)
|
||||
if strings.Contains(s, "Lock") || strings.Contains(s, "RLock") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func report(all map[string]*toolAudit) {
|
||||
names := make([]string, 0, len(all))
|
||||
for n := range all {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var safe, serial, unknown int
|
||||
for _, n := range names {
|
||||
ta := all[n]
|
||||
switch ta.verdict {
|
||||
case verdictSafe:
|
||||
safe++
|
||||
case verdictSerial:
|
||||
serial++
|
||||
default:
|
||||
unknown++
|
||||
}
|
||||
fmt.Printf("%-8s %-28s %s", ta.verdict, n, ta.file)
|
||||
if ta.implFile != "" {
|
||||
fmt.Printf(" → %s:%d", ta.implFile, ta.implLine)
|
||||
}
|
||||
for _, f := range ta.findings {
|
||||
if f.line == 0 {
|
||||
fmt.Printf("\n · %s", f.what)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Printf("\n共 %d:SAFE %d / SERIAL %d / UNKNOWN %d\n", len(all), safe, serial, unknown)
|
||||
fmt.Println("⚠ UNKNOWN 一律不声明并发安全 —— 追不到实现就不能声称安全。")
|
||||
}
|
||||
Reference in New Issue
Block a user