Files
junhong_cmp_fiber/internal/governance/auditcoverage/scanner.go
break ff44305d0e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m19s
实现审计覆盖门禁与外部集成日志闭环
2026-07-23 21:31:22 +09:00

449 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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), "__", "_"), "_")
}