diff --git a/README.md b/README.md index 515117c..700707e 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ triage status (resolve, reopen, or mute). History-backed and best-effort, like ### waf - `get`, `list`, `delete`. -- `set -f ` `-description` — apply a WAF zone from a YAML spec (description, rules, limits). `-f` is required. +- `set -f ` `-description` — apply a WAF zone from a YAML spec (description, rules, limits, managedRules). `-f` is required. Set replaces the whole zone: a spec that omits `managedRules` disables and clears it, so always start from `waf get -oyaml` output. - `metrics` / `limitmetrics` `-time-range 1h|6h|12h|1d|7d|30d`. ### wafList (`waflist`) diff --git a/go.mod b/go.mod index bbbf001..8b6a6ce 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/deploys-app/deploys go 1.26 require ( - github.com/deploys-app/api v0.0.0-20260713024420-a04c48ceccc7 + github.com/deploys-app/api v0.0.0-20260714032659-67fc2739ece3 github.com/moonrhythm/toon v0.0.0-20260702100246-6fcdad0a6a12 golang.org/x/mod v0.37.0 golang.org/x/oauth2 v0.14.0 diff --git a/go.sum b/go.sum index 042f14e..aa4b309 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deploys-app/api v0.0.0-20260713024420-a04c48ceccc7 h1:7ecexsz+To+O0+NNPAfDI7hLgHabTjS48I8n/5TKvLw= -github.com/deploys-app/api v0.0.0-20260713024420-a04c48ceccc7/go.mod h1:X70UJs5awlRV4Cmn0FPJAgEbn4N933K8YgSKc1y9aD8= +github.com/deploys-app/api v0.0.0-20260714032659-67fc2739ece3 h1:byHI6dFiiu8694sfP/c6dr5q5ALi+tMtHEkwn7Pavoc= +github.com/deploys-app/api v0.0.0-20260714032659-67fc2739ece3/go.mod h1:X70UJs5awlRV4Cmn0FPJAgEbn4N933K8YgSKc1y9aD8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/internal/runner/help.go b/internal/runner/help.go index 3a2d190..74d79a1 100644 --- a/internal/runner/help.go +++ b/internal/runner/help.go @@ -192,7 +192,7 @@ var commands = []command{ subs: []subcommand{ {name: "get", short: "show the WAF zone"}, {name: "list", short: "list WAF zones in a project"}, - {name: "set", args: "-f [-description]", short: "apply a WAF zone from a YAML spec"}, + {name: "set", args: "-f [-description]", short: "apply a WAF zone from a YAML spec (whole replace; omitting managedRules clears it)"}, {name: "delete", short: "delete the WAF zone"}, {name: "metrics", args: "[-time-range 1h|6h|12h|1d|7d|30d]", short: "show WAF request metrics"}, {name: "limitmetrics", args: "[-time-range 1h|6h|12h|1d|7d|30d]", short: "show WAF rate-limit metrics"}, diff --git a/internal/runner/waf.go b/internal/runner/waf.go index 19a7446..2e52633 100644 --- a/internal/runner/waf.go +++ b/internal/runner/waf.go @@ -37,17 +37,19 @@ func (rn Runner) waf(args ...string) error { f.Parse(args[1:]) resp, err = s.List(context.Background(), &req) case "set": - // Set replaces the whole zone (rules and limits) all-or-nothing, so it - // takes a spec file rather than per-rule flags. The file is the yaml - // form of waf get (description, rules, limits); project and location - // flags override values in the file. + // Set replaces the whole zone (rules, limits, and managed rules) + // all-or-nothing, so it takes a spec file rather than per-rule flags. + // The file is the yaml form of waf get (description, rules, limits, + // managedRules); project and location flags override values in the + // file. Omitting managedRules disables and clears it — always start + // from waf get output. var ( fn string project string location string description string ) - f.StringVar(&fn, "f", "", "spec file (yaml: description, rules, limits)") + f.StringVar(&fn, "f", "", "spec file (yaml: description, rules, limits, managedRules)") f.StringVar(&project, "project", "", "project id") f.StringVar(&location, "location", "", "location") f.StringVar(&description, "description", "", "zone description") diff --git a/internal/runner/waf_test.go b/internal/runner/waf_test.go new file mode 100644 index 0000000..06b662d --- /dev/null +++ b/internal/runner/waf_test.go @@ -0,0 +1,86 @@ +package runner + +import ( + "reflect" + "testing" + + "github.com/deploys-app/api" + "gopkg.in/yaml.v2" +) + +// TestWAFSetSpecManagedRules pins the spec-file contract the waf set help +// promises (SPEC-waf-managed-rules §2.2/§6): the CLI never touches +// managedRules itself — it relies on the api module's yaml tags — so an api +// tag regression would silently drop or rename the field on the wire. These +// cases fail loudly instead. +func TestWAFSetSpecManagedRules(t *testing.T) { + t.Parallel() + + parse := func(t *testing.T, spec string) api.WAFSet { + t.Helper() + var req api.WAFSet + if err := yaml.Unmarshal([]byte(spec), &req); err != nil { + t.Fatalf("parse spec: %v", err) + } + return req + } + + t.Run("OmittedDecodesNil", func(t *testing.T) { + t.Parallel() + + // A pre-managedRules spec must decode to nil (= server disables and + // clears the block under whole-replace semantics), not a zero struct. + req := parse(t, "project: p1\nlocation: gke.cluster-rcf2\nrules: []\nlimits: []\n") + if req.ManagedRules != nil { + t.Fatalf("managedRules omitted, want nil, got %+v", req.ManagedRules) + } + }) + + t.Run("DisabledKeepsTuning", func(t *testing.T) { + t.Parallel() + + // enabled:false with tuning is the mid-incident toggle-off shape; the + // curated exclusion list must survive the CLI parse. + req := parse(t, ` +project: p1 +location: gke.cluster-rcf2 +managedRules: + enabled: false + excludedRules: [942100] +`) + want := &api.WAFManagedRules{Enabled: false, ExcludedRules: []int{942100}} + if !reflect.DeepEqual(req.ManagedRules, want) { + t.Fatalf("managedRules = %+v, want %+v", req.ManagedRules, want) + } + }) + + t.Run("GetOutputRoundTrips", func(t *testing.T) { + t.Parallel() + + // The documented workflow is `waf get -oyaml` → edit → `waf set -f`; + // WAFItem yaml (with its read-only extras) must feed back into WAFSet + // with the full managedRules block intact. + mr := &api.WAFManagedRules{ + Enabled: true, + Mode: "detect", + ParanoiaLevel: 2, + AnomalyThreshold: 10, + ExcludedRules: []int{941100, 942100}, + } + item := api.WAFItem{ + Project: "p1", + Location: "gke.cluster-rcf2", + Description: "zone", + ManagedRules: mr, + Status: api.Success, + } + b, err := yaml.Marshal(item) + if err != nil { + t.Fatalf("marshal waf get output: %v", err) + } + req := parse(t, string(b)) + if !reflect.DeepEqual(req.ManagedRules, mr) { + t.Fatalf("round-trip managedRules = %+v, want %+v", req.ManagedRules, mr) + } + }) +}