package app import ( "bufio" "bytes" "encoding/csv" "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/spf13/cobra" ) var ( auditCSVWrite = func(writer *csv.Writer, record []string) error { return writer.Write(record) } auditCSVFlush = func(writer *csv.Writer) { writer.Flush() } auditCSVError = func(writer *csv.Writer) error { return writer.Error() } auditExit = os.Exit auditVerify = audit.VerifyFile ) func newAuditCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/audit.json // products.audit). Catalog assembly stamps provenance contract_final. contract.RegisterProductDecl(contract.ProductDecl{ ID: "audit", Selection: contract.ProductSelectionDecl{ AgentSummary: "查看、导出和校验本地操作审计日志", UseWhen: []string{ "需要排查本机 CLI 操作审计记录,或验证审计文件完整性", }, AvoidWhen: []string{ "查钉钉业务数据或发消息请用对应产品命令,不要用 audit", }, }, }) cmd := &cobra.Command{ Use: "audit", Short: "操作审计日志管理", Long: "查看、导出和校验本地操作审计日志。", } cmd.AddCommand( newAuditTailCommand(), newAuditExportCommand(), newAuditVerifyCommand(), ) return cmd } func newAuditTailCommand() *cobra.Command { var n int cmd := &cobra.Command{ Use: "tail", Short: "查看最近的审计记录", RunE: func(cmd *cobra.Command, args []string) error { if n < 1 { return fmt.Errorf("--lines 必须为正整数,收到 %d", n) } dir := auditDir() file, err := audit.LatestAuditFile(dir) if err != nil { return fmt.Errorf("无审计记录: %w", err) } lines, err := tailFile(file, n) if err != nil { return err } for _, line := range lines { fmt.Println(line) } return nil }, } cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录") helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: helpers.LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "audit", Name: "tail", CanonicalPath: "audit.tail", CLIPath: "audit tail", PrimaryCLIPath: "audit tail", }, Description: "查看本地操作审计日志最近 N 条记录", Interface: &contract.InterfaceSpec{ Mode: "local", Availability: "available", Reason: "命令读取本地审计日志尾部,不绑定 pinned MCP RPC", }, Selection: contract.SelectionSpec{ AgentSummary: "查看本地操作审计日志最近 N 条记录", UseWhen: []string{"需要快速查看最近写入的审计记录(默认最近 20 条)"}, AvoidWhen: []string{ "需要按日期范围整段导出用 audit export", "需要校验哈希链用 audit verify", }, Examples: []string{ "dws audit tail", "dws audit tail --lines 50", }, }, }, }) return cmd } func newAuditExportCommand() *cobra.Command { var since, until, format string cmd := &cobra.Command{ Use: "export", Short: "导出审计日志", RunE: func(cmd *cobra.Command, args []string) error { dir := auditDir() sinceDate := strings.ReplaceAll(since, "-", "") untilDate := strings.ReplaceAll(until, "-", "") files, err := audit.AuditFilesInRange(dir, sinceDate, untilDate) if err != nil { return fmt.Errorf("查找审计文件失败: %w", err) } if len(files) == 0 { return fmt.Errorf("指定范围内无审计文件") } switch format { case "jsonl": return exportJSONL(files) case "csv": return exportCSV(files) default: return fmt.Errorf("不支持的格式: %s(可选 jsonl, csv)", format) } }, } cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)") cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)") cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv") helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: helpers.LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "audit", Name: "export", CanonicalPath: "audit.export", CLIPath: "audit export", PrimaryCLIPath: "audit export", }, Description: "按日期范围导出本地操作审计日志(jsonl 或 csv)", Interface: &contract.InterfaceSpec{ Mode: "local", Availability: "available", Reason: "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC", }, Selection: contract.SelectionSpec{ AgentSummary: "按日期范围导出本地操作审计日志(jsonl 或 csv)", UseWhen: []string{"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"}, AvoidWhen: []string{ "只看最近几条用 audit tail", "只校验哈希链完整性用 audit verify", }, Examples: []string{ "dws audit export --format jsonl", "dws audit export --since 2026-07-01 --until 2026-07-14 --format csv", }, }, }, }) return cmd } func newAuditVerifyCommand() *cobra.Command { var file string cmd := &cobra.Command{ Use: "verify", Short: "校验审计日志哈希链完整性", RunE: func(cmd *cobra.Command, args []string) error { target := file if target == "" { dir := auditDir() var err error target, err = audit.LatestAuditFile(dir) if err != nil { return fmt.Errorf("无审计文件: %w", err) } } valid, brokenAt, verifyErr := auditVerify(target) if output.ResolveFormat(cmd, output.FormatTable) == output.FormatJSON { if verifyErr != nil && brokenAt == 0 { return fmt.Errorf("校验失败: %w", verifyErr) } payload := map[string]any{ "valid": valid, "file": target, "brokenAt": brokenAt, } if verifyErr != nil { payload["reason"] = verifyErr.Error() } if err := output.WriteCommandPayload(cmd, payload, output.FormatTable); err != nil { return err } if !valid { auditExit(1) } return nil } if verifyErr != nil { return fmt.Errorf("校验失败: %w", verifyErr) } if valid { fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target)) } else { fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt) auditExit(1) } return nil }, } cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)") helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: helpers.LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "audit", Name: "verify", CanonicalPath: "audit.verify", CLIPath: "audit verify", PrimaryCLIPath: "audit verify", }, Description: "校验本地审计日志文件的哈希链完整性", Interface: &contract.InterfaceSpec{ Mode: "local", Availability: "available", Reason: "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC", }, Selection: contract.SelectionSpec{ AgentSummary: "校验本地审计日志文件的哈希链完整性", UseWhen: []string{"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"}, AvoidWhen: []string{"只浏览或导出日志内容时用 audit tail / audit export"}, Examples: []string{ "dws audit verify", "dws audit verify --file /path/to/audit.jsonl", }, }, }, }) return cmd } func auditDir() string { if dir := os.Getenv(audit.EnvAuditDir); dir != "" { return dir } return filepath.Join(defaultConfigDir(), "audit") } func tailFile(path string, n int) ([]string, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var lines []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) for scanner.Scan() { lines = append(lines, scanner.Text()) } if err := scanner.Err(); err != nil { return nil, err } if len(lines) > n { lines = lines[len(lines)-n:] } return lines, nil } func exportJSONL(files []string) error { for _, file := range files { f, err := os.Open(file) if err != nil { return err } scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) for scanner.Scan() { fmt.Println(scanner.Text()) } f.Close() if err := scanner.Err(); err != nil { return err } } return nil } func exportCSV(files []string) error { w := csv.NewWriter(os.Stdout) header := []string{"timestamp", "execution_id", "user_id", "corp_id", "product", "command", "result", "duration_ms", "error_category"} if err := auditCSVWrite(w, header); err != nil { return fmt.Errorf("写入 CSV 表头失败: %w", err) } for _, file := range files { f, err := os.Open(file) if err != nil { return err } scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) lineNum := 0 for scanner.Scan() { lineNum++ line := scanner.Bytes() if len(bytes.TrimSpace(line)) == 0 { continue } var evt audit.Event if err := json.Unmarshal(line, &evt); err != nil { f.Close() return fmt.Errorf("解析审计记录失败 %s:%d: %w", file, lineNum, err) } row := []string{ evt.Timestamp.Format(time.RFC3339), evt.ExecutionID, evt.Actor.UserID, evt.Actor.CorpID, evt.Product, evt.Command, evt.Result, strconv.FormatInt(evt.DurationMs, 10), evt.ErrCategory, } if err := auditCSVWrite(w, row); err != nil { f.Close() return fmt.Errorf("写入 CSV 记录失败: %w", err) } } if err := scanner.Err(); err != nil { f.Close() return err } f.Close() } auditCSVFlush(w) if err := auditCSVError(w); err != nil { return fmt.Errorf("刷新 CSV 输出失败: %w", err) } return nil }