Files
修雨 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

165 lines
4.7 KiB
Go

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()
}