From 3df3fe02875f8658eb7749ea5f4123bca2c6669c Mon Sep 17 00:00:00 2001 From: Joshua Sierles Date: Wed, 8 Jul 2026 16:19:52 +0200 Subject: [PATCH] Add zone WAF, bot-management, and rulesets commands Expose read-only Bot Management settings, ruleset inventory, and GraphQL firewall block analytics so agents can inspect Cloudflare WAF without ad-hoc API calls. Co-authored-by: Cursor --- README.md | 5 + bin/build | 4 + cmd/scopes.go | 27 ++++ cmd/zones_waf.go | 286 +++++++++++++++++++++++++++++++++++++++++ cmd/zones_waf_test.go | 47 +++++++ internal/cf/client.go | 17 +++ internal/cf/graphql.go | 141 ++++++++++++++++++++ 7 files changed, 527 insertions(+) create mode 100644 cmd/zones_waf.go create mode 100644 cmd/zones_waf_test.go create mode 100644 internal/cf/graphql.go diff --git a/README.md b/README.md index b4e6a96..7fd9e51 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,11 @@ cfcli zones get ZONE_ID cfcli zones settings list --zone-name example.com cfcli zones settings get email_obfuscation --zone-name example.com +cfcli zones bot-management get --zone-name example.com +cfcli zones rulesets list --zone-name example.com +cfcli zones rulesets get RULESET_ID --zone-name example.com +cfcli zones waf analytics --zone-name example.com --since 7d + cfcli dns list --zone-id ZONE_ID cfcli dns list --zone-name example.com --type A cfcli dns get --zone-id ZONE_ID --record-id RECORD_ID diff --git a/bin/build b/bin/build index f764c7c..97d92ba 100755 --- a/bin/build +++ b/bin/build @@ -18,6 +18,10 @@ echo "==> smoke testing" "$output" --help >/dev/null "$output" zones list --help >/dev/null "$output" zones get --help >/dev/null +"$output" zones settings list --help >/dev/null +"$output" zones bot-management get --help >/dev/null +"$output" zones rulesets list --help >/dev/null +"$output" zones waf analytics --help >/dev/null "$output" dns list --help >/dev/null "$output" dns get --help >/dev/null "$output" accounts list --help >/dev/null diff --git a/cmd/scopes.go b/cmd/scopes.go index 1912949..f15a6f5 100644 --- a/cmd/scopes.go +++ b/cmd/scopes.go @@ -111,6 +111,33 @@ func requiredScopes() []scopeInfo { "Custom: Zone / Zone Settings / Read", }, }, + { + Command: "zones bot-management get", + API: "Bot Management API", + Permission: "Zone → Bot Management → Read", + TokenTemplates: []string{ + "Custom: Zone / Bot Management / Read", + }, + }, + { + Command: "zones rulesets list|get", + API: "Rulesets API", + Permission: "Zone → Firewall Services → Read", + TokenTemplates: []string{ + "Custom: Zone / Firewall Services / Read", + }, + }, + { + Command: "zones waf analytics", + API: "GraphQL Analytics API", + Permission: "Zone → Analytics → Read", + Notes: []string{ + "Uses firewallEventsAdaptiveGroups via the Cloudflare GraphQL API.", + }, + TokenTemplates: []string{ + "Custom: Zone / Analytics / Read", + }, + }, { Command: "dns list|get", API: "DNS Records API", diff --git a/cmd/zones_waf.go b/cmd/zones_waf.go new file mode 100644 index 0000000..a45227d --- /dev/null +++ b/cmd/zones_waf.go @@ -0,0 +1,286 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/bandzoogle/cloudflare-cli/internal/output" + "github.com/cloudflare/cloudflare-go" + "github.com/spf13/cobra" +) + +var zonesBotManagementCmd = &cobra.Command{ + Use: "bot-management", + Short: "Bot Management settings (read-only)", +} + +var zonesBotManagementGetCmd = &cobra.Command{ + Use: "get", + Short: "Get Bot Management configuration for a zone", + Long: `Read Bot Management / AI bot protection settings for a zone. + +Includes ai_bots_protection, Super Bot Fight Mode heuristics, and related flags.`, + Example: ` cfcli zones bot-management get --zone-name bandzoogle.com`, + PreRunE: validateZoneFlags, + RunE: runZonesBotManagementGet, +} + +var zonesRulesetsCmd = &cobra.Command{ + Use: "rulesets", + Short: "Zone rulesets (read-only)", +} + +type rulesetSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Phase string `json:"phase"` + Kind string `json:"kind"` + Rules int `json:"rules"` + EnabledRules int `json:"enabled_rules"` +} + +var zonesRulesetsListCmd = &cobra.Command{ + Use: "list", + Short: "List rulesets deployed on a zone", + Example: ` cfcli zones rulesets list --zone-name bandzoogle.com`, + PreRunE: validateZoneFlags, + RunE: runZonesRulesetsList, +} + +var zonesRulesetsGetCmd = &cobra.Command{ + Use: "get RULESET_ID", + Short: "Get a ruleset by ID (includes rules)", + Example: ` cfcli zones rulesets get efb7b8c949ac4650a09736fc376e9aee --zone-name bandzoogle.com`, + Args: cobra.ExactArgs(1), + PreRunE: validateZoneFlags, + RunE: runZonesRulesetsGet, +} + +var zonesWafCmd = &cobra.Command{ + Use: "waf", + Short: "WAF analytics (read-only)", +} + +var zonesWafAnalyticsCmd = &cobra.Command{ + Use: "analytics", + Short: "Summarize recent firewall block events for a zone", + Long: `Query Cloudflare GraphQL firewallEventsAdaptiveGroups for a zone. + +Groups blocked/challenged requests by rule description and host. Requires +analytics read permission on the API token.`, + Example: ` cfcli zones waf analytics --zone-name bandzoogle.com --since 7d + cfcli zones waf analytics --zone-id ZONE_ID --since 24h --limit 15`, + PreRunE: validateZoneFlags, + RunE: runZonesWAFAnalytics, +} + +func init() { + zonesCmd.AddCommand(zonesBotManagementCmd) + zonesBotManagementCmd.AddCommand(zonesBotManagementGetCmd) + + zonesCmd.AddCommand(zonesRulesetsCmd) + zonesRulesetsCmd.AddCommand(zonesRulesetsListCmd) + zonesRulesetsCmd.AddCommand(zonesRulesetsGetCmd) + + zonesCmd.AddCommand(zonesWafCmd) + zonesWafCmd.AddCommand(zonesWafAnalyticsCmd) + + for _, c := range []*cobra.Command{ + zonesBotManagementGetCmd, + zonesRulesetsListCmd, + zonesRulesetsGetCmd, + zonesWafAnalyticsCmd, + } { + c.Flags().String("zone-id", "", "Zone ID") + c.Flags().String("zone-name", "", "Zone apex domain name (resolved to zone ID)") + } + + zonesWafAnalyticsCmd.Flags().String("since", "24h", "Lookback window (e.g. 24h, 72h, 7d)") + zonesWafAnalyticsCmd.Flags().Int("limit", 25, "Maximum grouped rows to return") +} + +func runZonesBotManagementGet(cmd *cobra.Command, args []string) error { + client, err := cfClient(cmd) + if err != nil { + return err + } + ctx := commandContext() + zid, _ := cmd.Flags().GetString("zone-id") + zname, _ := cmd.Flags().GetString("zone-name") + zoneID, err := resolveZoneFlags(ctx, client, zid, zname) + if err != nil { + return err + } + + bm, err := client.API.GetBotManagement(ctx, cloudflare.ZoneIdentifier(zoneID)) + if err != nil { + return apiError("zones bot-management get", err) + } + + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "zones bot-management get", "zone_id": zoneID}, + meta(client.DefaultAccountID(), map[string]any{"zone_query": zname}), + bm, + outputOptions(), + ) +} + +func runZonesRulesetsList(cmd *cobra.Command, args []string) error { + client, err := cfClient(cmd) + if err != nil { + return err + } + ctx := commandContext() + zid, _ := cmd.Flags().GetString("zone-id") + zname, _ := cmd.Flags().GetString("zone-name") + zoneID, err := resolveZoneFlags(ctx, client, zid, zname) + if err != nil { + return err + } + + rulesets, err := client.API.ListRulesets(ctx, cloudflare.ZoneIdentifier(zoneID), cloudflare.ListRulesetsParams{}) + if err != nil { + return apiError("zones rulesets list", err) + } + + summaries := make([]rulesetSummary, 0, len(rulesets)) + for _, rs := range rulesets { + summaries = append(summaries, summarizeRuleset(rs)) + } + + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "zones rulesets list", "zone_id": zoneID}, + meta(client.DefaultAccountID(), map[string]any{ + "zone_query": zname, + "count": len(summaries), + }), + summaries, + outputOptions(), + ) +} + +func runZonesRulesetsGet(cmd *cobra.Command, args []string) error { + client, err := cfClient(cmd) + if err != nil { + return err + } + ctx := commandContext() + zid, _ := cmd.Flags().GetString("zone-id") + zname, _ := cmd.Flags().GetString("zone-name") + rulesetID := args[0] + zoneID, err := resolveZoneFlags(ctx, client, zid, zname) + if err != nil { + return err + } + + rs, err := client.API.GetRuleset(ctx, cloudflare.ZoneIdentifier(zoneID), rulesetID) + if err != nil { + return apiError("zones rulesets get", err) + } + + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "zones rulesets get", "zone_id": zoneID, "ruleset_id": rulesetID}, + meta(client.DefaultAccountID(), map[string]any{ + "zone_query": zname, + "enabled_rules": countEnabledRules(rs.Rules), + "rules": len(rs.Rules), + }), + rs, + outputOptions(), + ) +} + +func runZonesWAFAnalytics(cmd *cobra.Command, args []string) error { + client, err := cfClient(cmd) + if err != nil { + return err + } + ctx := commandContext() + zid, _ := cmd.Flags().GetString("zone-id") + zname, _ := cmd.Flags().GetString("zone-name") + sinceFlag, _ := cmd.Flags().GetString("since") + limit, _ := cmd.Flags().GetInt("limit") + + lookback, err := parseSinceDuration(sinceFlag) + if err != nil { + return err + } + zoneID, err := resolveZoneFlags(ctx, client, zid, zname) + if err != nil { + return err + } + + groups, err := client.ListFirewallEventGroups(ctx, zoneID, time.Now().Add(-lookback), limit) + if err != nil { + return apiError("zones waf analytics", err) + } + + total := 0 + for _, g := range groups { + total += g.Count + } + + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "zones waf analytics", "zone_id": zoneID, "since": sinceFlag}, + meta(client.DefaultAccountID(), map[string]any{ + "zone_query": zname, + "count": len(groups), + "total_events": total, + }), + groups, + outputOptions(), + ) +} + +func summarizeRuleset(rs cloudflare.Ruleset) rulesetSummary { + return rulesetSummary{ + ID: rs.ID, + Name: rs.Name, + Phase: rs.Phase, + Kind: rs.Kind, + Rules: len(rs.Rules), + EnabledRules: countEnabledRules(rs.Rules), + } +} + +func countEnabledRules(rules []cloudflare.RulesetRule) int { + n := 0 + for _, rule := range rules { + if rule.Enabled != nil && *rule.Enabled { + n++ + } + } + return n +} + +func parseSinceDuration(raw string) (time.Duration, error) { + raw = trimSpace(raw) + if raw == "" { + return 24 * time.Hour, nil + } + if d, err := time.ParseDuration(raw); err == nil { + return d, nil + } + if len(raw) >= 2 && raw[len(raw)-1] == 'd' { + var days int + if _, err := fmt.Sscanf(raw, "%dd", &days); err != nil { + return 0, fmt.Errorf("invalid --since %q: use Go duration (24h) or days (7d)", raw) + } + if days <= 0 { + return 0, fmt.Errorf("--since days must be positive") + } + return time.Duration(days) * 24 * time.Hour, nil + } + return 0, fmt.Errorf("invalid --since %q: use Go duration (24h) or days (7d)", raw) +} + +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end && (s[start] == ' ' || s[start] == '\t') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { + end-- + } + return s[start:end] +} diff --git a/cmd/zones_waf_test.go b/cmd/zones_waf_test.go new file mode 100644 index 0000000..fe43b9f --- /dev/null +++ b/cmd/zones_waf_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/cloudflare/cloudflare-go" +) + +func TestParseSinceDurationHours(t *testing.T) { + d, err := parseSinceDuration("24h") + if err != nil { + t.Fatal(err) + } + if d != 24*time.Hour { + t.Fatalf("got %v", d) + } +} + +func TestParseSinceDurationDays(t *testing.T) { + d, err := parseSinceDuration("7d") + if err != nil { + t.Fatal(err) + } + if d != 7*24*time.Hour { + t.Fatalf("got %v", d) + } +} + +func TestParseSinceDurationInvalid(t *testing.T) { + if _, err := parseSinceDuration("bad"); err == nil { + t.Fatal("expected error") + } +} + +func TestCountEnabledRules(t *testing.T) { + enabled := true + disabled := false + got := countEnabledRules([]cloudflare.RulesetRule{ + {Enabled: &enabled}, + {Enabled: &disabled}, + {Enabled: nil}, + }) + if got != 1 { + t.Fatalf("got %d", got) + } +} diff --git a/internal/cf/client.go b/internal/cf/client.go index 3315732..c6afeab 100644 --- a/internal/cf/client.go +++ b/internal/cf/client.go @@ -21,6 +21,8 @@ type Options struct { type Client struct { API *cloudflare.API AccountID string + apiToken string + http *http.Client } func ResolveOptions(opts Options) Options { @@ -55,9 +57,24 @@ func NewClient(opts Options) (*Client, error) { return &Client{ API: api, AccountID: opts.AccountID, + apiToken: opts.APIToken, + http: httpClient, }, nil } +// APIToken returns the configured API token. +func (c *Client) APIToken() string { + return c.apiToken +} + +// HTTPClient returns the HTTP client used for API calls. +func (c *Client) HTTPClient() *http.Client { + if c.http != nil { + return c.http + } + return http.DefaultClient +} + func validateAuth(opts Options) error { if strings.TrimSpace(opts.APIToken) == "" { return errors.New("missing Cloudflare API token: set CLOUDFLARE_API_TOKEN or CF_API_TOKEN, or pass --api-token") diff --git a/internal/cf/graphql.go b/internal/cf/graphql.go new file mode 100644 index 0000000..1ac3fe1 --- /dev/null +++ b/internal/cf/graphql.go @@ -0,0 +1,141 @@ +package cf + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const graphqlEndpoint = "https://api.cloudflare.com/client/v4/graphql" + +type graphqlRequest struct { + Query string `json:"query"` +} + +type graphqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []json.RawMessage `json:"errors"` +} + +// FirewallEventGroup is a grouped firewall event row from GraphQL analytics. +type FirewallEventGroup struct { + Count int `json:"count"` + Action string `json:"action"` + Description string `json:"description"` + Source string `json:"source"` + Host string `json:"host"` + RuleID string `json:"rule_id"` +} + +type firewallEventsQuery struct { + Viewer struct { + Zones []struct { + FirewallEventsAdaptiveGroups []struct { + Count int `json:"count"` + Dimensions struct { + Action string `json:"action"` + Description string `json:"description"` + Source string `json:"source"` + ClientRequestHTTPHost string `json:"clientRequestHTTPHost"` + RuleID string `json:"ruleId"` + } `json:"dimensions"` + } `json:"firewallEventsAdaptiveGroups"` + } `json:"zones"` + } `json:"viewer"` +} + +// ListFirewallEventGroups queries firewallEventsAdaptiveGroups for a zone. +func (c *Client) ListFirewallEventGroups(ctx context.Context, zoneID string, since time.Time, limit int) ([]FirewallEventGroup, error) { + if limit <= 0 { + limit = 25 + } + sinceISO := since.UTC().Format(time.RFC3339) + query := fmt.Sprintf(`{ + viewer { + zones(filter: {zoneTag: %q}) { + firewallEventsAdaptiveGroups( + limit: %d, + filter: {datetime_geq: %q}, + orderBy: [count_DESC] + ) { + count + dimensions { + action + description + source + clientRequestHTTPHost + ruleId + } + } + } + } +}`, zoneID, limit, sinceISO) + + body, err := json.Marshal(graphqlRequest{Query: query}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, graphqlEndpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.APIToken()) + req.Header.Set("Content-Type", "application/json") + + httpClient := c.HTTPClient() + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("graphql request failed: HTTP %d: %s", resp.StatusCode, truncate(string(raw), 300)) + } + + var envelope graphqlResponse + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, err + } + if len(envelope.Errors) > 0 { + return nil, fmt.Errorf("graphql errors: %s", truncate(string(envelope.Errors[0]), 300)) + } + + var parsed firewallEventsQuery + if err := json.Unmarshal(envelope.Data, &parsed); err != nil { + return nil, err + } + if len(parsed.Viewer.Zones) == 0 { + return []FirewallEventGroup{}, nil + } + + rows := parsed.Viewer.Zones[0].FirewallEventsAdaptiveGroups + out := make([]FirewallEventGroup, 0, len(rows)) + for _, row := range rows { + out = append(out, FirewallEventGroup{ + Count: row.Count, + Action: row.Dimensions.Action, + Description: row.Dimensions.Description, + Source: row.Dimensions.Source, + Host: row.Dimensions.ClientRequestHTTPHost, + RuleID: row.Dimensions.RuleID, + }) + } + return out, nil +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +}