diff --git a/internal/auth/header.go b/internal/auth/header.go index 8f37136..ee90657 100644 --- a/internal/auth/header.go +++ b/internal/auth/header.go @@ -5,12 +5,33 @@ // which requires Authorization headers to contain the API key directly // without any scheme prefix (e.g., NOT "Bearer "). // -// Example usage: +// The package provides both full parsing with error handling (ParseAuthHeader) +// and convenience methods for specific use cases (ExtractAgentID, ValidateAPIKey). // +// Usage Guidelines: +// +// - Use ParseAuthHeader() for complete authentication with error handling: +// Returns both API key and agent ID, with errors for missing/invalid headers. +// +// - Use ExtractAgentID() when you only need the agent ID and want automatic +// fallback to "default" instead of error handling. +// +// - Use ValidateAPIKey() to check if a provided key matches the expected value. +// Automatically handles the case where authentication is disabled (no expected key). +// +// Example: +// +// // Full authentication // apiKey, agentID, err := auth.ParseAuthHeader(r.Header.Get("Authorization")) // if err != nil { -// // Handle error +// return err // } +// if !auth.ValidateAPIKey(apiKey, expectedKey) { +// return errors.New("invalid API key") +// } +// +// // Extract agent ID only (for context, not authentication) +// agentID := auth.ExtractAgentID(r.Header.Get("Authorization")) package auth import ( @@ -18,6 +39,7 @@ import ( "strings" "github.com/githubnext/gh-aw-mcpg/internal/logger" + "github.com/githubnext/gh-aw-mcpg/internal/logger/sanitize" ) var log = logger.New("auth:header") @@ -29,18 +51,6 @@ var ( ErrInvalidAuthHeader = errors.New("invalid Authorization header format") ) -// sanitizeForLogging returns a sanitized version of the input string for safe logging. -// It shows only the first 4 characters followed by "..." to prevent exposing sensitive data. -// For strings with 4 or fewer characters, it returns only "...". -func sanitizeForLogging(input string) string { - if len(input) > 4 { - return input[:4] + "..." - } else if len(input) > 0 { - return "..." - } - return "" -} - // ParseAuthHeader parses the Authorization header and extracts the API key and agent ID. // Per MCP spec 7.1, the Authorization header should contain the API key directly // without any Bearer prefix or other scheme. @@ -54,7 +64,7 @@ func sanitizeForLogging(input string) string { // - agentID: The extracted agent/session identifier // - error: ErrMissingAuthHeader if header is empty, nil otherwise func ParseAuthHeader(authHeader string) (apiKey string, agentID string, error error) { - log.Printf("Parsing auth header: sanitized=%s, length=%d", sanitizeForLogging(authHeader), len(authHeader)) + log.Printf("Parsing auth header: sanitized=%s, length=%d", sanitize.TruncateSecret(authHeader), len(authHeader)) if authHeader == "" { log.Print("Auth header missing, returning error") @@ -96,3 +106,23 @@ func ValidateAPIKey(provided, expected string) bool { log.Printf("API key validation result: matches=%t", matches) return matches } + +// ExtractAgentID extracts the agent ID from an Authorization header. +// This is a convenience wrapper around ParseAuthHeader that only returns the agent ID. +// Returns "default" if the header is empty or cannot be parsed. +// +// This function is intended for use cases where you only need the agent ID +// and don't need full error handling. For complete authentication handling, +// use ParseAuthHeader instead. +func ExtractAgentID(authHeader string) string { + if authHeader == "" { + return "default" + } + + _, agentID, err := ParseAuthHeader(authHeader) + if err != nil { + return "default" + } + + return agentID +} diff --git a/internal/auth/header_test.go b/internal/auth/header_test.go index d4beff6..7eb4b61 100644 --- a/internal/auth/header_test.go +++ b/internal/auth/header_test.go @@ -5,9 +5,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/githubnext/gh-aw-mcpg/internal/logger/sanitize" ) -func TestSanitizeForLogging(t *testing.T) { +func TestTruncateSecret(t *testing.T) { tests := []struct { name string input string @@ -62,7 +64,7 @@ func TestSanitizeForLogging(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := sanitizeForLogging(tt.input) + got := sanitize.TruncateSecret(tt.input) assert.Equal(t, tt.want, got) }) } @@ -235,3 +237,49 @@ func TestValidateAPIKey(t *testing.T) { }) } } + +func TestExtractAgentID(t *testing.T) { + tests := []struct { + name string + authHeader string + want string + }{ + { + name: "Empty header returns default", + authHeader: "", + want: "default", + }, + { + name: "Plain API key", + authHeader: "my-api-key", + want: "my-api-key", + }, + { + name: "Bearer token", + authHeader: "Bearer my-token-123", + want: "my-token-123", + }, + { + name: "Agent format", + authHeader: "Agent agent-abc", + want: "agent-abc", + }, + { + name: "Long API key", + authHeader: "my-super-long-api-key-with-many-characters", + want: "my-super-long-api-key-with-many-characters", + }, + { + name: "API key with special characters", + authHeader: "key!@#$%^&*()", + want: "key!@#$%^&*()", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractAgentID(tt.authHeader) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/guard/context.go b/internal/guard/context.go index 497daad..349ced8 100644 --- a/internal/guard/context.go +++ b/internal/guard/context.go @@ -1,8 +1,31 @@ +// Package guard provides security context management and guard registry for the MCP Gateway. +// +// This package is responsible for managing security labels (DIFC - Decentralized Information +// Flow Control) and extracting agent identifiers from request contexts. +// +// Relationship with internal/auth: +// +// - internal/auth: Primary authentication logic (header parsing, validation) +// - internal/guard: Security context management (agent ID tracking, guard registry) +// +// For new code, prefer using internal/auth for authentication-related operations. +// The guard package's ExtractAgentIDFromAuthHeader() is deprecated and delegates +// to auth.ExtractAgentID() for centralized authentication logic. +// +// Example: +// +// // Store agent ID in context (use auth.ExtractAgentID for parsing) +// agentID := auth.ExtractAgentID(authHeader) +// ctx = guard.SetAgentIDInContext(ctx, agentID) +// +// // Retrieve agent ID from context +// agentID := guard.GetAgentIDFromContext(ctx) // Returns "default" if not found package guard import ( "context" - "strings" + + "github.com/githubnext/gh-aw-mcpg/internal/auth" ) // ContextKey is used for storing values in context @@ -30,35 +53,14 @@ func SetAgentIDInContext(ctx context.Context, agentID string) context.Context { return context.WithValue(ctx, AgentIDContextKey, agentID) } -// ExtractAgentIDFromAuthHeader extracts agent ID from Authorization header +// ExtractAgentIDFromAuthHeader extracts agent ID from Authorization header. // -// Note: For MCP spec 7.1 compliant parsing, see internal/auth.ParseAuthHeader() -// which provides centralized authentication header parsing. +// Deprecated: Use auth.ExtractAgentID() instead for centralized authentication parsing. +// This function is maintained for backward compatibility but delegates to the auth package. // -// This function supports formats: -// - "Bearer " - uses token as agent ID -// - "Agent " - uses agent-id directly -// - Any other format - uses the entire value as agent ID +// For MCP spec 7.1 compliant parsing with full error handling, use auth.ParseAuthHeader(). func ExtractAgentIDFromAuthHeader(authHeader string) string { - if authHeader == "" { - return "default" - } - - // Handle "Bearer " format - if strings.HasPrefix(authHeader, "Bearer ") { - token := strings.TrimPrefix(authHeader, "Bearer ") - // Use the token as the agent ID - // In production, you might want to validate/decode the token - return token - } - - // Handle "Agent " format - if strings.HasPrefix(authHeader, "Agent ") { - return strings.TrimPrefix(authHeader, "Agent ") - } - - // Use the entire header value as agent ID - return authHeader + return auth.ExtractAgentID(authHeader) } // GetRequestStateFromContext retrieves guard request state from context diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index ae53dc2..8dd1df6 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -10,32 +10,13 @@ import ( "github.com/githubnext/gh-aw-mcpg/internal/config" "github.com/githubnext/gh-aw-mcpg/internal/logger" + "github.com/githubnext/gh-aw-mcpg/internal/logger/sanitize" "github.com/githubnext/gh-aw-mcpg/internal/mcp" "github.com/githubnext/gh-aw-mcpg/internal/tty" ) var logLauncher = logger.New("launcher:launcher") -// sanitizeEnvForLogging returns a sanitized version of environment variables -// where each value is truncated to first 4 characters followed by "..." -// This prevents sensitive information like API keys from being logged in full. -func sanitizeEnvForLogging(env map[string]string) map[string]string { - if env == nil { - return nil - } - sanitized := make(map[string]string, len(env)) - for key, value := range env { - if len(value) <= 4 { - // If value is 4 chars or less, just use "..." - sanitized[key] = "..." - } else { - // Show first 4 characters and append "..." - sanitized[key] = value[:4] + "..." - } - } - return sanitized -} - // Launcher manages backend MCP server connections type Launcher struct { ctx context.Context @@ -161,7 +142,7 @@ func GetOrLaunch(l *Launcher, serverID string) (*mcp.Connection, error) { } if len(serverCfg.Env) > 0 { - log.Printf("[LAUNCHER] Additional env vars: %v", sanitizeEnvForLogging(serverCfg.Env)) + log.Printf("[LAUNCHER] Additional env vars: %v", sanitize.TruncateSecretMap(serverCfg.Env)) } // Create connection @@ -174,7 +155,7 @@ func GetOrLaunch(l *Launcher, serverID string) (*mcp.Connection, error) { log.Printf("[LAUNCHER] Debug Information:") log.Printf("[LAUNCHER] - Command: %s", serverCfg.Command) log.Printf("[LAUNCHER] - Args: %v", serverCfg.Args) - log.Printf("[LAUNCHER] - Env vars: %v", sanitizeEnvForLogging(serverCfg.Env)) + log.Printf("[LAUNCHER] - Env vars: %v", sanitize.TruncateSecretMap(serverCfg.Env)) log.Printf("[LAUNCHER] - Running in container: %v", l.runningInContainer) log.Printf("[LAUNCHER] - Is direct command: %v", isDirectCommand) diff --git a/internal/launcher/launcher_test.go b/internal/launcher/launcher_test.go index 8627ceb..beaa31a 100644 --- a/internal/launcher/launcher_test.go +++ b/internal/launcher/launcher_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/githubnext/gh-aw-mcpg/internal/config" + "github.com/githubnext/gh-aw-mcpg/internal/logger/sanitize" ) // loadConfigFromJSON is a test helper that creates a config from JSON via stdin @@ -180,7 +181,7 @@ func TestMixedHTTPAndStdioServers(t *testing.T) { assert.Equal(t, "stdio", stdioServer.Type) } -func TestSanitizeEnvForLogging(t *testing.T) { +func TestTruncateSecretMap(t *testing.T) { tests := []struct { name string input map[string]string @@ -242,14 +243,14 @@ func TestSanitizeEnvForLogging(t *testing.T) { "EMPTY": "", }, expected: map[string]string{ - "EMPTY": "...", + "EMPTY": "", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := sanitizeEnvForLogging(tt.input) + result := sanitize.TruncateSecretMap(tt.input) if tt.expected == nil { assert.Nil(t, result) diff --git a/internal/logger/global_patterns.go b/internal/logger/global_helpers.go similarity index 75% rename from internal/logger/global_patterns.go rename to internal/logger/global_helpers.go index 896fe89..fcb73dc 100644 --- a/internal/logger/global_patterns.go +++ b/internal/logger/global_helpers.go @@ -1,3 +1,17 @@ +// Package logger provides structured logging for the MCP Gateway. +// +// This file contains helper functions for managing global logger state with proper +// mutex handling. These helpers encapsulate common patterns for initializing and +// closing global loggers (FileLogger, JSONLLogger, MarkdownLogger) to reduce code +// duplication while maintaining thread safety. +// +// Functions in this file follow a consistent pattern: +// +// - init*: Initialize a global logger with proper locking and cleanup of any existing logger +// - close*: Close and clear a global logger with proper locking +// +// These helpers are used internally by the logger package and should not be called +// directly by external code. Use the public Init* and Close* functions instead. package logger // This file contains helper functions that encapsulate the common patterns diff --git a/internal/logger/rpc_formatter.go b/internal/logger/rpc_formatter.go new file mode 100644 index 0000000..f03417e --- /dev/null +++ b/internal/logger/rpc_formatter.go @@ -0,0 +1,143 @@ +// Package logger provides structured logging for the MCP Gateway. +// +// This file contains formatting functions for RPC messages in text and markdown formats. +// +// Text Format: Compact, single-line format optimized for grep and command-line tools +// +// Example: "github→tools/list 1234b {...}" +// +// Markdown Format: Human-readable with syntax highlighting, suitable for documentation +// +// Example: "**github**→`tools/list`\n\n```json\n{...}\n```" +// +// Both formats use directional arrows (→ for outbound, ← for inbound) and support +// special handling for tools/call methods by extracting and displaying tool names. +package logger + +import ( + "encoding/json" + "fmt" + "strings" +) + +// formatRPCMessage formats an RPC message for logging +func formatRPCMessage(info *RPCMessageInfo) string { + // Short format: server→method (or server←resp) size payload + var dir string + if info.Direction == RPCDirectionOutbound { + dir = "→" + } else { + dir = "←" + } + + var parts []string + + // Server and direction + if info.ServerID != "" { + if info.Method != "" { + parts = append(parts, fmt.Sprintf("%s%s%s", info.ServerID, dir, info.Method)) + } else { + parts = append(parts, fmt.Sprintf("%s%sresp", info.ServerID, dir)) + } + } + + // Size + parts = append(parts, fmt.Sprintf("%db", info.PayloadSize)) + + // Error (if present) + if info.Error != "" { + parts = append(parts, fmt.Sprintf("err:%s", info.Error)) + } + + // Payload preview (if present) + if info.Payload != "" { + parts = append(parts, info.Payload) + } + + return strings.Join(parts, " ") +} + +// formatJSONWithoutFields formats JSON by removing specified fields and compacting to single line +// Returns the formatted string, a boolean indicating if the JSON was valid, and a boolean indicating if empty +func formatJSONWithoutFields(jsonStr string, fieldsToRemove []string) (string, bool, bool) { + var data map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + // If not valid JSON, return as-is with false + return jsonStr, false, false + } + + // Remove specified fields + for _, field := range fieldsToRemove { + delete(data, field) + } + + // Check if only "params": null remains (or equivalent empty state) + isEmpty := isEffectivelyEmpty(data) + + // Re-marshal as compact single line + formatted, err := json.Marshal(data) + if err != nil { + return jsonStr, false, false + } + + return string(formatted), true, isEmpty +} + +// formatRPCMessageMarkdown formats an RPC message for markdown logging +func formatRPCMessageMarkdown(info *RPCMessageInfo) string { + // Concise format: **server**→method \n```json \n{formatted json} \n``` + var dir string + if info.Direction == RPCDirectionOutbound { + dir = "→" + } else { + dir = "←" + } + + var message string + + // Server, direction, and method/type + if info.ServerID != "" { + if info.Method != "" { + message = fmt.Sprintf("**%s**%s`%s`", info.ServerID, dir, info.Method) + + // For tools/call, extract and display the tool name + if info.Method == "tools/call" && info.Payload != "" { + var data map[string]interface{} + if err := json.Unmarshal([]byte(info.Payload), &data); err == nil { + if params, ok := data["params"].(map[string]interface{}); ok { + if toolName, ok := params["name"].(string); ok && toolName != "" { + message += fmt.Sprintf(" `%s`", toolName) + } + } + } + } + } else { + message = fmt.Sprintf("**%s**%s`resp`", info.ServerID, dir) + } + } + + // Add formatted payload in code block + if info.Payload != "" { + // Remove jsonrpc and method fields, then format + formatted, isValidJSON, isEmpty := formatJSONWithoutFields(info.Payload, []string{"jsonrpc", "method"}) + if isValidJSON { + // Don't show JSON block if it's effectively empty (only params: null) + if !isEmpty { + // Valid JSON: use json code block for syntax highlighting (compact single line) + // Empty line before code block per markdown convention + // Code fences on their own lines with compact JSON content + message += fmt.Sprintf("\n\n```json\n%s\n```", formatted) + } + } else { + // Invalid JSON: use inline backticks to avoid malformed markdown + message += fmt.Sprintf(" `%s`", formatted) + } + } + + // Error (if present) + if info.Error != "" { + message += fmt.Sprintf(" ⚠️`%s`", info.Error) + } + + return message +} diff --git a/internal/logger/rpc_helpers.go b/internal/logger/rpc_helpers.go new file mode 100644 index 0000000..7df77cf --- /dev/null +++ b/internal/logger/rpc_helpers.go @@ -0,0 +1,95 @@ +// Package logger provides structured logging for the MCP Gateway. +// +// This file contains helper functions for processing RPC message payloads. +// +// Functions in this file: +// +// - truncateAndSanitize: Combines secret sanitization with length truncation +// - extractEssentialFields: Extracts key JSON-RPC fields for compact logging +// - getMapKeys: Utility for extracting map keys without values +// - isEffectivelyEmpty: Checks if data is effectively empty (e.g., only params: null) +// +// These helpers are used by the RPC logging system to safely and efficiently +// process message payloads before logging them. +package logger + +import ( + "encoding/json" + + "github.com/githubnext/gh-aw-mcpg/internal/logger/sanitize" +) + +// truncateAndSanitize truncates the payload to max length and sanitizes secrets +func truncateAndSanitize(payload string, maxLength int) string { + // First sanitize secrets + sanitized := sanitize.SanitizeString(payload) + + // Then truncate if needed + if len(sanitized) > maxLength { + return sanitized[:maxLength] + "..." + } + return sanitized +} + +// extractEssentialFields extracts key fields from the payload for logging +func extractEssentialFields(payload []byte) map[string]interface{} { + var data map[string]interface{} + if err := json.Unmarshal(payload, &data); err != nil { + return nil + } + + // Extract only essential fields + essential := make(map[string]interface{}) + + // Common JSON-RPC fields + if method, ok := data["method"].(string); ok { + essential["method"] = method + } + if id, ok := data["id"]; ok { + essential["id"] = id + } + if jsonrpc, ok := data["jsonrpc"].(string); ok { + essential["jsonrpc"] = jsonrpc + } + + // For responses, include error info + if errData, ok := data["error"]; ok { + essential["error"] = errData + } + + // For requests, include params summary (but not full params) + if params, ok := data["params"]; ok { + if paramsMap, ok := params.(map[string]interface{}); ok { + // Include param count and keys, but not values + essential["params_keys"] = getMapKeys(paramsMap) + } + } + + return essential +} + +// getMapKeys returns the keys of a map +func getMapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// isEffectivelyEmpty checks if the data is effectively empty (only contains params: null) +func isEffectivelyEmpty(data map[string]interface{}) bool { + // If empty, it's empty + if len(data) == 0 { + return true + } + + // If only one field and it's "params" with null value, it's empty + if len(data) == 1 { + if params, ok := data["params"]; ok && params == nil { + return true + } + } + + return false +} diff --git a/internal/logger/rpc_logger.go b/internal/logger/rpc_logger.go index 0081a9a..3714200 100644 --- a/internal/logger/rpc_logger.go +++ b/internal/logger/rpc_logger.go @@ -1,13 +1,26 @@ +// Package logger provides structured logging for the MCP Gateway. +// +// This file contains RPC message logging coordination, managing the flow of messages +// across multiple output formats (text, markdown, JSONL). +// +// File Organization: +// +// - rpc_logger.go (this file): Coordination of RPC logging across formats +// - rpc_formatter.go: Text and markdown formatting functions +// - rpc_helpers.go: Utility functions for payload processing +// +// The package supports logging RPC messages in three formats: +// +// 1. Text logs: Compact single-line format for grep-friendly searching +// 2. Markdown logs: Human-readable format with syntax highlighting +// 3. JSONL logs: Machine-readable format for structured analysis +// +// Example: +// +// logger.LogRPCRequest(logger.RPCDirectionOutbound, "github", "tools/list", payload) +// logger.LogRPCResponse(logger.RPCDirectionInbound, "github", responsePayload, nil) package logger -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/githubnext/gh-aw-mcpg/internal/logger/sanitize" -) - // RPCMessageType represents the direction of an RPC message type RPCMessageType string @@ -46,203 +59,6 @@ type RPCMessageInfo struct { Error string // Error message if any (for responses) } -// truncateAndSanitize truncates the payload to max length and sanitizes secrets -func truncateAndSanitize(payload string, maxLength int) string { - // First sanitize secrets - sanitized := sanitize.SanitizeString(payload) - - // Then truncate if needed - if len(sanitized) > maxLength { - return sanitized[:maxLength] + "..." - } - return sanitized -} - -// extractEssentialFields extracts key fields from the payload for logging -func extractEssentialFields(payload []byte) map[string]interface{} { - var data map[string]interface{} - if err := json.Unmarshal(payload, &data); err != nil { - return nil - } - - // Extract only essential fields - essential := make(map[string]interface{}) - - // Common JSON-RPC fields - if method, ok := data["method"].(string); ok { - essential["method"] = method - } - if id, ok := data["id"]; ok { - essential["id"] = id - } - if jsonrpc, ok := data["jsonrpc"].(string); ok { - essential["jsonrpc"] = jsonrpc - } - - // For responses, include error info - if errData, ok := data["error"]; ok { - essential["error"] = errData - } - - // For requests, include params summary (but not full params) - if params, ok := data["params"]; ok { - if paramsMap, ok := params.(map[string]interface{}); ok { - // Include param count and keys, but not values - essential["params_keys"] = getMapKeys(paramsMap) - } - } - - return essential -} - -// getMapKeys returns the keys of a map -func getMapKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} - -// formatRPCMessage formats an RPC message for logging -func formatRPCMessage(info *RPCMessageInfo) string { - // Short format: server→method (or server←resp) size payload - var dir string - if info.Direction == RPCDirectionOutbound { - dir = "→" - } else { - dir = "←" - } - - var parts []string - - // Server and direction - if info.ServerID != "" { - if info.Method != "" { - parts = append(parts, fmt.Sprintf("%s%s%s", info.ServerID, dir, info.Method)) - } else { - parts = append(parts, fmt.Sprintf("%s%sresp", info.ServerID, dir)) - } - } - - // Size - parts = append(parts, fmt.Sprintf("%db", info.PayloadSize)) - - // Error (if present) - if info.Error != "" { - parts = append(parts, fmt.Sprintf("err:%s", info.Error)) - } - - // Payload preview (if present) - if info.Payload != "" { - parts = append(parts, info.Payload) - } - - return strings.Join(parts, " ") -} - -// formatJSONWithoutFields formats JSON by removing specified fields and compacting to single line -// Returns the formatted string, a boolean indicating if the JSON was valid, and a boolean indicating if empty -func formatJSONWithoutFields(jsonStr string, fieldsToRemove []string) (string, bool, bool) { - var data map[string]interface{} - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - // If not valid JSON, return as-is with false - return jsonStr, false, false - } - - // Remove specified fields - for _, field := range fieldsToRemove { - delete(data, field) - } - - // Check if only "params": null remains (or equivalent empty state) - isEmpty := isEffectivelyEmpty(data) - - // Re-marshal as compact single line - formatted, err := json.Marshal(data) - if err != nil { - return jsonStr, false, false - } - - return string(formatted), true, isEmpty -} - -// isEffectivelyEmpty checks if the data is effectively empty (only contains params: null) -func isEffectivelyEmpty(data map[string]interface{}) bool { - // If empty, it's empty - if len(data) == 0 { - return true - } - - // If only one field and it's "params" with null value, it's empty - if len(data) == 1 { - if params, ok := data["params"]; ok && params == nil { - return true - } - } - - return false -} - -// formatRPCMessageMarkdown formats an RPC message for markdown logging -func formatRPCMessageMarkdown(info *RPCMessageInfo) string { - // Concise format: **server**→method \n```json \n{formatted json} \n``` - var dir string - if info.Direction == RPCDirectionOutbound { - dir = "→" - } else { - dir = "←" - } - - var message string - - // Server, direction, and method/type - if info.ServerID != "" { - if info.Method != "" { - message = fmt.Sprintf("**%s**%s`%s`", info.ServerID, dir, info.Method) - - // For tools/call, extract and display the tool name - if info.Method == "tools/call" && info.Payload != "" { - var data map[string]interface{} - if err := json.Unmarshal([]byte(info.Payload), &data); err == nil { - if params, ok := data["params"].(map[string]interface{}); ok { - if toolName, ok := params["name"].(string); ok && toolName != "" { - message += fmt.Sprintf(" `%s`", toolName) - } - } - } - } - } else { - message = fmt.Sprintf("**%s**%s`resp`", info.ServerID, dir) - } - } - - // Add formatted payload in code block - if info.Payload != "" { - // Remove jsonrpc and method fields, then format - formatted, isValidJSON, isEmpty := formatJSONWithoutFields(info.Payload, []string{"jsonrpc", "method"}) - if isValidJSON { - // Don't show JSON block if it's effectively empty (only params: null) - if !isEmpty { - // Valid JSON: use json code block for syntax highlighting (compact single line) - // Empty line before code block per markdown convention - // Code fences on their own lines with compact JSON content - message += fmt.Sprintf("\n\n```json\n%s\n```", formatted) - } - } else { - // Invalid JSON: use inline backticks to avoid malformed markdown - message += fmt.Sprintf(" `%s`", formatted) - } - } - - // Error (if present) - if info.Error != "" { - message += fmt.Sprintf(" ⚠️`%s`", info.Error) - } - - return message -} - // logRPCMessageToAll is a helper that logs RPC messages to text, markdown, and JSONL logs func logRPCMessageToAll(direction RPCMessageDirection, messageType RPCMessageType, serverID, method string, payload []byte, err error) { // Create info for text log (with larger payload preview) diff --git a/internal/logger/sanitize/sanitize.go b/internal/logger/sanitize/sanitize.go index 3cdfcce..0e6ee48 100644 --- a/internal/logger/sanitize/sanitize.go +++ b/internal/logger/sanitize/sanitize.go @@ -1,3 +1,31 @@ +// Package sanitize provides utilities for redacting sensitive information from logs. +// +// This package offers two complementary approaches to secret sanitization: +// +// 1. Pattern-based detection: SanitizeString() and SanitizeJSON() use regex patterns +// to identify and redact secrets like API keys, tokens, and passwords. +// +// 2. Prefix truncation: TruncateSecret() and TruncateSecretMap() show only the first +// 4 characters of values, making them safe for logging without exposing full secrets. +// +// Usage Guidelines: +// +// - Use TruncateSecret()/TruncateSecretMap() for auth headers and environment variables +// where you want to preserve a hint of the value for debugging. +// +// - Use SanitizeString()/SanitizeJSON() for full payload sanitization where secrets +// may appear in various formats throughout the data. +// +// Example: +// +// // For auth headers +// log.Printf("Auth: %s", sanitize.TruncateSecret(authHeader)) // "ghp_..." instead of full token +// +// // For environment variables +// log.Printf("Env: %v", sanitize.TruncateSecretMap(envVars)) +// +// // For JSON payloads +// sanitized := sanitize.SanitizeJSON(payload) // Replaces detected secrets with [REDACTED] package sanitize import ( @@ -40,6 +68,33 @@ func SanitizeString(message string) string { return result } +// TruncateSecret returns a sanitized version of the input string for safe logging. +// It shows only the first 4 characters followed by "..." to prevent exposing sensitive data. +// For strings with 4 or fewer characters, it returns only "...". +// For empty strings, it returns an empty string. +func TruncateSecret(input string) string { + if len(input) > 4 { + return input[:4] + "..." + } else if len(input) > 0 { + return "..." + } + return "" +} + +// TruncateSecretMap returns a sanitized version of environment variables +// where each value is truncated to first 4 characters followed by "..." +// This prevents sensitive information like API keys from being logged in full. +func TruncateSecretMap(env map[string]string) map[string]string { + if env == nil { + return nil + } + sanitized := make(map[string]string, len(env)) + for key, value := range env { + sanitized[key] = TruncateSecret(value) + } + return sanitized +} + // SanitizeJSON sanitizes a JSON payload by applying regex patterns to the entire string // It takes raw bytes, applies regex sanitization in one pass, and returns sanitized bytes func SanitizeJSON(payloadBytes []byte) json.RawMessage { diff --git a/internal/logger/sanitize/sanitize_test.go b/internal/logger/sanitize/sanitize_test.go index 3a458e3..16e54b1 100644 --- a/internal/logger/sanitize/sanitize_test.go +++ b/internal/logger/sanitize/sanitize_test.go @@ -340,3 +340,158 @@ func TestSecretPatternsCount(t *testing.T) { assert.Equal(t, expectedPatternCount, actualCount, "%d secret patterns, got %d") } + +func TestTruncateSecret(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "Empty string", + input: "", + want: "", + }, + { + name: "Single character", + input: "a", + want: "...", + }, + { + name: "Four characters", + input: "abcd", + want: "...", + }, + { + name: "Five characters", + input: "abcde", + want: "abcd...", + }, + { + name: "Long string", + input: "my-secret-api-key-12345", + want: "my-s...", + }, + { + name: "API key with Bearer prefix", + input: "Bearer my-token-123", + want: "Bear...", + }, + { + name: "Unicode characters", + input: "key-with-émojis-🔑", + want: "key-...", + }, + { + name: "Very long API key", + input: "my-super-long-api-key-with-many-characters-12345678901234567890", + want: "my-s...", + }, + { + name: "Special characters", + input: "key!@#$%^&*()", + want: "key!...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateSecret(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTruncateSecretMap(t *testing.T) { + tests := []struct { + name string + input map[string]string + expected map[string]string + }{ + { + name: "nil env map", + input: nil, + expected: nil, + }, + { + name: "empty env map", + input: map[string]string{}, + expected: map[string]string{}, + }, + { + name: "single env var with long value", + input: map[string]string{ + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghs_1234567890abcdefghijklmnop", + }, + expected: map[string]string{ + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghs_...", + }, + }, + { + name: "multiple env vars with various lengths", + input: map[string]string{ + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghs_1234567890abcdefghijklmnop", + "API_KEY": "key_abc123xyz", + "SHORT": "abc", + }, + expected: map[string]string{ + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghs_...", + "API_KEY": "key_...", + "SHORT": "...", + }, + }, + { + name: "env var with exactly 4 characters", + input: map[string]string{ + "TEST": "1234", + }, + expected: map[string]string{ + "TEST": "...", + }, + }, + { + name: "env var with 5 characters", + input: map[string]string{ + "TEST": "12345", + }, + expected: map[string]string{ + "TEST": "1234...", + }, + }, + { + name: "env var with empty value", + input: map[string]string{ + "EMPTY": "", + }, + expected: map[string]string{ + "EMPTY": "", + }, + }, + { + name: "multiple env vars with mixed lengths", + input: map[string]string{ + "VAR1": "a", + "VAR2": "ab", + "VAR3": "abc", + "VAR4": "abcd", + "VAR5": "abcde", + "VAR6": "abcdef", + }, + expected: map[string]string{ + "VAR1": "...", + "VAR2": "...", + "VAR3": "...", + "VAR4": "...", + "VAR5": "abcd...", + "VAR6": "abcd...", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TruncateSecretMap(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +}