Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions client/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,12 @@ func (c collectorClient) SetCacheResultUsage(ctx context.Context, m *api.Collect
}
return &res, nil
}

func (c collectorClient) SetWAFEvents(ctx context.Context, m *api.CollectorSetWAFEvents) (*api.Empty, error) {
var res api.Empty
err := c.inv.invoke(ctx, "collector.setWAFEvents", m, &res)
if err != nil {
return nil, err
}
return &res, nil
}
9 changes: 9 additions & 0 deletions client/waf.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,12 @@ func (c wafClient) Test(ctx context.Context, m *api.WAFTest) (*api.WAFTestResult
}
return &res, nil
}

func (c wafClient) Events(ctx context.Context, m *api.WAFEvents) (*api.WAFEventsResult, error) {
var res api.WAFEventsResult
err := c.inv.invoke(ctx, "waf.events", m, &res)
if err != nil {
return nil, err
}
return &res, nil
}
33 changes: 33 additions & 0 deletions collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type Collector interface {
SetCacheOverrideUsage(ctx context.Context, m *CollectorSetCacheOverrideUsage) (*Empty, error)
// SetCacheResultUsage requires the location's collector token (internal endpoint authenticated by the per-location collector_token, not a user permission).
SetCacheResultUsage(ctx context.Context, m *CollectorSetCacheResultUsage) (*Empty, error)
// SetWAFEvents requires the location's collector token (internal endpoint authenticated by the per-location collector_token, not a user permission).
SetWAFEvents(ctx context.Context, m *CollectorSetWAFEvents) (*Empty, error)
}

type CollectorLocation struct {
Expand Down Expand Up @@ -172,3 +174,34 @@ type CollectorCacheResultUsageItem struct {
Bytes float64 `json:"bytes" yaml:"bytes"` // bytes served in the window
At int64 `json:"at" yaml:"at"` // unix second, minute-aligned bucket
}

// CollectorSetWAFEvents appends sampled WAF match events captured from the
// controller's event ring (SPEC-waf-events). Items are deduplicated by ID
// (the controller-minted ULID), so at-least-once shipping after cursor loss
// is safe. ProjectID is parsed by the collector from the project-prefixed
// RuleID (<projectID>-<rand>), exactly like CollectorSetWAFUsage; the server
// re-checks the pairing against the rule-id prefix before storing.
//
// Unlike the usage setters this RPC is synchronous server-side: events have
// no healing second source (usage loops re-query Prometheus, the event ring
// is drained), so a failed insert must surface as an error and the collector
// must advance its per-pod ring cursor only after a successful call.
type CollectorSetWAFEvents struct {
Location string `json:"location" yaml:"location"`
List []*CollectorWAFEventItem `json:"list" yaml:"list"`
}

type CollectorWAFEventItem struct {
ID string `json:"id" yaml:"id"` // controller ULID, dedupe key
ProjectID int64 `json:"projectId,string" yaml:"projectId"`
RuleID string `json:"ruleId" yaml:"ruleId"` // full generated id (<projectID>-<rand>)
Action string `json:"action" yaml:"action"` // log|allow|block
Status int `json:"status" yaml:"status"`
At int64 `json:"at" yaml:"at"` // unix second (event time at the engine)
ClientIP string `json:"clientIp" yaml:"clientIp"`
Country string `json:"country" yaml:"country"` // ISO 3166-1 alpha-2, "" if unresolved
ASN int64 `json:"asn" yaml:"asn"` // 0 if unresolved
Method string `json:"method" yaml:"method"`
Host string `json:"host" yaml:"host"`
Path string `json:"path" yaml:"path"` // URL path only (no query)
}
19 changes: 19 additions & 0 deletions constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ const (
WAFTestMaxQueryLength = 2048
WAFTestMaxMethodLength = 16
WAFTestMaxASN = 4294967295 // ASNs are 32-bit

// WAF events (waf.events / collector.setWAFEvents). Events are sampled at
// the engine and retained 3 days; counters stay the source of truth for
// counts. The read caps bound a page; the item caps bound what the server
// accepts from a collector — the engine truncates only host/path, and an
// attacker-chosen method token can be multi-KB, so every string is capped
// here independently of engine behavior.
WAFEventsDefaultLimit = 50
WAFEventsMaxLimit = 200
// WAFEventIDLength is the exact length of a controller-minted event id
// (a ULID: 26 chars of Crockford base32, time-ordered).
WAFEventIDLength = 26
// WAFEventsMaxBatch caps one collector.setWAFEvents call's List.
WAFEventsMaxBatch = 5000
WAFEventMaxMethodLength = 32
WAFEventMaxHostLength = 255
WAFEventMaxPathLength = 200
WAFEventMaxClientIPLength = 64
WAFEventMaxCountryLength = 2
)

// WAF lists
Expand Down
118 changes: 118 additions & 0 deletions waf.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type WAF interface {
LimitMetrics(ctx context.Context, m *WAFLimitMetrics) (*WAFLimitMetricsResult, error)
// Test requires the `waf.get` permission.
Test(ctx context.Context, m *WAFTest) (*WAFTestResult, error)
// Events requires the `waf.get` permission.
Events(ctx context.Context, m *WAFEvents) (*WAFEventsResult, error)
}

// WAFRule mirrors parapet's waf.Rule. Expression is a CEL expression returning
Expand Down Expand Up @@ -634,3 +636,119 @@ type WAFTestLimitResult struct {
Counted bool `json:"counted" yaml:"counted"`
Error string `json:"error" yaml:"error"`
}

// WAFEvents reads a zone's recent sampled match events, newest first. Events
// are SAMPLES (capture caps per controller pod: ≤10/min per rule — blocks
// exempt — and ≤60/min per zone) retained 3 days; waf.metrics remains the
// exact count.
//
// There is deliberately no time-range filter: the store holds 3 days total,
// so "everything, newest first, paginated" plus the two filters covers the
// UI; one can be added compatibly later.
type WAFEvents struct {
Project string `json:"project" yaml:"project"`
Location string `json:"location" yaml:"location"`
RuleID string `json:"ruleId" yaml:"ruleId"` // optional filter, short project-local id
Action string `json:"action" yaml:"action"` // optional filter: log|allow|block
Before string `json:"before" yaml:"before"` // keyset cursor: events with id < before (ULIDs are time-ordered)
Limit int `json:"limit" yaml:"limit"` // 0 = 50, max 200
}

// validWAFEventAction reports whether s names a WAF action an event can carry
// (the string form of WAFAction; events travel as plain strings because they
// are read-only rows, not rule config).
func validWAFEventAction(s string) bool {
switch s {
case "log", "allow", "block":
return true
default:
return false
}
}

// ValidWAFEventID reports whether s has the shape of a controller-minted
// event id: a 26-character uppercase Crockford-base32 ULID. Case is strict —
// the server pages with a lexicographic `id < before`, which is only
// time-ordered over the exact alphabet the engine mints. Exported (like
// WAFEventIDLength) so the apiserver applies the same shape check to item IDs
// at ingest (SPEC-waf-events §E.2): an off-alphabet id that got stored would
// come back as WAFEventsResult.Next and then fail this check as Before,
// wedging pagination — both ends must enforce the same shape.
func ValidWAFEventID(s string) bool {
if len(s) != WAFEventIDLength {
return false
}
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c >= '0' && c <= '9':
case c >= 'A' && c <= 'Z' && c != 'I' && c != 'L' && c != 'O' && c != 'U':
default:
return false
}
}
return true
}

