diff --git a/client/collector.go b/client/collector.go index eccaa1d..1eeea8e 100644 --- a/client/collector.go +++ b/client/collector.go @@ -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 +} diff --git a/client/waf.go b/client/waf.go index fb680ab..4341eda 100644 --- a/client/waf.go +++ b/client/waf.go @@ -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 +} diff --git a/collector.go b/collector.go index ce4a9e4..78dbb7e 100644 --- a/collector.go +++ b/collector.go @@ -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 { @@ -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 (-), 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 (-) + 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) +} diff --git a/constraint.go b/constraint.go index 8ab446f..5cbc4a8 100644 --- a/constraint.go +++ b/constraint.go @@ -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 diff --git a/waf.go b/waf.go index bea560d..acc0437 100644 --- a/waf.go +++ b/waf.go @@ -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 @@ -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) +} diff --git a/wafevents_test.go b/wafevents_test.go new file mode 100644 index 0000000..537a257 --- /dev/null +++ b/wafevents_test.go @@ -0,0 +1,261 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func validWAFEvents() *WAFEvents { + return &WAFEvents{ + Project: "p", + Location: "gke.cluster", + } +} + +func TestWAFEventsValid(t *testing.T) { + if err := validWAFEvents().Valid(); err != nil { + t.Fatalf("a minimal valid request was rejected: %v", err) + } + + full := validWAFEvents() + full.RuleID = "abc-123" + full.Action = "block" + full.Before = "01HZZZZZZZZZZZZZZZZZZZZZZZ" + full.Limit = WAFEventsMaxLimit + if err := full.Valid(); err != nil { + t.Fatalf("a fully-populated valid request was rejected: %v", err) + } + + for _, a := range []string{"", "log", "allow", "block"} { + m := validWAFEvents() + m.Action = a + if err := m.Valid(); err != nil { + t.Fatalf("action %q must be valid: %v", a, err) + } + } + + cases := []struct { + name string + mod func(*WAFEvents) + msg string + }{ + {"missing project", func(m *WAFEvents) { m.Project = "" }, "project required"}, + {"missing location", func(m *WAFEvents) { m.Location = "" }, "location required"}, + {"unknown action", func(m *WAFEvents) { m.Action = "deny" }, "action invalid"}, + // action is a filter over stored strings, so case matters + {"upper-case action", func(m *WAFEvents) { m.Action = "Block" }, "action invalid"}, + {"negative limit", func(m *WAFEvents) { m.Limit = -1 }, "limit out of bounds"}, + {"limit over cap", func(m *WAFEvents) { m.Limit = WAFEventsMaxLimit + 1 }, "limit out of bounds"}, + {"ruleId bad shape", func(m *WAFEvents) { m.RuleID = "-abc" }, "ruleId invalid"}, + {"ruleId too long", func(m *WAFEvents) { m.RuleID = strings.Repeat("a", WAFMaxRuleIDLength+1) }, "ruleId must not exceed"}, + {"before too short", func(m *WAFEvents) { m.Before = "01HZZ" }, "before invalid"}, + {"before too long", func(m *WAFEvents) { m.Before = strings.Repeat("0", WAFEventIDLength+1) }, "before invalid"}, + // lower case would break the server's lexicographic id < before paging + {"before lower case", func(m *WAFEvents) { m.Before = strings.Repeat("z", WAFEventIDLength) }, "before invalid"}, + // I, L, O, U are not in the Crockford base32 alphabet + {"before non-crockford", func(m *WAFEvents) { m.Before = "01HI" + strings.Repeat("0", WAFEventIDLength-4) }, "before invalid"}, + } + for _, tc := range cases { + m := validWAFEvents() + tc.mod(m) + err := m.Valid() + if err == nil { + t.Fatalf("%s: must be rejected", tc.name) + } + if !strings.Contains(err.Error(), tc.msg) { + t.Fatalf("%s: error %q must contain %q", tc.name, err.Error(), tc.msg) + } + } + + // limit bounds are inclusive; 0 means the server default + for _, l := range []int{0, WAFEventsDefaultLimit, WAFEventsMaxLimit} { + m := validWAFEvents() + m.Limit = l + if err := m.Valid(); err != nil { + t.Fatalf("limit %d must be valid: %v", l, err) + } + } + + // filters are trimmed like WAFRule.ID (a copy-pasted value keeps working; + // §G blesses hand-editing the query params these map onto) + m := validWAFEvents() + m.RuleID = " abc " + m.Action = " block " + m.Before = " 01HZZZZZZZZZZZZZZZZZZZZZZZ " + if err := m.Valid(); err != nil { + t.Fatalf("padded filters must validate after trim: %v", err) + } + if m.RuleID != "abc" { + t.Fatalf("ruleId must be trimmed, got %q", m.RuleID) + } + if m.Action != "block" { + t.Fatalf("action must be trimmed, got %q", m.Action) + } + if m.Before != "01HZZZZZZZZZZZZZZZZZZZZZZZ" { + t.Fatalf("before must be trimmed, got %q", m.Before) + } +} + +func TestWAFEventsResultTable(t *testing.T) { + at := time.Date(2026, 7, 10, 12, 34, 56, 0, time.UTC) + res := &WAFEventsResult{ + Items: []*WAFEvent{{ + ID: "01HZZZZZZZZZZZZZZZZZZZZZZZ", + At: at, + RuleID: "abc", + Action: "block", + Status: 403, + ClientIP: "203.0.113.7", + Country: "TH", + ASN: 13335, + Method: "POST", + Host: "app.example.com", + Path: "/wp-login.php", + }}, + Next: "01HZZZZZZZZZZZZZZZZZZZZZZZ", + } + table := res.Table() + if len(table) != 2 { + t.Fatalf("table must have header + 1 row, got %d rows", len(table)) + } + header := []string{"TIME", "ACTION", "RULE", "IP", "COUNTRY", "METHOD", "HOST", "PATH"} + for i, h := range header { + if table[0][i] != h { + t.Fatalf("header[%d]=%q want %q", i, table[0][i], h) + } + } + row := table[1] + want := []string{"2026-07-10T12:34:56Z", "block", "abc", "203.0.113.7", "TH", "POST", "app.example.com", "/wp-login.php"} + if len(row) != len(header) { + t.Fatalf("row width %d must match header width %d", len(row), len(header)) + } + for i := range want { + if row[i] != want[i] { + t.Fatalf("row[%d]=%q want %q", i, row[i], want[i]) + } + } + + empty := (&WAFEventsResult{}).Table() + if len(empty) != 1 { + t.Fatalf("empty result must render header only, got %d rows", len(empty)) + } +} + +// TestCollectorWAFEventItemWire pins the JSON field names shared with the +// engine's cursor-endpoint Event (SPEC-waf-events §C.1/§D.1): the collector +// copies fields by name between the two, so a silent rename here would break +// the pipeline at compile-distance from both ends. +func TestCollectorWAFEventItemWire(t *testing.T) { + b, err := json.Marshal(&CollectorWAFEventItem{ + ID: "01HZZZZZZZZZZZZZZZZZZZZZZZ", + ProjectID: 42, + RuleID: "42-abc", + Action: "block", + Status: 403, + At: 1760000000, + ClientIP: "203.0.113.7", + Country: "TH", + ASN: 13335, + Method: "POST", + Host: "app.example.com", + Path: "/wp-login.php", + }) + if err != nil { + t.Fatal(err) + } + var got map[string]json.RawMessage + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + for _, k := range []string{"id", "projectId", "ruleId", "action", "status", "at", "clientIp", "country", "asn", "method", "host", "path"} { + if _, ok := got[k]; !ok { + t.Fatalf("wire field %q missing (got %s)", k, b) + } + } + if len(got) != 12 { + t.Fatalf("unexpected wire fields (got %s)", b) + } + // ProjectID travels as a string like every collector item + if string(got["projectId"]) != `"42"` { + t.Fatalf(`projectId must encode as "42", got %s`, got["projectId"]) + } +} + +func TestValidWAFEventID(t *testing.T) { + cases := []struct { + id string + ok bool + }{ + {"01HZZZZZZZZZZZZZZZZZZZZZZZ", true}, + {"00000000000000000000000000", true}, + {"0123456789ABCDEFGHJKMNPQRS", true}, // every legal char class + {"", false}, + {"01HZ", false}, + {"01HZZZZZZZZZZZZZZZZZZZZZZZZ", false}, // 27 chars + {"01hzzzzzzzzzzzzzzzzzzzzzzz", false}, // lower case + {"01HIZZZZZZZZZZZZZZZZZZZZZZ", false}, // I excluded + {"01HLZZZZZZZZZZZZZZZZZZZZZZ", false}, // L excluded + {"01HOZZZZZZZZZZZZZZZZZZZZZZ", false}, // O excluded + {"01HUZZZZZZZZZZZZZZZZZZZZZZ", false}, // U excluded + {"01H-ZZZZZZZZZZZZZZZZZZZZZZ", false}, // punctuation + {"01H ZZZZZZZZZZZZZZZZZZZZZZ", false}, // space + } + for _, tc := range cases { + if got := ValidWAFEventID(tc.id); got != tc.ok { + t.Fatalf("ValidWAFEventID(%q)=%v want %v", tc.id, got, tc.ok) + } + } +} + +// TestWAFEventWire pins the read-API JSON field names: console (TypeScript, +// no shared types), the MCP catalog, and the CLI all consume them by name, so +// a silent tag rename here would break them at runtime-distance. +func TestWAFEventWire(t *testing.T) { + b, err := json.Marshal(&WAFEvent{ + ID: "01HZZZZZZZZZZZZZZZZZZZZZZZ", + At: time.Date(2026, 7, 10, 12, 34, 56, 0, time.UTC), + RuleID: "abc", + Action: "block", + Status: 403, + ClientIP: "203.0.113.7", + Country: "TH", + ASN: 13335, + Method: "POST", + Host: "app.example.com", + Path: "/wp-login.php", + }) + if err != nil { + t.Fatal(err) + } + var got map[string]json.RawMessage + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + for _, k := range []string{"id", "at", "ruleId", "action", "status", "clientIp", "country", "asn", "method", "host", "path"} { + if _, ok := got[k]; !ok { + t.Fatalf("wire field %q missing (got %s)", k, b) + } + } + if len(got) != 11 { + t.Fatalf("unexpected wire fields (got %s)", b) + } + + rb, err := json.Marshal(&WAFEventsResult{Next: "01HZZZZZZZZZZZZZZZZZZZZZZZ"}) + if err != nil { + t.Fatal(err) + } + var res map[string]json.RawMessage + if err := json.Unmarshal(rb, &res); err != nil { + t.Fatal(err) + } + for _, k := range []string{"items", "next"} { + if _, ok := res[k]; !ok { + t.Fatalf("result wire field %q missing (got %s)", k, rb) + } + } + if len(res) != 2 { + t.Fatalf("unexpected result wire fields (got %s)", rb) + } +}