mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Some checks failed
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Public docs / check (push) Waiting to run
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Waiting to run
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Has been cancelled
239 lines
7.9 KiB
Go
239 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// lockedLogBuffer keeps log capture safe when another package goroutine logs
|
|
// while an auth-denial test owns the process-wide zerolog logger. bytes.Buffer
|
|
// is not safe for concurrent writes or for a read racing a write.
|
|
type lockedLogBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *lockedLogBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *lockedLogBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
// captureAuthDenialLogs swaps the global logger for a buffer at debug level and
|
|
// resets the denial tracker, so each case starts from a known window.
|
|
func captureAuthDenialLogs(t *testing.T) *lockedLogBuffer {
|
|
t.Helper()
|
|
|
|
var buf lockedLogBuffer
|
|
prevLogger := log.Logger
|
|
prevLevel := zerolog.GlobalLevel()
|
|
log.Logger = zerolog.New(&buf).Level(zerolog.DebugLevel)
|
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
|
|
authDenialMu.Lock()
|
|
authDenials = make(map[string]*authDenialCounter)
|
|
authDenialMu.Unlock()
|
|
|
|
prevNow := authDenialNow
|
|
t.Cleanup(func() {
|
|
log.Logger = prevLogger
|
|
zerolog.SetGlobalLevel(prevLevel)
|
|
authDenialNow = prevNow
|
|
authDenialMu.Lock()
|
|
authDenials = make(map[string]*authDenialCounter)
|
|
authDenialMu.Unlock()
|
|
})
|
|
|
|
return &buf
|
|
}
|
|
|
|
func countLogEvents(t *testing.T, buf *lockedLogBuffer, level, message string) int {
|
|
t.Helper()
|
|
|
|
count := 0
|
|
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var event struct {
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("decode captured log event %q: %v", line, err)
|
|
}
|
|
if event.Level == level && event.Message == message {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// A non-admin browsing a UI that still mounts an admin-only surface produces a
|
|
// steady trickle of refusals. Warning on each is what made #1601's rc.9 reporter
|
|
// read a working RBAC gate as a break, so ordinary refusals must stay at debug.
|
|
func TestAuthDenialBelowThresholdStaysDebug(t *testing.T) {
|
|
buf := captureAuthDenialLogs(t)
|
|
req := httptest.NewRequest("GET", "/api/connections", nil)
|
|
const message = "Non-admin user attempted to access admin endpoint"
|
|
|
|
for i := 0; i < authDenialWarnThreshold-1; i++ {
|
|
logAuthDenial(req, "viewer", message, nil)
|
|
}
|
|
|
|
if got := countLogEvents(t, buf, "debug", message); got != authDenialWarnThreshold-1 {
|
|
t.Fatalf("debug lines = %d, want %d", got, authDenialWarnThreshold-1)
|
|
}
|
|
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 0 {
|
|
t.Fatalf("warn lines = %d, want 0 below the probing threshold", got)
|
|
}
|
|
}
|
|
|
|
// The signal worth an operator's attention is the rate, not the individual
|
|
// refusal — and the escalation must not itself become the new spam.
|
|
func TestAuthDenialEscalatesOncePerWindow(t *testing.T) {
|
|
buf := captureAuthDenialLogs(t)
|
|
req := httptest.NewRequest("GET", "/api/connections", nil)
|
|
|
|
for i := 0; i < authDenialWarnThreshold*3; i++ {
|
|
logAuthDenial(req, "viewer", "Non-admin user attempted to access admin endpoint", nil)
|
|
}
|
|
|
|
output := buf.String()
|
|
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
|
t.Fatalf("warn lines = %d, want exactly 1 for a single window", got)
|
|
}
|
|
if !strings.Contains(output, "possible endpoint probing") {
|
|
t.Fatalf("escalation missing from output: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestAuthDenialWindowRolloverRearmsEscalation(t *testing.T) {
|
|
buf := captureAuthDenialLogs(t)
|
|
req := httptest.NewRequest("GET", "/api/connections", nil)
|
|
|
|
base := time.Unix(1_700_000_000, 0)
|
|
authDenialNow = func() time.Time { return base }
|
|
for i := 0; i < authDenialWarnThreshold; i++ {
|
|
logAuthDenial(req, "viewer", "denied", nil)
|
|
}
|
|
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
|
t.Fatalf("first window warn lines = %d, want 1", got)
|
|
}
|
|
|
|
// A caller that goes quiet and starts probing again later is a fresh signal,
|
|
// not a permanently silenced one.
|
|
authDenialNow = func() time.Time { return base.Add(authDenialWindow + time.Second) }
|
|
for i := 0; i < authDenialWarnThreshold; i++ {
|
|
logAuthDenial(req, "viewer", "denied", nil)
|
|
}
|
|
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 2 {
|
|
t.Fatalf("warn lines after rollover = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
// Attribution follows the identity when there is one, so a probing account is
|
|
// still caught after rotating source addresses.
|
|
func TestAuthDenialTracksIdentityAcrossAddresses(t *testing.T) {
|
|
buf := captureAuthDenialLogs(t)
|
|
|
|
for i := 0; i < authDenialWarnThreshold; i++ {
|
|
req := httptest.NewRequest("GET", "/api/connections", nil)
|
|
req.RemoteAddr = "10.0.0." + string(rune('1'+i%9)) + ":5000"
|
|
logAuthDenial(req, "viewer", "denied", nil)
|
|
}
|
|
|
|
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
|
t.Fatalf("warn lines = %d, want 1 — identity should aggregate across addresses", got)
|
|
}
|
|
}
|
|
|
|
// Separate callers must not pool into one another's budget, or one busy client
|
|
// would silence the signal for everybody else.
|
|
func TestAuthDenialSeparatesCallers(t *testing.T) {
|
|
buf := captureAuthDenialLogs(t)
|
|
req := httptest.NewRequest("GET", "/api/connections", nil)
|
|
|
|
for i := 0; i < authDenialWarnThreshold-1; i++ {
|
|
logAuthDenial(req, "viewer-a", "denied", nil)
|
|
logAuthDenial(req, "viewer-b", "denied", nil)
|
|
}
|
|
|
|
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 0 {
|
|
t.Fatalf("warn lines = %d, want 0 — neither caller crossed the threshold", got)
|
|
}
|
|
}
|
|
|
|
// The API package can have background monitor goroutines logging while these
|
|
// tests run, and production handlers may record denials concurrently. Exercise
|
|
// that concurrency directly so an unsynchronized capture sink or tracker state
|
|
// fails deterministically under go test -race.
|
|
func TestAuthDenialConcurrentLoggingIsRaceFreeAndWarnsOnce(t *testing.T) {
|
|
buf := captureAuthDenialLogs(t)
|
|
|
|
base := time.Unix(1_700_000_000, 0)
|
|
authDenialNow = func() time.Time { return base }
|
|
const (
|
|
identity = "concurrent-viewer"
|
|
message = "concurrent denial"
|
|
attempts = authDenialWarnThreshold * 8
|
|
)
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(attempts)
|
|
for i := 0; i < attempts; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
req := httptest.NewRequest("GET", "/api/connections", nil)
|
|
logAuthDenial(req, identity, message, nil)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := countLogEvents(t, buf, "debug", message); got != attempts {
|
|
t.Fatalf("debug lines = %d, want %d", got, attempts)
|
|
}
|
|
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
|
t.Fatalf("warn lines = %d, want exactly 1 for concurrent denials in one window", got)
|
|
}
|
|
|
|
authDenialMu.Lock()
|
|
entry := *authDenials["user:"+identity]
|
|
authDenialMu.Unlock()
|
|
if entry.count != attempts || !entry.warned {
|
|
t.Fatalf("counter = {count:%d warned:%t}, want {count:%d warned:true}", entry.count, entry.warned, attempts)
|
|
}
|
|
}
|
|
|
|
func TestAuthDenialPruneKeepsTrackedSetBounded(t *testing.T) {
|
|
captureAuthDenialLogs(t)
|
|
|
|
base := time.Unix(1_700_000_000, 0)
|
|
authDenialNow = func() time.Time { return base }
|
|
for i := 0; i < authDenialMaxTracked+64; i++ {
|
|
req := httptest.NewRequest("GET", "/api/connections", nil)
|
|
logAuthDenial(req, "spoofed-"+strings.Repeat("x", i%7)+string(rune(i)), "denied", nil)
|
|
}
|
|
|
|
authDenialMu.Lock()
|
|
tracked := len(authDenials)
|
|
authDenialMu.Unlock()
|
|
|
|
if tracked > authDenialMaxTracked {
|
|
t.Fatalf("tracked callers = %d, want <= %d", tracked, authDenialMaxTracked)
|
|
}
|
|
}
|