diff --git a/constraint.go b/constraint.go index 8ab446f..f8642a7 100644 --- a/constraint.go +++ b/constraint.go @@ -107,6 +107,17 @@ const ( WAFTestMaxQueryLength = 2048 WAFTestMaxMethodLength = 16 WAFTestMaxASN = 4294967295 // ASNs are 32-bit + + // Managed rules (OWASP CRS via the parapet Coraza engine). + // WAFManagedMaxExcludedRules bounds excludedRules; the id bounds restrict + // exclusion to the CRS detection rules — below the floor sit the platform + // SecActions (900000/900110, whose removal would strip the paranoia/ + // threshold setvars themselves) and the CRS setup (901xxx); at and above + // the ceiling sits the anomaly scoring/blocking machinery (949110 etc.), + // whose "exclusion" would be a confusing way to spell enabled:false. + WAFManagedMaxExcludedRules = 50 + WAFManagedExcludedRuleIDMin = 911100 + WAFManagedExcludedRuleIDMax = 948999 ) // WAF lists diff --git a/deployer.go b/deployer.go index 95aa1d9..cbbc351 100644 --- a/deployer.go +++ b/deployer.go @@ -226,6 +226,11 @@ type DeployerCommandDomainCertDelete struct { // zone) bound via the parapet.moonrhythm.io/ratelimit-zone annotation. An // empty Limits removes that ConfigMap and annotation, so the parapet // controller drops the zone entirely instead of keeping an empty set. +// +// ManagedRules materializes the same way again into a parapet Coraza zone +// ConfigMap (name = CorazaZoneID, labeled parapet.moonrhythm.io/coraza: zone) +// holding the generated SecLang document, bound via the +// parapet.moonrhythm.io/coraza-zone annotation. type DeployerCommandWAFSet struct { ID int64 `json:"id"` ProjectID int64 `json:"projectId"` @@ -233,16 +238,28 @@ type DeployerCommandWAFSet struct { RateLimitZoneID string `json:"rateLimitZoneId"` Rules []WAFRule `json:"rules"` Limits []WAFLimit `json:"limits"` + // CorazaZoneID is the project's Coraza (managed rules) zone ConfigMap name. + // Empty means the command came from an apiserver that predates managed + // rules — leave everything Coraza-related untouched (the RateLimitZoneID + // mixed-version pattern). + CorazaZoneID string `json:"corazaZoneId"` + // ManagedRules materializes into the Coraza zone ConfigMap; nil/disabled + // removes the ConfigMap and annotation so the engine drops the zone. + ManagedRules *WAFManagedRules `json:"managedRules"` } // DeployerCommandWAFDelete asks the location's deployer to remove the project's -// WAF zone and ratelimit zone ConfigMaps and strip the waf-zone/ratelimit-zone -// annotations from its Ingresses. Issued when waf_zones.action is Delete. +// WAF zone, ratelimit zone, and Coraza zone ConfigMaps and strip the +// waf-zone/ratelimit-zone/coraza-zone annotations from its Ingresses. Issued +// when waf_zones.action is Delete. type DeployerCommandWAFDelete struct { ID int64 `json:"id"` ProjectID int64 `json:"projectId"` ZoneID string `json:"zoneId"` RateLimitZoneID string `json:"rateLimitZoneId"` + // CorazaZoneID follows the same empty-means-untouched mixed-version guard + // as DeployerCommandWAFSet.CorazaZoneID. + CorazaZoneID string `json:"corazaZoneId"` } // DeployerCommandCacheSet asks the location's deployer to materialize the diff --git a/location.go b/location.go index 831e1e3..1cdee88 100644 --- a/location.go +++ b/location.go @@ -65,7 +65,10 @@ func (m *LocationItem) Table() [][]string { type LocationFeatures struct { WorkloadIdentity bool `json:"workloadIdentity,omitempty" yaml:"workloadIdentity"` Disk *struct{} `json:"disk,omitempty" yaml:"disk"` - WAF *struct{} `json:"waf,omitempty" yaml:"waf"` + // WAF gates the waf.* RPCs; non-nil = the location supports WAF zones. + // Stored/legacy "waf": {} unmarshals to the zero WAFLocationFeature + // (ManagedRules false), so widening from *struct{} is JSON-compatible. + WAF *WAFLocationFeature `json:"waf,omitempty" yaml:"waf"` // Cache gates the edge cache-override feature (cache.* RPCs). It is EDGE-only // and independent of WAF: enable it only for locations whose edge control // plane runs CP_CACHE_ENABLED (the apiserver cannot verify edge readiness, so @@ -81,6 +84,14 @@ type LocationFeatures struct { Transform *struct{} `json:"transform,omitempty" yaml:"transform"` } +// WAFLocationFeature parameterizes the waf feature flag. ManagedRules is the +// human contract that the location's parapet controller runs CORAZA_ENABLED +// (the apiserver cannot verify engine readiness); enabling it before the +// engine is live makes managedRules a silently bound-but-unconsumed no-op. +type WAFLocationFeature struct { + ManagedRules bool `json:"managedRules,omitempty" yaml:"managedRules"` +} + type LocationGet struct { ID string `json:"id" yaml:"id"` } diff --git a/waf.go b/waf.go index bea560d..470e1fb 100644 --- a/waf.go +++ b/waf.go @@ -228,6 +228,67 @@ func validWAFLimits(v *validator.Validator, limits []WAFLimit) { } } +// WAFManagedRules configures the zone's managed signature ruleset (OWASP Core +// Rule Set, evaluated by the parapet Coraza engine after the zone's CEL rules +// and before its rate limits). The platform generates the underlying SecLang +// document from these fields; raw SecLang is never accepted. +// +// On WAFSet the field follows the zone's whole-replace semantics: nil clears +// the whole block (disabled, tuning discarded); enabled:false disables +// enforcement but keeps the tuning (paranoia/threshold/excludedRules) stored, +// so a toggle-off during an incident doesn't destroy a curated exclusion list. +// Always waf.get, edit, and re-set. +type WAFManagedRules struct { + Enabled bool `json:"enabled" yaml:"enabled"` + // Mode: "" or "enforce" = anomaly-scored requests over the threshold are + // blocked (403); "detect" = SecRuleEngine DetectionOnly — rules evaluate + // and log but never block. + Mode string `json:"mode" yaml:"mode,omitempty"` + // ParanoiaLevel maps to tx.blocking_paranoia_level: 0 = default (1); 1..4. + // Higher levels enable stricter CRS rules with more false positives. + ParanoiaLevel int `json:"paranoiaLevel" yaml:"paranoiaLevel,omitempty"` + // AnomalyThreshold maps to tx.inbound_anomaly_score_threshold: 0 = default + // (5); 1..100. A request blocks when its summed anomaly score reaches the + // threshold (each critical match scores 5). + AnomalyThreshold int `json:"anomalyThreshold" yaml:"anomalyThreshold,omitempty"` + // ExcludedRules lists CRS rule ids to disable (SecRuleRemoveById), for + // false-positive relief. Only detection-rule ids are accepted + // (911100..948999); the CRS setup (900xxx) and scoring/evaluation + // machinery (949xxx+) cannot be excluded. + ExcludedRules []int `json:"excludedRules,omitempty" yaml:"excludedRules,omitempty"` +} + +// validWAFManagedRules validates the structural contract of a managed-rules +// block. The same checks apply whether Enabled is true or false: a disabled +// block with tuning is valid and persisted, so toggling off (e.g. mid-incident) +// never forces the tenant to rebuild a curated exclusion list on re-enable. +func validWAFManagedRules(v *validator.Validator, m *WAFManagedRules) { + v.Must(m.Mode == "" || m.Mode == "enforce" || m.Mode == "detect", "managedRules: mode invalid (want enforce|detect)") + v.Must(m.ParanoiaLevel == 0 || (m.ParanoiaLevel >= 1 && m.ParanoiaLevel <= 4), "managedRules: paranoiaLevel out of range (want 1..4)") + v.Must(m.AnomalyThreshold == 0 || (m.AnomalyThreshold >= 1 && m.AnomalyThreshold <= 100), "managedRules: anomalyThreshold out of range (want 1..100)") + + v.Mustf(len(m.ExcludedRules) <= WAFManagedMaxExcludedRules, "managedRules: excludedRules must not exceed %d rules", WAFManagedMaxExcludedRules) + seen := make(map[int]bool, len(m.ExcludedRules)) + for _, id := range m.ExcludedRules { + v.Mustf(id >= WAFManagedExcludedRuleIDMin && id <= WAFManagedExcludedRuleIDMax, "managedRules: excluded rule %d out of range (want %d..%d)", id, WAFManagedExcludedRuleIDMin, WAFManagedExcludedRuleIDMax) + v.Mustf(!seen[id], "managedRules: excluded rule %d duplicated", id) + seen[id] = true + } +} + +// crsColumn renders a managed-rules block for table output: "on" enabled, +// "off" disabled with tuning kept, "-" never configured / cleared. +func crsColumn(m *WAFManagedRules) string { + switch { + case m == nil: + return "-" + case m.Enabled: + return "on" + default: + return "off" + } +} + type WAFGet struct { Project string `json:"project" yaml:"project"` Location string `json:"location" yaml:"location"` @@ -242,15 +303,18 @@ func (m *WAFGet) Valid() error { return WrapValidate(v) } -// WAFSet upserts the project's zone, replacing the whole ruleset and limit -// set. Mirrors parapet's all-or-nothing SetRules/SetLimits: one bad rule or -// limit rejects the batch and the previous good set stays live. +// WAFSet upserts the project's zone, replacing the whole ruleset, limit set, +// and managed-rules block. Mirrors parapet's all-or-nothing SetRules/SetLimits: +// one bad rule or limit rejects the batch and the previous good set stays live. +// The whole-replace contract covers ManagedRules too: omitting the field +// clears it (see WAFManagedRules) — always waf.get, edit, re-set. type WAFSet struct { - Project string `json:"project" yaml:"project"` - Location string `json:"location" yaml:"location"` - Description string `json:"description" yaml:"description"` - Rules []WAFRule `json:"rules" yaml:"rules"` - Limits []WAFLimit `json:"limits" yaml:"limits"` + Project string `json:"project" yaml:"project"` + Location string `json:"location" yaml:"location"` + Description string `json:"description" yaml:"description"` + Rules []WAFRule `json:"rules" yaml:"rules"` + Limits []WAFLimit `json:"limits" yaml:"limits"` + ManagedRules *WAFManagedRules `json:"managedRules" yaml:"managedRules,omitempty"` } func (m *WAFSet) Valid() error { @@ -260,6 +324,9 @@ func (m *WAFSet) Valid() error { v.Must(m.Location != "", "location required") validWAFRules(v, m.Rules) validWAFLimits(v, m.Limits) + if m.ManagedRules != nil { + validWAFManagedRules(v, m.ManagedRules) + } return WrapValidate(v) } @@ -297,13 +364,14 @@ type WAFListResult struct { func (m *WAFListResult) Table() [][]string { table := [][]string{ - {"LOCATION", "RULES", "LIMITS", "STATUS", "AGE"}, + {"LOCATION", "RULES", "LIMITS", "CRS", "STATUS", "AGE"}, } for _, x := range m.Items { table = append(table, []string{ x.Location, strconv.Itoa(len(x.Rules)), strconv.Itoa(len(x.Limits)), + crsColumn(x.ManagedRules), x.Status.Text(), age(x.CreatedAt), }) @@ -317,6 +385,12 @@ type WAFItem struct { Description string `json:"description" yaml:"description"` Rules []WAFRule `json:"rules" yaml:"rules"` Limits []WAFLimit `json:"limits" yaml:"limits"` + // ManagedRules is nil (and omitted from JSON) when the block was never + // configured, was cleared by a Set that omitted it, or was set all-zero — + // the server normalizes the zero value to unset (spec §3.2), so managedRules:{} + // does not round-trip as "off". A disabled-but-tuned block round-trips + // through Get → edit → Set intact so re-enabling restores the tuning. + ManagedRules *WAFManagedRules `json:"managedRules,omitempty" yaml:"managedRules,omitempty"` // Status and Action expose the materialization state: Status is Pending // while the deployer is (un)applying the zone and Success once live; Action // is Create (set) or Delete (tearing down). Both are read-only. @@ -328,12 +402,13 @@ type WAFItem struct { func (m *WAFItem) Table() [][]string { table := [][]string{ - {"PROJECT", "LOCATION", "RULES", "LIMITS", "STATUS", "AGE"}, + {"PROJECT", "LOCATION", "RULES", "LIMITS", "CRS", "STATUS", "AGE"}, { m.Project, m.Location, strconv.Itoa(len(m.Rules)), strconv.Itoa(len(m.Limits)), + crsColumn(m.ManagedRules), m.Status.Text(), age(m.CreatedAt), }, diff --git a/waf_managedrules_test.go b/waf_managedrules_test.go new file mode 100644 index 0000000..1c571c9 --- /dev/null +++ b/waf_managedrules_test.go @@ -0,0 +1,285 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" +) + +func validManagedRulesSet() WAFSet { + return WAFSet{ + Project: "p", + Location: "gke", + ManagedRules: &WAFManagedRules{ + Enabled: true, + Mode: "enforce", + ParanoiaLevel: 2, + AnomalyThreshold: 5, + ExcludedRules: []int{942100, 920420}, + }, + } +} + +func TestWAFSetValidManagedRules(t *testing.T) { + t.Parallel() + + t.Run("valid cases", func(t *testing.T) { + cases := []struct { + name string + mutate func(*WAFSet) + }{ + {"nil block", func(m *WAFSet) { m.ManagedRules = nil }}, + {"full tuning", func(m *WAFSet) {}}, + {"zero defaults", func(m *WAFSet) { m.ManagedRules = &WAFManagedRules{Enabled: true} }}, + {"detect mode", func(m *WAFSet) { m.ManagedRules.Mode = "detect" }}, + // enabled:false with tuning kept must validate — toggling off must + // not destroy a curated exclusion list (spec §2.2). + {"disabled with tuning kept", func(m *WAFSet) { m.ManagedRules.Enabled = false }}, + {"bounds", func(m *WAFSet) { + m.ManagedRules.ParanoiaLevel = 4 + m.ManagedRules.AnomalyThreshold = 100 + m.ManagedRules.ExcludedRules = []int{WAFManagedExcludedRuleIDMin, WAFManagedExcludedRuleIDMax} + }}, + {"max excluded rules", func(m *WAFSet) { + ids := make([]int, WAFManagedMaxExcludedRules) + for i := range ids { + ids[i] = WAFManagedExcludedRuleIDMin + i + } + m.ManagedRules.ExcludedRules = ids + }}, + } + for _, tc := range cases { + m := validManagedRulesSet() + tc.mutate(&m) + if err := m.Valid(); err != nil { + t.Errorf("%s: valid WAFSet rejected: %v", tc.name, err) + } + } + }) + + t.Run("invalid cases", func(t *testing.T) { + cases := []struct { + name string + mutate func(*WAFSet) + wantMsg string + }{ + {"unknown mode", func(m *WAFSet) { m.ManagedRules.Mode = "shadow" }, "mode invalid"}, + {"paranoia too high", func(m *WAFSet) { m.ManagedRules.ParanoiaLevel = 5 }, "paranoiaLevel out of range"}, + {"paranoia negative", func(m *WAFSet) { m.ManagedRules.ParanoiaLevel = -1 }, "paranoiaLevel out of range"}, + {"threshold too high", func(m *WAFSet) { m.ManagedRules.AnomalyThreshold = 101 }, "anomalyThreshold out of range"}, + {"threshold negative", func(m *WAFSet) { m.ManagedRules.AnomalyThreshold = -1 }, "anomalyThreshold out of range"}, + // below the floor: the platform SecActions and CRS setup must be + // unreachable by exclusion. + {"excluded platform SecAction", func(m *WAFSet) { m.ManagedRules.ExcludedRules = []int{900000} }, "out of range"}, + {"excluded CRS setup", func(m *WAFSet) { m.ManagedRules.ExcludedRules = []int{901100} }, "out of range"}, + // at/above the ceiling: the blocking-evaluation machinery. + {"excluded scoring rule", func(m *WAFSet) { m.ManagedRules.ExcludedRules = []int{949110} }, "out of range"}, + {"duplicate excluded rule", func(m *WAFSet) { m.ManagedRules.ExcludedRules = []int{942100, 942100} }, "duplicated"}, + {"too many excluded rules", func(m *WAFSet) { + ids := make([]int, WAFManagedMaxExcludedRules+1) + for i := range ids { + ids[i] = WAFManagedExcludedRuleIDMin + i + } + m.ManagedRules.ExcludedRules = ids + }, "must not exceed"}, + // a disabled block is still fully validated, so garbage tuning can't + // hide behind enabled:false and explode on re-enable. + {"disabled block still validated", func(m *WAFSet) { + m.ManagedRules.Enabled = false + m.ManagedRules.ParanoiaLevel = 9 + }, "paranoiaLevel out of range"}, + } + for _, tc := range cases { + m := validManagedRulesSet() + tc.mutate(&m) + err := m.Valid() + if err == nil { + t.Errorf("%s: expected rejection", tc.name) + continue + } + if !strings.Contains(err.Error(), tc.wantMsg) { + t.Errorf("%s: error %q does not contain %q", tc.name, err.Error(), tc.wantMsg) + } + } + }) +} + +// TestWAFTableCRSColumn guards the on/off/- three-state cell: "off" (disabled +// but tuned) must render distinctly from "-" (never configured), because the +// two states differ in what a re-enable restores. +func TestWAFTableCRSColumn(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + managed *WAFManagedRules + want string + }{ + {"unset", nil, "-"}, + {"enabled", &WAFManagedRules{Enabled: true}, "on"}, + {"disabled with tuning", &WAFManagedRules{ExcludedRules: []int{942100}}, "off"}, + } + + for _, tc := range cases { + item := WAFItem{Project: "p", Location: "gke", ManagedRules: tc.managed} + + table := item.Table() + header, row := table[0], table[1] + if header[4] != "CRS" { + t.Fatalf("%s: WAFItem header[4] = %q, want CRS", tc.name, header[4]) + } + if row[4] != tc.want { + t.Errorf("%s: WAFItem CRS cell = %q, want %q", tc.name, row[4], tc.want) + } + + list := WAFListResult{Project: "p", Items: []*WAFItem{&item}} + table = list.Table() + header, row = table[0], table[1] + if header[3] != "CRS" { + t.Fatalf("%s: WAFListResult header[3] = %q, want CRS", tc.name, header[3]) + } + if row[3] != tc.want { + t.Errorf("%s: WAFListResult CRS cell = %q, want %q", tc.name, row[3], tc.want) + } + } +} + +// TestWAFManagedRulesWire pins the wire field names — the console, CLI, MCP, +// apiserver JSONB column, and deployer command all round-trip these exact +// keys, and the deployer's mixed-version guard depends on corazaZoneId +// unmarshaling to "" from old-apiserver commands. +func TestWAFManagedRulesWire(t *testing.T) { + t.Parallel() + + t.Run("WAFSet round-trip", func(t *testing.T) { + in := validManagedRulesSet() + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{`"managedRules"`, `"enabled"`, `"mode"`, `"paranoiaLevel"`, `"anomalyThreshold"`, `"excludedRules"`} { + if !strings.Contains(string(b), key) { + t.Errorf("marshaled WAFSet missing %s: %s", key, b) + } + } + var out WAFSet + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + if out.ManagedRules == nil || !out.ManagedRules.Enabled || out.ManagedRules.ParanoiaLevel != 2 || + out.ManagedRules.Mode != "enforce" || len(out.ManagedRules.ExcludedRules) != 2 { + t.Errorf("round-trip mismatch: %+v", out.ManagedRules) + } + }) + + t.Run("item omits unset managed rules", func(t *testing.T) { + b, err := json.Marshal(WAFItem{Project: "p", Location: "gke"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), `"managedRules"`) { + t.Errorf(`unset managedRules must be omitted, not emitted as null: %s`, b) + } + + b, err = json.Marshal(WAFItem{Project: "p", Location: "gke", ManagedRules: &WAFManagedRules{}}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), `"excludedRules"`) { + t.Errorf(`empty excludedRules must be omitted, not emitted as null: %s`, b) + } + if !strings.Contains(string(b), `"managedRules"`) { + t.Errorf(`configured managedRules block must be emitted: %s`, b) + } + }) + + t.Run("old payload leaves field nil", func(t *testing.T) { + var m WAFSet + if err := json.Unmarshal([]byte(`{"project":"p","location":"gke","rules":[],"limits":[]}`), &m); err != nil { + t.Fatal(err) + } + if m.ManagedRules != nil { + t.Errorf("absent managedRules must unmarshal to nil, got %+v", m.ManagedRules) + } + }) + + t.Run("deployer command", func(t *testing.T) { + cmd := DeployerCommandWAFSet{ + ID: 1, + ProjectID: 42, + ZoneID: "waf-42", + CorazaZoneID: "coraza-42", + ManagedRules: &WAFManagedRules{Enabled: true}, + } + b, err := json.Marshal(cmd) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{`"corazaZoneId":"coraza-42"`, `"managedRules"`} { + if !strings.Contains(string(b), key) { + t.Errorf("marshaled DeployerCommandWAFSet missing %s: %s", key, b) + } + } + + var oldSet DeployerCommandWAFSet + if err := json.Unmarshal([]byte(`{"id":1,"projectId":42,"zoneId":"waf-42","rateLimitZoneId":"ratelimit-42","rules":[],"limits":[]}`), &oldSet); err != nil { + t.Fatal(err) + } + if oldSet.CorazaZoneID != "" || oldSet.ManagedRules != nil { + t.Errorf("old-apiserver WAFSet command must leave Coraza fields zero, got %q %+v", oldSet.CorazaZoneID, oldSet.ManagedRules) + } + + var oldDelete DeployerCommandWAFDelete + if err := json.Unmarshal([]byte(`{"id":1,"projectId":42,"zoneId":"waf-42","rateLimitZoneId":"ratelimit-42"}`), &oldDelete); err != nil { + t.Fatal(err) + } + if oldDelete.CorazaZoneID != "" { + t.Errorf("old-apiserver WAFDelete command must leave CorazaZoneID empty, got %q", oldDelete.CorazaZoneID) + } + }) +} + +// TestWAFLocationFeatureCompat guards the *struct{} → *WAFLocationFeature +// widening: every stored/legacy `"waf": {}` must keep meaning "WAF supported, +// managed rules off", and absence must stay nil (feature off). +func TestWAFLocationFeatureCompat(t *testing.T) { + t.Parallel() + + var f LocationFeatures + if err := json.Unmarshal([]byte(`{"waf":{}}`), &f); err != nil { + t.Fatal(err) + } + if f.WAF == nil { + t.Fatal(`legacy {"waf":{}} must unmarshal to a non-nil WAF feature`) + } + if f.WAF.ManagedRules { + t.Error(`legacy {"waf":{}} must not enable managed rules`) + } + + f = LocationFeatures{} + if err := json.Unmarshal([]byte(`{"waf":{"managedRules":true}}`), &f); err != nil { + t.Fatal(err) + } + if f.WAF == nil || !f.WAF.ManagedRules { + t.Errorf("managedRules flag lost: %+v", f.WAF) + } + + f = LocationFeatures{} + if err := json.Unmarshal([]byte(`{}`), &f); err != nil { + t.Fatal(err) + } + if f.WAF != nil { + t.Errorf("absent waf feature must stay nil, got %+v", f.WAF) + } + + // Marshal direction: a zero WAFLocationFeature must round-trip back to the + // legacy {"waf":{}} shape (ManagedRules omitempty), so stored features and + // old-CLI location output stay byte-stable if the server re-marshals them. + b, err := json.Marshal(LocationFeatures{WAF: &WAFLocationFeature{}}) + if err != nil { + t.Fatal(err) + } + if string(b) != `{"waf":{}}` { + t.Errorf(`zero WAF feature must marshal to {"waf":{}}, got %s`, b) + } +}