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
11 changes: 11 additions & 0 deletions constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions deployer.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,23 +226,40 @@ 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"`
ZoneID string `json:"zoneId"`
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
Expand Down
13 changes: 12 additions & 1 deletion location.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
}
Expand Down
95 changes: 85 additions & 10 deletions waf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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),
})
Expand All @@ -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.
Expand All @@ -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),
},
Expand Down
Loading