From edd30d384859b139b8d329687f3605c406fee0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:23:56 +0800 Subject: [PATCH 1/8] feat(audit): per-command structured audit log with pluggable enterprise sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面向所有部署 dws 的企业的通用合规审计能力:为每次命令调用产出一条 结构化审计记录,把员工经 dws 的操作留痕。遵循开源惯例,把"产生事件" 与"投递事件"分离: - 通道A 本地审计文件 /logs/audit.jsonl(源头真相,operator 自有) - 通道B 转发到企业自有 sink(endpoint 由部署企业配,不写死厂商;转发前可脱敏) - 默认全关:不设 DWS_AUDIT_ENABLED 则零产出、热路径零影响 字段尽量不丢:time/trace_id、actor/org(token)、device(opt-in 含 sn_no)、 自然语言 intent(仅 agent 层能注入,标记 provenance 表不可验真)、 module/command/subcommand、子命令介绍与敏感度(catalog)、操作对象(参数)、 数据流向(推断)、outcome。 接入点为 executeInvocation 收口处的 defer,与既有 LogCommandEnd 并列。 新增 internal/audit(schema/sink/forwarder/device/config)+ docs/audit.md, 全部带测试:脱敏分档、本地+转发投递、设备指纹 opt-in 门控(darwin 实采 ioreg 验证)、配置注册、app 层端到端字段填充与 flow 分类。 --- docs/audit.md | 85 ++++++++++ internal/app/audit_runtime.go | 253 +++++++++++++++++++++++++++++ internal/app/audit_runtime_test.go | 154 ++++++++++++++++++ internal/app/runner.go | 9 + internal/audit/collect.go | 128 +++++++++++++++ internal/audit/device.go | 109 +++++++++++++ internal/audit/event.go | 230 ++++++++++++++++++++++++++ internal/audit/event_test.go | 126 ++++++++++++++ internal/audit/forward.go | 81 +++++++++ internal/audit/integration_test.go | 130 +++++++++++++++ internal/audit/sink.go | 98 +++++++++++ 11 files changed, 1403 insertions(+) create mode 100644 docs/audit.md create mode 100644 internal/app/audit_runtime.go create mode 100644 internal/app/audit_runtime_test.go create mode 100644 internal/audit/collect.go create mode 100644 internal/audit/device.go create mode 100644 internal/audit/event.go create mode 100644 internal/audit/event_test.go create mode 100644 internal/audit/forward.go create mode 100644 internal/audit/integration_test.go create mode 100644 internal/audit/sink.go diff --git a/docs/audit.md b/docs/audit.md new file mode 100644 index 00000000..0b5be9f4 --- /dev/null +++ b/docs/audit.md @@ -0,0 +1,85 @@ +# 操作审计(Audit) + +dws 可以为**每一次命令调用**生成一条结构化审计记录,用于满足**企业合规审计**的通用需求 +——任何部署 dws 的企业都可以开启,把员工经 dws 的操作留痕。 + +设计遵循开源惯例,把「产生事件」和「投递事件」分开: + +- **通道 A — 本地审计文件**:始终是源头真相,operator 自己拥有、可随时 `grep`。 +- **通道 B — 转发到企业自有 sink**:可选,endpoint 由**部署企业**配置, + **绝不写死到厂商**。转发前可按脱敏档位降级。 + +> 审计**默认全关**。不设置 `DWS_AUDIT_ENABLED` 时,dws 不产生任何审计数据, +> 热路径零影响。 + +## 启用 + +| 环境变量 | 说明 | 示例 | +|---|---|---| +| `DWS_AUDIT_ENABLED` | 启用本地审计文件 | `true` | +| `DWS_AUDIT_FORWARD_URL` | 转发目标(企业自有 sink,非厂商默认) | `https://audit.internal.example.com/dws` | +| `DWS_AUDIT_FORWARD_TOKEN` | 企业 sink 的 Bearer 鉴权 | `xxxxx` | +| `DWS_AUDIT_FORWARD_REDACT` | 转发脱敏档:`none` / `hashed` / `minimal` | `none` | +| `DWS_AUDIT_REDACT_SALT` | `hashed` 档的加盐值 | `tenant-salt` | +| `DWS_AUDIT_DEVICE_FINGERPRINT` | 采集 `device_id` / `sn_no`(PIPL 个人信息,默认关) | `true` | +| `DWS_AUDIT_NL_INTENT` | 上层 agent 注入的自然语言原文 | `把上周听记导出` | + +本地文件路径:`/logs/audit.jsonl`(每行一条 JSON)。 + +## 字段 + +| 字段 | 含义 | 来源 | +|---|---|---| +| `ts` / `trace_id` | 时间 / 唯一 trace | CLI(`trace_id` == 传输层 execution_id) | +| `actor` | 用户 id / 姓名 | 登录 token | +| `org` | 组织 corp_id / 名称 | 登录 token | +| `device` | os / hostname / device_id / sn_no | 本机;`device_id`/`sn_no` 需 opt-in | +| `intent` | 自然语言原文 + `provenance` | 仅 agent 层可注入,标记 `provenance=agent`,CLI 无法验真 | +| `module` / `command` / `subcommand` | 操作模块 / skill 命令 / 子命令 | CLI | +| `subcommand_desc` | 子命令介绍 | 命令 catalog | +| `target` | 操作对象 id / 名称 / 摘要 / 敏感度 | 调用参数 + catalog(`sensitive` → `confidential`) | +| `flow` | 数据流向 + api + 本地路径 / endpoint / peer ids | 调用参数推断 | +| `outcome` / `err_class` / `exit_code` | 成败与错误分类 | CLI | + +### `flow.direction` 取值 + +- `local-export`:参数里带本地路径(如 `--output`),数据落到本机磁盘。 +- `read`:只读命令(list/get/query/search…),无数据移动。 +- `intra-tenant`:数据在租户内对象之间流转,`peer_ids` 收集涉及的人/群/文档 id。 +- `external-api`:流向租户外接口(预留)。 + +## 脱敏档位(仅作用于转发,本地文件始终全量) + +| 档位 | 行为 | 适用 | +|---|---|---| +| `none` | 原样转发 | sink 在企业自己信任域内(企业内部审计库) | +| `hashed` | 自然语言、对象名、序列号、peer ids 替换为加盐哈希,可关联不可还原 | 跨信任域但仍需关联 | +| `minimal` | 只留维度(命令×版本×成败×方向),丢弃一切内容/身份 | 纯运维监控 | + +## 企业接入示例 + +数据进企业自己的审计库,全字段、含设备指纹: + +```bash +export DWS_AUDIT_ENABLED=true +export DWS_AUDIT_FORWARD_URL="https://audit.internal.example.com/dws" +export DWS_AUDIT_FORWARD_TOKEN="" +export DWS_AUDIT_FORWARD_REDACT=none +export DWS_AUDIT_DEVICE_FINGERPRINT=true +# 由上层 agent/skill 在每次调用前注入: +# export DWS_AUDIT_NL_INTENT="<用户这次的自然语言请求>" +``` + +验证: + +```bash +dws minutes export --minute-id m-77 --output ~/Desktop/q2.md --format json +tail -n1 ~/.dws/logs/audit.jsonl | jq . # 路径随 DWS_CONFIG_DIR / edition 变化 +``` + +## 隐私与合规 + +- `device_id` / `sn_no` 是 PIPL 下的个人信息,**默认不采集**,企业需显式开启并告知用户。 +- 自然语言原文只有上层 agent 能提供,审计记录里以 `provenance=agent` 标注, + 表明该字段非 CLI 实测、不可验真。 +- 富审计数据是**企业的合规资产**,应进入企业自有 sink;dws 不提供任何厂商默认收集端点。 diff --git a/internal/app/audit_runtime.go b/internal/app/audit_runtime.go new file mode 100644 index 00000000..e196c55e --- /dev/null +++ b/internal/app/audit_runtime.go @@ -0,0 +1,253 @@ +// 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. + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "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/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +const auditFileName = "audit.jsonl" + +// setupAuditSink builds the active audit sink. When auditing is disabled +// (DWS_AUDIT_ENABLED unset) it returns audit.NopSink so emit is always safe. +// The local file lives next to the diagnostic log but is a SEPARATE file — +// audit and debug logs must not be conflated. The forwarder (if configured) +// targets the organization's own endpoint, never a vendor default. +func setupAuditSink() audit.Sink { + if !audit.Enabled() { + return audit.NopSink{} + } + logDir := filepath.Join(defaultConfigDir(), "logs") + _ = os.MkdirAll(logDir, config.DirPerm) + f, err := os.OpenFile(filepath.Join(logDir, auditFileName), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm) + if err != nil { + // File unavailable (e.g. read-only home): still honor a configured + // forwarder by passing a nil writer — BuildSink degrades gracefully. + return audit.BuildSink(nil) + } + return audit.BuildSink(f) +} + +// deviceOnce memoizes the device fingerprint: it is process-stable and the +// darwin/linux/windows collectors shell out, so we must not pay that cost on +// every command. +var ( + deviceOnce sync.Once + deviceInfo audit.Device +) + +func collectDeviceCached() audit.Device { + deviceOnce.Do(func() { + deviceInfo = audit.CollectDevice(audit.DeviceFingerprintEnabled()) + }) + return deviceInfo +} + +// emitAudit assembles and emits one audit event for a finished invocation. It +// is called from executeInvocation's defer, where every dimension is known. +// Cheap to skip: audit.Enabled() is a single env read, so when auditing is off +// none of the heavier work (token load, device collect) runs. +func (r *runtimeRunner) emitAudit(ctx context.Context, execID, endpoint string, + inv executor.Invocation, ok bool, errClass string, start time.Time) { + + if r == nil || r.auditSink == nil || !audit.Enabled() { + return + } + + ev := audit.New(time.Now(), execID) + + // Actor + Org from the persisted token (fully obtainable). + if td, err := authpkg.LoadTokenData(defaultConfigDir()); err == nil && td != nil { + ev.Actor = audit.Actor{UserID: td.UserID, Name: td.UserName} + ev.Org = audit.Org{CorpID: td.CorpID, Name: td.CorpName} + } + + ev.Device = collectDeviceCached() + + // Natural-language intent only exists at the agent layer; mark provenance. + if nl := audit.NLIntent(); nl != "" { + ev.Intent.NLInput = nl + } + + ev.Module = inv.CanonicalProduct + ev.Command = inv.CanonicalProduct + ev.Subcommand = inv.Tool + ev.SubcommandDesc, ev.Target.Sensitivity = r.lookupToolMeta(ctx, inv) + + ev.Target = mergeTarget(ev.Target, buildTarget(inv.Params)) + ev.Flow = inferFlow(inv, endpoint) + + if ok { + ev.Outcome = "ok" + } else { + ev.Outcome = "error" + ev.ErrClass = errClass + ev.ExitCode = 1 + } + + _ = r.auditSink.Emit(ev) +} + +// lookupToolMeta pulls the static subcommand description and sensitivity from +// the catalog. Best-effort: the catalog is already loaded/cached for the +// command tree, so this is a cheap in-memory scan; any failure yields empties. +func (r *runtimeRunner) lookupToolMeta(ctx context.Context, inv executor.Invocation) (string, audit.Sensitivity) { + if r.loader == nil { + return "", audit.SensitivityUnknown + } + cat, err := r.loader.Load(ctx) + if err != nil { + return "", audit.SensitivityUnknown + } + for _, p := range cat.Products { + if p.ID != inv.CanonicalProduct { + continue + } + for _, t := range p.Tools { + if t.RPCName == inv.Tool || t.CLIName == inv.Tool || t.CanonicalPath == inv.CanonicalPath { + desc := t.Description + if desc == "" { + desc = t.Title + } + sens := audit.SensitivityUnknown + if t.Sensitive { + sens = audit.SensitivityConfidential + } + return desc, sens + } + } + } + return "", audit.SensitivityUnknown +} + +// localPathKeys are param names that indicate data is exported to local disk. +var localPathKeys = map[string]bool{ + "output": true, "out": true, "path": true, "file": true, "filepath": true, + "dir": true, "directory": true, "save_path": true, "local_path": true, + "dest": true, "destination": true, "output_path": true, "target_path": true, +} + +// peerIDKeySubstrings mark params carrying an intra-tenant object/person id. +var peerIDKeySubstrings = []string{ + "groupid", "openid", "userid", "unionid", "docid", "conversationid", + "chatid", "cid", "fileid", "spaceid", "dentryid", "nodeid", "minuteid", +} + +// readVerbs mark a tool as read-only (no data movement). +var readVerbs = []string{"list", "get", "query", "search", "detail", "info", "fetch", "view", "read", "describe"} + +// nameKeySubstrings mark params holding a human-readable object name. +var nameKeySubstrings = []string{"name", "title", "subject"} + +// buildTarget extracts a best-effort object identity from the call params. +func buildTarget(params map[string]any) audit.Target { + var t audit.Target + for k, v := range params { + sv, ok := v.(string) + if !ok || sv == "" { + continue + } + lk := strings.ToLower(k) + if t.Name == "" && containsAny(lk, nameKeySubstrings) { + t.Name = sv + } + if t.ID == "" && (lk == "id" || strings.HasSuffix(lk, "id")) { + t.ID = sv + } + } + return t +} + +// mergeTarget overlays b onto a without clobbering a's already-set fields +// (a carries Sensitivity from the catalog; b carries id/name from params). +func mergeTarget(a, b audit.Target) audit.Target { + if a.ID == "" { + a.ID = b.ID + } + if a.Name == "" { + a.Name = b.Name + } + if a.Type == "" { + a.Type = b.Type + } + return a +} + +// inferFlow classifies the data-movement footprint of the command. +func inferFlow(inv executor.Invocation, endpoint string) audit.Flow { + f := audit.Flow{API: inv.Tool, Endpoint: endpoint} + + // Local export wins: an explicit local path means data left the tenant to disk. + for k, v := range inv.Params { + if localPathKeys[strings.ToLower(k)] { + if sv, ok := v.(string); ok && sv != "" { + f.Direction = audit.DirectionLocalExport + f.LocalPath = sv + return f + } + } + } + + verb := lastPathSegment(inv.CanonicalPath) + if verb == "" { + verb = strings.ToLower(inv.Tool) + } + if containsAny(strings.ToLower(verb), readVerbs) { + f.Direction = audit.DirectionRead + return f + } + + // Otherwise data moves between objects inside the tenant; collect peer ids. + for k, v := range inv.Params { + lk := strings.ToLower(k) + if containsAny(lk, peerIDKeySubstrings) { + if sv, ok := v.(string); ok && sv != "" { + f.PeerIDs = append(f.PeerIDs, sv) + } + } + } + f.Direction = audit.DirectionIntraTenant + return f +} + +func containsAny(s string, subs []string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} + +func lastPathSegment(p string) string { + if p == "" { + return "" + } + parts := strings.FieldsFunc(p, func(r rune) bool { return r == ' ' || r == '.' || r == '/' }) + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} diff --git a/internal/app/audit_runtime_test.go b/internal/app/audit_runtime_test.go new file mode 100644 index 00000000..0ae98767 --- /dev/null +++ b/internal/app/audit_runtime_test.go @@ -0,0 +1,154 @@ +// 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. + +package app + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir" +) + +type fakeLoader struct{ cat ir.Catalog } + +func (f fakeLoader) Load(context.Context) (ir.Catalog, error) { return f.cat, nil } + +func auditTestCatalog() ir.Catalog { + return ir.Catalog{Products: []ir.CanonicalProduct{{ + ID: "minutes", + Tools: []ir.ToolDescriptor{{ + RPCName: "export", + Title: "导出听记", + Description: "导出听记纪要为本地文件", + Sensitive: true, + }}, + }}} +} + +// End-to-end wiring test: a finished invocation must produce a fully-populated +// audit event in BOTH the local file and the organization's forward sink, with +// every obtainable field set (only token-derived actor/org are absent in a +// test env with no login). +func TestEmitAudit_PopulatesAllObtainableFields(t *testing.T) { + var forwarded []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b bytes.Buffer + _, _ = b.ReadFrom(r.Body) + forwarded = b.Bytes() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + t.Setenv(audit.EnvEnabled, "true") + t.Setenv(audit.EnvForwardURL, srv.URL) + t.Setenv(audit.EnvForwardRedact, "none") // org's own sink: ship verbatim + t.Setenv(audit.EnvNLIntent, "把上周的战略会听记导出到桌面") + + var file bytes.Buffer + r := &runtimeRunner{ + loader: fakeLoader{cat: auditTestCatalog()}, + auditSink: audit.BuildSink(&file), + } + + inv := executor.Invocation{ + CanonicalProduct: "minutes", + Tool: "export", + CanonicalPath: "minutes export", + Params: map[string]any{ + "minuteId": "m-77", + "name": "Q2 战略会", + "output": "/Users/x/Desktop/q2.md", + }, + } + + r.emitAudit(context.Background(), "trace-xyz", "https://gw.internal/mcp", inv, true, "", time.Now()) + + // --- local file: full record --- + var local audit.Event + if err := json.Unmarshal(bytes.TrimSpace(file.Bytes()), &local); err != nil { + t.Fatalf("local audit not valid JSON: %v\n%s", err, file.String()) + } + checks := map[string]struct{ got, want string }{ + "trace_id": {local.TraceID, "trace-xyz"}, + "module": {local.Module, "minutes"}, + "command": {local.Command, "minutes"}, + "subcommand": {local.Subcommand, "export"}, + "subcommand_desc": {local.SubcommandDesc, "导出听记纪要为本地文件"}, + "target.id": {local.Target.ID, "m-77"}, + "target.name": {local.Target.Name, "Q2 战略会"}, + "intent.nl": {local.Intent.NLInput, "把上周的战略会听记导出到桌面"}, + "outcome": {local.Outcome, "ok"}, + "flow.localpath": {local.Flow.LocalPath, "/Users/x/Desktop/q2.md"}, + "flow.api": {local.Flow.API, "export"}, + } + for field, c := range checks { + if c.got != c.want { + t.Errorf("%s = %q, want %q", field, c.got, c.want) + } + } + if local.Target.Sensitivity != audit.SensitivityConfidential { + t.Errorf("sensitivity = %q, want confidential (tool marked Sensitive)", local.Target.Sensitivity) + } + if local.Flow.Direction != audit.DirectionLocalExport { + t.Errorf("direction = %q, want local-export (output path present)", local.Flow.Direction) + } + if local.Intent.Provenance != audit.ProvenanceAgent { + t.Errorf("NL provenance must be agent, got %q", local.Intent.Provenance) + } + if local.Device.OS == "" { + t.Error("device.os should always be set") + } + + // --- forward sink received the same trace --- + if len(forwarded) == 0 { + t.Fatal("forwarder received nothing") + } + var fwd audit.Event + if err := json.Unmarshal(forwarded, &fwd); err != nil { + t.Fatalf("forwarded audit not valid JSON: %v", err) + } + if fwd.TraceID != "trace-xyz" || fwd.Subcommand != "export" { + t.Errorf("forwarded event lost fields: %+v", fwd) + } +} + +// A read-only command must classify as DirectionRead with no peer-id leakage. +func TestEmitAudit_ReadVerbClassification(t *testing.T) { + t.Setenv(audit.EnvEnabled, "true") + var file bytes.Buffer + r := &runtimeRunner{ + loader: fakeLoader{cat: ir.Catalog{}}, + auditSink: audit.BuildSink(&file), + } + inv := executor.Invocation{ + CanonicalProduct: "doc", + Tool: "list", + CanonicalPath: "doc list", + Params: map[string]any{"spaceId": "s-1"}, + } + r.emitAudit(context.Background(), "t2", "https://gw/mcp", inv, true, "", time.Now()) + + var ev audit.Event + _ = json.Unmarshal(bytes.TrimSpace(file.Bytes()), &ev) + if ev.Flow.Direction != audit.DirectionRead { + t.Errorf("list should be read, got %q", ev.Flow.Direction) + } +} diff --git a/internal/app/runner.go b/internal/app/runner.go index eb026b15..74c36719 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -26,6 +26,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" @@ -141,6 +142,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(), } } @@ -152,6 +154,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) { @@ -302,6 +305,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, logging.LogCommandEnd(fl, execID, invocation.CanonicalProduct, invocation.Tool, retErr == nil, time.Since(invokeStart), errCat, errReason) + + // Audit: emit one structured record per invocation to the operator's + // local file and (if configured) the organization's own audit sink. + // No-op unless DWS_AUDIT_ENABLED is set, so the hot path is unaffected + // for everyone else. + r.emitAudit(ctx, execID, endpoint, invocation, retErr == nil, errCat, invokeStart) }() // Check if this product has plugin-level auth credentials registered. diff --git a/internal/audit/collect.go b/internal/audit/collect.go new file mode 100644 index 00000000..c31522a2 --- /dev/null +++ b/internal/audit/collect.go @@ -0,0 +1,128 @@ +// 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. + +package audit + +import ( + "os" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta" +) + +// Environment variables that drive auditing. All default OFF: the CLI emits +// nothing unless the deploying organization opts in, and the forward target is +// always the organization's own endpoint — never a vendor default. +const ( + // EnvEnabled turns the local audit file on ("true"/"1"). + EnvEnabled = "DWS_AUDIT_ENABLED" + // EnvForwardURL points at the ORGANIZATION's own audit sink. Empty = no + // forwarding (local file only). + EnvForwardURL = "DWS_AUDIT_FORWARD_URL" + // EnvForwardToken is the bearer the org uses to authenticate to its sink. + EnvForwardToken = "DWS_AUDIT_FORWARD_TOKEN" + // EnvForwardRedact selects the off-box redaction tier: none|hashed|minimal. + // Defaults to "none" because the org's own sink is inside its trust + // boundary; set hashed/minimal to ship less. + EnvForwardRedact = "DWS_AUDIT_FORWARD_REDACT" + // EnvRedactSalt salts the hashed tier so correlation is possible without + // raw content. Required when redact=hashed. + EnvRedactSalt = "DWS_AUDIT_REDACT_SALT" + // EnvDeviceFingerprint opts in to collecting device_id / sn_no (PIPL + // personal information). Default off. + EnvDeviceFingerprint = "DWS_AUDIT_DEVICE_FINGERPRINT" + // EnvNLIntent carries the user's natural-language request, injected by the + // orchestrating agent/skill. The CLI cannot verify it (provenance=agent). + EnvNLIntent = "DWS_AUDIT_NL_INTENT" +) + +func init() { + for _, it := range []configmeta.ConfigItem{ + {Name: EnvEnabled, Category: configmeta.CategorySecurity, Description: "启用本地审计日志(JSONL)", Example: "true"}, + {Name: EnvForwardURL, Category: configmeta.CategorySecurity, Description: "审计转发目标(企业自有 sink,非厂商默认)", Example: "https://audit.internal.example.com/dws"}, + {Name: EnvForwardToken, Category: configmeta.CategorySecurity, Description: "企业审计 sink 的 Bearer 鉴权", Example: "xxxxx"}, + {Name: EnvForwardRedact, Category: configmeta.CategorySecurity, Description: "转发脱敏档: none|hashed|minimal", Example: "none"}, + {Name: EnvRedactSalt, Category: configmeta.CategorySecurity, Description: "hashed 档的加盐值", Example: "tenant-salt"}, + {Name: EnvDeviceFingerprint, Category: configmeta.CategorySecurity, Description: "采集 device_id/sn_no(PIPL 个人信息,默认关)", Example: "true"}, + {Name: EnvNLIntent, Category: configmeta.CategorySecurity, Description: "上层 agent 注入的自然语言原文(provenance=agent)", Example: "把上周听记导出"}, + } { + configmeta.Register(it) + } +} + +// Enabled reports whether auditing should run at all. +func Enabled() bool { + return truthy(os.Getenv(EnvEnabled)) +} + +// DeviceFingerprintEnabled reports the opt-in for device_id/sn_no collection. +func DeviceFingerprintEnabled() bool { + return truthy(os.Getenv(EnvDeviceFingerprint)) +} + +// NLIntent returns the agent-injected natural-language request (may be empty). +func NLIntent() string { + return os.Getenv(EnvNLIntent) +} + +// redactLevelFromEnv maps the env string to a RedactLevel (default none). +func redactLevelFromEnv() RedactLevel { + switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvForwardRedact))) { + case "hashed": + return RedactHashed + case "minimal": + return RedactMinimal + default: + return RedactNone + } +} + +// BuildSink assembles the active sink from env: a local FileSink (writing to +// fileW, the operator-owned durable file) plus, when EnvForwardURL is set, a +// RedactingSink wrapping an HTTPForwarder to the organization's endpoint. When +// auditing is disabled or fileW is nil and no forward URL is set, returns +// NopSink so callers never need a nil check. +func BuildSink(fileW interface{ Write([]byte) (int, error) }) Sink { + if !Enabled() { + return NopSink{} + } + var sinks []Sink + if fileW != nil { + sinks = append(sinks, NewFileSink(fileW)) + } + if url := strings.TrimSpace(os.Getenv(EnvForwardURL)); url != "" { + fwd := &RedactingSink{ + Inner: NewHTTPForwarder(url, os.Getenv(EnvForwardToken)), + Level: redactLevelFromEnv(), + Salt: os.Getenv(EnvRedactSalt), + } + sinks = append(sinks, fwd) + } + switch len(sinks) { + case 0: + return NopSink{} + case 1: + return sinks[0] + default: + return &MultiSink{Sinks: sinks} + } +} + +func truthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/internal/audit/device.go b/internal/audit/device.go new file mode 100644 index 00000000..580f2b0b --- /dev/null +++ b/internal/audit/device.go @@ -0,0 +1,109 @@ +// 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. + +package audit + +import ( + "os" + "os/exec" + "regexp" + "runtime" + "strings" +) + +// CollectDevice fills a Device record. +// +// OS is always cheap and non-identifying, so it is always set. DeviceID +// (machine UUID) and SerialNo (hardware serial) are personal information under +// PIPL — they are collected ONLY when fingerprint == true (the enterprise must +// explicitly opt in and disclose it to the user). Hostname is included with the +// fingerprint tier since it can identify a machine/user. +func CollectDevice(fingerprint bool) Device { + d := Device{OS: runtime.GOOS} + if !fingerprint { + return d + } + if h, err := os.Hostname(); err == nil { + d.Hostname = h + } + d.DeviceID = machineID() + d.SerialNo = serialNo() + return d +} + +// ioregField extracts a quoted value for the given key from `ioreg` output, +// e.g. `"IOPlatformSerialNumber" = "C02XXXXXXXXX"`. +func ioregField(key string) string { + out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() + if err != nil { + return "" + } + re := regexp.MustCompile(`"` + regexp.QuoteMeta(key) + `"\s*=\s*"([^"]+)"`) + m := re.FindSubmatch(out) + if len(m) < 2 { + return "" + } + return string(m[1]) +} + +// machineID returns a stable per-machine identifier, best-effort per OS. +func machineID() string { + switch runtime.GOOS { + case "darwin": + return ioregField("IOPlatformUUID") + case "linux": + // /etc/machine-id is the systemd-standard stable id; fall back to the + // dbus copy. Neither requires root. + for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} { + if b, err := os.ReadFile(p); err == nil { + if id := strings.TrimSpace(string(b)); id != "" { + return id + } + } + } + case "windows": + // MachineGuid lives in the registry; read via reg.exe to avoid a + // Windows-only build dependency. + out, err := exec.Command("reg", "query", + `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid").Output() + if err == nil { + fields := strings.Fields(string(out)) + if len(fields) > 0 { + return fields[len(fields)-1] + } + } + } + return "" +} + +// serialNo returns the hardware serial number, best-effort per OS. +func serialNo() string { + switch runtime.GOOS { + case "darwin": + return ioregField("IOPlatformSerialNumber") + case "linux": + // Usually root-only; return what we can read, empty otherwise. + if b, err := os.ReadFile("/sys/class/dmi/id/product_serial"); err == nil { + return strings.TrimSpace(string(b)) + } + case "windows": + out, err := exec.Command("wmic", "bios", "get", "serialnumber").Output() + if err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) >= 2 { + return strings.TrimSpace(lines[1]) + } + } + } + return "" +} diff --git a/internal/audit/event.go b/internal/audit/event.go new file mode 100644 index 00000000..3caddf07 --- /dev/null +++ b/internal/audit/event.go @@ -0,0 +1,230 @@ +// 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. + +// Package audit defines the structured audit event emitted once per dws +// command invocation, plus pluggable sinks that decide WHERE it goes. +// +// Design principles (open-source norms): +// - One versioned schema, two emission channels: a local file the operator +// owns (transparent, always inspectable) and an OPTIONAL forwarder to a +// sink the *deploying organization* configures — never hardcoded to the +// vendor. +// - Field minimization by tier: Redact() produces the remote-safe view so a +// forwarder can ship hashed/minimal data even when the local file keeps the +// full record. +// - Honest provenance: fields the CLI binary cannot observe (e.g. the user's +// natural-language intent, which only the orchestrating agent sees) are +// marked Provenance != ProvenanceCLI so consumers know they were injected +// from an outer layer rather than measured. +package audit + +import ( + "crypto/sha256" + "encoding/hex" + "time" +) + +// SchemaVersion is bumped on any breaking change to Event's JSON shape. +const SchemaVersion = "1" + +// Direction enumerates where data flowed as a result of the command. +type Direction string + +const ( + // DirectionLocalExport: data left the tenant onto the local disk. + DirectionLocalExport Direction = "local-export" + // DirectionExternalAPI: data crossed to an endpoint outside the tenant. + DirectionExternalAPI Direction = "external-api" + // DirectionIntraTenant: data moved between objects inside DingTalk + // (person id / group id / doc id), no egress. + DirectionIntraTenant Direction = "intra-tenant" + // DirectionRead: read-only, no data movement. + DirectionRead Direction = "read" +) + +// Provenance records which layer produced a field — the audit's honesty knob. +type Provenance string + +const ( + // ProvenanceCLI: observed by the dws binary itself (trustworthy). + ProvenanceCLI Provenance = "cli" + // ProvenanceAgent: injected by the orchestrating agent/skill layer via + // env (e.g. DWS_AUDIT_NL_INTENT). The binary cannot verify it. + ProvenanceAgent Provenance = "agent" +) + +// Sensitivity is a coarse classification of the operated object, used to +// decide whether the record itself needs stricter handling downstream. +type Sensitivity string + +const ( + SensitivityUnknown Sensitivity = "unknown" + SensitivityPublic Sensitivity = "public" + SensitivityInternal Sensitivity = "internal" + SensitivityConfidential Sensitivity = "confidential" +) + +// Actor identifies the human behind the invocation. +type Actor struct { + UserID string `json:"user_id,omitempty"` // DingTalk open_id / staff id + Name string `json:"name,omitempty"` +} + +// Org identifies the tenant. +type Org struct { + CorpID string `json:"corp_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// Device identifies the machine. DeviceID/SerialNo are NEW collection and +// count as personal information under PIPL — both are empty unless the operator +// explicitly opts in (see collector). +type Device struct { + DeviceID string `json:"device_id,omitempty"` + SerialNo string `json:"sn_no,omitempty"` // hardware serial; sensitive PII + OS string `json:"os,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + +// Intent is the user's natural-language request. Provenance is ALWAYS +// ProvenanceAgent because the dws binary never sees NL — only structured argv. +type Intent struct { + NLInput string `json:"nl_input,omitempty"` + Provenance Provenance `json:"provenance"` +} + +// Target is the object the command acted on. +type Target struct { + Type string `json:"type,omitempty"` // group / doc / minutes / table ... + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Summary string `json:"summary,omitempty"` // short digest for sensitivity review + Sensitivity Sensitivity `json:"sensitivity,omitempty"` +} + +// Flow records the data-movement footprint of the command. +type Flow struct { + Direction Direction `json:"direction"` + LocalPath string `json:"local_path,omitempty"` // for local-export + Endpoint string `json:"endpoint,omitempty"` // for external-api + API string `json:"api,omitempty"` // MCP tool / REST path invoked + PeerIDs []string `json:"peer_ids,omitempty"` // for intra-tenant (person/group/doc ids) +} + +// Event is the full audit record for one dws command. The local file keeps it +// verbatim; Redact() derives the remote-safe view for forwarders. +type Event struct { + SchemaVersion string `json:"schema_version"` + Timestamp time.Time `json:"ts"` + TraceID string `json:"trace_id"` // == transport execution_id / x-dingtalk-trace-id + + Actor Actor `json:"actor"` + Org Org `json:"org"` + Device Device `json:"device"` + Intent Intent `json:"intent"` + + Module string `json:"module"` // 操作模块: doc / group / minutes / table + Command string `json:"command"` // skill 命令, e.g. "doc" + Subcommand string `json:"subcommand"` // skill 子命令, e.g. "create" + SubcommandDesc string `json:"subcommand_desc"` // static, from command catalog + + Target Target `json:"target"` + Flow Flow `json:"flow"` + + Outcome string `json:"outcome"` // ok / error + ErrClass string `json:"err_class,omitempty"` + ExitCode int `json:"exit_code"` +} + +// New stamps schema version and intent provenance. The caller supplies the +// timestamp and trace id (the package never reads the wall clock itself, to +// stay deterministic and testable). +func New(ts time.Time, traceID string) *Event { + return &Event{ + SchemaVersion: SchemaVersion, + Timestamp: ts, + TraceID: traceID, + Intent: Intent{Provenance: ProvenanceAgent}, + } +} + +// RedactLevel controls how much a forwarder ships. +type RedactLevel int + +const ( + // RedactNone: ship verbatim. Only legitimate when the sink is inside the + // enterprise's own trust boundary (e.g. its internal audit store). + RedactNone RedactLevel = iota + // RedactHashed: replace free-text/PII with stable salted hashes so the + // sink can still correlate without holding raw content. + RedactHashed + // RedactMinimal: drop all content; keep only counters/dimensions needed + // for ops monitoring ("did this release break a command at scale"). + RedactMinimal +) + +// Redact returns a copy adjusted for the given level. The receiver is unchanged +// so the local full record is never mutated. +func (e *Event) Redact(level RedactLevel, salt string) *Event { + cp := *e + switch level { + case RedactNone: + return &cp + case RedactHashed: + cp.Intent.NLInput = hashed(cp.Intent.NLInput, salt) + cp.Actor.Name = "" + cp.Target.Name = hashed(cp.Target.Name, salt) + cp.Target.Summary = "" + cp.Device.SerialNo = hashed(cp.Device.SerialNo, salt) + cp.Flow.PeerIDs = hashEach(cp.Flow.PeerIDs, salt) + return &cp + case RedactMinimal: + // Keep only the dimensions an ops dashboard needs; drop every + // content-bearing or identifying field. + return &Event{ + SchemaVersion: cp.SchemaVersion, + Timestamp: cp.Timestamp, + TraceID: cp.TraceID, + Org: Org{CorpID: cp.Org.CorpID}, + Module: cp.Module, + Command: cp.Command, + Subcommand: cp.Subcommand, + Flow: Flow{Direction: cp.Flow.Direction, API: cp.Flow.API}, + Outcome: cp.Outcome, + ErrClass: cp.ErrClass, + ExitCode: cp.ExitCode, + Intent: Intent{Provenance: cp.Intent.Provenance}, + } + default: + return &cp + } +} + +func hashed(s, salt string) string { + if s == "" { + return "" + } + sum := sha256.Sum256([]byte(salt + ":" + s)) + return "h:" + hex.EncodeToString(sum[:8]) +} + +func hashEach(in []string, salt string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, len(in)) + for i, s := range in { + out[i] = hashed(s, salt) + } + return out +} diff --git a/internal/audit/event_test.go b/internal/audit/event_test.go new file mode 100644 index 00000000..3d1c047f --- /dev/null +++ b/internal/audit/event_test.go @@ -0,0 +1,126 @@ +// 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. + +package audit + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" +) + +func sampleEvent() *Event { + ts := time.Date(2026, 6, 3, 10, 0, 0, 0, time.UTC) + e := New(ts, "trace-abc") + e.Actor = Actor{UserID: "staff-001", Name: "张三"} + e.Org = Org{CorpID: "corp-001", Name: "示例企业"} + e.Device = Device{DeviceID: "dev-9", SerialNo: "C02SN12345", OS: "darwin"} + e.Intent.NLInput = "把上周的听记导出到桌面" + e.Module = "minutes" + e.Command = "minutes" + e.Subcommand = "export" + e.SubcommandDesc = "导出听记纪要" + e.Target = Target{Type: "minutes", ID: "m-77", Name: "Q2 战略会", Summary: "营收与人事", Sensitivity: SensitivityConfidential} + e.Flow = Flow{Direction: DirectionLocalExport, LocalPath: "/Users/x/Desktop/q2.md", API: "minutes.export"} + e.Outcome = "ok" + e.ExitCode = 0 + return e +} + +// Intent provenance must never claim the CLI observed the NL — it can't. +func TestNL_IntentProvenanceIsAgent(t *testing.T) { + e := New(time.Unix(0, 0).UTC(), "t") + if e.Intent.Provenance != ProvenanceAgent { + t.Fatalf("NL intent must be agent-provenanced, got %q", e.Intent.Provenance) + } +} + +// RedactNone is verbatim; the local-file tier keeps everything. +func TestRedactNone_Verbatim(t *testing.T) { + e := sampleEvent() + got := e.Redact(RedactNone, "salt") + if got.Intent.NLInput != e.Intent.NLInput || got.Device.SerialNo != e.Device.SerialNo { + t.Fatal("RedactNone must not alter content") + } +} + +// RedactHashed must strip raw content/PII but keep correlatable hashes. +func TestRedactHashed_StripsRawPII(t *testing.T) { + e := sampleEvent() + got := e.Redact(RedactHashed, "salt") + if strings.Contains(got.Intent.NLInput, "听记") { + t.Errorf("hashed NL still contains raw text: %q", got.Intent.NLInput) + } + if got.Device.SerialNo == "C02SN12345" { + t.Error("serial must be hashed, not raw") + } + if got.Target.Summary != "" { + t.Error("object summary must be dropped at hashed tier") + } + if !strings.HasPrefix(got.Intent.NLInput, "h:") { + t.Errorf("expected hash marker, got %q", got.Intent.NLInput) + } + // Receiver must be untouched (no mutation of the local full record). + if e.Intent.NLInput == got.Intent.NLInput { + t.Error("Redact mutated the original event") + } +} + +// RedactMinimal is the ops-monitoring tier: dimensions only, zero content. +func TestRedactMinimal_DimensionsOnly(t *testing.T) { + e := sampleEvent() + got := e.Redact(RedactMinimal, "salt") + if got.Intent.NLInput != "" || got.Target.ID != "" || got.Device.SerialNo != "" || got.Actor.UserID != "" { + t.Error("minimal tier leaked identifying/content fields") + } + if got.Command != "minutes" || got.Outcome != "ok" || got.Flow.Direction != DirectionLocalExport { + t.Error("minimal tier must keep monitoring dimensions") + } +} + +// FileSink writes one JSONL line per event, round-trippable. +func TestFileSink_JSONL(t *testing.T) { + var buf bytes.Buffer + s := NewFileSink(&buf) + if err := s.Emit(sampleEvent()); err != nil { + t.Fatal(err) + } + if err := s.Emit(sampleEvent()); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 JSONL lines, got %d", len(lines)) + } + var back Event + if err := json.Unmarshal([]byte(lines[0]), &back); err != nil { + t.Fatalf("line not valid JSON: %v", err) + } + if back.TraceID != "trace-abc" || back.SchemaVersion != SchemaVersion { + t.Errorf("round-trip lost fields: %+v", back) + } +} + +// RedactingSink must hand the wrapped sink the redacted copy, not the raw one. +func TestRedactingSink_AppliesLevel(t *testing.T) { + var buf bytes.Buffer + s := &RedactingSink{Inner: NewFileSink(&buf), Level: RedactMinimal, Salt: "s"} + if err := s.Emit(sampleEvent()); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), "C02SN12345") || strings.Contains(buf.String(), "听记") { + t.Error("forwarder shipped raw PII/content despite RedactMinimal") + } +} diff --git a/internal/audit/forward.go b/internal/audit/forward.go new file mode 100644 index 00000000..b028d8e1 --- /dev/null +++ b/internal/audit/forward.go @@ -0,0 +1,81 @@ +// 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. + +package audit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// HTTPForwarder ships audit events to an endpoint the DEPLOYING ORGANIZATION +// controls (its own internal audit store) — never a vendor-hardcoded URL. +// It is best-effort: the local FileSink is the durable source of truth, so a +// transient forward failure must never block or fail the user's command. It +// POSTs a single JSON event per call (application/json); batching can be layered +// on later without changing the Sink contract. +type HTTPForwarder struct { + URL string + Token string // optional bearer; enterprise's own auth to its sink + Header map[string]string + Client *http.Client +} + +// NewHTTPForwarder builds a forwarder with a short default timeout so auditing +// never stalls a command. Auditing is a side effect, not a gate. +func NewHTTPForwarder(url, token string) *HTTPForwarder { + return &HTTPForwarder{ + URL: url, + Token: token, + Client: &http.Client{Timeout: 3 * time.Second}, + } +} + +// Emit POSTs e as JSON. A non-2xx or transport error is returned to the caller +// (typically MultiSink, which logs it) but the event is already persisted +// locally, so loss here is recoverable by replaying the file. +func (f *HTTPForwarder) Emit(e *Event) error { + data, err := json.Marshal(e) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), f.Client.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.URL, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Dws-Audit-Schema", SchemaVersion) + if f.Token != "" { + req.Header.Set("Authorization", "Bearer "+f.Token) + } + for k, v := range f.Header { + req.Header.Set(k, v) + } + + resp, err := f.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("audit forward: sink returned %d", resp.StatusCode) + } + return nil +} diff --git a/internal/audit/integration_test.go b/internal/audit/integration_test.go new file mode 100644 index 00000000..73f9b445 --- /dev/null +++ b/internal/audit/integration_test.go @@ -0,0 +1,130 @@ +// 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. + +package audit + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" +) + +// HTTPForwarder must POST a valid event with schema header and bearer to the +// organization's endpoint. +func TestHTTPForwarder_PostsToOrgSink(t *testing.T) { + var gotBody []byte + var gotAuth, gotSchema string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = bytesReadAll(r) + gotAuth = r.Header.Get("Authorization") + gotSchema = r.Header.Get("X-Dws-Audit-Schema") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := NewHTTPForwarder(srv.URL, "org-token") + if err := f.Emit(sampleEvent()); err != nil { + t.Fatalf("forward failed: %v", err) + } + if gotAuth != "Bearer org-token" { + t.Errorf("missing/short bearer: %q", gotAuth) + } + if gotSchema != SchemaVersion { + t.Errorf("missing schema header: %q", gotSchema) + } + var back Event + if err := json.Unmarshal(gotBody, &back); err != nil { + t.Fatalf("server got invalid JSON: %v", err) + } + if back.TraceID != "trace-abc" { + t.Errorf("event lost in transit: %+v", back) + } +} + +// Non-2xx from the sink surfaces as an error (so MultiSink can log it) but the +// local file already holds the record. +func TestHTTPForwarder_Non2xxErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + if err := NewHTTPForwarder(srv.URL, "").Emit(sampleEvent()); err == nil { + t.Fatal("expected error on 500") + } +} + +// BuildSink: disabled => NopSink, no emission anywhere. +func TestBuildSink_DisabledIsNop(t *testing.T) { + t.Setenv(EnvEnabled, "") + if _, ok := BuildSink(&bytes.Buffer{}).(NopSink); !ok { + t.Fatal("disabled audit must yield NopSink") + } +} + +// BuildSink: enabled + forward URL => file AND forwarder, forwarder redacted. +func TestBuildSink_FileAndForward(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := bytesReadAll(r) + if bytes.Contains(body, []byte("C02SN12345")) { + t.Error("minimal-redacted forward leaked raw serial") + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvForwardURL, srv.URL) + t.Setenv(EnvForwardRedact, "minimal") + + var file bytes.Buffer + sink := BuildSink(&file) + if _, ok := sink.(*MultiSink); !ok { + t.Fatalf("expected MultiSink, got %T", sink) + } + if err := sink.Emit(sampleEvent()); err != nil { + t.Fatal(err) + } + // Local file keeps the FULL record (serial present) — trust boundary is local. + if !bytes.Contains(file.Bytes(), []byte("C02SN12345")) { + t.Error("local file should keep full record verbatim") + } +} + +// Device fingerprint is gated: off => no id/serial; OS is always present. +func TestCollectDevice_OptInGate(t *testing.T) { + off := CollectDevice(false) + if off.DeviceID != "" || off.SerialNo != "" { + t.Errorf("fingerprint off must not collect device id/serial: %+v", off) + } + if off.OS == "" { + t.Error("OS should always be set") + } + // On darwin (the dev/customer platform) opt-in must actually yield a + // machine UUID — proves the ioreg path works, not just compiles. + if runtime.GOOS == "darwin" { + on := CollectDevice(true) + if on.DeviceID == "" { + t.Error("darwin opt-in should return IOPlatformUUID") + } + } +} + +// helpers (avoid importing io just for ReadAll in this file). +func bytesReadAll(r *http.Request) ([]byte, error) { + var b bytes.Buffer + _, err := b.ReadFrom(r.Body) + return b.Bytes(), err +} diff --git a/internal/audit/sink.go b/internal/audit/sink.go new file mode 100644 index 00000000..7cf21501 --- /dev/null +++ b/internal/audit/sink.go @@ -0,0 +1,98 @@ +// 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. + +package audit + +import ( + "encoding/json" + "io" + "sync" +) + +// Sink consumes audit events. Implementations must be safe for concurrent use +// and must never block the command on slow I/O for longer than they have to — +// auditing is a side effect, not a gate on the user's command. +type Sink interface { + Emit(e *Event) error +} + +// FileSink appends one JSON object per line (JSONL) to a writer the operator +// owns. This is the transparent, always-available channel: the source of truth +// the user/customer can inspect with grep. It writes the FULL event verbatim — +// the local file is inside the operator's own trust boundary. +type FileSink struct { + mu sync.Mutex + w io.Writer +} + +// NewFileSink wraps w (typically a rotating file writer). +func NewFileSink(w io.Writer) *FileSink { + return &FileSink{w: w} +} + +// Emit serializes e as a single JSONL line. Marshal happens under the lock-free +// section; only the write is serialized. +func (s *FileSink) Emit(e *Event) error { + data, err := json.Marshal(e) + if err != nil { + return err + } + data = append(data, '\n') + s.mu.Lock() + defer s.mu.Unlock() + _, err = s.w.Write(data) + return err +} + +// RedactingSink wraps another Sink, applying a RedactLevel before forwarding. +// Use this to point a forwarder at a remote endpoint while guaranteeing the +// content tier shipped off-box matches policy — the wrapped sink never sees the +// raw event. +type RedactingSink struct { + Inner Sink + Level RedactLevel + Salt string +} + +// Emit redacts then delegates. +func (s *RedactingSink) Emit(e *Event) error { + return s.Inner.Emit(e.Redact(s.Level, s.Salt)) +} + +// MultiSink fans an event out to several sinks, collecting the first error but +// always attempting every sink (one failing forwarder must not starve the +// local file). +type MultiSink struct { + Sinks []Sink +} + +// Emit delivers to all sinks. +func (m *MultiSink) Emit(e *Event) error { + var firstErr error + for _, s := range m.Sinks { + if s == nil { + continue + } + if err := s.Emit(e); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// NopSink discards events. Default when auditing is disabled — emitting is +// always safe, so callers never need a nil check. +type NopSink struct{} + +// Emit does nothing. +func (NopSink) Emit(*Event) error { return nil } From 02980b1100983e0a734ce4ba73142bbed7dd7aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:34:49 +0800 Subject: [PATCH 2/8] feat(audit): add trustworthy client identity (agent_id/source/cli_version), defer forgeable env fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按可信度分层:只记录不可被调用方伪造的字段。 - 新增 client 块:agent_id(identity.json 装机 id)、source、cli_version(编译注入), 以及 actor.user_id / org.corp_id(token 网关验签)——均不可 per-call 伪造。 - host_agent(DINGTALK_AGENT) / channel(DWS_CHANNEL) / agent_code 是调用方自报的 环境变量、可伪造,故先不记录;待网关回带签名 agent 凭证再加(见 docs TODO)。 - schema v2;脱敏:hashed 档哈希 agent_id,minimal 档仅留 cli_version 做运维维度。 - docs/audit.md:字段按可信/暂不上分类,补"日志去向/中心化收集"与 TODO 两节。 真实验证:dws minutes list mine 实跑,client.agent_id/source/cli_version 落盘, org.corp_id 来自真 token;故意注入 DINGTALK_AGENT=fake 不进审计。 --- docs/audit.md | 50 +++++++++++++++++++++++------- internal/app/audit_runtime.go | 18 ++++++++++- internal/app/audit_runtime_test.go | 4 +++ internal/audit/event.go | 20 +++++++++++- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index 0b5be9f4..906aeb6e 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -28,18 +28,26 @@ dws 可以为**每一次命令调用**生成一条结构化审计记录,用于 ## 字段 -| 字段 | 含义 | 来源 | -|---|---|---| -| `ts` / `trace_id` | 时间 / 唯一 trace | CLI(`trace_id` == 传输层 execution_id) | -| `actor` | 用户 id / 姓名 | 登录 token | -| `org` | 组织 corp_id / 名称 | 登录 token | -| `device` | os / hostname / device_id / sn_no | 本机;`device_id`/`sn_no` 需 opt-in | -| `intent` | 自然语言原文 + `provenance` | 仅 agent 层可注入,标记 `provenance=agent`,CLI 无法验真 | -| `module` / `command` / `subcommand` | 操作模块 / skill 命令 / 子命令 | CLI | -| `subcommand_desc` | 子命令介绍 | 命令 catalog | -| `target` | 操作对象 id / 名称 / 摘要 / 敏感度 | 调用参数 + catalog(`sensitive` → `confidential`) | -| `flow` | 数据流向 + api + 本地路径 / endpoint / peer ids | 调用参数推断 | -| `outcome` / `err_class` / `exit_code` | 成败与错误分类 | CLI | +字段按**可信度**分两类,只有可信字段会被记录: + +**① 可信字段(已上)** —— token 验证 / dws 自管 / dws 实测,调用方无法 per-call 伪造: + +| 字段 | 含义 | 来源 | 可信原因 | +|---|---|---|---| +| `ts` / `trace_id` | 时间 / 唯一 trace | CLI(`trace_id` == 传输层 execution_id) | dws 实测 | +| `actor` | 用户 id / 姓名 | 登录 token | 网关验签,`user_id` 仅登录流程捕获时有 | +| `org` | 组织 corp_id / 名称 | 登录 token | 网关验签,不可伪造 | +| `client` | `agent_id`(装机 id)/ `source` / `cli_version` | identity.json + 编译版本 | dws 自管/编译注入,非调用方自报 | +| `device` | os / hostname / device_id / sn_no | 本机;`device_id`/`sn_no` 需 opt-in | 读真硬件 | +| `intent` | 自然语言原文 + `provenance` | 仅 agent 层注入 | **标记 `provenance=agent`,明示不可验真** | +| `module` / `command` / `subcommand` | 操作模块 / skill 命令 / 子命令 | CLI 解析实际执行的命令 | dws 实测 | +| `subcommand_desc` | 子命令介绍 | 命令 catalog | 线上 catalog | +| `target` | 操作对象 id / 名称 / 摘要 / 敏感度 | 调用参数 + catalog(`sensitive` → `confidential`) | dws 实测 | +| `flow` | 数据流向 + api + 本地路径 / endpoint / peer ids | 调用参数推断 | dws 实测 | +| `outcome` / `err_class` / `exit_code` | 成败与错误分类 | CLI | dws 实测 | + +**② 暂不上字段(可伪造,待网关签名)** —— 见下方 TODO: +`host_agent`(装在哪个 agent,`DINGTALK_AGENT`)、`channel`(渠道,`DWS_CHANNEL`)、`agent_code`(`DINGTALK_DWS_AGENTCODE`)。这三个是调用方自报的环境变量,`export` 即可冒充,**不可信,故先不记录**。 ### `flow.direction` 取值 @@ -77,9 +85,27 @@ dws minutes export --minute-id m-77 --output ~/Desktop/q2.md --format json tail -n1 ~/.dws/logs/audit.jsonl | jq . # 路径随 DWS_CONFIG_DIR / edition 变化 ``` +## 日志存在哪里 / 能否中心化收集 + +- **默认:每个用户自己机器上**,`/logs/audit.jsonl`,不开转发就不出本机。 +- **要中心化收集**:配置 `DWS_AUDIT_FORWARD_URL` 指向一个收集端点,每个用户每次调用就会 POST 一条上去。 + - **企业合规场景**:endpoint 指向**企业自己的审计库**,钉钉/厂商不持有数据(推荐,合规干净)。 + - **平台侧统一收集(钉钉这边收)**:技术上可行——把 endpoint 指向钉钉的审计 ingest 服务即可; + 但这等于厂商集中持有用户操作数据,必须 **opt-in + 明确告知**,否则就是开源 CLI 最忌讳的“偷偷上报”。 + 建议拆成两条:**合规全量审计 → 企业自有 sink**;**匿名极简遥测(`minimal` 档)→ 钉钉平台**做运维监控, + 隐私边界才清楚。 +- 不管哪种,本地文件始终是源头真相;转发是尽力而为,丢了可从本地文件回补。 + +## TODO + +- **网关签名的 agent 身份**:`host_agent` / `channel` / `agent_code` 目前是调用方自报的环境变量、可伪造, + 故暂不记录。待网关能回带一个**与 token 绑定的签名 agent 凭证**后再加入审计,确保“装在哪个 agent / 哪个渠道”不可冒充。 +- **`actor.user_id` 稳定化**:让登录流程把 `user_id` 落进 token,使其每次都非空(当前仅部分登录流程捕获)。 + ## 隐私与合规 - `device_id` / `sn_no` 是 PIPL 下的个人信息,**默认不采集**,企业需显式开启并告知用户。 - 自然语言原文只有上层 agent 能提供,审计记录里以 `provenance=agent` 标注, 表明该字段非 CLI 实测、不可验真。 - 富审计数据是**企业的合规资产**,应进入企业自有 sink;dws 不提供任何厂商默认收集端点。 +- `host_agent` / `channel` / `agent_code` 等调用方自报字段在网关签名前**不记录**,避免可伪造数据混入审计。 diff --git a/internal/app/audit_runtime.go b/internal/app/audit_runtime.go index e196c55e..7c34ceb7 100644 --- a/internal/app/audit_runtime.go +++ b/internal/app/audit_runtime.go @@ -78,12 +78,28 @@ func (r *runtimeRunner) emitAudit(ctx context.Context, execID, endpoint string, ev := audit.New(time.Now(), execID) - // Actor + Org from the persisted token (fully obtainable). + // Actor + Org from the persisted token — TRUSTWORTHY: the token is + // validated by the gateway, so corp_id / user_id can't be spoofed by the + // caller. (user_id is only present when the login flow captured it.) if td, err := authpkg.LoadTokenData(defaultConfigDir()); err == nil && td != nil { ev.Actor = audit.Actor{UserID: td.UserID, Name: td.UserName} ev.Org = audit.Org{CorpID: td.CorpID, Name: td.CorpName} } + // Client: dws-managed install identity + compiled-in version. TRUSTWORTHY + // (not caller-asserted per call). Load (not EnsureExists) so auditing never + // creates identity state as a side effect. + ev.Client.CLIVersion = version + if id := authpkg.Load(defaultConfigDir()); id != nil { + ev.Client.AgentID = id.AgentID + ev.Client.Source = id.Source + } + // TODO(audit): host_agent (DINGTALK_AGENT) / channel (DWS_CHANNEL) / + // agent_code (DINGTALK_DWS_AGENTCODE) are caller-supplied env vars — + // FORGEABLE, so they are intentionally NOT recorded here. Add them only + // once the gateway returns a SIGNED agent identity bound to the token, so + // the audit can't be spoofed. See docs/audit.md "TODO". + ev.Device = collectDeviceCached() // Natural-language intent only exists at the agent layer; mark provenance. diff --git a/internal/app/audit_runtime_test.go b/internal/app/audit_runtime_test.go index 0ae98767..dc7257b6 100644 --- a/internal/app/audit_runtime_test.go +++ b/internal/app/audit_runtime_test.go @@ -116,6 +116,10 @@ func TestEmitAudit_PopulatesAllObtainableFields(t *testing.T) { if local.Device.OS == "" { t.Error("device.os should always be set") } + // client.cli_version is the compiled-in version (trustworthy, dws-managed). + if local.Client.CLIVersion != version { + t.Errorf("client.cli_version = %q, want %q", local.Client.CLIVersion, version) + } // --- forward sink received the same trace --- if len(forwarded) == 0 { diff --git a/internal/audit/event.go b/internal/audit/event.go index 3caddf07..a50f31f0 100644 --- a/internal/audit/event.go +++ b/internal/audit/event.go @@ -35,7 +35,8 @@ import ( ) // SchemaVersion is bumped on any breaking change to Event's JSON shape. -const SchemaVersion = "1" +// v2 adds the Client block (agent_id / host_agent / channel / cli_version). +const SchemaVersion = "2" // Direction enumerates where data flowed as a result of the command. type Direction string @@ -86,6 +87,20 @@ type Org struct { Name string `json:"name,omitempty"` } +// Client identifies the dws install + version. Only TRUSTWORTHY fields live +// here: AgentID/Source are dws-managed install state, CLIVersion is compiled +// in — none are caller-asserted-per-call. +// +// Deliberately ABSENT (forgeable, env-asserted by the caller — see audit TODO): +// host_agent (DINGTALK_AGENT), channel (DWS_CHANNEL), agent_code +// (DINGTALK_DWS_AGENTCODE). They will be added only once the gateway can hand +// back a SIGNED agent identity so they can't be spoofed. +type Client struct { + AgentID string `json:"agent_id,omitempty"` // 装机标识: install-time UUID (x-dws-agent-id) + Source string `json:"source,omitempty"` // identity source, 默认 "dws" + CLIVersion string `json:"cli_version,omitempty"` // dws 版本 +} + // Device identifies the machine. DeviceID/SerialNo are NEW collection and // count as personal information under PIPL — both are empty unless the operator // explicitly opts in (see collector). @@ -130,6 +145,7 @@ type Event struct { Actor Actor `json:"actor"` Org Org `json:"org"` + Client Client `json:"client"` Device Device `json:"device"` Intent Intent `json:"intent"` @@ -186,6 +202,7 @@ func (e *Event) Redact(level RedactLevel, salt string) *Event { cp.Target.Name = hashed(cp.Target.Name, salt) cp.Target.Summary = "" cp.Device.SerialNo = hashed(cp.Device.SerialNo, salt) + cp.Client.AgentID = hashed(cp.Client.AgentID, salt) cp.Flow.PeerIDs = hashEach(cp.Flow.PeerIDs, salt) return &cp case RedactMinimal: @@ -196,6 +213,7 @@ func (e *Event) Redact(level RedactLevel, salt string) *Event { Timestamp: cp.Timestamp, TraceID: cp.TraceID, Org: Org{CorpID: cp.Org.CorpID}, + Client: Client{CLIVersion: cp.Client.CLIVersion}, // version is an ops dimension; drop the install id Module: cp.Module, Command: cp.Command, Subcommand: cp.Subcommand, From 975a05f472b39eb8e92ff1c6bb9844a14f5ee07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:45:44 +0800 Subject: [PATCH 3/8] docs(audit): add reference ingest server (local + Aliyun SLS) for forward target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/audit-ingest:dws 审计转发的最小参考接收端。 - 本地验证版(纯标准库):校验 Bearer + schema,落 JSONL,已端到端实测 (真 dws minutes 调用 → 转发 → ingest 收下落库)。 - README:阿里云 SLS 生产版(PutLogs 落 Logstore + 索引)、函数计算 FC 部署步骤、 SLS 控制台开通指引、数据边界提醒。 让运维方照此把 DWS_AUDIT_FORWARD_URL 指向自己的端点即可统一收集审计。 --- examples/audit-ingest/README.md | 106 ++++++++++++++++++++++++++ examples/audit-ingest/main.go | 127 ++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 examples/audit-ingest/README.md create mode 100644 examples/audit-ingest/main.go diff --git a/examples/audit-ingest/README.md b/examples/audit-ingest/README.md new file mode 100644 index 00000000..fa02b92f --- /dev/null +++ b/examples/audit-ingest/README.md @@ -0,0 +1,106 @@ +# audit-ingest —— dws 审计转发的参考接收端 + +dws 开启审计转发后(`DWS_AUDIT_FORWARD_URL`),每次命令会 POST 一条审计事件到你的端点。 +这个目录是一个**最小参考实现**,先让你本地把整条链路跑通,再照搬到阿里云 SLS。 + +## 接收契约(dws 这边固定) + +``` +POST / +Content-Type: application/json +Authorization: Bearer # 可选;下方 -token 设了就强校验 +X-Dws-Audit-Schema: 2 +Body: 一条审计事件 JSON +返回 2xx 即算成功(否则 dws 端按失败处理,本地文件仍是源头真相可回补) +``` + +## 1. 本地验证版(纯标准库,开箱即用) + +```bash +# 起接收端 +go run ./examples/audit-ingest -addr :8088 -token secret-token -out ingest.jsonl + +# 另开一个终端,让 dws 真转发过来 +export DWS_AUDIT_ENABLED=true +export DWS_AUDIT_FORWARD_URL=http://localhost:8088 +export DWS_AUDIT_FORWARD_TOKEN=secret-token +export DWS_AUDIT_FORWARD_REDACT=none +dws minutes list mine --max 2 --format json +# 看 ingest.jsonl,每行一条审计事件 +``` + +落地点只有一个函数 `fileSink.write()`,换成 SLS 写入就是生产版(见下)。 + +## 2. 阿里云 SLS 版(生产推荐:自带存储/检索/Dashboard/留存) + +### 2.1 在 SLS 控制台开通 + +1. 阿里云控制台 → **日志服务 SLS** → 创建 **Project**(如 `dws-audit`)。 +2. Project 下创建 **Logstore**(如 `events`),设留存天数(合规一般 180/365 天)。 +3. 给 Logstore 开**索引**,把 `trace_id`、`command`、`subcommand`、`outcome`、`corp_id`、`agent_id` + 设为字段索引,方便检索与做 Dashboard。 +4. 记下 **Endpoint**(如 `cn-hangzhou.log.aliyuncs.com`)、**Project**、**Logstore**, + 并准备一个有写权限的 **AccessKey**(建议用 RAM 子账号,仅授予 `log:PostLogStoreLogs`)。 + +### 2.2 把 write() 换成 SLS PutLogs + +依赖:`go get github.com/aliyun/aliyun-log-go-sdk` + +```go +import sls "github.com/aliyun/aliyun-log-go-sdk" + +type slsSink struct { + client sls.ClientInterface + project, logstore string +} + +func newSLSSink() *slsSink { + c := sls.CreateNormalInterface( + os.Getenv("SLS_ENDPOINT"), + os.Getenv("SLS_AK"), os.Getenv("SLS_SK"), "") + return &slsSink{client: c, + project: os.Getenv("SLS_PROJECT"), + logstore: os.Getenv("SLS_LOGSTORE")} +} + +// 把整条事件作为一个 event 字段,另抽几个 top-level 字段做索引。 +func (s *slsSink) write(body []byte) error { + var e map[string]any + _ = json.Unmarshal(body, &e) + str := func(k string) string { v, _ := e[k].(string); return v } + contents := []*sls.LogContent{ + {Key: proto.String("event"), Value: proto.String(string(body))}, + {Key: proto.String("trace_id"), Value: proto.String(str("trace_id"))}, + {Key: proto.String("command"), Value: proto.String(str("command"))}, + {Key: proto.String("subcommand"), Value: proto.String(str("subcommand"))}, + {Key: proto.String("outcome"), Value: proto.String(str("outcome"))}, + } + if org, ok := e["org"].(map[string]any); ok { + if cid, ok := org["corp_id"].(string); ok { + contents = append(contents, &sls.LogContent{Key: proto.String("corp_id"), Value: proto.String(cid)}) + } + } + lg := &sls.LogGroup{Logs: []*sls.Log{{ + Time: proto.Uint32(uint32(time.Now().Unix())), + Contents: contents, + }}} + return s.client.PutLogs(s.project, s.logstore, lg) +} +``` + +把 `handler.sink` 从 `*fileSink` 换成 `*slsSink` 即可(接口一致:`write([]byte) error`)。 + +### 2.3 部署:函数计算 FC(最省运维) + +1. 阿里云 → **函数计算 FC** → 创建函数,运行时 Go,触发器选 **HTTP 触发器**。 +2. 入口换成 FC 的 HTTP handler 形态(FC Go SDK 的 `RegisterHttpHandler`),逻辑就是上面的 `ServeHTTP`。 +3. 环境变量配 `SLS_ENDPOINT/SLS_PROJECT/SLS_LOGSTORE/SLS_AK/SLS_SK` 和接收用的 `-token`。 +4. 拿到 FC 的 HTTP 触发器公网地址,作为 `DWS_AUDIT_FORWARD_URL` 下发给各端 dws。 + +> 也可直接跑在 ECS / K8s 上(本目录二进制 + 反代 HTTPS),看你们运维习惯。 +> SLS 本身就有查询、告警、Dashboard,落进去后“谁、什么时候、操作了什么、成没成”直接在 SLS 控制台查。 + +## 数据边界提醒 + +- 转发是**尽力而为**,本地 `audit.jsonl` 始终是源头真相,FC/SLS 偶发不可用可从本地回补。 +- 合规全量审计建议进**企业自有** SLS;若要钉钉平台统一收,请走 `minimal` 档的匿名遥测,别把全量含身份数据集中到厂商侧。详见 `docs/audit.md`。 diff --git a/examples/audit-ingest/main.go b/examples/audit-ingest/main.go new file mode 100644 index 00000000..b1aecb30 --- /dev/null +++ b/examples/audit-ingest/main.go @@ -0,0 +1,127 @@ +// 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. + +// Command audit-ingest is a minimal reference receiver for dws audit forwarding +// (the DWS_AUDIT_FORWARD_URL target). It implements the exact contract the dws +// HTTP forwarder speaks, so you can validate the whole chain end-to-end before +// wiring a real sink. +// +// Contract (see internal/audit/forward.go): +// +// POST / +// Content-Type: application/json +// Authorization: Bearer (optional; enforced here if -token set) +// X-Dws-Audit-Schema: +// Body: one audit Event JSON +// -> respond 2xx +// +// Local-validation build: it appends each accepted event to a JSONL file. To +// ship audit to Aliyun SLS instead, replace store.write() with an SLS PutLogs +// call (one function — see README.md in this directory). +// +// Run: +// +// go run ./examples/audit-ingest -addr :8088 -token secret -out ingest.jsonl +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "sync" +) + +func main() { + addr := flag.String("addr", ":8088", "listen address") + token := flag.String("token", "", "expected Bearer token (empty = no auth check)") + out := flag.String("out", "ingest.jsonl", "output JSONL file (local-validation sink)") + flag.Parse() + + sink, err := newFileSink(*out) + if err != nil { + log.Fatalf("open sink: %v", err) + } + defer sink.Close() + + h := &handler{token: *token, sink: sink} + http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + http.Handle("/", h) + + log.Printf("audit-ingest listening on %s (auth=%v, out=%s)", *addr, *token != "", *out) + log.Fatal(http.ListenAndServe(*addr, nil)) +} + +type handler struct { + token string + sink *fileSink +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.token != "" && r.Header.Get("Authorization") != "Bearer "+h.token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + // Validate it is well-formed JSON before accepting (reject garbage early). + var probe map[string]any + if err := json.Unmarshal(body, &probe); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if err := h.sink.write(body); err != nil { + http.Error(w, "sink error", http.StatusInternalServerError) + return + } + log.Printf("accepted event: trace_id=%v schema=%s command=%v/%v outcome=%v", + probe["trace_id"], r.Header.Get("X-Dws-Audit-Schema"), probe["command"], probe["subcommand"], probe["outcome"]) + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(w, `{"ok":true}`) +} + +// fileSink appends one JSON object per line. Swap this for SLS PutLogs to ship +// to Aliyun Log Service instead — see README.md. +type fileSink struct { + mu sync.Mutex + f *os.File +} + +func newFileSink(path string) (*fileSink, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + return &fileSink{f: f}, nil +} + +func (s *fileSink) write(line []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.f.Write(append(line, '\n')); err != nil { + return err + } + return s.f.Sync() +} + +func (s *fileSink) Close() error { return s.f.Close() } From 670d4df4fcb8232adea2e491c6ec30e303360322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:51:54 +0800 Subject: [PATCH 4/8] =?UTF-8?q?docs(audit):=20=E5=88=A0=E6=8E=89=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=20ingest=20server=EF=BC=8C=E6=8A=8A=E6=8E=A5=E6=94=B6?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=20+=20SLS=20=E6=8E=A5=E5=85=A5=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E5=B9=B6=E5=85=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/audit-ingest 的任务(本地证明转发链路可通)已完成;作为 CLI 仓库里的 常驻代码它是噪音——收数据的服务端不该由 CLI 仓库承载。删除该目录,把真正有价值的 "接收端契约 + 阿里云 SLS/FC 接入步骤"并入 docs/audit.md,知识留下、toy 去掉。 --- docs/audit.md | 28 +++++++ examples/audit-ingest/README.md | 106 -------------------------- examples/audit-ingest/main.go | 127 -------------------------------- 3 files changed, 28 insertions(+), 233 deletions(-) delete mode 100644 examples/audit-ingest/README.md delete mode 100644 examples/audit-ingest/main.go diff --git a/docs/audit.md b/docs/audit.md index 906aeb6e..f8006ce7 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -96,6 +96,34 @@ tail -n1 ~/.dws/logs/audit.jsonl | jq . # 路径随 DWS_CONFIG_DIR / edition 隐私边界才清楚。 - 不管哪种,本地文件始终是源头真相;转发是尽力而为,丢了可从本地文件回补。 +### 接收端契约 + +收集端点(`DWS_AUDIT_FORWARD_URL`)只需实现: + +``` +POST / +Content-Type: application/json +Authorization: Bearer # 对应 DWS_AUDIT_FORWARD_TOKEN +X-Dws-Audit-Schema: 2 +Body: 一条审计事件 JSON +返回 2xx 即成功 +``` + +任何 HTTP 服务都能接,不需要专用组件。 + +### 接入阿里云 SLS(生产推荐) + +SLS(日志服务)自带写入 / 存储 / 检索 / Dashboard / 留存,是审计落地的标准选型: + +1. SLS 控制台建 **Project** + **Logstore**,设留存天数(合规常用 180/365 天), + 给 `trace_id` / `command` / `subcommand` / `outcome` / `corp_id` / `agent_id` 开字段索引。 +2. 立一个收 POST 的端点(**函数计算 FC** HTTP 触发器最省运维,或 ECS/K8s), + 校验 Bearer 后把 body 作为一条日志 `PutLogs` 写进 Logstore(整条 JSON 放 `event` 字段, + 另抽 `trace_id`/`command`/`outcome`/`corp_id` 做索引列)。 +3. 把 FC 地址作为 `DWS_AUDIT_FORWARD_URL` 下发给各端 dws。 + +之后“谁 / 何时 / 操作了什么 / 成没成 / 数据流向”直接在 SLS 控制台查询与做看板。 + ## TODO - **网关签名的 agent 身份**:`host_agent` / `channel` / `agent_code` 目前是调用方自报的环境变量、可伪造, diff --git a/examples/audit-ingest/README.md b/examples/audit-ingest/README.md deleted file mode 100644 index fa02b92f..00000000 --- a/examples/audit-ingest/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# audit-ingest —— dws 审计转发的参考接收端 - -dws 开启审计转发后(`DWS_AUDIT_FORWARD_URL`),每次命令会 POST 一条审计事件到你的端点。 -这个目录是一个**最小参考实现**,先让你本地把整条链路跑通,再照搬到阿里云 SLS。 - -## 接收契约(dws 这边固定) - -``` -POST / -Content-Type: application/json -Authorization: Bearer # 可选;下方 -token 设了就强校验 -X-Dws-Audit-Schema: 2 -Body: 一条审计事件 JSON -返回 2xx 即算成功(否则 dws 端按失败处理,本地文件仍是源头真相可回补) -``` - -## 1. 本地验证版(纯标准库,开箱即用) - -```bash -# 起接收端 -go run ./examples/audit-ingest -addr :8088 -token secret-token -out ingest.jsonl - -# 另开一个终端,让 dws 真转发过来 -export DWS_AUDIT_ENABLED=true -export DWS_AUDIT_FORWARD_URL=http://localhost:8088 -export DWS_AUDIT_FORWARD_TOKEN=secret-token -export DWS_AUDIT_FORWARD_REDACT=none -dws minutes list mine --max 2 --format json -# 看 ingest.jsonl,每行一条审计事件 -``` - -落地点只有一个函数 `fileSink.write()`,换成 SLS 写入就是生产版(见下)。 - -## 2. 阿里云 SLS 版(生产推荐:自带存储/检索/Dashboard/留存) - -### 2.1 在 SLS 控制台开通 - -1. 阿里云控制台 → **日志服务 SLS** → 创建 **Project**(如 `dws-audit`)。 -2. Project 下创建 **Logstore**(如 `events`),设留存天数(合规一般 180/365 天)。 -3. 给 Logstore 开**索引**,把 `trace_id`、`command`、`subcommand`、`outcome`、`corp_id`、`agent_id` - 设为字段索引,方便检索与做 Dashboard。 -4. 记下 **Endpoint**(如 `cn-hangzhou.log.aliyuncs.com`)、**Project**、**Logstore**, - 并准备一个有写权限的 **AccessKey**(建议用 RAM 子账号,仅授予 `log:PostLogStoreLogs`)。 - -### 2.2 把 write() 换成 SLS PutLogs - -依赖:`go get github.com/aliyun/aliyun-log-go-sdk` - -```go -import sls "github.com/aliyun/aliyun-log-go-sdk" - -type slsSink struct { - client sls.ClientInterface - project, logstore string -} - -func newSLSSink() *slsSink { - c := sls.CreateNormalInterface( - os.Getenv("SLS_ENDPOINT"), - os.Getenv("SLS_AK"), os.Getenv("SLS_SK"), "") - return &slsSink{client: c, - project: os.Getenv("SLS_PROJECT"), - logstore: os.Getenv("SLS_LOGSTORE")} -} - -// 把整条事件作为一个 event 字段,另抽几个 top-level 字段做索引。 -func (s *slsSink) write(body []byte) error { - var e map[string]any - _ = json.Unmarshal(body, &e) - str := func(k string) string { v, _ := e[k].(string); return v } - contents := []*sls.LogContent{ - {Key: proto.String("event"), Value: proto.String(string(body))}, - {Key: proto.String("trace_id"), Value: proto.String(str("trace_id"))}, - {Key: proto.String("command"), Value: proto.String(str("command"))}, - {Key: proto.String("subcommand"), Value: proto.String(str("subcommand"))}, - {Key: proto.String("outcome"), Value: proto.String(str("outcome"))}, - } - if org, ok := e["org"].(map[string]any); ok { - if cid, ok := org["corp_id"].(string); ok { - contents = append(contents, &sls.LogContent{Key: proto.String("corp_id"), Value: proto.String(cid)}) - } - } - lg := &sls.LogGroup{Logs: []*sls.Log{{ - Time: proto.Uint32(uint32(time.Now().Unix())), - Contents: contents, - }}} - return s.client.PutLogs(s.project, s.logstore, lg) -} -``` - -把 `handler.sink` 从 `*fileSink` 换成 `*slsSink` 即可(接口一致:`write([]byte) error`)。 - -### 2.3 部署:函数计算 FC(最省运维) - -1. 阿里云 → **函数计算 FC** → 创建函数,运行时 Go,触发器选 **HTTP 触发器**。 -2. 入口换成 FC 的 HTTP handler 形态(FC Go SDK 的 `RegisterHttpHandler`),逻辑就是上面的 `ServeHTTP`。 -3. 环境变量配 `SLS_ENDPOINT/SLS_PROJECT/SLS_LOGSTORE/SLS_AK/SLS_SK` 和接收用的 `-token`。 -4. 拿到 FC 的 HTTP 触发器公网地址,作为 `DWS_AUDIT_FORWARD_URL` 下发给各端 dws。 - -> 也可直接跑在 ECS / K8s 上(本目录二进制 + 反代 HTTPS),看你们运维习惯。 -> SLS 本身就有查询、告警、Dashboard,落进去后“谁、什么时候、操作了什么、成没成”直接在 SLS 控制台查。 - -## 数据边界提醒 - -- 转发是**尽力而为**,本地 `audit.jsonl` 始终是源头真相,FC/SLS 偶发不可用可从本地回补。 -- 合规全量审计建议进**企业自有** SLS;若要钉钉平台统一收,请走 `minimal` 档的匿名遥测,别把全量含身份数据集中到厂商侧。详见 `docs/audit.md`。 diff --git a/examples/audit-ingest/main.go b/examples/audit-ingest/main.go deleted file mode 100644 index b1aecb30..00000000 --- a/examples/audit-ingest/main.go +++ /dev/null @@ -1,127 +0,0 @@ -// 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. - -// Command audit-ingest is a minimal reference receiver for dws audit forwarding -// (the DWS_AUDIT_FORWARD_URL target). It implements the exact contract the dws -// HTTP forwarder speaks, so you can validate the whole chain end-to-end before -// wiring a real sink. -// -// Contract (see internal/audit/forward.go): -// -// POST / -// Content-Type: application/json -// Authorization: Bearer (optional; enforced here if -token set) -// X-Dws-Audit-Schema: -// Body: one audit Event JSON -// -> respond 2xx -// -// Local-validation build: it appends each accepted event to a JSONL file. To -// ship audit to Aliyun SLS instead, replace store.write() with an SLS PutLogs -// call (one function — see README.md in this directory). -// -// Run: -// -// go run ./examples/audit-ingest -addr :8088 -token secret -out ingest.jsonl -package main - -import ( - "encoding/json" - "flag" - "fmt" - "io" - "log" - "net/http" - "os" - "sync" -) - -func main() { - addr := flag.String("addr", ":8088", "listen address") - token := flag.String("token", "", "expected Bearer token (empty = no auth check)") - out := flag.String("out", "ingest.jsonl", "output JSONL file (local-validation sink)") - flag.Parse() - - sink, err := newFileSink(*out) - if err != nil { - log.Fatalf("open sink: %v", err) - } - defer sink.Close() - - h := &handler{token: *token, sink: sink} - http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) - http.Handle("/", h) - - log.Printf("audit-ingest listening on %s (auth=%v, out=%s)", *addr, *token != "", *out) - log.Fatal(http.ListenAndServe(*addr, nil)) -} - -type handler struct { - token string - sink *fileSink -} - -func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if h.token != "" && r.Header.Get("Authorization") != "Bearer "+h.token { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - http.Error(w, "read error", http.StatusBadRequest) - return - } - // Validate it is well-formed JSON before accepting (reject garbage early). - var probe map[string]any - if err := json.Unmarshal(body, &probe); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - if err := h.sink.write(body); err != nil { - http.Error(w, "sink error", http.StatusInternalServerError) - return - } - log.Printf("accepted event: trace_id=%v schema=%s command=%v/%v outcome=%v", - probe["trace_id"], r.Header.Get("X-Dws-Audit-Schema"), probe["command"], probe["subcommand"], probe["outcome"]) - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintln(w, `{"ok":true}`) -} - -// fileSink appends one JSON object per line. Swap this for SLS PutLogs to ship -// to Aliyun Log Service instead — see README.md. -type fileSink struct { - mu sync.Mutex - f *os.File -} - -func newFileSink(path string) (*fileSink, error) { - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return nil, err - } - return &fileSink{f: f}, nil -} - -func (s *fileSink) write(line []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - if _, err := s.f.Write(append(line, '\n')); err != nil { - return err - } - return s.f.Sync() -} - -func (s *fileSink) Close() error { return s.f.Close() } From 1e6a87d002b3fbacedaf702b22603aaf216b8a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:51:36 +0800 Subject: [PATCH 5/8] feat(audit): record client.channel (which agent/channel), spec gateway-signed identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 client.channel:来自 DWS_CHANNEL,记录"哪个 agent/渠道在调用" (OpenClaw / Qoder…)。定位为半可信——网关按 allowedChannels 白名单校验 membership(乱填渠道会被拒),但未做密码学绑定,已登记渠道间仍可冒充; minimal 脱敏档保留 channel 作为运维维度。 host_agent(DINGTALK_AGENT) / agent_code 仍是无校验纯标签、完全可伪造, 继续不记录。docs/audit.md 补"网关侧支持需求":签发与 token 绑定的签名 agent 凭证 + 调用回带 + 网关验签后回带已验证身份的接口契约草案,让 channel 升为完全可信、解锁 host_agent/agent_code。 真实验证:dws minutes list mine + DWS_CHANNEL=openclaw → client.channel=openclaw 落盘。 --- docs/audit.md | 38 ++++++++++++++++++++++++++---- internal/app/audit_runtime.go | 16 +++++++++---- internal/app/audit_runtime_test.go | 5 ++++ internal/audit/event.go | 24 ++++++++++++------- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index f8006ce7..0e0e190c 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -38,6 +38,7 @@ dws 可以为**每一次命令调用**生成一条结构化审计记录,用于 | `actor` | 用户 id / 姓名 | 登录 token | 网关验签,`user_id` 仅登录流程捕获时有 | | `org` | 组织 corp_id / 名称 | 登录 token | 网关验签,不可伪造 | | `client` | `agent_id`(装机 id)/ `source` / `cli_version` | identity.json + 编译版本 | dws 自管/编译注入,非调用方自报 | +| `client.channel` | 渠道 / 哪个 agent 在调用(OpenClaw / Qoder…) | `DWS_CHANNEL` | **半可信**:网关按 `allowedChannels` 白名单校验 membership,乱填会被拒;但未做密码学绑定,已登记渠道之间仍可冒充。可按渠道统计,待网关签名后升为完全可信 | | `device` | os / hostname / device_id / sn_no | 本机;`device_id`/`sn_no` 需 opt-in | 读真硬件 | | `intent` | 自然语言原文 + `provenance` | 仅 agent 层注入 | **标记 `provenance=agent`,明示不可验真** | | `module` / `command` / `subcommand` | 操作模块 / skill 命令 / 子命令 | CLI 解析实际执行的命令 | dws 实测 | @@ -46,8 +47,10 @@ dws 可以为**每一次命令调用**生成一条结构化审计记录,用于 | `flow` | 数据流向 + api + 本地路径 / endpoint / peer ids | 调用参数推断 | dws 实测 | | `outcome` / `err_class` / `exit_code` | 成败与错误分类 | CLI | dws 实测 | -**② 暂不上字段(可伪造,待网关签名)** —— 见下方 TODO: -`host_agent`(装在哪个 agent,`DINGTALK_AGENT`)、`channel`(渠道,`DWS_CHANNEL`)、`agent_code`(`DINGTALK_DWS_AGENTCODE`)。这三个是调用方自报的环境变量,`export` 即可冒充,**不可信,故先不记录**。 +**② 暂不上字段(完全可伪造,待网关签名)** —— 见下方 TODO: +`host_agent`(装在哪个 agent,`DINGTALK_AGENT`)、`agent_code`(`DINGTALK_DWS_AGENTCODE`)。这两个是调用方自报的纯环境变量标签,`export` 即可冒充、网关也不校验,**完全不可信,故先不记录**。 + +> `channel` 与它们的区别:`channel` 网关有 `allowedChannels` 白名单会校验 membership(半可信,已上);`host_agent`/`agent_code` 无任何校验(完全可伪造,未上)。 ### `flow.direction` 取值 @@ -126,10 +129,37 @@ SLS(日志服务)自带写入 / 存储 / 检索 / Dashboard / 留存,是 ## TODO -- **网关签名的 agent 身份**:`host_agent` / `channel` / `agent_code` 目前是调用方自报的环境变量、可伪造, - 故暂不记录。待网关能回带一个**与 token 绑定的签名 agent 凭证**后再加入审计,确保“装在哪个 agent / 哪个渠道”不可冒充。 +- **网关签名的 agent 身份(让 `channel` 升为完全可信、`host_agent`/`agent_code` 可上)**:见下方「网关侧支持需求」。 - **`actor.user_id` 稳定化**:让登录流程把 `user_id` 落进 token,使其每次都非空(当前仅部分登录流程捕获)。 +## 网关侧支持需求(给网关团队) + +**目标**:让审计里的「哪个 agent / 渠道在调用」不可伪造。 + +**现状与缺口**:dws 已记录 `client.channel`(来自 `DWS_CHANNEL`)。网关虽有 `allowedChannels` 白名单做 membership 校验(乱填的渠道会被拒),但渠道码只是**明文字符串、未与调用方身份做密码学绑定**,因此**一个已登记渠道可冒充另一个已登记渠道**;`DINGTALK_AGENT` / `DINGTALK_DWS_AGENTCODE` 更是无任何校验的纯标签。要达到「不可伪造」,需网关侧支持三件事: + +1. **签发与 token 绑定的签名 agent 凭证** + - dws 完成 OAuth/PAT 鉴权时,网关基于**已验证的 token + 已登记的 channel**,签发一个带签名的凭证(JWT 或 HMAC 串),内含:`channel_code`、`agent_code`、颁发时间、有效期、与 token 绑定的指纹(如 `hash(corp_id+user_id)`)。 + - 鉴权响应新增字段:`agentCredential`、`agentCredentialExpiry`。 + +2. **每次调用校验签名,回带「网关认证过的身份」** + - dws 后续每次调用回带 `x-dws-agent-credential` 请求头。 + - 网关验签(签名 + 有效期 + token 绑定一致性)通过后,在响应回带 `x-dws-verified-channel` / `x-dws-verified-agent`。 + - dws 审计**记录网关回带的已验证值**,而非本地 env 自报值 → 冒充需伪造网关签名,不可行。 + +3. **渠道注册表 + 接入方身份校验** + - 维护 `channel_code → 接入方(OpenClaw / Qoder / …)` 的登记关系;签发凭证时校验接入方身份(接入方 AppKey / 证书),确保 `channel_code` 只能由其真正持有者使用。 + +**接口契约草案** + +| 位置 | 新增 | 说明 | +|---|---|---| +| 鉴权响应 | `agentCredential` / `agentCredentialExpiry` | 与 token 绑定的签名凭证 | +| 调用请求头 | `x-dws-agent-credential` | dws 回带凭证 | +| 调用响应头 | `x-dws-verified-channel` / `x-dws-verified-agent` | 网关验签后回带,dws 据此写审计 | + +**dws 侧配合(网关就绪后)**:把 `client.channel` 从 env 自报切到「网关回带的已验证值」,并解锁 `host_agent` / `agent_code` 入审计、标记为完全可信。 + ## 隐私与合规 - `device_id` / `sn_no` 是 PIPL 下的个人信息,**默认不采集**,企业需显式开启并告知用户。 diff --git a/internal/app/audit_runtime.go b/internal/app/audit_runtime.go index 7c34ceb7..09274f3d 100644 --- a/internal/app/audit_runtime.go +++ b/internal/app/audit_runtime.go @@ -94,11 +94,17 @@ func (r *runtimeRunner) emitAudit(ctx context.Context, execID, endpoint string, ev.Client.AgentID = id.AgentID ev.Client.Source = id.Source } - // TODO(audit): host_agent (DINGTALK_AGENT) / channel (DWS_CHANNEL) / - // agent_code (DINGTALK_DWS_AGENTCODE) are caller-supplied env vars — - // FORGEABLE, so they are intentionally NOT recorded here. Add them only - // once the gateway returns a SIGNED agent identity bound to the token, so - // the audit can't be spoofed. See docs/audit.md "TODO". + // Channel (DWS_CHANNEL): which integration/agent is driving dws. SEMI-trusted + // — the gateway validates channel membership against allowedChannels (an + // unregistered channel is rejected), so it isn't an arbitrary label; but it + // is not yet cryptographically bound, so a registered channel could still + // impersonate another. Recorded so audit can group by "which agent called". + ev.Client.Channel = strings.TrimSpace(os.Getenv(envDWSChannel)) + // TODO(audit): host_agent (DINGTALK_AGENT) / agent_code + // (DINGTALK_DWS_AGENTCODE) are plain caller-supplied env labels — FULLY + // FORGEABLE, so they are intentionally NOT recorded here. Add them (and + // upgrade channel to fully-trusted) only once the gateway returns a SIGNED + // agent identity bound to the token. See docs/audit.md "TODO". ev.Device = collectDeviceCached() diff --git a/internal/app/audit_runtime_test.go b/internal/app/audit_runtime_test.go index dc7257b6..f8faff97 100644 --- a/internal/app/audit_runtime_test.go +++ b/internal/app/audit_runtime_test.go @@ -61,6 +61,7 @@ func TestEmitAudit_PopulatesAllObtainableFields(t *testing.T) { t.Setenv(audit.EnvForwardURL, srv.URL) t.Setenv(audit.EnvForwardRedact, "none") // org's own sink: ship verbatim t.Setenv(audit.EnvNLIntent, "把上周的战略会听记导出到桌面") + t.Setenv(envDWSChannel, "openclaw") // which agent/channel is driving dws var file bytes.Buffer r := &runtimeRunner{ @@ -120,6 +121,10 @@ func TestEmitAudit_PopulatesAllObtainableFields(t *testing.T) { if local.Client.CLIVersion != version { t.Errorf("client.cli_version = %q, want %q", local.Client.CLIVersion, version) } + // client.channel comes from DWS_CHANNEL (which agent/channel is calling). + if local.Client.Channel != "openclaw" { + t.Errorf("client.channel = %q, want %q", local.Client.Channel, "openclaw") + } // --- forward sink received the same trace --- if len(forwarded) == 0 { diff --git a/internal/audit/event.go b/internal/audit/event.go index a50f31f0..b814e41e 100644 --- a/internal/audit/event.go +++ b/internal/audit/event.go @@ -87,16 +87,24 @@ type Org struct { Name string `json:"name,omitempty"` } -// Client identifies the dws install + version. Only TRUSTWORTHY fields live -// here: AgentID/Source are dws-managed install state, CLIVersion is compiled -// in — none are caller-asserted-per-call. +// Client identifies the dws install, version, and the integration channel. // -// Deliberately ABSENT (forgeable, env-asserted by the caller — see audit TODO): -// host_agent (DINGTALK_AGENT), channel (DWS_CHANNEL), agent_code -// (DINGTALK_DWS_AGENTCODE). They will be added only once the gateway can hand -// back a SIGNED agent identity so they can't be spoofed. +// Trust tiers: +// - AgentID/Source/CLIVersion: dws-managed install state / compiled-in — +// not caller-asserted-per-call. +// - Channel (DWS_CHANNEL): SEMI-trusted. The gateway validates channel +// membership against allowedChannels (an unregistered channel is rejected, +// see auth.classifyDenialReason), so it can't be an arbitrary value — but +// it is NOT yet cryptographically bound, so one registered channel could +// still impersonate another. Recorded for "which agent/channel called", +// flagged as semi-trusted until the gateway signs it (see audit TODO). +// +// Deliberately ABSENT (fully forgeable, plain env labels — see audit TODO): +// host_agent (DINGTALK_AGENT), agent_code (DINGTALK_DWS_AGENTCODE). Added only +// once the gateway hands back a SIGNED agent identity. type Client struct { AgentID string `json:"agent_id,omitempty"` // 装机标识: install-time UUID (x-dws-agent-id) + Channel string `json:"channel,omitempty"` // 渠道/哪个 agent: DWS_CHANNEL (网关校验 membership, 半可信) Source string `json:"source,omitempty"` // identity source, 默认 "dws" CLIVersion string `json:"cli_version,omitempty"` // dws 版本 } @@ -213,7 +221,7 @@ func (e *Event) Redact(level RedactLevel, salt string) *Event { Timestamp: cp.Timestamp, TraceID: cp.TraceID, Org: Org{CorpID: cp.Org.CorpID}, - Client: Client{CLIVersion: cp.Client.CLIVersion}, // version is an ops dimension; drop the install id + Client: Client{CLIVersion: cp.Client.CLIVersion, Channel: cp.Client.Channel}, // version + channel are ops dimensions; drop the install id Module: cp.Module, Command: cp.Command, Subcommand: cp.Subcommand, From 7fc16737d44c9279713581c11adba4f6e526f11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:19:19 +0800 Subject: [PATCH 6/8] docs(audit): switch audit strings, comments and docs to English Open-source repo convention: configmeta descriptions, struct-field comments, test fixtures and docs/audit.md are now English. No behavior change; audit tests still pass (redaction-boundary assertions updated to English sentinels). --- docs/audit.md | 296 +++++++++++++++++------------ internal/app/audit_runtime_test.go | 14 +- internal/audit/collect.go | 14 +- internal/audit/event.go | 14 +- internal/audit/event_test.go | 14 +- 5 files changed, 200 insertions(+), 152 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index 0e0e190c..ae65143b 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -1,75 +1,86 @@ -# 操作审计(Audit) +# Operation Audit -dws 可以为**每一次命令调用**生成一条结构化审计记录,用于满足**企业合规审计**的通用需求 -——任何部署 dws 的企业都可以开启,把员工经 dws 的操作留痕。 +dws can produce a structured audit record for **every command invocation**, to +meet the general need for **enterprise compliance auditing** — any organization +deploying dws can turn it on and keep a trail of what employees do through dws. -设计遵循开源惯例,把「产生事件」和「投递事件」分开: +The design follows open-source norms by separating "producing an event" from +"delivering an event": -- **通道 A — 本地审计文件**:始终是源头真相,operator 自己拥有、可随时 `grep`。 -- **通道 B — 转发到企业自有 sink**:可选,endpoint 由**部署企业**配置, - **绝不写死到厂商**。转发前可按脱敏档位降级。 +- **Channel A — local audit file**: always the source of truth, owned by the + operator and `grep`-able at any time. +- **Channel B — forward to the enterprise's own sink**: optional; the endpoint + is configured by the **deploying organization** and is **never hardcoded to a + vendor**. Content can be downgraded by redaction tier before forwarding. -> 审计**默认全关**。不设置 `DWS_AUDIT_ENABLED` 时,dws 不产生任何审计数据, -> 热路径零影响。 +> Auditing is **off by default**. With `DWS_AUDIT_ENABLED` unset, dws produces +> no audit data and the hot path is unaffected. -## 启用 +## Enabling -| 环境变量 | 说明 | 示例 | +| Environment variable | Description | Example | |---|---|---| -| `DWS_AUDIT_ENABLED` | 启用本地审计文件 | `true` | -| `DWS_AUDIT_FORWARD_URL` | 转发目标(企业自有 sink,非厂商默认) | `https://audit.internal.example.com/dws` | -| `DWS_AUDIT_FORWARD_TOKEN` | 企业 sink 的 Bearer 鉴权 | `xxxxx` | -| `DWS_AUDIT_FORWARD_REDACT` | 转发脱敏档:`none` / `hashed` / `minimal` | `none` | -| `DWS_AUDIT_REDACT_SALT` | `hashed` 档的加盐值 | `tenant-salt` | -| `DWS_AUDIT_DEVICE_FINGERPRINT` | 采集 `device_id` / `sn_no`(PIPL 个人信息,默认关) | `true` | -| `DWS_AUDIT_NL_INTENT` | 上层 agent 注入的自然语言原文 | `把上周听记导出` | +| `DWS_AUDIT_ENABLED` | Enable the local audit file | `true` | +| `DWS_AUDIT_FORWARD_URL` | Forward target (enterprise's own sink, not a vendor default) | `https://audit.internal.example.com/dws` | +| `DWS_AUDIT_FORWARD_TOKEN` | Bearer token for the enterprise sink | `xxxxx` | +| `DWS_AUDIT_FORWARD_REDACT` | Forward redaction tier: `none` / `hashed` / `minimal` | `none` | +| `DWS_AUDIT_REDACT_SALT` | Salt for the `hashed` tier | `tenant-salt` | +| `DWS_AUDIT_DEVICE_FINGERPRINT` | Collect `device_id` / `sn_no` (PIPL personal information; off by default) | `true` | +| `DWS_AUDIT_NL_INTENT` | Natural-language input injected by the orchestrating agent | `export last week's minutes` | -本地文件路径:`/logs/audit.jsonl`(每行一条 JSON)。 +Local file path: `/logs/audit.jsonl` (one JSON object per line). -## 字段 +## Fields -字段按**可信度**分两类,只有可信字段会被记录: +Fields are split by **trustworthiness**; only trustworthy fields are recorded: -**① 可信字段(已上)** —— token 验证 / dws 自管 / dws 实测,调用方无法 per-call 伪造: +**① Trustworthy fields (recorded)** — token-verified / dws-managed / dws-measured, +not forgeable by the caller per call: -| 字段 | 含义 | 来源 | 可信原因 | +| Field | Meaning | Source | Why trustworthy | |---|---|---|---| -| `ts` / `trace_id` | 时间 / 唯一 trace | CLI(`trace_id` == 传输层 execution_id) | dws 实测 | -| `actor` | 用户 id / 姓名 | 登录 token | 网关验签,`user_id` 仅登录流程捕获时有 | -| `org` | 组织 corp_id / 名称 | 登录 token | 网关验签,不可伪造 | -| `client` | `agent_id`(装机 id)/ `source` / `cli_version` | identity.json + 编译版本 | dws 自管/编译注入,非调用方自报 | -| `client.channel` | 渠道 / 哪个 agent 在调用(OpenClaw / Qoder…) | `DWS_CHANNEL` | **半可信**:网关按 `allowedChannels` 白名单校验 membership,乱填会被拒;但未做密码学绑定,已登记渠道之间仍可冒充。可按渠道统计,待网关签名后升为完全可信 | -| `device` | os / hostname / device_id / sn_no | 本机;`device_id`/`sn_no` 需 opt-in | 读真硬件 | -| `intent` | 自然语言原文 + `provenance` | 仅 agent 层注入 | **标记 `provenance=agent`,明示不可验真** | -| `module` / `command` / `subcommand` | 操作模块 / skill 命令 / 子命令 | CLI 解析实际执行的命令 | dws 实测 | -| `subcommand_desc` | 子命令介绍 | 命令 catalog | 线上 catalog | -| `target` | 操作对象 id / 名称 / 摘要 / 敏感度 | 调用参数 + catalog(`sensitive` → `confidential`) | dws 实测 | -| `flow` | 数据流向 + api + 本地路径 / endpoint / peer ids | 调用参数推断 | dws 实测 | -| `outcome` / `err_class` / `exit_code` | 成败与错误分类 | CLI | dws 实测 | - -**② 暂不上字段(完全可伪造,待网关签名)** —— 见下方 TODO: -`host_agent`(装在哪个 agent,`DINGTALK_AGENT`)、`agent_code`(`DINGTALK_DWS_AGENTCODE`)。这两个是调用方自报的纯环境变量标签,`export` 即可冒充、网关也不校验,**完全不可信,故先不记录**。 - -> `channel` 与它们的区别:`channel` 网关有 `allowedChannels` 白名单会校验 membership(半可信,已上);`host_agent`/`agent_code` 无任何校验(完全可伪造,未上)。 - -### `flow.direction` 取值 - -- `local-export`:参数里带本地路径(如 `--output`),数据落到本机磁盘。 -- `read`:只读命令(list/get/query/search…),无数据移动。 -- `intra-tenant`:数据在租户内对象之间流转,`peer_ids` 收集涉及的人/群/文档 id。 -- `external-api`:流向租户外接口(预留)。 - -## 脱敏档位(仅作用于转发,本地文件始终全量) - -| 档位 | 行为 | 适用 | +| `ts` / `trace_id` | time / unique trace | CLI (`trace_id` == transport execution_id) | dws-measured | +| `actor` | user id / name | login token | gateway-verified; `user_id` present only when the login flow captured it | +| `org` | org corp_id / name | login token | gateway-verified, unforgeable | +| `client` | `agent_id` (install id) / `source` / `cli_version` | identity.json + compiled-in version | dws-managed / compiled-in, not caller-asserted | +| `client.channel` | channel / which agent is calling (OpenClaw / Qoder…) | `DWS_CHANNEL` | **semi-trusted**: the gateway validates membership against the `allowedChannels` allowlist (a bogus value is rejected), but there is no cryptographic binding yet, so one registered channel could still impersonate another. Usable for grouping by channel; upgrade to fully trusted once the gateway signs it | +| `device` | os / hostname / device_id / sn_no | local machine; `device_id`/`sn_no` require opt-in | reads real hardware | +| `intent` | natural-language input + `provenance` | injected at the agent layer only | **flagged `provenance=agent`, explicitly unverifiable** | +| `module` / `command` / `subcommand` | operated module / skill command / subcommand | the command the CLI actually parsed and ran | dws-measured | +| `subcommand_desc` | subcommand description | command catalog | online catalog | +| `target` | operated object id / name / summary / sensitivity | call params + catalog (`sensitive` → `confidential`) | dws-measured | +| `flow` | data direction + api + local path / endpoint / peer ids | inferred from call params | dws-measured | +| `outcome` / `err_class` / `exit_code` | success/failure and error class | CLI | dws-measured | + +**② Fields deliberately NOT recorded yet (fully forgeable, pending gateway +signing)** — see the TODO below: `host_agent` (which agent it is installed in, +`DINGTALK_AGENT`) and `agent_code` (`DINGTALK_DWS_AGENTCODE`). These are +plain caller-supplied environment-variable labels — an `export` is enough to +spoof them and the gateway does not validate them, so they are **fully +untrusted and therefore not recorded**. + +> Difference vs `channel`: the gateway validates `channel` membership against +> the `allowedChannels` allowlist (semi-trusted, recorded); `host_agent` / +> `agent_code` have no validation at all (fully forgeable, not recorded). + +### `flow.direction` values + +- `local-export`: params carry a local path (e.g. `--output`); data lands on the local disk. +- `read`: read-only command (list/get/query/search…), no data movement. +- `intra-tenant`: data moves between objects inside the tenant; `peer_ids` collects the person/group/doc ids involved. +- `external-api`: flows to an endpoint outside the tenant (reserved). + +## Redaction tiers (applied to forwarding only; the local file is always full) + +| Tier | Behavior | When to use | |---|---|---| -| `none` | 原样转发 | sink 在企业自己信任域内(企业内部审计库) | -| `hashed` | 自然语言、对象名、序列号、peer ids 替换为加盐哈希,可关联不可还原 | 跨信任域但仍需关联 | -| `minimal` | 只留维度(命令×版本×成败×方向),丢弃一切内容/身份 | 纯运维监控 | +| `none` | forward verbatim | sink is inside the enterprise's own trust boundary (its internal audit store) | +| `hashed` | natural language, object names, serial numbers, peer ids replaced by salted hashes — correlatable but not reversible | crosses a trust boundary but still needs correlation | +| `minimal` | keep dimensions only (command × version × outcome × direction), drop all content/identity | pure ops monitoring | -## 企业接入示例 +## Enterprise integration example -数据进企业自己的审计库,全字段、含设备指纹: +Data goes into the enterprise's own audit store, all fields, including device fingerprint: ```bash export DWS_AUDIT_ENABLED=true @@ -77,93 +88,130 @@ export DWS_AUDIT_FORWARD_URL="https://audit.internal.example.com/dws" export DWS_AUDIT_FORWARD_TOKEN="" export DWS_AUDIT_FORWARD_REDACT=none export DWS_AUDIT_DEVICE_FINGERPRINT=true -# 由上层 agent/skill 在每次调用前注入: -# export DWS_AUDIT_NL_INTENT="<用户这次的自然语言请求>" +# Injected by the orchestrating agent/skill before each call: +# export DWS_AUDIT_NL_INTENT="" ``` -验证: +Verify: ```bash dws minutes export --minute-id m-77 --output ~/Desktop/q2.md --format json -tail -n1 ~/.dws/logs/audit.jsonl | jq . # 路径随 DWS_CONFIG_DIR / edition 变化 +tail -n1 ~/.dws/logs/audit.jsonl | jq . # path varies with DWS_CONFIG_DIR / edition ``` -## 日志存在哪里 / 能否中心化收集 - -- **默认:每个用户自己机器上**,`/logs/audit.jsonl`,不开转发就不出本机。 -- **要中心化收集**:配置 `DWS_AUDIT_FORWARD_URL` 指向一个收集端点,每个用户每次调用就会 POST 一条上去。 - - **企业合规场景**:endpoint 指向**企业自己的审计库**,钉钉/厂商不持有数据(推荐,合规干净)。 - - **平台侧统一收集(钉钉这边收)**:技术上可行——把 endpoint 指向钉钉的审计 ingest 服务即可; - 但这等于厂商集中持有用户操作数据,必须 **opt-in + 明确告知**,否则就是开源 CLI 最忌讳的“偷偷上报”。 - 建议拆成两条:**合规全量审计 → 企业自有 sink**;**匿名极简遥测(`minimal` 档)→ 钉钉平台**做运维监控, - 隐私边界才清楚。 -- 不管哪种,本地文件始终是源头真相;转发是尽力而为,丢了可从本地文件回补。 - -### 接收端契约 - -收集端点(`DWS_AUDIT_FORWARD_URL`)只需实现: +## Where the log lives / can it be centrally collected + +- **Default: on each user's own machine**, `/logs/audit.jsonl`; with + forwarding off, nothing leaves the machine. +- **For central collection**: set `DWS_AUDIT_FORWARD_URL` to a collection + endpoint, and each user POSTs one record per invocation. + - **Enterprise compliance**: point the endpoint at the **enterprise's own + audit store**; DingTalk/the vendor holds no data (recommended, clean for compliance). + - **Platform-side collection (DingTalk receives it)**: technically possible — + point the endpoint at DingTalk's audit ingest service; but that means the + vendor centrally holds user operation data, so it must be **opt-in and + clearly disclosed**, otherwise it is the "silent reporting" that open-source + CLIs most want to avoid. The recommendation is to split into two streams: + **full compliance audit → enterprise's own sink**; **anonymous minimal + telemetry (`minimal` tier) → DingTalk platform** for ops monitoring, so the + privacy boundary is clear. +- Either way, the local file is always the source of truth; forwarding is + best-effort and a loss can be backfilled from the local file. + +### Ingest contract + +The collection endpoint (`DWS_AUDIT_FORWARD_URL`) only needs to implement: ``` POST / Content-Type: application/json -Authorization: Bearer # 对应 DWS_AUDIT_FORWARD_TOKEN +Authorization: Bearer # matches DWS_AUDIT_FORWARD_TOKEN X-Dws-Audit-Schema: 2 -Body: 一条审计事件 JSON -返回 2xx 即成功 +Body: one audit event as JSON +2xx means success ``` -任何 HTTP 服务都能接,不需要专用组件。 +Any HTTP service can receive it; no special component required. -### 接入阿里云 SLS(生产推荐) +### Wiring up Alibaba Cloud SLS (recommended for production) -SLS(日志服务)自带写入 / 存储 / 检索 / Dashboard / 留存,是审计落地的标准选型: +SLS (Log Service) provides ingestion / storage / search / dashboards / retention +out of the box, and is the standard choice for landing audit data: -1. SLS 控制台建 **Project** + **Logstore**,设留存天数(合规常用 180/365 天), - 给 `trace_id` / `command` / `subcommand` / `outcome` / `corp_id` / `agent_id` 开字段索引。 -2. 立一个收 POST 的端点(**函数计算 FC** HTTP 触发器最省运维,或 ECS/K8s), - 校验 Bearer 后把 body 作为一条日志 `PutLogs` 写进 Logstore(整条 JSON 放 `event` 字段, - 另抽 `trace_id`/`command`/`outcome`/`corp_id` 做索引列)。 -3. 把 FC 地址作为 `DWS_AUDIT_FORWARD_URL` 下发给各端 dws。 +1. In the SLS console create a **Project** + **Logstore** with a retention + period (180/365 days are common for compliance), and index + `trace_id` / `command` / `subcommand` / `outcome` / `corp_id` / `agent_id`. +2. Stand up an endpoint that receives the POST (a **Function Compute (FC)** HTTP + trigger is the lowest-ops option, or ECS/K8s): verify the bearer token, then + write the body as one log via `PutLogs` (store the full JSON in an `event` + field, and promote `trace_id`/`command`/`outcome`/`corp_id` to indexed columns). +3. Roll out the FC address as `DWS_AUDIT_FORWARD_URL` to each dws install. -之后“谁 / 何时 / 操作了什么 / 成没成 / 数据流向”直接在 SLS 控制台查询与做看板。 +Then "who / when / did what / succeeded or not / data direction" can be queried +and dashboarded directly in the SLS console. ## TODO -- **网关签名的 agent 身份(让 `channel` 升为完全可信、`host_agent`/`agent_code` 可上)**:见下方「网关侧支持需求」。 -- **`actor.user_id` 稳定化**:让登录流程把 `user_id` 落进 token,使其每次都非空(当前仅部分登录流程捕获)。 - -## 网关侧支持需求(给网关团队) - -**目标**:让审计里的「哪个 agent / 渠道在调用」不可伪造。 - -**现状与缺口**:dws 已记录 `client.channel`(来自 `DWS_CHANNEL`)。网关虽有 `allowedChannels` 白名单做 membership 校验(乱填的渠道会被拒),但渠道码只是**明文字符串、未与调用方身份做密码学绑定**,因此**一个已登记渠道可冒充另一个已登记渠道**;`DINGTALK_AGENT` / `DINGTALK_DWS_AGENTCODE` 更是无任何校验的纯标签。要达到「不可伪造」,需网关侧支持三件事: - -1. **签发与 token 绑定的签名 agent 凭证** - - dws 完成 OAuth/PAT 鉴权时,网关基于**已验证的 token + 已登记的 channel**,签发一个带签名的凭证(JWT 或 HMAC 串),内含:`channel_code`、`agent_code`、颁发时间、有效期、与 token 绑定的指纹(如 `hash(corp_id+user_id)`)。 - - 鉴权响应新增字段:`agentCredential`、`agentCredentialExpiry`。 - -2. **每次调用校验签名,回带「网关认证过的身份」** - - dws 后续每次调用回带 `x-dws-agent-credential` 请求头。 - - 网关验签(签名 + 有效期 + token 绑定一致性)通过后,在响应回带 `x-dws-verified-channel` / `x-dws-verified-agent`。 - - dws 审计**记录网关回带的已验证值**,而非本地 env 自报值 → 冒充需伪造网关签名,不可行。 - -3. **渠道注册表 + 接入方身份校验** - - 维护 `channel_code → 接入方(OpenClaw / Qoder / …)` 的登记关系;签发凭证时校验接入方身份(接入方 AppKey / 证书),确保 `channel_code` 只能由其真正持有者使用。 - -**接口契约草案** - -| 位置 | 新增 | 说明 | +- **Gateway-signed agent identity (so `channel` becomes fully trusted and + `host_agent`/`agent_code` can be recorded)**: see "Gateway-side support + requirements" below. +- **Stabilize `actor.user_id`**: have the login flow persist `user_id` into the + token so it is always non-empty (currently captured by only some login flows). + +## Gateway-side support requirements (for the gateway team) + +**Goal**: make "which agent / channel is calling" in the audit unforgeable. + +**Status and gap**: dws already records `client.channel` (from `DWS_CHANNEL`). +The gateway does validate membership against the `allowedChannels` allowlist (a +bogus channel is rejected), but the channel code is just a **plaintext string, +not cryptographically bound to the caller's identity**, so **one registered +channel can impersonate another**; `DINGTALK_AGENT` / `DINGTALK_DWS_AGENTCODE` +are plain labels with no validation at all. To make it "unforgeable", the +gateway needs to support three things: + +1. **Issue a signed agent credential bound to the token** + - When dws completes OAuth/PAT authentication, the gateway — based on the + **verified token + registered channel** — issues a signed credential (a JWT + or HMAC string) containing: `channel_code`, `agent_code`, issue time, + expiry, and a token-bound fingerprint (e.g. `hash(corp_id+user_id)`). + - New auth-response fields: `agentCredential`, `agentCredentialExpiry`. + +2. **Verify the signature on every call and return the "gateway-authenticated identity"** + - dws sends back an `x-dws-agent-credential` header on every subsequent call. + - After the gateway verifies it (signature + expiry + token-binding + consistency), it returns `x-dws-verified-channel` / `x-dws-verified-agent` + in the response. + - dws audit **records the verified values the gateway returns**, not the + locally self-asserted env values → impersonation would require forging the + gateway's signature, which is infeasible. + +3. **Channel registry + integrator identity check** + - Maintain a `channel_code → integrator (OpenClaw / Qoder / …)` registry; + when issuing the credential, verify the integrator's identity (its AppKey / + certificate) so a `channel_code` can only be used by its true owner. + +**Draft interface contract** + +| Location | Added | Description | |---|---|---| -| 鉴权响应 | `agentCredential` / `agentCredentialExpiry` | 与 token 绑定的签名凭证 | -| 调用请求头 | `x-dws-agent-credential` | dws 回带凭证 | -| 调用响应头 | `x-dws-verified-channel` / `x-dws-verified-agent` | 网关验签后回带,dws 据此写审计 | - -**dws 侧配合(网关就绪后)**:把 `client.channel` 从 env 自报切到「网关回带的已验证值」,并解锁 `host_agent` / `agent_code` 入审计、标记为完全可信。 - -## 隐私与合规 - -- `device_id` / `sn_no` 是 PIPL 下的个人信息,**默认不采集**,企业需显式开启并告知用户。 -- 自然语言原文只有上层 agent 能提供,审计记录里以 `provenance=agent` 标注, - 表明该字段非 CLI 实测、不可验真。 -- 富审计数据是**企业的合规资产**,应进入企业自有 sink;dws 不提供任何厂商默认收集端点。 -- `host_agent` / `channel` / `agent_code` 等调用方自报字段在网关签名前**不记录**,避免可伪造数据混入审计。 +| auth response | `agentCredential` / `agentCredentialExpiry` | signed credential bound to the token | +| call request header | `x-dws-agent-credential` | dws returns the credential | +| call response header | `x-dws-verified-channel` / `x-dws-verified-agent` | returned after the gateway verifies; dws writes these into the audit | + +**dws-side follow-up (once the gateway is ready)**: switch `client.channel` from +the self-asserted env value to "the verified value the gateway returns", and +unlock `host_agent` / `agent_code` into the audit, flagged as fully trusted. + +## Privacy and compliance + +- `device_id` / `sn_no` are personal information under PIPL, **not collected by + default**; the enterprise must explicitly enable them and inform users. +- Natural-language input can only be provided by the orchestrating agent and is + flagged `provenance=agent` in the audit record, indicating it is not + CLI-measured and cannot be verified. +- Rich audit data is the **enterprise's compliance asset** and should go into + the enterprise's own sink; dws provides no vendor-default collection endpoint. +- Caller-self-asserted fields such as `host_agent` / `channel` / `agent_code` + are **not recorded** before gateway signing, to keep forgeable data out of the + audit. diff --git a/internal/app/audit_runtime_test.go b/internal/app/audit_runtime_test.go index f8faff97..f3de3203 100644 --- a/internal/app/audit_runtime_test.go +++ b/internal/app/audit_runtime_test.go @@ -36,8 +36,8 @@ func auditTestCatalog() ir.Catalog { ID: "minutes", Tools: []ir.ToolDescriptor{{ RPCName: "export", - Title: "导出听记", - Description: "导出听记纪要为本地文件", + Title: "Export minutes", + Description: "Export meeting minutes to a local file", Sensitive: true, }}, }}} @@ -60,7 +60,7 @@ func TestEmitAudit_PopulatesAllObtainableFields(t *testing.T) { t.Setenv(audit.EnvEnabled, "true") t.Setenv(audit.EnvForwardURL, srv.URL) t.Setenv(audit.EnvForwardRedact, "none") // org's own sink: ship verbatim - t.Setenv(audit.EnvNLIntent, "把上周的战略会听记导出到桌面") + t.Setenv(audit.EnvNLIntent, "export last week's strategy review minutes to the desktop") t.Setenv(envDWSChannel, "openclaw") // which agent/channel is driving dws var file bytes.Buffer @@ -75,7 +75,7 @@ func TestEmitAudit_PopulatesAllObtainableFields(t *testing.T) { CanonicalPath: "minutes export", Params: map[string]any{ "minuteId": "m-77", - "name": "Q2 战略会", + "name": "Q2 Strategy Review", "output": "/Users/x/Desktop/q2.md", }, } @@ -92,10 +92,10 @@ func TestEmitAudit_PopulatesAllObtainableFields(t *testing.T) { "module": {local.Module, "minutes"}, "command": {local.Command, "minutes"}, "subcommand": {local.Subcommand, "export"}, - "subcommand_desc": {local.SubcommandDesc, "导出听记纪要为本地文件"}, + "subcommand_desc": {local.SubcommandDesc, "Export meeting minutes to a local file"}, "target.id": {local.Target.ID, "m-77"}, - "target.name": {local.Target.Name, "Q2 战略会"}, - "intent.nl": {local.Intent.NLInput, "把上周的战略会听记导出到桌面"}, + "target.name": {local.Target.Name, "Q2 Strategy Review"}, + "intent.nl": {local.Intent.NLInput, "export last week's strategy review minutes to the desktop"}, "outcome": {local.Outcome, "ok"}, "flow.localpath": {local.Flow.LocalPath, "/Users/x/Desktop/q2.md"}, "flow.api": {local.Flow.API, "export"}, diff --git a/internal/audit/collect.go b/internal/audit/collect.go index c31522a2..0e0621e0 100644 --- a/internal/audit/collect.go +++ b/internal/audit/collect.go @@ -48,13 +48,13 @@ const ( func init() { for _, it := range []configmeta.ConfigItem{ - {Name: EnvEnabled, Category: configmeta.CategorySecurity, Description: "启用本地审计日志(JSONL)", Example: "true"}, - {Name: EnvForwardURL, Category: configmeta.CategorySecurity, Description: "审计转发目标(企业自有 sink,非厂商默认)", Example: "https://audit.internal.example.com/dws"}, - {Name: EnvForwardToken, Category: configmeta.CategorySecurity, Description: "企业审计 sink 的 Bearer 鉴权", Example: "xxxxx"}, - {Name: EnvForwardRedact, Category: configmeta.CategorySecurity, Description: "转发脱敏档: none|hashed|minimal", Example: "none"}, - {Name: EnvRedactSalt, Category: configmeta.CategorySecurity, Description: "hashed 档的加盐值", Example: "tenant-salt"}, - {Name: EnvDeviceFingerprint, Category: configmeta.CategorySecurity, Description: "采集 device_id/sn_no(PIPL 个人信息,默认关)", Example: "true"}, - {Name: EnvNLIntent, Category: configmeta.CategorySecurity, Description: "上层 agent 注入的自然语言原文(provenance=agent)", Example: "把上周听记导出"}, + {Name: EnvEnabled, Category: configmeta.CategorySecurity, Description: "Enable the local audit log (JSONL)", Example: "true"}, + {Name: EnvForwardURL, Category: configmeta.CategorySecurity, Description: "Audit forward target (enterprise's own sink, not a vendor default)", Example: "https://audit.internal.example.com/dws"}, + {Name: EnvForwardToken, Category: configmeta.CategorySecurity, Description: "Bearer token for the enterprise audit sink", Example: "xxxxx"}, + {Name: EnvForwardRedact, Category: configmeta.CategorySecurity, Description: "Forward redaction tier: none|hashed|minimal", Example: "none"}, + {Name: EnvRedactSalt, Category: configmeta.CategorySecurity, Description: "Salt for the hashed tier", Example: "tenant-salt"}, + {Name: EnvDeviceFingerprint, Category: configmeta.CategorySecurity, Description: "Collect device_id/sn_no (PIPL personal information; off by default)", Example: "true"}, + {Name: EnvNLIntent, Category: configmeta.CategorySecurity, Description: "Natural-language input injected by the orchestrating agent (provenance=agent)", Example: "export last week's minutes"}, } { configmeta.Register(it) } diff --git a/internal/audit/event.go b/internal/audit/event.go index b814e41e..6d5362d7 100644 --- a/internal/audit/event.go +++ b/internal/audit/event.go @@ -103,10 +103,10 @@ type Org struct { // host_agent (DINGTALK_AGENT), agent_code (DINGTALK_DWS_AGENTCODE). Added only // once the gateway hands back a SIGNED agent identity. type Client struct { - AgentID string `json:"agent_id,omitempty"` // 装机标识: install-time UUID (x-dws-agent-id) - Channel string `json:"channel,omitempty"` // 渠道/哪个 agent: DWS_CHANNEL (网关校验 membership, 半可信) - Source string `json:"source,omitempty"` // identity source, 默认 "dws" - CLIVersion string `json:"cli_version,omitempty"` // dws 版本 + AgentID string `json:"agent_id,omitempty"` // install identity: install-time UUID (x-dws-agent-id) + Channel string `json:"channel,omitempty"` // channel / which agent: DWS_CHANNEL (gateway validates membership, semi-trusted) + Source string `json:"source,omitempty"` // identity source, defaults to "dws" + CLIVersion string `json:"cli_version,omitempty"` // dws version } // Device identifies the machine. DeviceID/SerialNo are NEW collection and @@ -157,9 +157,9 @@ type Event struct { Device Device `json:"device"` Intent Intent `json:"intent"` - Module string `json:"module"` // 操作模块: doc / group / minutes / table - Command string `json:"command"` // skill 命令, e.g. "doc" - Subcommand string `json:"subcommand"` // skill 子命令, e.g. "create" + Module string `json:"module"` // operated module: doc / group / minutes / table + Command string `json:"command"` // skill command, e.g. "doc" + Subcommand string `json:"subcommand"` // skill subcommand, e.g. "create" SubcommandDesc string `json:"subcommand_desc"` // static, from command catalog Target Target `json:"target"` diff --git a/internal/audit/event_test.go b/internal/audit/event_test.go index 3d1c047f..8331033b 100644 --- a/internal/audit/event_test.go +++ b/internal/audit/event_test.go @@ -24,15 +24,15 @@ import ( func sampleEvent() *Event { ts := time.Date(2026, 6, 3, 10, 0, 0, 0, time.UTC) e := New(ts, "trace-abc") - e.Actor = Actor{UserID: "staff-001", Name: "张三"} - e.Org = Org{CorpID: "corp-001", Name: "示例企业"} + e.Actor = Actor{UserID: "staff-001", Name: "Zhang San"} + e.Org = Org{CorpID: "corp-001", Name: "Example Corp"} e.Device = Device{DeviceID: "dev-9", SerialNo: "C02SN12345", OS: "darwin"} - e.Intent.NLInput = "把上周的听记导出到桌面" + e.Intent.NLInput = "export last week's minutes to the desktop" e.Module = "minutes" e.Command = "minutes" e.Subcommand = "export" - e.SubcommandDesc = "导出听记纪要" - e.Target = Target{Type: "minutes", ID: "m-77", Name: "Q2 战略会", Summary: "营收与人事", Sensitivity: SensitivityConfidential} + e.SubcommandDesc = "export meeting minutes" + e.Target = Target{Type: "minutes", ID: "m-77", Name: "Q2 Strategy Review", Summary: "revenue and headcount", Sensitivity: SensitivityConfidential} e.Flow = Flow{Direction: DirectionLocalExport, LocalPath: "/Users/x/Desktop/q2.md", API: "minutes.export"} e.Outcome = "ok" e.ExitCode = 0 @@ -60,7 +60,7 @@ func TestRedactNone_Verbatim(t *testing.T) { func TestRedactHashed_StripsRawPII(t *testing.T) { e := sampleEvent() got := e.Redact(RedactHashed, "salt") - if strings.Contains(got.Intent.NLInput, "听记") { + if strings.Contains(got.Intent.NLInput, "minutes") { t.Errorf("hashed NL still contains raw text: %q", got.Intent.NLInput) } if got.Device.SerialNo == "C02SN12345" { @@ -120,7 +120,7 @@ func TestRedactingSink_AppliesLevel(t *testing.T) { if err := s.Emit(sampleEvent()); err != nil { t.Fatal(err) } - if strings.Contains(buf.String(), "C02SN12345") || strings.Contains(buf.String(), "听记") { + if strings.Contains(buf.String(), "C02SN12345") || strings.Contains(buf.String(), "headcount") { t.Error("forwarder shipped raw PII/content despite RedactMinimal") } } From 836df6f8a533ba8895dff75eaddaca1cb2a4790f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:48:06 +0800 Subject: [PATCH 7/8] docs(audit): reposition as local diagnostic trail; pin file path + format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the client-side audit as a best-effort LOCAL diagnostic/troubleshooting trail, not a mandatory compliance record — a user controls their own machine and can bypass it, so authoritative/mandatory audit belongs on the MCP gateway. Adds a 'Scope and limits' note up top and a gateway pointer in the collection section. Also make the file location explicit (default ~/.dws/logs/audit.jsonl, per-OS table, DWS_CONFIG_DIR override) and document the JSONL format choice with ready jq examples. --- docs/audit.md | 77 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index ae65143b..e0016962 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -1,20 +1,29 @@ -# Operation Audit +# Operation Audit (local diagnostic trail) -dws can produce a structured audit record for **every command invocation**, to -meet the general need for **enterprise compliance auditing** — any organization -deploying dws can turn it on and keep a trail of what employees do through dws. +dws can record a structured line for **every command invocation** as a local +**diagnostic / troubleshooting trail** — so an operator can reconstruct what a +machine did through dws, which step failed, and where data went. -The design follows open-source norms by separating "producing an event" from -"delivering an event": +> **Scope and limits — read this first.** +> This is a **best-effort, local** trail. It runs on the user's own machine and +> is opt-in, so the user can disable or bypass it; it is **not** a tamper-proof, +> mandatory compliance record. Authoritative, non-bypassable audit belongs on +> the **MCP gateway** (server side), through which every dws call must pass — +> that is the system of record. The client-side trail here is a *complement*: it +> captures local detail the gateway cannot see (e.g. local export paths and the +> agent-injected natural-language intent), not a replacement. -- **Channel A — local audit file**: always the source of truth, owned by the - operator and `grep`-able at any time. -- **Channel B — forward to the enterprise's own sink**: optional; the endpoint - is configured by the **deploying organization** and is **never hardcoded to a - vendor**. Content can be downgraded by redaction tier before forwarding. +The design separates "producing an event" from "delivering an event": -> Auditing is **off by default**. With `DWS_AUDIT_ENABLED` unset, dws produces -> no audit data and the hot path is unaffected. +- **Channel A — local file**: the primary use; the trail an operator owns and + can `grep`/`jq` at any time. +- **Channel B — forward to a collector**: optional; the endpoint is configured + by the **deployer** and is **never hardcoded to a vendor**. Useful for pulling + several machines' trails into one place for investigation. Content can be + downgraded by redaction tier before forwarding. + +> Off by default. With `DWS_AUDIT_ENABLED` unset, dws produces nothing and the +> hot path is unaffected. ## Enabling @@ -28,7 +37,34 @@ The design follows open-source norms by separating "producing an event" from | `DWS_AUDIT_DEVICE_FINGERPRINT` | Collect `device_id` / `sn_no` (PIPL personal information; off by default) | `true` | | `DWS_AUDIT_NL_INTENT` | Natural-language input injected by the orchestrating agent | `export last week's minutes` | -Local file path: `/logs/audit.jsonl` (one JSON object per line). +## Where the file lives + +The trail is written to `/logs/audit.jsonl`, and `` +defaults to `~/.dws`: + +| OS | Default path | +|---|---| +| macOS | `/Users//.dws/logs/audit.jsonl` | +| Linux | `/home//.dws/logs/audit.jsonl` | +| Windows | `C:\Users\\.dws\logs\audit.jsonl` | + +Override the base directory with `DWS_CONFIG_DIR` (the file then becomes +`$DWS_CONFIG_DIR/logs/audit.jsonl`). A packaged edition may relocate +``; if home cannot be resolved, dws falls back to a `.dws` directory +next to the executable. + +### Format: JSONL (one JSON object per line) + +The file is **JSONL** — one event per line. This is the mainstream format for +structured, append-only logs: it never rewrites existing lines, stays +human-inspectable, and is ingested natively by every log pipeline (`jq`, +fluentd/Vector, Loki, Splunk, Alibaba Cloud SLS…). Read it directly: + +```bash +tail -n 1 ~/.dws/logs/audit.jsonl | jq . # last event, pretty-printed +jq 'select(.flow.direction=="local-export")' ~/.dws/logs/audit.jsonl # everything exported to local disk +jq 'select(.outcome=="error") | {ts,command,subcommand,err_class}' ~/.dws/logs/audit.jsonl +``` ## Fields @@ -101,7 +137,12 @@ tail -n1 ~/.dws/logs/audit.jsonl | jq . # path varies with DWS_CONFIG_DIR / ed ## Where the log lives / can it be centrally collected -- **Default: on each user's own machine**, `/logs/audit.jsonl`; with +> Reminder: central collection here is still **best-effort** — the user controls +> the client and can disable or bypass it. For an **authoritative, mandatory** +> record, audit on the **MCP gateway** (every call passes through it); this +> client-side forwarding is for convenience of investigation, not enforcement. + +- **Default: on each user's own machine**, `/logs/audit.jsonl`; with forwarding off, nothing leaves the machine. - **For central collection**: set `DWS_AUDIT_FORWARD_URL` to a collection endpoint, and each user POSTs one record per invocation. @@ -210,8 +251,10 @@ unlock `host_agent` / `agent_code` into the audit, flagged as fully trusted. - Natural-language input can only be provided by the orchestrating agent and is flagged `provenance=agent` in the audit record, indicating it is not CLI-measured and cannot be verified. -- Rich audit data is the **enterprise's compliance asset** and should go into - the enterprise's own sink; dws provides no vendor-default collection endpoint. +- If forwarded, this trail can carry sensitive operational detail and should go + to a collector the **deployer** owns; dws provides no vendor-default endpoint. + (Authoritative compliance audit is a gateway-side concern — see "Scope and + limits" at the top.) - Caller-self-asserted fields such as `host_agent` / `channel` / `agent_code` are **not recorded** before gateway signing, to keep forgeable data out of the audit. From 6e8ea77dfc0d60138c1c5440d2b3efa236dab2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:57:33 +0800 Subject: [PATCH 8/8] feat(audit): rotate the local audit file by date with retention The single audit.jsonl grew unbounded. Switch to one file per calendar day (audit-YYYY-MM-DD.jsonl) via a stdlib DateRotatingWriter that rolls at midnight and prunes files older than DWS_AUDIT_MAX_AGE_DAYS (default 30; 0 = keep all). Works for both the short-lived CLI and long-running stdio mode. Docs updated; rotation + pruning covered by tests. --- docs/audit.md | 43 ++++++---- internal/app/audit_runtime.go | 20 ++--- internal/audit/collect.go | 23 ++++++ internal/audit/rotate.go | 142 ++++++++++++++++++++++++++++++++++ internal/audit/rotate_test.go | 115 +++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 27 deletions(-) create mode 100644 internal/audit/rotate.go create mode 100644 internal/audit/rotate_test.go diff --git a/docs/audit.md b/docs/audit.md index e0016962..89b3d7e8 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -36,34 +36,43 @@ The design separates "producing an event" from "delivering an event": | `DWS_AUDIT_REDACT_SALT` | Salt for the `hashed` tier | `tenant-salt` | | `DWS_AUDIT_DEVICE_FINGERPRINT` | Collect `device_id` / `sn_no` (PIPL personal information; off by default) | `true` | | `DWS_AUDIT_NL_INTENT` | Natural-language input injected by the orchestrating agent | `export last week's minutes` | +| `DWS_AUDIT_MAX_AGE_DAYS` | Days of dated audit files to keep (0 = keep all) | `30` | ## Where the file lives -The trail is written to `/logs/audit.jsonl`, and `` -defaults to `~/.dws`: +The trail is written to `/logs/`, one file **per calendar day** named +`audit-YYYY-MM-DD.jsonl`. `` defaults to `~/.dws`: -| OS | Default path | +| OS | Default path (today's file) | |---|---| -| macOS | `/Users//.dws/logs/audit.jsonl` | -| Linux | `/home//.dws/logs/audit.jsonl` | -| Windows | `C:\Users\\.dws\logs\audit.jsonl` | +| macOS | `/Users//.dws/logs/audit-2026-06-04.jsonl` | +| Linux | `/home//.dws/logs/audit-2026-06-04.jsonl` | +| Windows | `C:\Users\\.dws\logs\audit-2026-06-04.jsonl` | -Override the base directory with `DWS_CONFIG_DIR` (the file then becomes -`$DWS_CONFIG_DIR/logs/audit.jsonl`). A packaged edition may relocate -``; if home cannot be resolved, dws falls back to a `.dws` directory -next to the executable. +Override the base directory with `DWS_CONFIG_DIR` (files then live under +`$DWS_CONFIG_DIR/logs/`). A packaged edition may relocate ``; if home +cannot be resolved, dws falls back to a `.dws` directory next to the executable. + +### Rotation & retention + +The file **rotates by date** so it never grows unbounded: each day's events go to +that day's `audit-YYYY-MM-DD.jsonl`, and files older than the retention window are +pruned automatically. The window is `DWS_AUDIT_MAX_AGE_DAYS` (default **30**; set +`0` to keep everything). Long-running modes roll at midnight; the short-lived CLI +simply writes today's file each invocation. ### Format: JSONL (one JSON object per line) The file is **JSONL** — one event per line. This is the mainstream format for structured, append-only logs: it never rewrites existing lines, stays human-inspectable, and is ingested natively by every log pipeline (`jq`, -fluentd/Vector, Loki, Splunk, Alibaba Cloud SLS…). Read it directly: +fluentd/Vector, Loki, Splunk, Alibaba Cloud SLS…). Read it directly (glob across +days): ```bash -tail -n 1 ~/.dws/logs/audit.jsonl | jq . # last event, pretty-printed -jq 'select(.flow.direction=="local-export")' ~/.dws/logs/audit.jsonl # everything exported to local disk -jq 'select(.outcome=="error") | {ts,command,subcommand,err_class}' ~/.dws/logs/audit.jsonl +tail -n 1 ~/.dws/logs/audit-*.jsonl | jq . # latest events +jq 'select(.flow.direction=="local-export")' ~/.dws/logs/audit-*.jsonl # everything exported to local disk +jq 'select(.outcome=="error") | {ts,command,subcommand,err_class}' ~/.dws/logs/audit-*.jsonl ``` ## Fields @@ -132,7 +141,7 @@ Verify: ```bash dws minutes export --minute-id m-77 --output ~/Desktop/q2.md --format json -tail -n1 ~/.dws/logs/audit.jsonl | jq . # path varies with DWS_CONFIG_DIR / edition +tail -n1 ~/.dws/logs/audit-*.jsonl | jq . # path varies with DWS_CONFIG_DIR / edition ``` ## Where the log lives / can it be centrally collected @@ -142,8 +151,8 @@ tail -n1 ~/.dws/logs/audit.jsonl | jq . # path varies with DWS_CONFIG_DIR / ed > record, audit on the **MCP gateway** (every call passes through it); this > client-side forwarding is for convenience of investigation, not enforcement. -- **Default: on each user's own machine**, `/logs/audit.jsonl`; with - forwarding off, nothing leaves the machine. +- **Default: on each user's own machine**, `/logs/audit-YYYY-MM-DD.jsonl`; + with forwarding off, nothing leaves the machine. - **For central collection**: set `DWS_AUDIT_FORWARD_URL` to a collection endpoint, and each user POSTs one record per invocation. - **Enterprise compliance**: point the endpoint at the **enterprise's own diff --git a/internal/app/audit_runtime.go b/internal/app/audit_runtime.go index 09274f3d..5e27224e 100644 --- a/internal/app/audit_runtime.go +++ b/internal/app/audit_runtime.go @@ -27,27 +27,27 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) -const auditFileName = "audit.jsonl" +// auditFilePrefix is the base name of the dated audit files +// (`-YYYY-MM-DD.jsonl`). +const auditFilePrefix = "audit" // setupAuditSink builds the active audit sink. When auditing is disabled // (DWS_AUDIT_ENABLED unset) it returns audit.NopSink so emit is always safe. // The local file lives next to the diagnostic log but is a SEPARATE file — // audit and debug logs must not be conflated. The forwarder (if configured) // targets the organization's own endpoint, never a vendor default. +// +// The local file is date-rotated (audit-YYYY-MM-DD.jsonl) so it never grows +// unbounded; files older than DWS_AUDIT_MAX_AGE_DAYS are pruned. The writer +// opens lazily on first write, so a read-only home simply yields a write error +// per event while a configured forwarder still works. func setupAuditSink() audit.Sink { if !audit.Enabled() { return audit.NopSink{} } logDir := filepath.Join(defaultConfigDir(), "logs") - _ = os.MkdirAll(logDir, config.DirPerm) - f, err := os.OpenFile(filepath.Join(logDir, auditFileName), - os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm) - if err != nil { - // File unavailable (e.g. read-only home): still honor a configured - // forwarder by passing a nil writer — BuildSink degrades gracefully. - return audit.BuildSink(nil) - } - return audit.BuildSink(f) + w := audit.NewDateRotatingWriter(logDir, auditFilePrefix, audit.MaxAgeDays(), config.FilePerm, config.DirPerm) + return audit.BuildSink(w) } // deviceOnce memoizes the device fingerprint: it is process-stable and the diff --git a/internal/audit/collect.go b/internal/audit/collect.go index 0e0621e0..4f7ccce0 100644 --- a/internal/audit/collect.go +++ b/internal/audit/collect.go @@ -15,6 +15,7 @@ package audit import ( "os" + "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta" @@ -44,8 +45,15 @@ const ( // EnvNLIntent carries the user's natural-language request, injected by the // orchestrating agent/skill. The CLI cannot verify it (provenance=agent). EnvNLIntent = "DWS_AUDIT_NL_INTENT" + // EnvMaxAgeDays sets how many days of rotated audit files to keep. The file + // rotates per calendar day (audit-YYYY-MM-DD.jsonl); files older than this + // are pruned. 0 (or negative) keeps everything. + EnvMaxAgeDays = "DWS_AUDIT_MAX_AGE_DAYS" ) +// DefaultMaxAgeDays is the retention applied when EnvMaxAgeDays is unset. +const DefaultMaxAgeDays = 30 + func init() { for _, it := range []configmeta.ConfigItem{ {Name: EnvEnabled, Category: configmeta.CategorySecurity, Description: "Enable the local audit log (JSONL)", Example: "true"}, @@ -55,6 +63,7 @@ func init() { {Name: EnvRedactSalt, Category: configmeta.CategorySecurity, Description: "Salt for the hashed tier", Example: "tenant-salt"}, {Name: EnvDeviceFingerprint, Category: configmeta.CategorySecurity, Description: "Collect device_id/sn_no (PIPL personal information; off by default)", Example: "true"}, {Name: EnvNLIntent, Category: configmeta.CategorySecurity, Description: "Natural-language input injected by the orchestrating agent (provenance=agent)", Example: "export last week's minutes"}, + {Name: EnvMaxAgeDays, Category: configmeta.CategorySecurity, Description: "Days of dated audit files to keep (0 = keep all)", DefaultValue: "30", Example: "30"}, } { configmeta.Register(it) } @@ -75,6 +84,20 @@ func NLIntent() string { return os.Getenv(EnvNLIntent) } +// MaxAgeDays returns the audit-file retention in days. Unset/invalid falls back +// to DefaultMaxAgeDays; an explicit 0 (or negative) means keep everything. +func MaxAgeDays() int { + v := strings.TrimSpace(os.Getenv(EnvMaxAgeDays)) + if v == "" { + return DefaultMaxAgeDays + } + n, err := strconv.Atoi(v) + if err != nil { + return DefaultMaxAgeDays + } + return n +} + // redactLevelFromEnv maps the env string to a RedactLevel (default none). func redactLevelFromEnv() RedactLevel { switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvForwardRedact))) { diff --git a/internal/audit/rotate.go b/internal/audit/rotate.go new file mode 100644 index 00000000..82137d0f --- /dev/null +++ b/internal/audit/rotate.go @@ -0,0 +1,142 @@ +// 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. + +package audit + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// dayLayout is the date stamp embedded in each rotated file name. +const dayLayout = "2006-01-02" + +// DateRotatingWriter appends audit lines to a per-day file +// (`/-YYYY-MM-DD.jsonl`), rolling to a new file when the local +// calendar day changes, and pruning files older than maxAgeDays. It is safe for +// concurrent use. +// +// Why date-based: audit/access trails are conventionally sliced by day so a +// single file never grows unbounded and retention is a simple per-file delete. +// For the common short-lived CLI process this just opens today's file; a +// long-running mode (e.g. the stdio server) rolls at midnight because the day is +// re-checked on every write. +type DateRotatingWriter struct { + dir string + prefix string + maxAgeDays int // <= 0 keeps everything + perm os.FileMode + dirPerm os.FileMode + now func() time.Time // injectable for tests + + mu sync.Mutex + curDay string + f *os.File +} + +// NewDateRotatingWriter builds a writer rooted at dir. Files are named +// "-YYYY-MM-DD.jsonl"; files older than maxAgeDays are pruned on each +// roll (maxAgeDays <= 0 disables pruning). +func NewDateRotatingWriter(dir, prefix string, maxAgeDays int, perm, dirPerm os.FileMode) *DateRotatingWriter { + return &DateRotatingWriter{ + dir: dir, + prefix: prefix, + maxAgeDays: maxAgeDays, + perm: perm, + dirPerm: dirPerm, + now: time.Now, + } +} + +// Write appends p to today's file, rolling first if the day changed. +func (w *DateRotatingWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + day := w.now().Format(dayLayout) + if w.f == nil || day != w.curDay { + if err := w.openDay(day); err != nil { + return 0, err + } + } + return w.f.Write(p) +} + +// Close closes the current file (safe to call multiple times). +func (w *DateRotatingWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.f == nil { + return nil + } + err := w.f.Close() + w.f = nil + return err +} + +// openDay closes any current file and opens the file for `day`, then prunes old +// files. Caller holds the lock. +func (w *DateRotatingWriter) openDay(day string) error { + if w.f != nil { + _ = w.f.Close() + w.f = nil + } + if err := os.MkdirAll(w.dir, w.dirPerm); err != nil { + return err + } + name := filepath.Join(w.dir, fmt.Sprintf("%s-%s.jsonl", w.prefix, day)) + f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_APPEND, w.perm) + if err != nil { + return err + } + w.f = f + w.curDay = day + w.prune(day) // best-effort; failures must not block auditing + return nil +} + +// prune removes "-*.jsonl" files whose embedded date is older than +// maxAgeDays relative to `today`. +func (w *DateRotatingWriter) prune(today string) { + if w.maxAgeDays <= 0 { + return + } + t, err := time.Parse(dayLayout, today) + if err != nil { + return + } + cutoff := t.AddDate(0, 0, -w.maxAgeDays) + + entries, err := os.ReadDir(w.dir) + if err != nil { + return + } + pfx := w.prefix + "-" + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasPrefix(n, pfx) || !strings.HasSuffix(n, ".jsonl") { + continue + } + datePart := strings.TrimSuffix(strings.TrimPrefix(n, pfx), ".jsonl") + d, err := time.Parse(dayLayout, datePart) + if err != nil { + continue // not a dated file we manage + } + if d.Before(cutoff) { + _ = os.Remove(filepath.Join(w.dir, n)) + } + } +} diff --git a/internal/audit/rotate_test.go b/internal/audit/rotate_test.go new file mode 100644 index 00000000..0465b621 --- /dev/null +++ b/internal/audit/rotate_test.go @@ -0,0 +1,115 @@ +// 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. + +package audit + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDateRotatingWriter_RollsAndPrunes(t *testing.T) { + dir := t.TempDir() + w := NewDateRotatingWriter(dir, "audit", 7, 0o600, 0o700) + + // Drive a fake clock so the test is deterministic. + day := time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC) + w.now = func() time.Time { return day } + + if _, err := w.Write([]byte("d1-a\n")); err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("d1-b\n")); err != nil { + t.Fatal(err) + } + + // Next calendar day -> a new file. + day = day.AddDate(0, 0, 1) + if _, err := w.Write([]byte("d2-a\n")); err != nil { + t.Fatal(err) + } + _ = w.Close() + + f1 := filepath.Join(dir, "audit-2026-06-04.jsonl") + f2 := filepath.Join(dir, "audit-2026-06-05.jsonl") + if got := readFile(t, f1); got != "d1-a\nd1-b\n" { + t.Errorf("day1 file = %q", got) + } + if got := readFile(t, f2); got != "d2-a\n" { + t.Errorf("day2 file = %q", got) + } +} + +func TestDateRotatingWriter_PrunesOldFiles(t *testing.T) { + dir := t.TempDir() + + // Seed an old file (well beyond retention) and an in-window file. + old := filepath.Join(dir, "audit-2026-01-01.jsonl") + recent := filepath.Join(dir, "audit-2026-06-03.jsonl") + unrelated := filepath.Join(dir, "audit-notes.txt") // must be left alone + for _, f := range []string{old, recent, unrelated} { + if err := os.WriteFile(f, []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + w := NewDateRotatingWriter(dir, "audit", 7, 0o600, 0o700) + w.now = func() time.Time { return time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC) } + + // Writing triggers openDay -> prune. + if _, err := w.Write([]byte("today\n")); err != nil { + t.Fatal(err) + } + _ = w.Close() + + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Errorf("old file should have been pruned, stat err=%v", err) + } + if _, err := os.Stat(recent); err != nil { + t.Errorf("in-window file must be kept: %v", err) + } + if _, err := os.Stat(unrelated); err != nil { + t.Errorf("non-dated file must not be touched: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "audit-2026-06-04.jsonl")); err != nil { + t.Errorf("today's file must exist: %v", err) + } +} + +func TestDateRotatingWriter_MaxAgeZeroKeepsAll(t *testing.T) { + dir := t.TempDir() + old := filepath.Join(dir, "audit-2020-01-01.jsonl") + if err := os.WriteFile(old, []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + w := NewDateRotatingWriter(dir, "audit", 0, 0o600, 0o700) // 0 = keep forever + w.now = func() time.Time { return time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC) } + if _, err := w.Write([]byte("today\n")); err != nil { + t.Fatal(err) + } + _ = w.Close() + if _, err := os.Stat(old); err != nil { + t.Errorf("with maxAge=0 nothing should be pruned: %v", err) + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + return string(b) +}