376 lines
10 KiB
Go
376 lines
10 KiB
Go
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
|
||
}
|