Compare commits

...
Author SHA1 Message Date
修雨 96986dfbff style(audit): gofmt trailing newline in audit_runtime_test.go
Fixes the Lint (Format Check) CI failure introduced by the previous
commit; gofmt flagged a trailing blank line at EOF.
2026-07-14 14:35:29 +08:00
修雨 27c3449036 fix(audit): drain forwards on error exit, fail CSV on corrupt JSONL
Address second-round review on PR #555:

- Move CloseAuditSink into the unconditional Execute defer so async remote
  forwards are drained on BOTH success and failure paths. Cobra skips
  PersistentPostRunE when RunE returns an error, which previously dropped
  in-flight forwards for failed commands. Make CloseAuditSink idempotent via
  sync.Once so the success-path hook and the defer can both call it.
- CSV export now returns a "文件:行号" error on malformed JSONL instead of
  silently skipping the line and exiting 0.
- Add regressions: TestCloseAuditSinkDrainsOnErrorPath (error-path drain),
  TestExportCSVFailsOnMalformedJSON (corrupt JSONL visible), and
  TestAuditIdentityReresolvesOnProfileSwitch (per-profile Actor via an
  injectable token loader seam).
2026-07-14 14:22:13 +08:00
修雨 d0787ce8ee fix(audit): stateless hash chain, waitable forwarder, profile actor, observability
Address PR #555 review:
- chain: derive prev_hash from file tail under cross-process flock, drop the
  global .chain sidecar so per-day files stay independently verifiable and
  concurrent dws processes cannot fork the chain
- forward: track async forwards with WaitGroup and add bounded Close(ctx) so
  in-flight deliveries are not dropped on process exit
- actor: resolve Actor from the active runtime profile (profile-keyed cache)
- observability: BuildSink returns init errors; write/forward failures reported
  to file log and to stderr when DWS_AUDIT_DEBUG is set
- cli: reject `audit tail --lines` < 1; check CSV writer/flush errors
- wire CloseAuditSink into PersistentPostRunE
- add regression tests for cross-date/cross-process chain, forwarder
  wait/timeout, init-failure, tail validation, CSV export
