From 48913c7fa34ec109a205958afb55018a2ea01ef4 Mon Sep 17 00:00:00 2001 From: Paperlz <2305927848@qq.com> Date: Sat, 8 Aug 2026 11:32:41 +0800 Subject: [PATCH] feat: include sanitized tool arguments in audit records --- audit/audit.go | 43 ++++++++++------ audit/sanitize.go | 125 ++++++++++++++++++++++++++++++++++++++++++++++ model/mcp.go | 1 + 3 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 audit/sanitize.go diff --git a/audit/audit.go b/audit/audit.go index c2574951a..b56db2fdb 100644 --- a/audit/audit.go +++ b/audit/audit.go @@ -32,20 +32,24 @@ import ( "time" ) -const timeFormat = "2006-01-02T15:04:05.000Z07:00" +const ( + timeFormat = "2006-01-02T15:04:05.000Z07:00" + maxArgumentsBytes = 64 * 1024 +) // Event is one audit line. Fields are omitted when empty so the format stays // small and forward-compatible: a reader ignores fields it does not know. type Event struct { - Timestamp string `json:"timestamp"` - SessionID string `json:"sessionId,omitempty"` - Type string `json:"type"` - Tool string `json:"tool,omitempty"` - Server string `json:"server,omitempty"` - Model string `json:"model,omitempty"` - ArgumentsLength int `json:"argumentsLength,omitempty"` - Outcome string `json:"outcome,omitempty"` - DurationMs int64 `json:"durationMs,omitempty"` + Timestamp string `json:"timestamp"` + SessionID string `json:"sessionId,omitempty"` + Type string `json:"type"` + Tool string `json:"tool,omitempty"` + Server string `json:"server,omitempty"` + Model string `json:"model,omitempty"` + Arguments map[string]interface{} `json:"arguments,omitempty"` + ArgumentsLength int `json:"argumentsLength,omitempty"` + Outcome string `json:"outcome,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` // Effect, Reason and Rule carry the guard verdict once the guard is wired // into the tool path; empty until then. Effect string `json:"effect,omitempty"` @@ -53,9 +57,8 @@ type Event struct { Rule string `json:"rule,omitempty"` } -// queueSize bounds how many events may be waiting to be written. It is generous -// because each event is tiny; if it is ever exceeded, events are dropped rather -// than allowed to block a tool call. +// queueSize bounds how many events may be waiting to be written. If it is ever +// exceeded, events are dropped rather than allowed to block a tool call. const queueSize = 4096 // queued is one unit of work for the background writer. A marker carries only @@ -81,6 +84,15 @@ func Record(event Event) { return } event.Timestamp = time.Now().UTC().Format(timeFormat) + event.Arguments = sanitizeToolInput(event.Tool, event.Arguments) + if encoded, err := json.Marshal(event.Arguments); err != nil { + return + } else if len(encoded) > maxArgumentsBytes { + event.Arguments = map[string]interface{}{ + "truncated": true, + "originalBytes": len(encoded), + } + } line, err := json.Marshal(event) if err != nil { @@ -117,11 +129,14 @@ func writeLine(session string, line []byte) { if err := os.MkdirAll(dir, 0o755); err != nil { return } - file, err := os.OpenFile(filepath.Join(dir, session+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + file, err := os.OpenFile(filepath.Join(dir, session+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return } defer file.Close() + if err := file.Chmod(0o600); err != nil { + return + } _, _ = file.Write(line) } diff --git a/audit/sanitize.go b/audit/sanitize.go new file mode 100644 index 000000000..bec1f8418 --- /dev/null +++ b/audit/sanitize.go @@ -0,0 +1,125 @@ +// Copyright 2025 The OpenAgent Authors. All Rights Reserved. +// +// 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 ( + "path" + "regexp" + "strings" +) + +var ( + credentialPattern = regexp.MustCompile(`(?i)\b(?:sk-(?:ant-|proj-)?[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9a-z_-]{30,}|xox[baprs]-[0-9a-z-]{12,}|eyJ[a-z0-9_-]{10,}\.[a-z0-9_-]{10,}\.[a-z0-9_-]{10,})\b`) + bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)[a-z0-9._~+/=-]{12,}`) + privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [^-\n]*PRIVATE KEY-----.*?-----END [^-\n]*PRIVATE KEY-----`) +) + +func sanitizeToolInput(toolName string, input map[string]interface{}) map[string]interface{} { + if input == nil { + return nil + } + sanitized := sanitizeMap(input) + if !isSensitiveWrite(toolName) || !hasSensitivePath(input) { + return sanitized + } + for key := range sanitized { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + switch normalized { + case "content", "oldstring", "newstring": + sanitized[key] = "[REDACTED: sensitive file content]" + } + } + return sanitized +} + +func sanitizeMap(input map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(input)) + for key, value := range input { + result[key] = sanitizeValue(key, value) + } + return result +} + +func sanitizeValue(key string, value interface{}) interface{} { + if sensitiveKey(key) { + return "[REDACTED]" + } + switch typed := value.(type) { + case map[string]interface{}: + return sanitizeMap(typed) + case []interface{}: + result := make([]interface{}, len(typed)) + for i, child := range typed { + result[i] = sanitizeValue("", child) + } + return result + case string: + return sanitizeString(typed) + default: + return value + } +} + +func sensitiveKey(key string) bool { + normalized := strings.ToLower(key) + normalized = strings.NewReplacer("_", "", "-", "", ".", "").Replace(normalized) + if normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "idtoken" { + return true + } + for _, marker := range []string{"secret", "token", "password", "passwd", "credential", "privatekey", "apikey", "accesskey", "authorization", "cookie"} { + if strings.Contains(normalized, marker) { + return true + } + } + return false +} + +func sanitizeString(value string) string { + value = privateKeyPattern.ReplaceAllString(value, "[REDACTED PRIVATE KEY]") + value = bearerPattern.ReplaceAllString(value, "${1}[REDACTED]") + return credentialPattern.ReplaceAllString(value, "[REDACTED]") +} + +func isSensitiveWrite(toolName string) bool { + normalized := strings.ToLower(toolName) + return normalized == "write" || normalized == "edit" || normalized == "write_file" || normalized == "edit_file" || + strings.HasSuffix(normalized, "__write_file") || strings.HasSuffix(normalized, "__edit_file") +} + +func hasSensitivePath(input map[string]interface{}) bool { + filePath, _ := input["file_path"].(string) + if filePath == "" { + filePath, _ = input["path"].(string) + } + return isSensitivePath(filePath) +} + +func isSensitivePath(filePath string) bool { + if filePath == "" { + return false + } + normalized := strings.ToLower(strings.ReplaceAll(filePath, `\`, "/")) + base := path.Base(normalized) + if strings.HasPrefix(base, ".env") && base != ".env.example" && base != ".env.sample" && base != ".env.template" { + return true + } + if strings.HasPrefix(normalized, ".ssh/") || strings.Contains(normalized, "/.ssh/") || normalized == ".aws/credentials" || strings.HasSuffix(normalized, "/.aws/credentials") { + return true + } + if base == ".npmrc" || base == ".pypirc" || base == "credentials" || base == "id_rsa" || base == "id_ed25519" { + return true + } + return strings.HasSuffix(base, ".pem") || strings.HasSuffix(base, ".key") +} diff --git a/model/mcp.go b/model/mcp.go index 5894a2cad..ce7fc4a19 100644 --- a/model/mcp.go +++ b/model/mcp.go @@ -317,6 +317,7 @@ func callMcpTool(toolCall openai.ToolCall, serverName, toolName, sessionID strin auditEvent.Outcome = "failure" return nil, nil, false, fmt.Errorf(i18n.Translate(lang, "model:failed to parse tool arguments: %v"), err) } + auditEvent.Arguments = arguments // Send tool-start event immediately so the frontend can show the tool call before execution toolStartData := ToolCall{