fix(ISSUE-005): add log sanitizer; integrate into SecurityLogger
sanitize.go: ANSI strip, control-char scrub, per-field 4KB cap, total 16KB cap, JSON validation. SecurityLogger.writeToFile now routes all string fields through SanitizeForLog before writing.
This commit is contained in:
parent
37d9731f21
commit
32c80debfb
3 changed files with 105 additions and 4 deletions
|
|
@ -98,7 +98,7 @@ func NewSecurityLogger(agentConfig *config.Config, logDir string) (*SecurityLogg
|
|||
logger: log.New(file, "[SECURITY] ", log.LstdFlags|log.LUTC),
|
||||
file: file,
|
||||
buffer: make([]*SecurityEvent, 0, secConfig.BatchSize),
|
||||
lastFlush: time.Now(),
|
||||
lastFlush: time.Now().UTC(),
|
||||
}
|
||||
|
||||
// Start flush timer
|
||||
|
|
@ -402,7 +402,7 @@ func (sl *SecurityLogger) flushBufferUnsafe() {
|
|||
}
|
||||
|
||||
sl.buffer = sl.buffer[:0]
|
||||
sl.lastFlush = time.Now()
|
||||
sl.lastFlush = time.Now().UTC()
|
||||
|
||||
// Reset timer if not closed
|
||||
if !sl.closed && sl.flushTimer != nil {
|
||||
|
|
|
|||
94
server/internal/logging/sanitize.go
Normal file
94
server/internal/logging/sanitize.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package logging
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxLoggedFieldBytes caps any single string field written to the log to
|
||||
// prevent log-blowup attacks via oversized payloads.
|
||||
const MaxLoggedFieldBytes = 4096
|
||||
|
||||
// MaxLoggedJSONBytes caps the total serialized size of a log entry.
|
||||
const MaxLoggedJSONBytes = 16384
|
||||
|
||||
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
|
||||
|
||||
// StripANSI removes ANSI escape sequences from s.
|
||||
func StripANSI(s string) string {
|
||||
return ansiEscape.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// SanitizeForLog strips ANSI sequences and replaces control characters
|
||||
// (CR/LF/TAB and other C0 codes) with single spaces. This blocks log
|
||||
// injection where attacker-controlled input contains newlines that
|
||||
// would otherwise forge new log lines.
|
||||
func SanitizeForLog(s string) string {
|
||||
s = StripANSI(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\t' || r == '\n' || r == '\r':
|
||||
b.WriteByte(' ')
|
||||
case r < 0x20 || r == 0x7f:
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return truncate(b.String(), MaxLoggedFieldBytes)
|
||||
}
|
||||
|
||||
// SanitizeMap walks a map and sanitizes any string values in place,
|
||||
// recursing into nested maps and slices. Non-string scalars are
|
||||
// passed through unchanged.
|
||||
func SanitizeMap(m map[string]interface{}) map[string]interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
out[SanitizeForLog(k)] = sanitizeValue(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeValue(v interface{}) interface{} {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return SanitizeForLog(x)
|
||||
case map[string]interface{}:
|
||||
return SanitizeMap(x)
|
||||
case []interface{}:
|
||||
out := make([]interface{}, len(x))
|
||||
for i, item := range x {
|
||||
out[i] = sanitizeValue(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateJSONForLog confirms that data can be marshaled to JSON within
|
||||
// the size limit. Returns the marshaled bytes (truncated marker appended
|
||||
// if oversized) or an error if marshaling fails entirely.
|
||||
func ValidateJSONForLog(data interface{}) ([]byte, error) {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > MaxLoggedJSONBytes {
|
||||
return append(b[:MaxLoggedJSONBytes], []byte(`..."truncated":true}`)...), nil
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "...[truncated]"
|
||||
}
|
||||
|
|
@ -268,9 +268,16 @@ func (sl *SecurityLogger) writeEvent(event *models.SecurityEvent) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// writeToFile writes the event as JSON to the log file
|
||||
// writeToFile writes the event as JSON to the log file. The message and
|
||||
// details are sanitized to strip ANSI escapes and control characters that
|
||||
// could be used for log injection, and oversized payloads are truncated.
|
||||
func (sl *SecurityLogger) writeToFile(event *models.SecurityEvent) error {
|
||||
jsonData, err := json.Marshal(event)
|
||||
safeEvent := *event
|
||||
safeEvent.Message = SanitizeForLog(event.Message)
|
||||
safeEvent.Details = SanitizeMap(event.Details)
|
||||
safeEvent.Metadata = SanitizeMap(event.Metadata)
|
||||
|
||||
jsonData, err := ValidateJSONForLog(&safeEvent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal security event: %w", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue