// Package auditcoverage 提供全系统入口的可复核审计覆盖扫描。 package auditcoverage import ( "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "sort" "strconv" "strings" "github.com/bytedance/sonic" ) // Entry 是一个必须经过审计分类的 HTTP、Worker 或定时任务入口。 type Entry struct { Key string `json:"key"` Kind string `json:"kind"` CodeEntry string `json:"code_entry"` Owner string `json:"owner"` Method string `json:"method,omitempty"` Path string `json:"path,omitempty"` Summary string `json:"summary"` AuditEvent string `json:"audit_event"` DomainLedger string `json:"domain_ledger"` IntegrationLog string `json:"integration_log"` Outbox string `json:"outbox"` ActionCode string `json:"action_code,omitempty"` ActionName string `json:"action_name,omitempty"` Category string `json:"category,omitempty"` Risk string `json:"risk,omitempty"` PrimaryResource string `json:"primary_resource,omitempty"` AffectedResource string `json:"affected_resource,omitempty"` ActorSource string `json:"actor_source"` Transaction string `json:"transaction"` FailureStrategy string `json:"failure_strategy"` SensitivePolicy string `json:"sensitive_policy"` BeforeAfterPolicy string `json:"before_after_policy"` TestSeam string `json:"test_seam"` NAReason string `json:"na_reason,omitempty"` } // Scan 扫描当前仓库中对外 HTTP、Asynq Worker 和定时任务注册入口。 func Scan(root string) ([]Entry, error) { var entries []Entry files := []string{"internal/routes", "internal/application", "internal/domain", "internal/service", "pkg/queue", "cmd/worker"} for _, directory := range files { err := filepath.Walk(filepath.Join(root, directory), func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } found, err := scanFile(root, path) if err != nil { return err } entries = append(entries, found...) return nil }) if err != nil { return nil, err } } sort.Slice(entries, func(i, j int) bool { return entries[i].Key < entries[j].Key }) return entries, nil } // LoadManifest 读取经评审的显式覆盖快照。 func LoadManifest(path string) ([]Entry, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var entries []Entry if err := sonic.Unmarshal(data, &entries); err != nil { return nil, err } return entries, nil } // MarshalManifest 将扫描结果输出为稳定、便于评审的 JSON。 func MarshalManifest(entries []Entry) ([]byte, error) { return sonic.ConfigStd.MarshalIndent(entries, "", " ") } func scanFile(root, path string) ([]Entry, error) { set := token.NewFileSet() file, err := parser.ParseFile(set, path, nil, 0) if err != nil { return nil, err } relative, err := filepath.Rel(root, path) if err != nil { return nil, err } var entries []Entry ast.Inspect(file, func(node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok { return true } position := set.Position(call.Pos()) if identifier, ok := call.Fun.(*ast.Ident); ok && identifier.Name == "Register" && len(call.Args) >= 7 { method, methodOK := stringLiteral(call.Args[3]) pathSuffix, pathOK := stringLiteral(call.Args[4]) if methodOK && pathOK { entry := classifyHTTP(relative, position.Line, method, pathSuffix, expression(call.Args[5]), routeSummary(call.Args[6])) entries = append(entries, entry) } return true } selector, ok := call.Fun.(*ast.SelectorExpr) if !ok { return true } switch selector.Sel.Name { case "HandleFunc": if len(call.Args) >= 2 { entries = append(entries, classifyWorker(relative, position.Line, expression(call.Args[0]), expression(call.Args[1]))) } case "Register": if strings.HasPrefix(relative, "cmd/worker/") { if taskType, schedule, ok := scheduledTask(call); ok { entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule)) } } } return true }) if strings.HasPrefix(relative, "internal/application/") || strings.HasPrefix(relative, "internal/domain/") || strings.HasPrefix(relative, "internal/service/") { for _, declaration := range file.Decls { function, ok := declaration.(*ast.FuncDecl) if !ok || function.Recv == nil || !isBusinessMethod(function.Name.Name) { continue } position := set.Position(function.Pos()) entries = append(entries, classifyBusinessMethod(relative, position.Line, function.Name.Name)) } } return entries, nil } func classifyHTTP(file string, line int, method, path, handler, summary string) Entry { owner := strings.TrimSuffix(filepath.Base(file), ".go") if summary == "" { summary = handler } entry := Entry{ Key: fmt.Sprintf("http:%s:%d:%s:%s", file, line, method, path), Kind: "http", CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner, Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file), DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path), Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", SensitivePolicy: "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", } if method == "GET" && !isSensitiveRead(file, path, summary) { entry.AuditEvent = "N/A" entry.Transaction = "N/A" entry.FailureStrategy = "Access Log 记录统一错误;普通读取不创建业务审计" entry.NAReason = "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" return entry } entry.AuditEvent = "必须" entry.ActionCode = actionCode(owner, handler) entry.ActionName = summary entry.Category = categoryFor(owner) entry.Risk = riskFor(owner, path, summary) entry.PrimaryResource = owner entry.AffectedResource = "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展" entry.Transaction = "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入" entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical" return entry } func classifyWorker(file string, line int, taskType, handler string) Entry { owner := workerOwner(taskType) entry := Entry{ Key: fmt.Sprintf("worker:%s:%d:%s", file, line, taskType), Kind: "worker", CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner, Summary: "处理异步任务 " + taskType, AuditEvent: "按状态变化、人工触发、连续失败或高风险异常决定", DomainLedger: ledgerDecision(owner), IntegrationLog: workerIntegrationDecision(taskType), Outbox: "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", ActionCode: actionCode(owner, handler), ActionName: "处理异步任务(" + taskType + ")", Category: categoryFor(owner), Risk: riskFor(owner, taskType, handler), PrimaryResource: owner, AffectedResource: "任务载荷定位的直接业务资源", ActorSource: "system_task/asynq", Transaction: "业务状态变化、领域流水和 Audit Event 按用例原子提交", FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", TestSeam: "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口", } return entry } func classifySchedule(file string, line int, taskType, schedule string) Entry { return Entry{ Key: fmt.Sprintf("schedule:%s:%d:%s", file, line, taskType), Kind: "scheduled_job", CodeEntry: fmt.Sprintf("%s:%d", file, line), Owner: workerOwner(taskType), Summary: "按 " + schedule + " 调度 " + taskType, AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "N/A", ActorSource: "system_task/scheduled_job", Transaction: "N/A", FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", SensitivePolicy: "调度日志仅记录任务类型与安全时间信息", BeforeAfterPolicy: "N/A:调度入口不修改业务事实", TestSeam: "调度注册公开函数 + 覆盖门禁静态比对本入口", NAReason: "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker", } } func classifyBusinessMethod(file string, line int, method string) Entry { parts := strings.Split(file, "/") layer := parts[1] owner := strings.TrimSuffix(filepath.Base(filepath.Dir(file)), ".go") if owner == "service" || owner == "application" || owner == "domain" { owner = strings.TrimSuffix(filepath.Base(file), ".go") } entry := Entry{ Key: fmt.Sprintf("%s:%s:%d:%s", layer, file, line, method), Kind: layer, CodeEntry: fmt.Sprintf("%s:%d %s", file, line, method), Owner: owner, Summary: "业务方法 " + method, DomainLedger: ledgerDecision(owner), IntegrationLog: businessIntegrationDecision(file, method), Outbox: "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", ActionCode: actionCode(owner, method), Risk: riskFor(owner, file, method), PrimaryResource: owner, AffectedResource: "完整用例直接修改或引用的资源", ActorSource: "由调用入口传入操作者与来源快照", SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", TestSeam: "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口", } if layer == "domain" { entry.AuditEvent = "N/A" entry.Transaction = "由 Application 组合根负责" entry.FailureStrategy = "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计" entry.NAReason = "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入" entry.ActionCode = "" entry.ActionName = "" entry.Category = "" entry.Risk = "" entry.PrimaryResource = "" return entry } entry.AuditEvent = "必须" entry.ActionName = "执行业务方法(" + method + ")" entry.Category = categoryFor(owner) entry.Transaction = "关键成功事件与业务事实同一 GORM 事务" entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical" return entry } func routeSummary(expr ast.Expr) string { composite, ok := expr.(*ast.CompositeLit) if !ok { return "" } for _, element := range composite.Elts { pair, ok := element.(*ast.KeyValueExpr) if !ok || expression(pair.Key) != "Summary" { continue } value, _ := stringLiteral(pair.Value) return value } return "" } func scheduledTask(call *ast.CallExpr) (string, string, bool) { if len(call.Args) < 2 { return "", "", false } schedule, _ := stringLiteral(call.Args[0]) taskCall, ok := call.Args[1].(*ast.CallExpr) if !ok { return "", "", false } selector, ok := taskCall.Fun.(*ast.SelectorExpr) if !ok || selector.Sel.Name != "NewTask" || len(taskCall.Args) == 0 { return "", "", false } return expression(taskCall.Args[0]), schedule, true } func stringLiteral(expr ast.Expr) (string, bool) { literal, ok := expr.(*ast.BasicLit) if !ok || literal.Kind != token.STRING { return "", false } value, err := strconv.Unquote(literal.Value) return value, err == nil } func expression(expr ast.Expr) string { switch value := expr.(type) { case *ast.Ident: return value.Name case *ast.SelectorExpr: return expression(value.X) + "." + value.Sel.Name case *ast.BasicLit: return value.Value case *ast.CallExpr: return expression(value.Fun) default: return fmt.Sprintf("%T", expr) } } func httpActorSource(file string) string { switch { case strings.HasSuffix(file, "personal.go"): return "personal_customer/personal_api" case strings.HasSuffix(file, "order.go"): return "按路由分为 admin_user/admin_api 或外部回调入口" default: return "登录账号快照/admin_api" } } func isSensitiveRead(file, path, summary string) bool { text := strings.ToLower(file + " " + path + " " + summary) for _, marker := range []string{"download", "export", "realname-link", "实名", "敏感", "完整", "operation-password"} { if strings.Contains(text, marker) { return true } } return false } func integrationDecision(file, path string) string { text := strings.ToLower(file + " " + path) if strings.Contains(text, "callback") || strings.HasSuffix(file, "order.go") && (strings.Contains(path, "pay") || strings.Contains(path, "alipay")) { return "必须:业务处理前保存入站安全摘要与幂等标识" } return "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须" } func workerIntegrationDecision(taskType string) string { text := strings.ToLower(taskType) if strings.Contains(text, "polling") { return "必须:每次实际请求或未发送裁决均记录" } return "Worker 调用外部系统时必须;纯本地处理 N/A" } func businessIntegrationDecision(file, method string) string { text := strings.ToLower(file + " " + method) for _, marker := range []string{"polling", "gateway", "payment", "wechat", "wecom", "carrier", "sms"} { if strings.Contains(text, marker) { return "调用外部系统或处理回调时必须;纯本地分支 N/A" } } return "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类" } func ledgerDecision(owner string) string { for _, marker := range []string{"order", "recharge", "refund", "commission", "wallet"} { if strings.Contains(owner, marker) { return "必须:订单、充值、退款、钱包流水等既有业务表是领域权威" } } if strings.Contains(owner, "import") || strings.Contains(owner, "export") { return "业务任务及明细表是批量结果权威" } return "既有业务表是状态事实;Audit Event 不替代业务模型" } func riskFor(owner, path, summary string) string { text := strings.ToLower(owner + " " + path + " " + summary) for _, marker := range []string{"wallet", "refund", "recharge", "permission", "role", "password", "config", "权限", "资金", "退款", "充值"} { if strings.Contains(text, marker) { return "high" } } return "normal" } func categoryFor(owner string) string { text := strings.ToLower(owner) for _, marker := range []string{"wallet", "refund", "recharge", "commission", "order"} { if strings.Contains(text, marker) { return "finance" } } for _, marker := range []string{"account", "role", "permission", "auth"} { if strings.Contains(text, marker) { return "security" } } for _, marker := range []string{"card", "device", "asset", "polling"} { if strings.Contains(text, marker) { return "asset" } } return "business" } func actionCode(owner, handler string) string { method := handler if index := strings.LastIndex(method, "."); index >= 0 { method = method[index+1:] } return normalize(owner) + "." + normalize(method) } func workerOwner(taskType string) string { return normalize(strings.TrimPrefix(taskType, "constants.TaskType")) } func isBusinessMethod(name string) bool { for _, prefix := range []string{ "Create", "Update", "Delete", "Set", "Assign", "Remove", "Cancel", "Reject", "Approve", "Import", "Allocate", "Recall", "Stop", "Resume", "Bind", "Unbind", "Reset", "Activate", "Deactivate", "Trigger", "Handle", "Process", "Execute", "Replay", "Release", "Change", "Pay", "Refund", "Recharge", "Withdraw", "Grant", "Revoke", "Deduct", "Credit", "Debit", "Freeze", "Unfreeze", "Resolve", "Expire", "Invalidate", "Archive", "Cleanup", "Adjust", "Add", "Batch", "Enable", "Disable", "Login", "Logout", "Refresh", "Upload", "Download", "Save", "Restore", "Submit", "Sync", "Migrate", "Send", } { if strings.HasPrefix(name, prefix) { return true } } return false } func normalize(value string) string { value = strings.Trim(value, "\"") var output []rune for index, current := range []rune(value) { if current >= 'A' && current <= 'Z' { if index > 0 { output = append(output, '_') } current += 'a' - 'A' } if current == '-' || current == ':' || current == '/' { current = '_' } output = append(output, current) } return strings.Trim(strings.ReplaceAll(string(output), "__", "_"), "_") }