Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96986dfbff | ||
|
|
27c3449036 | ||
|
|
d0787ce8ee | ||
|
|
32c1d772de | ||
|
|
39b3003e2f | ||
|
|
5ac5fcbf16 | ||
|
|
4a717bd92f |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user