func (m *WAFEvents) Valid() error {
v := validator.New()

// every filter is trimmed: §G blesses hand-editing query params that map
// 1:1 onto these fields, and a copy-pasted value often carries padding
m.RuleID = strings.TrimSpace(m.RuleID)
m.Action = strings.TrimSpace(m.Action)
m.Before = strings.TrimSpace(m.Before)

v.Must(m.Project != "", "project required")
v.Must(m.Location != "", "location required")
if m.RuleID != "" {
v.Must(ReValidWAFRuleID.MatchString(m.RuleID), "ruleId invalid "+ReValidWAFRuleIDStr)
v.Mustf(utf8.RuneCountInString(m.RuleID) <= WAFMaxRuleIDLength, "ruleId must not exceed %d characters", WAFMaxRuleIDLength)
}
v.Must(m.Action == "" || validWAFEventAction(m.Action), "action invalid (want log|allow|block)")
v.Must(m.Before == "" || ValidWAFEventID(m.Before), "before invalid (want an event id)")
v.Mustf(m.Limit >= 0 && m.Limit <= WAFEventsMaxLimit, "limit out of bounds (want 0..%d)", WAFEventsMaxLimit)

return WrapValidate(v)
}

type WAFEventsResult struct {
Items []*WAFEvent `json:"items" yaml:"items"`
Next string `json:"next" yaml:"next"` // pass as Before for the next page; "" = exhausted
}

func (m *WAFEventsResult) Table() [][]string {
table := [][]string{
{"TIME", "ACTION", "RULE", "IP", "COUNTRY", "METHOD", "HOST", "PATH"},
}
for _, x := range m.Items {
table = append(table, []string{
x.At.UTC().Format(time.RFC3339),
x.Action,
x.RuleID,
x.ClientIP,
x.Country,
x.Method,
x.Host,
x.Path,
})
}
return table
}

// WAFEvent is one sampled match. RuleID is the short, project-local id,
// matching WAF.Get so the caller can join an event to its rule — but events
// outlive rules by up to 3 days (and waf.set regenerates unknown ids), so the
// id may match nothing in the current ruleset.
type WAFEvent struct {
ID string `json:"id" yaml:"id"`
At time.Time `json:"at" yaml:"at"`
RuleID string `json:"ruleId" yaml:"ruleId"`
Action string `json:"action" yaml:"action"` // log|allow|block
Status int `json:"status" yaml:"status"`
ClientIP string `json:"clientIp" yaml:"clientIp"`
Country string `json:"country" yaml:"country"` // ISO 3166-1 alpha-2, "" if unresolved
ASN int64 `json:"asn" yaml:"asn"` // 0 if unresolved
Method string `json:"method" yaml:"method"`
Host string `json:"host" yaml:"host"`
Path string `json:"path" yaml:"path"` // URL path only (no query)
}
Loading