diff --git a/docs/audit.md b/docs/audit.md new file mode 100644 index 00000000..89b3d7e8 --- /dev/null +++ b/docs/audit.md @@ -0,0 +1,269 @@ +# Operation Audit (local diagnostic trail) + +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. + +> **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. + +The design separates "producing an event" from "delivering an event": + +- **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 + +| Environment variable | Description | Example | +|---|---|---| +| `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` | +| `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/`, one file **per calendar day** named +`audit-YYYY-MM-DD.jsonl`. `` defaults to `~/.dws`: + +| OS | Default path (today's file) | +|---|---| +| 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` (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 (glob across +days): + +```bash +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 + +Fields are split by **trustworthiness**; only trustworthy fields are recorded: + +**① Trustworthy fields (recorded)** — token-verified / dws-managed / dws-measured, +not forgeable by the caller per call: + +| Field | Meaning | Source | Why trustworthy | +|---|---|---|---| +| `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` | 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 +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 +# 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 . # path varies with DWS_CONFIG_DIR / edition +``` + +## Where the log lives / can it be centrally collected + +> 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-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 + 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 # matches DWS_AUDIT_FORWARD_TOKEN +X-Dws-Audit-Schema: 2 +Body: one audit event as JSON +2xx means success +``` + +Any HTTP service can receive it; no special component required. + +### Wiring up Alibaba Cloud SLS (recommended for production) + +SLS (Log Service) provides ingestion / storage / search / dashboards / retention +out of the box, and is the standard choice for landing audit data: + +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. + +Then "who / when / did what / succeeded or not / data direction" can be queried +and dashboarded directly in the SLS console. + +## TODO + +- **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 | +|---|---|---| +| 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. +- 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. diff --git a/internal/app/audit_runtime.go b/internal/app/audit_runtime.go new file mode 100644 index 00000000..5e27224e --- /dev/null +++ b/internal/app/audit_runtime.go @@ -0,0 +1,275 @@ +// 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" +) + +// 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") + 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 +// 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 — 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 + } + // 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() + + // 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..f3de3203 --- /dev/null +++ b/internal/app/audit_runtime_test.go @@ -0,0 +1,163 @@ +// 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: "Export minutes", + Description: "Export meeting minutes to a local file", + 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, "export last week's strategy review minutes to the desktop") + t.Setenv(envDWSChannel, "openclaw") // which agent/channel is driving dws + + 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 Strategy Review", + "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, "Export meeting minutes to a local file"}, + "target.id": {local.Target.ID, "m-77"}, + "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"}, + } + 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") + } + // 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) + } + // 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 { + 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..4f7ccce0 --- /dev/null +++ b/internal/audit/collect.go @@ -0,0 +1,151 @@ +// 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" + "strconv" + "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" + // 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"}, + {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"}, + {Name: EnvMaxAgeDays, Category: configmeta.CategorySecurity, Description: "Days of dated audit files to keep (0 = keep all)", DefaultValue: "30", Example: "30"}, + } { + 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) +} + +// 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))) { + 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..6d5362d7 --- /dev/null +++ b/internal/audit/event.go @@ -0,0 +1,256 @@ +// 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. +// 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 + +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"` +} + +// Client identifies the dws install, version, and the integration channel. +// +// 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 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 +// 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"` + Client Client `json:"client"` + Device Device `json:"device"` + Intent Intent `json:"intent"` + + 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"` + 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.Client.AgentID = hashed(cp.Client.AgentID, 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}, + 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, + 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..8331033b --- /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: "Zhang San"} + e.Org = Org{CorpID: "corp-001", Name: "Example Corp"} + e.Device = Device{DeviceID: "dev-9", SerialNo: "C02SN12345", OS: "darwin"} + e.Intent.NLInput = "export last week's minutes to the desktop" + e.Module = "minutes" + e.Command = "minutes" + e.Subcommand = "export" + 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 + 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, "minutes") { + 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(), "headcount") { + 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/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) +} 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 }