security(alerts): decouple event query allocation

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-27 02:13:42 +01:00
parent 1b4b8ae1dc
commit e89bca00a0
4 changed files with 37 additions and 1 deletions
@@ -959,6 +959,12 @@ shared metric activation records one fired event when its pending incident
becomes firing. Repeated firing observations and persisted-alert restore must
not append another activation event, and the active-alert storage funnel must
never invent one.
Event-log reads accept a caller limit but normalize it to the store's bounded
maximum before it reaches SQLite. Result-slice allocation is independent of
that caller value: a request-provided limit is a row-count preference, never a
memory-allocation hint. This remains defense in depth even when an authenticated
API handler validates the query parameter, because non-HTTP manager callers use
the same store boundary.
The same dispatch policy owns firing-notification evidence on active alerts:
any alert that passes notification suppression and enters the fired callback
fan-out must carry `LastNotified` before the callback clone is emitted. Resolved
+10
View File
@@ -50,6 +50,16 @@ func TestAlertEventLogDoesNotInventFiringFromActiveStorage(t *testing.T) {
}
}
func TestAlertEventLogBoundsRequestDerivedLimit(t *testing.T) {
manager := newEventLogManager(t)
manager.recordAlertEvent(eventlog.TypeResolved, nil, "bounded-event", "resolved", "resolved", nil)
events := queryAlertEvents(t, manager, eventlog.Filter{Limit: math.MaxInt})
if len(events) != 1 || events[0].AlertID != "bounded-event" {
t.Fatalf("oversized-limit query = %+v, want the bounded event", events)
}
}
func TestGuestAlertIncludesTagsForNotificationRouting(t *testing.T) {
m := newTestManager(t)
m.ClearActiveAlerts()
+6 -1
View File
@@ -376,7 +376,12 @@ func (s *Store) Query(filter Filter) ([]Event, error) {
}
defer rows.Close()
events := make([]Event, 0, limit)
// Do not use the request-derived limit as an allocation hint. The SQL
// query is capped above, but keeping the result slice allocation independent
// of caller input makes that memory-safety boundary explicit and prevents a
// future query refactor from turning an oversized limit into an eager
// allocation.
events := make([]Event, 0, defaultQueryLimit)
for rows.Next() {
var event Event
var occurredAt, details string
+15
View File
@@ -1,6 +1,7 @@
package eventlog
import (
"math"
"testing"
"time"
)
@@ -99,6 +100,20 @@ func TestQueryLimitCaps(t *testing.T) {
}
}
func TestQueryOversizedLimitDoesNotControlAllocation(t *testing.T) {
store := newTestStore(t)
store.Append(Event{OccurredAt: time.Now(), Type: TypeResolved, AlertID: "a1"})
store.Flush()
events, err := store.Query(Filter{Limit: math.MaxInt})
if err != nil {
t.Fatalf("query with oversized limit: %v", err)
}
if len(events) != 1 || events[0].AlertID != "a1" {
t.Fatalf("events = %+v, want the one stored event", events)
}
}
func TestNilStoreIsSafe(t *testing.T) {
var store *Store
store.Append(Event{Type: TypeResolved, AlertID: "a1"})