2026-07-14 11:56:09 +08:00
修雨 32c1d772de fix(root): expose audit command in help and reserve it from plugins 2026-07-14 10:30:05 +08:00
修雨 39b3003e2f Merge branch 'main' into feat/audit-log-v2 2026-07-14 10:21:15 +08:00
修雨 5ac5fcbf16 Merge remote-tracking branch 'origin/main' into feat/audit-log-v2
# Conflicts:
#	internal/helpers/devapp_connect.go
2026-07-08 13:59:54 +08:00
修雨 4a717bd92f feat(audit): implement user operation audit log
- Add internal/audit package: Event struct, FileSink, date rotation, L1 hash chain, HTTP forwarding, 3-tier redaction
- Integrate with runner: emit audit event in executeInvocation defer
- Add dws audit tail/export/verify command group
- Register DWS_AUDIT* env vars in configmeta, enabled by default
2026-07-06 11:55:40 +08:00
19 changed files with 2003 additions and 6 deletions
+233
View File
@@ -0,0 +1,233 @@
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/audit"
"github.com/spf13/cobra"
)
func newAuditCommand() *cobra.Command {
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 条记录")
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")
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, err := audit.VerifyFile(target)
if err != nil {
return fmt.Errorf("校验失败: %w", err)
}
if valid {
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
} else {
fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt)
os.Exit(1)
}
return nil
},
}
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
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 := w.Write(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 := w.Write(row); err != nil {
f.Close()
return fmt.Errorf("写入 CSV 记录失败: %w", err)
}
}
if err := scanner.Err(); err != nil {
f.Close()
return err
}
f.Close()
}
w.Flush()
if err := w.Error(); err != nil {
return fmt.Errorf("刷新 CSV 输出失败: %w", err)
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestAuditTailRejectsNonPositiveLines(t *testing.T) {
for _, n := range []string{"0", "-1"} {
cmd := newAuditTailCommand()
cmd.SetArgs([]string{"--lines", n})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
if err == nil {
t.Fatalf("--lines %s: expected error, got nil", n)
}
if !strings.Contains(err.Error(), "正整数") {
t.Fatalf("--lines %s: unexpected error: %v", n, err)
}
}
}
func TestTailFileReturnsLastN(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit-20260101.jsonl")
if err := os.WriteFile(path, []byte("a\nb\nc\nd\ne\n"), 0o600); err != nil {
t.Fatal(err)
}
lines, err := tailFile(path, 2)
if err != nil {
t.Fatal(err)
}
if len(lines) != 2 || lines[0] != "d" || lines[1] != "e" {
t.Fatalf("got %v, want [d e]", lines)
}
}
func TestExportCSVWritesHeaderAndRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit-20260101.jsonl")
rec := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1","corp_id":"c1"},"product":"calendar","command":"event_list","result":"success","duration_ms":12,"hash":"h","prev_hash":""}`
if err := os.WriteFile(path, []byte(rec+"\n"), 0o600); err != nil {
t.Fatal(err)
}
stdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
exportErr := exportCSV([]string{path})
w.Close()
os.Stdout = stdout
if exportErr != nil {
t.Fatalf("exportCSV error: %v", exportErr)
}
buf := make([]byte, 4096)
n, _ := r.Read(buf)
out := string(buf[:n])
if !strings.Contains(out, "timestamp,execution_id") {
t.Fatalf("missing CSV header, got: %q", out)
}
if !strings.Contains(out, "e1") || !strings.Contains(out, "event_list") {
t.Fatalf("missing CSV row data, got: %q", out)
}
}
// TestExportCSVFailsOnMalformedJSON guards the reviewer's V9 finding: a corrupt
// JSONL line must surface an error with file/line evidence instead of being
// silently skipped while the command exits 0.
func TestExportCSVFailsOnMalformedJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit-20260101.jsonl")
good := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1"},"product":"calendar","command":"event_list","result":"success","duration_ms":1,"hash":"h","prev_hash":""}`
if err := os.WriteFile(path, []byte(good+"\nnot-json\n"), 0o600); err != nil {
t.Fatal(err)
}
stdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
exportErr := exportCSV([]string{path})
w.Close()
os.Stdout = stdout
// Drain the pipe so the writer never blocks.
buf := make([]byte, 4096)
_, _ = r.Read(buf)
if exportErr == nil {
t.Fatal("expected error on malformed JSONL, got nil")
}
if !strings.Contains(exportErr.Error(), "解析审计记录失败") {
t.Fatalf("error missing parse context: %v", exportErr)
}
if !strings.Contains(exportErr.Error(), ":2") {
t.Fatalf("error missing line evidence: %v", exportErr)
}
}
+164
View File
@@ -0,0 +1,164 @@
package app
import (
"errors"
"fmt"
"os"
"runtime"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
var (
auditSinkOnce sync.Once
auditCloseOnce sync.Once
sharedAuditSink audit.Sink
auditIDMu sync.Mutex
cachedActor audit.Actor
cachedAgentID string
cachedProfile string
identityLoaded bool
// loadTokenForProfile is the profile-scoped token loader. It is a package
// variable so profile-switch Actor attribution can be tested deterministically
// without touching the OS keychain.
loadTokenForProfile = auth.LoadTokenDataForProfile
)
// setupAuditSink builds the process-wide audit sink once and caches it so the
// runner and the shutdown hook share a single writer/forwarder instance.
func setupAuditSink() audit.Sink {
auditSinkOnce.Do(func() {
sink, err := audit.BuildSink(defaultConfigDir(), auditReport)
if err != nil {
auditReport("initialization failed, audit disabled for this session: %v", err)
sharedAuditSink = audit.NopSink{}
return
}
sharedAuditSink = sink
})
return sharedAuditSink
}
// CloseAuditSink flushes in-flight remote forwards and closes the audit writer.
// It is invoked from an unconditional defer in Execute so the drain happens for
// both successful and failed commands (Cobra skips PersistentPostRunE when RunE
// returns an error). The sync.Once makes repeated calls safe.
func CloseAuditSink() {
auditCloseOnce.Do(func() {
if sharedAuditSink == nil {
return
}
if err := sharedAuditSink.Close(); err != nil {
auditReport("close failed: %v", err)
}
})
}
// auditReport routes non-fatal audit-subsystem diagnostics to the structured
// file log (always, when available) and to stderr when DWS_AUDIT_DEBUG is set,
// so init/write/forward failures are observable instead of silently swallowed.
func auditReport(format string, args ...any) {
msg := "audit: " + fmt.Sprintf(format, args...)
if l := FileLoggerInstance(); l != nil {
l.Warn(msg)
}
if audit.DebugEnabled() {
fmt.Fprintln(os.Stderr, "[dws] "+msg)
}
}
// auditIdentity resolves the Actor for the active runtime profile. The result
// is cached per-profile so a profile switch within a long-running process (e.g.
// serve mode) re-resolves rather than reusing a stale identity.
func auditIdentity() (audit.Actor, string) {
profile := auth.RuntimeProfile()
auditIDMu.Lock()
defer auditIDMu.Unlock()
if identityLoaded && profile == cachedProfile {
return cachedActor, cachedAgentID
}
configDir := defaultConfigDir()
var actor audit.Actor
if td, err := loadTokenForProfile(configDir, profile); err == nil && td != nil {
actor = audit.Actor{
UserID: td.UserID,
Name: td.UserName,
CorpID: td.CorpID,
CorpName: td.CorpName,
}
} else if err != nil {
auditReport("resolve actor for profile %q failed: %v", profile, err)
}
agentID := ""
if id := auth.Load(configDir); id != nil {
agentID = id.AgentID
}
cachedActor, cachedAgentID, cachedProfile, identityLoaded = actor, agentID, profile, true
return actor, agentID
}
func emitAudit(sink audit.Sink, execID string, invokeStart time.Time, invocation executor.Invocation, endpoint string, retErr error, cliVersion string) {
if sink == nil {
return
}
if _, ok := sink.(audit.NopSink); ok {
return
}
actor, agentID := auditIdentity()
result := "success"
var errCat, errReason string
if retErr != nil {
result = "error"
errCat, errReason = classifyAuditError(retErr)
}
paramsSummary := logging.SanitizeArguments(invocation.Params, 1024)
evt := &audit.Event{
Timestamp: invokeStart,
ExecutionID: execID,
AgentID: agentID,
Actor: actor,
Product: invocation.CanonicalProduct,
Command: invocation.Tool,
Endpoint: transport.RedactURL(endpoint),
ParamsSummary: paramsSummary,
Result: result,
ErrCategory: errCat,
ErrReason: errReason,
DurationMs: time.Since(invokeStart).Milliseconds(),
CLIVersion: cliVersion,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
if err := sink.Emit(evt); err != nil {
auditReport("emit event failed (exec %s): %v", execID, err)
}
}
func classifyAuditError(err error) (category, reason string) {
if err == nil {
return "", ""
}
var typed *apperrors.Error
if errors.As(err, &typed) {
return string(typed.Category), typed.Reason
}
return "unknown", err.Error()
}
+131
View File
@@ -0,0 +1,131 @@
package app
import (
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
// TestAuditIdentityReresolvesOnProfileSwitch guards the reviewer's finding that a
// long-running process (e.g. serve mode) must attribute events to the ACTIVE
// runtime profile rather than reusing a process-global first Actor. It also
// asserts the per-profile cache avoids redundant token loads within one profile.
func TestAuditIdentityReresolvesOnProfileSwitch(t *testing.T) {
prevLoader := loadTokenForProfile
prevProfile := auth.RuntimeProfile()
t.Cleanup(func() {
loadTokenForProfile = prevLoader
auth.SetRuntimeProfile(prevProfile)
resetAuditIdentityCache()
})
resetAuditIdentityCache()
var mu sync.Mutex
calls := map[string]int{}
loadTokenForProfile = func(_ /*configDir*/, profile string) (*auth.TokenData, error) {
mu.Lock()
calls[profile]++
mu.Unlock()
switch profile {
case "orgA":
return &auth.TokenData{UserID: "ua", UserName: "Alice", CorpID: "ca", CorpName: "CorpA"}, nil
case "orgB":
return &auth.TokenData{UserID: "ub", UserName: "Bob", CorpID: "cb", CorpName: "CorpB"}, nil
default:
return nil, nil
}
}
auth.SetRuntimeProfile("orgA")
if actor, _ := auditIdentity(); actor.UserID != "ua" || actor.CorpName != "CorpA" {
t.Fatalf("orgA: got %+v, want Alice/CorpA", actor)
}
// Second call under the same profile must hit the cache (no extra load).
if actor, _ := auditIdentity(); actor.UserID != "ua" {
t.Fatalf("orgA cached: got %+v", actor)
}
auth.SetRuntimeProfile("orgB")
if actor, _ := auditIdentity(); actor.UserID != "ub" || actor.CorpName != "CorpB" {
t.Fatalf("orgB: got %+v, want Bob/CorpB (stale Actor reused?)", actor)
}
mu.Lock()
defer mu.Unlock()
if calls["orgA"] != 1 {
t.Fatalf("orgA loaded %d times, want 1 (cache miss?)", calls["orgA"])
}
if calls["orgB"] != 1 {
t.Fatalf("orgB loaded %d times, want 1", calls["orgB"])
}
}
func resetAuditIdentityCache() {
auditIDMu.Lock()
defer auditIDMu.Unlock()
cachedActor = audit.Actor{}
cachedAgentID = ""
cachedProfile = ""
identityLoaded = false
}
// TestCloseAuditSinkDrainsOnErrorPath guards the reviewer's V5 finding: when a
// command's RunE returns an error, Cobra skips PersistentPostRunE, so the audit
// drain must instead happen through the unconditional defer in Execute that calls
// CloseAuditSink. This test wires a real forwarder-backed sink into the shared
// slot and asserts CloseAuditSink flushes the queued forward exactly as the
// error-path defer would, and that a second call is a harmless no-op.
func TestCloseAuditSinkDrainsOnErrorPath(t *testing.T) {
var delivered int64
var releaseOnce sync.Once
release := make(chan struct{})
releaseFn := func() { releaseOnce.Do(func() { close(release) }) }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
<-release // hold the request until the drain awaits it
atomic.AddInt64(&delivered, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
defer releaseFn() // LIFO: unblock any in-flight handler before srv.Close()
writer, err := audit.NewDateRotatingWriter(t.TempDir(), 0)
if err != nil {
t.Fatal(err)
}
fwd := audit.NewHTTPForwarder(srv.URL, "", audit.RedactNone, nil)
sink := audit.NewFileSink(writer, audit.NewChain(""), fwd)
prevSink := sharedAuditSink
t.Cleanup(func() {
sharedAuditSink = prevSink
auditCloseOnce = sync.Once{}
})
sharedAuditSink = sink
auditCloseOnce = sync.Once{}
if err := sink.Emit(&audit.Event{Timestamp: time.Unix(0, 0), Product: "calendar", Command: "event_list", Result: "error"}); err != nil {
t.Fatalf("emit: %v", err)
}
if got := atomic.LoadInt64(&delivered); got != 0 {
t.Fatalf("forward delivered before drain: %d", got)
}
// Let the held request complete, then drain via the same entry point the
// error-path defer uses. CloseAuditSink blocks until the forward goroutine
// observes the HTTP response, so the counter is settled when it returns.
releaseFn()
CloseAuditSink()
if got := atomic.LoadInt64(&delivered); got != 1 {
t.Fatalf("forward not drained on error path: delivered=%d, want 1", got)
}
// Idempotent: the success-path PersistentPostRunE and the defer both call it.
CloseAuditSink()
}
+6 -1
View File
@@ -66,6 +66,8 @@ func Execute() (exitCode int) {
timing := NewTimingCollector()
defer func() {
StopAllStdioClients() // Ensure child processes are terminated on exit
CloseAuditSink() // Drain async audit forwards on all exit paths,
// including command errors where Cobra skips PersistentPostRunE.
timing.PrintIfEnabled()
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
}()
@@ -335,6 +337,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
StopAllStdioClients()
CloseAuditSink()
CloseFileLogger()
return closeOutputSink(cmd)
},
@@ -357,6 +360,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
newConfigCommand(),
newDoctorCommand(),
newEventCommand(),
newAuditCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
newUpgradeCommand(),
@@ -560,6 +564,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
staticCommands := map[string]bool{
"auth": true,
"api": true,
"audit": true,
"cache": true,
"config": true,
"dev": true,
@@ -594,7 +599,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
// not override. This protects core CLI functionality from being hijacked
// by a malicious or misconfigured plugin.
var reservedCommands = map[string]bool{
"auth": true, "api": true, "login": true, "logout": true,
"auth": true, "api": true, "audit": true, "login": true, "logout": true,
"plugin": true, "profile": true, "skill": true, "cache": true,
"config": true, "doctor": true, "completion": true,
"recovery": true, "upgrade": true, "version": true,
+4
View File
@@ -27,6 +27,7 @@ import (
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -142,6 +143,7 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
scanner: newRuntimeContentScanner(),
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
auditSink: setupAuditSink(),
}
}
@@ -153,6 +155,7 @@ type runtimeRunner struct {
scanner safety.Scanner
enforceContentScan bool
includeScanReport bool
auditSink audit.Sink
}
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
@@ -468,6 +471,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
logging.LogCommandEnd(fl, execID,
invocation.CanonicalProduct, invocation.Tool,
retErr == nil, time.Since(invokeStart), errCat, errReason)
emitAudit(r.auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
}()
// Check if this product has plugin-level auth credentials registered.
+250
View File
@@ -0,0 +1,250 @@
package audit
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestFileSinkEmit(t *testing.T) {
dir := t.TempDir()
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
chain := NewChain(dir)
sink := NewFileSink(writer, chain, nil)
defer sink.Close()
evt := &Event{
Timestamp: time.Now(),
ExecutionID: "abc123",
Actor: Actor{UserID: "u1", CorpID: "c1"},
Product: "calendar",
Command: "list_events",
Endpoint: "https://api.example.com/mcp",
Result: "success",
DurationMs: 150,
CLIVersion: "1.0.47",
OS: "darwin",
Arch: "arm64",
}
if err := sink.Emit(evt); err != nil {
t.Fatal(err)
}
if evt.Hash == "" {
t.Error("expected hash to be set")
}
if evt.PrevHash != "" {
t.Error("first event should have empty prev_hash")
}
file, err := LatestAuditFile(dir)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if decoded.ExecutionID != "abc123" {
t.Errorf("got execution_id=%s, want abc123", decoded.ExecutionID)
}
if decoded.Hash == "" {
t.Error("decoded hash should not be empty")
}
}
func TestChainIntegrity(t *testing.T) {
dir := t.TempDir()
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
chain := NewChain(dir)
sink := NewFileSink(writer, chain, nil)
for i := 0; i < 5; i++ {
evt := &Event{
Timestamp: time.Now(),
ExecutionID: "exec-" + string(rune('a'+i)),
Actor: Actor{UserID: "u1", CorpID: "c1"},
Product: "test",
Command: "cmd",
Result: "success",
DurationMs: int64(i * 10),
CLIVersion: "1.0.0",
OS: "linux",
Arch: "amd64",
}
if err := sink.Emit(evt); err != nil {
t.Fatal(err)
}
}
sink.Close()
file, err := LatestAuditFile(dir)
if err != nil {
t.Fatal(err)
}
valid, brokenAt, err := VerifyFile(file)
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !valid {
t.Errorf("expected valid chain, broken at line %d", brokenAt)
}
}
func TestChainDetectsTampering(t *testing.T) {
dir := t.TempDir()
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
chain := NewChain(dir)
sink := NewFileSink(writer, chain, nil)
for i := 0; i < 3; i++ {
evt := &Event{
Timestamp: time.Now(),
ExecutionID: "exec-" + string(rune('0'+i)),
Actor: Actor{UserID: "u1", CorpID: "c1"},
Product: "test",
Command: "cmd",
Result: "success",
DurationMs: 100,
CLIVersion: "1.0.0",
OS: "linux",
Arch: "amd64",
}
if err := sink.Emit(evt); err != nil {
t.Fatal(err)
}
}
sink.Close()
file, err := LatestAuditFile(dir)
if err != nil {
t.Fatal(err)
}
// Tamper with the file: modify a character in the second line
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
// Find second newline and change a char after it
lines := splitLines(data)
if len(lines) < 2 {
t.Fatal("expected at least 2 lines")
}
// Corrupt the second line by changing first char of product
var evt2 map[string]any
json.Unmarshal([]byte(lines[1]), &evt2)
evt2["product"] = "tampered"
tampered, _ := json.Marshal(evt2)
lines[1] = string(tampered)
corrupted := []byte(lines[0] + "\n" + lines[1] + "\n" + lines[2] + "\n")
os.WriteFile(file, corrupted, 0o600)
valid, brokenAt, _ := VerifyFile(file)
if valid {
t.Error("expected invalid chain after tampering")
}
if brokenAt != 2 {
t.Errorf("expected break at line 2, got %d", brokenAt)
}
}
func TestRetention(t *testing.T) {
dir := t.TempDir()
// Create old files
oldDate := time.Now().AddDate(0, 0, -100).Format("20060102")
recentDate := time.Now().AddDate(0, 0, -10).Format("20060102")
os.WriteFile(filepath.Join(dir, "audit-"+oldDate+".jsonl"), []byte("old"), 0o600)
os.WriteFile(filepath.Join(dir, "audit-"+recentDate+".jsonl"), []byte("recent"), 0o600)
_, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
// Give async pruning a moment
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(filepath.Join(dir, "audit-"+oldDate+".jsonl")); !os.IsNotExist(err) {
t.Error("expected old file to be pruned")
}
if _, err := os.Stat(filepath.Join(dir, "audit-"+recentDate+".jsonl")); err != nil {
t.Error("recent file should still exist")
}
}
func TestRedactEvent(t *testing.T) {
evt := Event{
Actor: Actor{UserID: "uid123", Name: "张三", CorpID: "corp1", CorpName: "公司A"},
Product: "calendar",
Command: "list",
ParamsSummary: `{"date":"2026-01-01"}`,
Result: "success",
}
hashed := RedactEvent(evt, RedactHashed)
if hashed.Actor.Name == "张三" {
t.Error("name should be hashed")
}
if hashed.ParamsSummary != "" {
t.Error("params should be cleared in hashed mode")
}
if hashed.Actor.UserID != "uid123" {
t.Error("user_id should remain in hashed mode")
}
minimal := RedactEvent(evt, RedactMinimal)
if minimal.Actor.UserID == "uid123" {
t.Error("user_id should be hashed in minimal mode")
}
if minimal.Endpoint != "" {
t.Error("endpoint should be cleared in minimal mode")
}
}
func TestNopSink(t *testing.T) {
var s NopSink
if err := s.Emit(&Event{}); err != nil {
t.Error("NopSink.Emit should not error")
}
if err := s.Close(); err != nil {
t.Error("NopSink.Close should not error")
}
}
func splitLines(data []byte) []string {
var lines []string
start := 0
for i, b := range data {
if b == '\n' {
if i > start {
lines = append(lines, string(data[start:i]))
}
start = i + 1
}
}
if start < len(data) {
lines = append(lines, string(data[start:]))
}
return lines
}
+184
View File
@@ -0,0 +1,184 @@
package audit
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
)
// Chain computes the L1 sha256 tamper-evidence chain. It is deliberately
// stateless: every seal derives prev_hash from the last record already present
// in the current day's file rather than from an in-memory or sidecar cursor.
//
// This makes the chain correct in two cases the previous global-sidecar design
// broke:
// - cross-date: each day rotates to a fresh file whose first record chains
// from "", so every file is independently verifiable by VerifyFile.
// - cross-process: because the caller holds an exclusive inter-process lock on
// the file while sealing, the tail read reflects records written by any
// other dws process, so concurrent writers cannot fork the chain.
type Chain struct{}
// NewChain keeps the historical constructor signature. The directory argument
// is no longer needed because prev_hash is derived from the target file.
func NewChain(string) *Chain { return &Chain{} }
// SealFromFile reads the hash of the last record in f (the current day's audit
// file) and returns the prev_hash / hash pair for the event whose hash-free
// body is provided. The caller must hold the file lock.
func (c *Chain) SealFromFile(f *os.File, body []byte) (prevHash, hash string, err error) {
prevHash, err = lastRecordHash(f)
if err != nil {
return "", "", err
}
return prevHash, ComputeHash(prevHash, body), nil
}
// lastRecordHash returns the "hash" field of the final non-empty JSONL record in
// f, or "" when the file is empty. It reads only the tail of the file so cost
// does not grow with file size.
func lastRecordHash(f *os.File) (string, error) {
fi, err := f.Stat()
if err != nil {
return "", err
}
size := fi.Size()
if size == 0 {
return "", nil
}
const tailWindow = 64 * 1024
start := size - tailWindow
if start < 0 {
start = 0
}
buf := make([]byte, size-start)
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
return "", err
}
// Trim trailing newlines, then isolate the last line within the window.
end := len(buf)
for end > 0 && (buf[end-1] == '\n' || buf[end-1] == '\r') {
end--
}
if end == 0 {
return "", nil
}
lineStart := end
for lineStart > 0 && buf[lineStart-1] != '\n' {
lineStart--
}
last := buf[lineStart:end]
var rec struct {
Hash string `json:"hash"`
}
if err := json.Unmarshal(last, &rec); err != nil {
// The last record spilled past our tail window (pathologically large
// line). Fall back to a full scan for correctness.
if lineStart == 0 && start > 0 {
return lastRecordHashFullScan(f)
}
return "", fmt.Errorf("audit: parse last record: %w", err)
}
return rec.Hash, nil
}
func lastRecordHashFullScan(f *os.File) (string, error) {
if _, err := f.Seek(0, io.SeekStart); err != nil {
return "", err
}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
var last []byte
for scanner.Scan() {
if b := scanner.Bytes(); len(b) > 0 {
last = append(last[:0], b...)
}
}
if err := scanner.Err(); err != nil {
return "", err
}
if len(last) == 0 {
return "", nil
}
var rec struct {
Hash string `json:"hash"`
}
if err := json.Unmarshal(last, &rec); err != nil {
return "", fmt.Errorf("audit: parse last record: %w", err)
}
return rec.Hash, nil
}
func VerifyFile(path string) (valid bool, brokenAt int, err error) {
f, err := os.Open(path)
if err != nil {
return false, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
prevHash := ""
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Bytes()
var evt struct {
PrevHash string `json:"prev_hash"`
Hash string `json:"hash"`
}
if err := json.Unmarshal(line, &evt); err != nil {
return false, lineNum, fmt.Errorf("line %d: invalid JSON: %w", lineNum, err)
}
if evt.PrevHash != prevHash {
return false, lineNum, fmt.Errorf("line %d: prev_hash mismatch", lineNum)
}
body := stripHashFields(line)
h := sha256.New()
h.Write([]byte(prevHash))
h.Write(body)
expected := hex.EncodeToString(h.Sum(nil))
if evt.Hash != expected {
return false, lineNum, fmt.Errorf("line %d: hash mismatch", lineNum)
}
prevHash = evt.Hash
}
if err := scanner.Err(); err != nil {
return false, lineNum, err
}
return true, 0, nil
}
func stripHashFields(line []byte) []byte {
var evt Event
if err := json.Unmarshal(line, &evt); err != nil {
return line
}
evt.PrevHash = ""
evt.Hash = ""
out, err := json.Marshal(evt)
if err != nil {
return line
}
return out
}
func ComputeHash(prevHash string, eventJSON []byte) string {
h := sha256.New()
h.Write([]byte(prevHash))
h.Write(eventJSON)
return hex.EncodeToString(h.Sum(nil))
}
+135
View File
@@ -0,0 +1,135 @@
package audit
import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
EnvAudit = "DWS_AUDIT"
EnvAuditDir = "DWS_AUDIT_DIR"
EnvRetentionDays = "DWS_AUDIT_RETENTION_DAYS"
EnvForwardURL = "DWS_AUDIT_FORWARD_URL"
EnvForwardToken = "DWS_AUDIT_FORWARD_TOKEN"
EnvForwardRedact = "DWS_AUDIT_FORWARD_REDACT"
EnvAuditDebug = "DWS_AUDIT_DEBUG"
defaultRetentionDays = 90
auditSubdir = "audit"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: EnvAudit,
Category: configmeta.CategoryAudit,
Description: "操作审计日志开关(默认启用,设 0/false/off 关闭)",
DefaultValue: "启用",
Example: "0",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvAuditDir,
Category: configmeta.CategoryAudit,
Description: "审计日志目录(默认 <configDir>/audit)",
Example: "/var/log/dws-audit",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvRetentionDays,
Category: configmeta.CategoryAudit,
Description: "审计日志留存天数",
DefaultValue: "90",
Example: "180",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvForwardURL,
Category: configmeta.CategoryAudit,
Description: "审计事件远端转发 URL(POST JSON)",
Example: "https://siem.example.com/audit",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvForwardToken,
Category: configmeta.CategoryAudit,
Description: "远端转发 Bearer Token",
Sensitive: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvForwardRedact,
Category: configmeta.CategoryAudit,
Description: "远端转发脱敏级别:none / hashed / minimal",
DefaultValue: "none",
Example: "hashed",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvAuditDebug,
Category: configmeta.CategoryAudit,
Description: "打印审计子系统初始化/写入/转发失败诊断到 stderr(设 1/true/on 开启)",
Example: "1",
})
}
// DebugEnabled reports whether audit-subsystem diagnostics should be surfaced to
// stderr. Failures are always eligible for the structured log; this gates the
// noisier stderr channel.
func DebugEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvAuditDebug))) {
case "1", "true", "on", "yes", "y":
return true
}
return false
}
func IsEnabled() bool {
v := os.Getenv(EnvAudit)
if v == "" {
return true
}
switch strings.ToLower(v) {
case "0", "false", "off", "no", "n":
return false
}
return true
}
// BuildSink constructs the audit sink for configDir. It returns an error when
// the audit subsystem is enabled but cannot initialize (e.g. the log directory
// is not writable) so the caller can surface the failure instead of silently
// degrading. report receives non-fatal forwarder diagnostics; it may be nil.
func BuildSink(configDir string, report func(format string, args ...any)) (Sink, error) {
if !IsEnabled() {
return NopSink{}, nil
}
dir := os.Getenv(EnvAuditDir)
if dir == "" {
dir = filepath.Join(configDir, auditSubdir)
}
retention := defaultRetentionDays
if v := os.Getenv(EnvRetentionDays); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
retention = n
}
}
writer, err := NewDateRotatingWriter(dir, retention)
if err != nil {
return NopSink{}, err
}
chain := NewChain(dir)
var forwarder *HTTPForwarder
if fwdURL := os.Getenv(EnvForwardURL); fwdURL != "" {
token := os.Getenv(EnvForwardToken)
redact := RedactLevel(strings.ToLower(os.Getenv(EnvForwardRedact)))
if redact != RedactHashed && redact != RedactMinimal {
redact = RedactNone
}
forwarder = NewHTTPForwarder(fwdURL, token, redact, report)
}
return NewFileSink(writer, chain, forwarder), nil
}
+30
View File
@@ -0,0 +1,30 @@
package audit
import "time"
type Event struct {
Timestamp time.Time `json:"ts"`
ExecutionID string `json:"execution_id"`
AgentID string `json:"agent_id,omitempty"`
Actor Actor `json:"actor"`
Product string `json:"product"`
Command string `json:"command"`
Endpoint string `json:"endpoint"`
ParamsSummary string `json:"params_summary,omitempty"`
Result string `json:"result"`
ErrCategory string `json:"error_category,omitempty"`
ErrReason string `json:"error_reason,omitempty"`
DurationMs int64 `json:"duration_ms"`
CLIVersion string `json:"cli_version"`
OS string `json:"os"`
Arch string `json:"arch"`
PrevHash string `json:"prev_hash"`
Hash string `json:"hash"`
}
type Actor struct {
UserID string `json:"user_id"`
Name string `json:"name,omitempty"`
CorpID string `json:"corp_id"`
CorpName string `json:"corp_name,omitempty"`
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package audit
import (
"os"
"syscall"
)
// lockFile takes a non-blocking exclusive advisory lock; returns an error when
// another process holds it so the caller can retry with a timeout.
func lockFile(f *os.File) error {
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
}
func unlockFile(f *os.File) {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package audit
import (
"os"
"unsafe"
"golang.org/x/sys/windows"
)
const lockfileExclusiveLock = 0x00000002
// lockFile takes a non-blocking exclusive lock on the first byte range; returns
// an error when another process holds it so the caller can retry with a timeout.
func lockFile(f *os.File) error {
ol := new(windows.Overlapped)
return windows.LockFileEx(
windows.Handle(f.Fd()),
lockfileExclusiveLock|windows.LOCKFILE_FAIL_IMMEDIATELY,
0,
1,
0,
(*windows.Overlapped)(unsafe.Pointer(ol)),
)
}
func unlockFile(f *os.File) {
ol := new(windows.Overlapped)
_ = windows.UnlockFileEx(
windows.Handle(f.Fd()),
0,
1,
0,
(*windows.Overlapped)(unsafe.Pointer(ol)),
)
}
+109
View File
@@ -0,0 +1,109 @@
package audit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
type HTTPForwarder struct {
url string
token string
redact RedactLevel
client *http.Client
wg sync.WaitGroup
report func(format string, args ...any)
timeout time.Duration
}
func NewHTTPForwarder(url, token string, redact RedactLevel, report func(string, ...any)) *HTTPForwarder {
if report == nil {
report = func(string, ...any) {}
}
return &HTTPForwarder{
url: url,
token: token,
redact: redact,
client: &http.Client{Timeout: 3 * time.Second},
report: report,
timeout: 3 * time.Second,
}
}
// Forward dispatches the event asynchronously while tracking the goroutine so
// Close can wait for delivery instead of the CLI dropping it on exit.
func (f *HTTPForwarder) Forward(evt Event) {
f.wg.Add(1)
go func() {
defer f.wg.Done()
f.send(evt)
}()
}
// Close waits for in-flight forwards to finish, bounded by ctx (and, if ctx has
// no deadline, by a small internal timeout) so shutdown never blocks forever.
func (f *HTTPForwarder) Close(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, f.timeout+2*time.Second)
defer cancel()
}
done := make(chan struct{})
go func() {
f.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
f.report("forward flush timed out: %v", ctx.Err())
return fmt.Errorf("audit: forward flush timed out: %w", ctx.Err())
}
}
func (f *HTTPForwarder) send(evt Event) {
var body []byte
var err error
if f.redact != RedactNone {
body, err = RedactEventJSON(evt, f.redact)
} else {
body, err = json.Marshal(evt)
}
if err != nil {
f.report("forward marshal failed: %v", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.url, bytes.NewReader(body))
if err != nil {
f.report("forward build request failed: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
if f.token != "" {
req.Header.Set("Authorization", "Bearer "+f.token)
}
resp, err := f.client.Do(req)
if err != nil {
f.report("forward request failed: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
f.report("forward rejected: status %d", resp.StatusCode)
}
}
+47
View File
@@ -0,0 +1,47 @@
package audit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
)
type RedactLevel string
const (
RedactNone RedactLevel = "none"
RedactHashed RedactLevel = "hashed"
RedactMinimal RedactLevel = "minimal"
)
func RedactEvent(evt Event, level RedactLevel) Event {
switch level {
case RedactHashed:
if evt.Actor.Name != "" {
evt.Actor.Name = hashString(evt.Actor.Name)
}
if evt.Actor.CorpName != "" {
evt.Actor.CorpName = hashString(evt.Actor.CorpName)
}
evt.ParamsSummary = ""
case RedactMinimal:
evt.Actor = Actor{UserID: hashString(evt.Actor.UserID), CorpID: hashString(evt.Actor.CorpID)}
evt.ParamsSummary = ""
evt.Endpoint = ""
evt.ErrReason = ""
evt.AgentID = ""
evt.PrevHash = ""
evt.Hash = ""
}
return evt
}
func RedactEventJSON(evt Event, level RedactLevel) ([]byte, error) {
redacted := RedactEvent(evt, level)
return json.Marshal(redacted)
}
func hashString(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:8])
}
+225
View File
@@ -0,0 +1,225 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
// writeEvent seals and appends one event through the writer+chain, mirroring
// FileSink.Emit's locking discipline, so tests exercise the real tail-derived
// hash chain path.
func writeEvent(t *testing.T, w *DateRotatingWriter, chain *Chain, evt *Event) {
t.Helper()
body, err := marshalWithoutHash(evt)
if err != nil {
t.Fatalf("marshal: %v", err)
}
f, release, err := w.beginAppend()
if err != nil {
t.Fatalf("beginAppend: %v", err)
}
prev, hash, err := chain.SealFromFile(f, body)
if err != nil {
release()
t.Fatalf("seal: %v", err)
}
evt.PrevHash, evt.Hash = prev, hash
line, err := json.Marshal(evt)
if err != nil {
release()
t.Fatalf("marshal final: %v", err)
}
if _, err := f.Write(append(line, '\n')); err != nil {
release()
t.Fatalf("write: %v", err)
}
release()
}
func sampleEvent(id string) *Event {
return &Event{
Timestamp: time.Now(),
ExecutionID: id,
Actor: Actor{UserID: "u1", CorpID: "c1"},
Product: "calendar",
Command: "event_list",
Result: "success",
DurationMs: 10,
CLIVersion: "1.0.0",
OS: "darwin",
Arch: "arm64",
}
}
// TestCrossDateChainIndependentPerFile verifies each day's file starts a fresh
// chain (prev_hash="") and verifies independently — the bug the removed global
// .chain sidecar introduced.
func TestCrossDateChainIndependentPerFile(t *testing.T) {
dir := t.TempDir()
chain := NewChain(dir)
// Simulate two calendar days by writing files directly with the same chain
// semantics: each file's first record must chain from "".
for _, day := range []string{"20260101", "20260102"} {
path := filepath.Join(dir, "audit-"+day+".jsonl")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
evt := sampleEvent(fmt.Sprintf("%s-%d", day, i))
body, _ := marshalWithoutHash(evt)
prev, hash, err := chain.SealFromFile(f, body)
if err != nil {
t.Fatal(err)
}
evt.PrevHash, evt.Hash = prev, hash
line, _ := json.Marshal(evt)
if _, err := f.Write(append(line, '\n')); err != nil {
t.Fatal(err)
}
}
f.Close()
valid, brokenAt, err := VerifyFile(path)
if err != nil {
t.Fatalf("verify %s: %v", day, err)
}
if !valid {
t.Fatalf("file %s chain broken at line %d", day, brokenAt)
}
}
}
// TestCrossProcessChainSharedFile simulates two independent writers (as two
// processes would) appending to the same day's file. Because each seal derives
// prev_hash from the file tail under the inter-process lock, the resulting chain
// must remain valid with no fork.
func TestCrossProcessChainSharedFile(t *testing.T) {
dir := t.TempDir()
w1, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
defer w1.Close()
w2, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
defer w2.Close()
chain := NewChain(dir)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
w := w1
if i%2 == 1 {
w = w2
}
go func(w *DateRotatingWriter, i int) {
defer wg.Done()
writeEvent(t, w, chain, sampleEvent(fmt.Sprintf("exec-%d", i)))
}(w, i)
}
wg.Wait()
file, err := LatestAuditFile(dir)
if err != nil {
t.Fatal(err)
}
valid, brokenAt, err := VerifyFile(file)
if err != nil {
t.Fatalf("verify: %v", err)
}
if !valid {
t.Fatalf("cross-process chain broken at line %d", brokenAt)
}
}
// TestForwarderCloseWaitsForDelivery ensures Close blocks until every async
// forward has been delivered to the remote endpoint.
func TestForwarderCloseWaitsForDelivery(t *testing.T) {
var received int64
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release // hold the handler so delivery is still in flight at Close time
atomic.AddInt64(&received, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
fwd := NewHTTPForwarder(srv.URL, "", RedactNone, nil)
const n = 5
for i := 0; i < n; i++ {
fwd.Forward(*sampleEvent(fmt.Sprintf("e-%d", i)))
}
// Nothing delivered yet because handlers are blocked.
if got := atomic.LoadInt64(&received); got != 0 {
t.Fatalf("expected 0 delivered before release, got %d", got)
}
close(release)
if err := fwd.Close(context.Background()); err != nil {
t.Fatalf("Close returned error: %v", err)
}
if got := atomic.LoadInt64(&received); got != n {
t.Fatalf("expected %d delivered after Close, got %d", n, got)
}
}
// TestForwarderCloseTimeoutReports verifies Close honors the ctx deadline and
// reports instead of blocking forever when the endpoint never responds.
func TestForwarderCloseTimeoutReports(t *testing.T) {
block := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-block
}))
// Defers run LIFO: close(block) first unblocks the handler so srv.Close can
// join its connection goroutine without deadlocking.
defer srv.Close()
defer close(block)
var reported int64
report := func(string, ...any) { atomic.AddInt64(&reported, 1) }
fwd := NewHTTPForwarder(srv.URL, "", RedactNone, report)
fwd.timeout = 10 * time.Second // keep the in-flight send alive past ctx
fwd.Forward(*sampleEvent("stuck"))
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
if err := fwd.Close(ctx); err == nil {
t.Fatal("expected timeout error from Close")
}
if atomic.LoadInt64(&reported) == 0 {
t.Fatal("expected Close timeout to be reported")
}
}
// TestBuildSinkInitFailureObservable verifies BuildSink surfaces an error (and
// the caller can fall back) when the audit directory cannot be created.
func TestBuildSinkInitFailureObservable(t *testing.T) {
dir := t.TempDir()
// Make a file where the audit subdir is expected so MkdirAll fails.
clash := filepath.Join(dir, "audit")
if err := os.WriteFile(clash, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(EnvAuditDir, filepath.Join(clash, "sub"))
var reported int64
_, err := BuildSink(dir, func(string, ...any) { atomic.AddInt64(&reported, 1) })
if err == nil {
t.Fatal("expected BuildSink to fail when audit dir is unusable")
}
}
+182
View File
@@ -0,0 +1,182 @@
package audit
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const (
auditLockFile = ".audit.lock"
auditLockTimeout = 3 * time.Second
auditLockRetry = 20 * time.Millisecond
)
type DateRotatingWriter struct {
mu sync.Mutex
dir string
curDate string
file *os.File
lock *os.File
retention int
}
func NewDateRotatingWriter(dir string, retentionDays int) (*DateRotatingWriter, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("audit: create dir: %w", err)
}
lock, err := os.OpenFile(filepath.Join(dir, auditLockFile), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, fmt.Errorf("audit: open lock file: %w", err)
}
w := &DateRotatingWriter{
dir: dir,
retention: retentionDays,
lock: lock,
}
go w.pruneOldFiles()
return w, nil
}
// beginAppend serializes writers within this process (mu) and across processes
// (flock), rotates to today's file, and returns the open handle plus a release
// func that unlocks in reverse order. The file is opened O_RDWR|O_APPEND so the
// chain can read the tail while every write still lands atomically at EOF even
// when another dws process appends concurrently.
func (w *DateRotatingWriter) beginAppend() (*os.File, func(), error) {
w.mu.Lock()
if err := w.acquireLock(); err != nil {
w.mu.Unlock()
return nil, nil, err
}
today := time.Now().Format("20060102")
if today != w.curDate || w.file == nil {
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
path := filepath.Join(w.dir, fmt.Sprintf("audit-%s.jsonl", today))
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
if err != nil {
unlockFile(w.lock)
w.mu.Unlock()
return nil, nil, fmt.Errorf("audit: open file: %w", err)
}
w.file = f
w.curDate = today
}
release := func() {
unlockFile(w.lock)
w.mu.Unlock()
}
return w.file, release, nil
}
func (w *DateRotatingWriter) acquireLock() error {
deadline := time.Now().Add(auditLockTimeout)
for {
if err := lockFile(w.lock); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("audit: timeout acquiring file lock after %v (another dws process may be writing)", auditLockTimeout)
}
time.Sleep(auditLockRetry)
}
}
func (w *DateRotatingWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
var firstErr error
if w.file != nil {
if err := w.file.Close(); err != nil {
firstErr = err
}
w.file = nil
}
if w.lock != nil {
if err := w.lock.Close(); err != nil && firstErr == nil {
firstErr = err
}
w.lock = nil
}
return firstErr
}
func (w *DateRotatingWriter) pruneOldFiles() {
if w.retention <= 0 {
return
}
entries, err := os.ReadDir(w.dir)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -w.retention).Format("20060102")
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, "audit-") || !strings.HasSuffix(name, ".jsonl") {
continue
}
dateStr := strings.TrimPrefix(name, "audit-")
dateStr = strings.TrimSuffix(dateStr, ".jsonl")
if len(dateStr) != 8 {
continue
}
if dateStr < cutoff {
_ = os.Remove(filepath.Join(w.dir, name))
}
}
}
func (w *DateRotatingWriter) Dir() string {
return w.dir
}
func LatestAuditFile(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var files []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), "audit-") && strings.HasSuffix(e.Name(), ".jsonl") {
files = append(files, e.Name())
}
}
if len(files) == 0 {
return "", fmt.Errorf("no audit files found in %s", dir)
}
sort.Strings(files)
return filepath.Join(dir, files[len(files)-1]), nil
}
func AuditFilesInRange(dir, since, until string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, "audit-") || !strings.HasSuffix(name, ".jsonl") {
continue
}
dateStr := strings.TrimPrefix(name, "audit-")
dateStr = strings.TrimSuffix(dateStr, ".jsonl")
if len(dateStr) != 8 {
continue
}
if (since == "" || dateStr >= since) && (until == "" || dateStr <= until) {
files = append(files, filepath.Join(dir, name))
}
}
sort.Strings(files)
return files, nil
}
+109
View File
@@ -0,0 +1,109 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"time"
)
type Sink interface {
Emit(event *Event) error
Close() error
}
type NopSink struct{}
func (NopSink) Emit(*Event) error { return nil }
func (NopSink) Close() error { return nil }
type FileSink struct {
writer *DateRotatingWriter
chain *Chain
forwarder *HTTPForwarder
}
func NewFileSink(writer *DateRotatingWriter, chain *Chain, forwarder *HTTPForwarder) *FileSink {
return &FileSink{
writer: writer,
chain: chain,
forwarder: forwarder,
}
}
func (s *FileSink) Emit(evt *Event) error {
body, err := marshalWithoutHash(evt)
if err != nil {
return fmt.Errorf("audit: marshal event: %w", err)
}
f, release, err := s.writer.beginAppend()
if err != nil {
return fmt.Errorf("audit: acquire writer: %w", err)
}
// Derive prev_hash from the file tail, seal, and append — all under the
// writer's process + inter-process lock so the chain cannot fork.
prevHash, hash, err := s.chain.SealFromFile(f, body)
if err != nil {
release()
return fmt.Errorf("audit: seal event: %w", err)
}
evt.PrevHash = prevHash
evt.Hash = hash
line, err := json.Marshal(evt)
if err != nil {
release()
return fmt.Errorf("audit: marshal final event: %w", err)
}
line = append(line, '\n')
if _, err := f.Write(line); err != nil {
release()
return fmt.Errorf("audit: write event: %w", err)
}
release()
if s.forwarder != nil {
s.forwarder.Forward(*evt)
}
return nil
}
// Close flushes in-flight remote forwards (bounded) before closing the writer,
// so events are not silently dropped when the CLI process exits right after
// emitting.
func (s *FileSink) Close() error {
var forwardErr error
if s.forwarder != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
forwardErr = s.forwarder.Close(ctx)
cancel()
}
if err := s.writer.Close(); err != nil {
return err
}
return forwardErr
}
func marshalWithoutHash(evt *Event) ([]byte, error) {
saved := Event{
Timestamp: evt.Timestamp,
ExecutionID: evt.ExecutionID,
AgentID: evt.AgentID,
Actor: evt.Actor,
Product: evt.Product,
Command: evt.Command,
Endpoint: evt.Endpoint,
ParamsSummary: evt.ParamsSummary,
Result: evt.Result,
ErrCategory: evt.ErrCategory,
ErrReason: evt.ErrReason,
DurationMs: evt.DurationMs,
CLIVersion: evt.CLIVersion,
OS: evt.OS,
Arch: evt.Arch,
}
return json.Marshal(saved)
}
+5 -3
View File
@@ -32,6 +32,7 @@ const (
CategoryAuth Category = "auth"
CategoryNetwork Category = "network"
CategorySecurity Category = "security"
CategoryAudit Category = "audit"
CategoryRuntime Category = "runtime"
CategoryDebug Category = "debug"
CategoryExternal Category = "external"
@@ -43,9 +44,10 @@ var categoryOrder = map[Category]int{
CategoryAuth: 1,
CategoryNetwork: 2,
CategorySecurity: 3,
CategoryRuntime: 4,
CategoryDebug: 5,
CategoryExternal: 6,
CategoryAudit: 4,
CategoryRuntime: 5,
CategoryDebug: 6,
CategoryExternal: 7,
}
// ConfigItem describes a single environment-variable configuration item.
+2 -2
View File
@@ -143,8 +143,8 @@ func TestMaskValue(t *testing.T) {
func TestCategories(t *testing.T) {
cats := Categories()
if len(cats) != 7 {
t.Fatalf("expected 7 categories, got %d", len(cats))
if len(cats) != 8 {
t.Fatalf("expected 8 categories, got %d", len(cats))
}
if cats[0] != CategoryCore {
t.Errorf("expected core first, got %s", cats[0])