Files
goclaw/internal/tools/credential_audit_log_test.go

187 lines
6.0 KiB
Go

// Phase 6 — audit log shape tests for `security.system_env_injection`.
//
// Pins the JSON field schema operators grep for in their SIEM. A change here
// breaks log-search dashboards in production, so the test asserts:
// - exact event name (`msg=security.system_env_injection`)
// - presence of every documented field
// - host scope hashed (not plaintext) — PII safety
// - env keys present, env values NOT present
// - PAT/SSH key bytes NOT present anywhere in the captured buffer
package tools
import (
"bytes"
"encoding/json"
"log/slog"
"regexp"
"strings"
"testing"
)
// capturedAudit runs fn with a JSON slog handler capturing into buf, then
// restores the previous default logger. Returns parsed JSON records.
func capturedAudit(t *testing.T, fn func()) ([]map[string]any, string) {
t.Helper()
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(prev)
fn()
raw := buf.String()
var records []map[string]any
for line := range strings.SplitSeq(strings.TrimRight(raw, "\n"), "\n") {
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("log line not JSON: %q (%v)", line, err)
}
records = append(records, rec)
}
return records, raw
}
// 1. Audit log shape — PAT path emits the documented field schema.
func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) {
scope := "github.com"
// Use sentinel values long enough that substring match in the captured
// log buffer is meaningful (short values like "1" would false-match digits
// in the timestamp).
patToken := "ghp_SENTINEL_PAT_VALUE_4242424242424242"
inj := &Injection{
Env: map[string]string{
"GIT_CONFIG_COUNT": "SENTINEL_COUNT_VALUE",
"GIT_CONFIG_KEY_0": "http.https://github.com/.extraheader",
"GIT_CONFIG_VALUE_0": "AUTHORIZATION: basic " + patToken,
},
// PAT path uses env only, no argv mutation.
ArgvPrefix: nil,
}
records, raw := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("git", "git", "user-42", "agent", inj, &scope)
})
if len(records) != 1 {
t.Fatalf("expected exactly 1 audit record, got %d (raw=%q)", len(records), raw)
}
rec := records[0]
if rec["msg"] != "security.system_env_injection" {
t.Fatalf("msg=%v, want security.system_env_injection", rec["msg"])
}
if rec["adapter"] != "git" {
t.Errorf("adapter=%v, want git", rec["adapter"])
}
if rec["binary"] != "git" {
t.Errorf("binary=%v, want git", rec["binary"])
}
if rec["user_id"] != "user-42" {
t.Errorf("user_id=%v, want user-42", rec["user_id"])
}
if rec["credential_source"] != "agent" {
t.Errorf("credential_source=%v, want agent", rec["credential_source"])
}
if rec["argv_prefix_len"] != float64(0) {
t.Errorf("argv_prefix_len=%v, want 0", rec["argv_prefix_len"])
}
keys, _ := rec["env_keys"].([]any)
want := []string{"GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"}
if len(keys) != len(want) {
t.Fatalf("env_keys=%v, want %v", keys, want)
}
for i, k := range want {
if keys[i] != k {
t.Errorf("env_keys[%d]=%v, want %s (must be sorted)", i, keys[i], k)
}
}
hash, _ := rec["host_scope_hash"].(string)
if !regexp.MustCompile(`^[0-9a-f]{8}$`).MatchString(hash) {
t.Errorf("host_scope_hash=%q, want 8 hex chars", hash)
}
if hash == scope {
t.Errorf("host_scope_hash leaked plaintext hostname")
}
// Plaintext hostname must NOT appear anywhere in the raw log output.
if strings.Contains(raw, scope) {
t.Errorf("plaintext hostname %q leaked into audit log: %s", scope, raw)
}
// Env VALUES must NOT appear in the audit log (only NAMES go in env_keys).
// Especially the PAT-like content: AC6 redaction guarantee.
for _, v := range inj.Env {
if strings.Contains(raw, v) {
t.Errorf("env value %q leaked into audit log: %s", v, raw)
}
}
if strings.Contains(raw, patToken) {
t.Errorf("PAT token leaked into audit log: %s", raw)
}
}
// 2. Audit log shape — SSH path: env_keys=[GIT_SSH_COMMAND], hash present,
// no PEM bytes in the log.
func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) {
scope := "gitlab.example.com:2222"
pemBody := "-----BEGIN OPENSSH PRIVATE KEY-----\nfakebody\n-----END OPENSSH PRIVATE KEY-----"
inj := &Injection{
Env: map[string]string{
"GIT_SSH_COMMAND": "ssh -i /tmp/goclaw-gitkey-xyz -o StrictHostKeyChecking=accept-new",
},
}
records, raw := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("git", "git", "u1", "user", inj, &scope)
})
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
rec := records[0]
keys, _ := rec["env_keys"].([]any)
if len(keys) != 1 || keys[0] != "GIT_SSH_COMMAND" {
t.Errorf("env_keys=%v, want [GIT_SSH_COMMAND]", keys)
}
hash, _ := rec["host_scope_hash"].(string)
if !regexp.MustCompile(`^[0-9a-f]{8}$`).MatchString(hash) {
t.Errorf("host_scope_hash=%q, want 8 hex chars", hash)
}
// PEM contents and plaintext hostname must NOT leak.
if strings.Contains(raw, "BEGIN OPENSSH") || strings.Contains(raw, pemBody) {
t.Errorf("PEM content leaked into audit log: %s", raw)
}
if strings.Contains(raw, "gitlab.example.com") {
t.Errorf("plaintext hostname leaked into audit log: %s", raw)
}
}
// 3. Nil injection: emitter is a no-op (audit only fires on actual injection).
func TestEmitSystemEnvInjectionAudit_NilInjection(t *testing.T) {
records, _ := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("git", "git", "u1", "user", nil, nil)
})
if len(records) != 0 {
t.Fatalf("expected 0 records for nil injection, got %d", len(records))
}
}
// 4. Nil host scope (passthrough adapter): hash = "none", not crashy.
func TestEmitSystemEnvInjectionAudit_NilHostScope(t *testing.T) {
inj := &Injection{Env: map[string]string{"FOO": "bar"}}
records, _ := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("passthrough", "gh", "u1", "", inj, nil)
})
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records[0]["host_scope_hash"] != "none" {
t.Errorf("host_scope_hash=%v, want none", records[0]["host_scope_hash"])
}
}