From 9686b18fad9d0efc1d576f5e2f47160bb258ed26 Mon Sep 17 00:00:00 2001 From: Joshua Sierles Date: Fri, 10 Jul 2026 18:17:47 +0200 Subject: [PATCH] Add appsec WAF commands for Datadog ASM inventory Expose custom rules, exclusion filters, and blocked-rule summaries so agents can inventory in-app WAF config without the Datadog UI. Assisted-by: Cursor (gpt-5.5-medium) Co-authored-by: Cursor --- README.md | 3 + bin/build | 2 + cmd/appsec.go | 257 ++++++++++++++++++++++++++++++++ cmd/scopes.go | 24 +++ internal/appsec/summary.go | 132 ++++++++++++++++ internal/appsec/summary_test.go | 79 ++++++++++ 6 files changed, 497 insertions(+) create mode 100644 cmd/appsec.go create mode 100644 internal/appsec/summary.go create mode 100644 internal/appsec/summary_test.go diff --git a/README.md b/README.md index e3110d7..513d927 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ ddcli hosts totals ddcli dashboards list --query app --limit 100 ddcli dashboards get abc-def-ghi ddcli apm spans --query 'service:api @http.status_code:500' --from now-15m --to now --limit 25 +ddcli appsec blocked-rules summary --from now-7d --limit 200 --pretty +ddcli appsec custom-rules list +ddcli appsec exclusion-filters list ddcli errors search --query 'service:api' --track trace --from now-1h --to now ddcli errors get ISSUE_ID ddcli cost analyze --group-by service --from now-30d --to now-2d --limit 25 diff --git a/bin/build b/bin/build index abd339d..3389575 100755 --- a/bin/build +++ b/bin/build @@ -22,6 +22,8 @@ echo "==> smoke testing" "$output" hosts list --help >/dev/null "$output" dashboards list --help >/dev/null "$output" apm spans --help >/dev/null +"$output" appsec blocked-rules summary --help >/dev/null +"$output" appsec custom-rules list --help >/dev/null "$output" errors search --help >/dev/null "$output" cost analyze --help >/dev/null "$output" scopes --help >/dev/null diff --git a/cmd/appsec.go b/cmd/appsec.go new file mode 100644 index 0000000..af30332 --- /dev/null +++ b/cmd/appsec.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadog" + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" + "github.com/bandzoogle/datadog-cli/internal/appsec" + "github.com/bandzoogle/datadog-cli/internal/dd" + "github.com/bandzoogle/datadog-cli/internal/output" + "github.com/spf13/cobra" +) + +var appsecCmd = &cobra.Command{ + Use: "appsec", + Short: "Application Security (ASM / in-app WAF) read-only queries", + Long: `Read-only Datadog Application Security configuration and blocked-rule summaries. + +Custom rules and exclusion filters require appsec_protect_read on the DD_APP_KEY. +Blocked-rule summaries use APM spans (apm_read) and work without appsec_protect_read.`, +} + +var appsecCustomRulesCmd = &cobra.Command{ + Use: "custom-rules", + Short: "List or get WAF custom rules", +} + +var appsecCustomRulesListCmd = &cobra.Command{ + Use: "list", + Short: "List all WAF custom rules", + RunE: runAppsecCustomRulesList, +} + +var appsecCustomRulesGetCmd = &cobra.Command{ + Use: "get RULE_ID", + Short: "Get a WAF custom rule by ID", + Args: cobra.ExactArgs(1), + RunE: runAppsecCustomRulesGet, +} + +var appsecExclusionFiltersCmd = &cobra.Command{ + Use: "exclusion-filters", + Aliases: []string{"exclusions", "passlist"}, + Short: "List or get WAF exclusion filters (passlist entries)", +} + +var appsecExclusionFiltersListCmd = &cobra.Command{ + Use: "list", + Short: "List all WAF exclusion filters", + RunE: runAppsecExclusionFiltersList, +} + +var appsecExclusionFiltersGetCmd = &cobra.Command{ + Use: "get FILTER_ID", + Short: "Get a WAF exclusion filter by ID", + Args: cobra.ExactArgs(1), + RunE: runAppsecExclusionFiltersGet, +} + +var appsecBlockedRulesCmd = &cobra.Command{ + Use: "blocked-rules", + Short: "Summarize blocked AppSec rules from APM spans", +} + +var appsecBlockedRulesSummaryCmd = &cobra.Command{ + Use: "summary", + Short: "Summarize blocked AppSec rules from APM spans", + Long: `Aggregate Datadog in-app WAF rule hits from blocked AppSec spans. + +Uses the Spans API (apm_read). Default query: + @appsec.blocked:true env:production service:web`, + RunE: runAppsecBlockedRulesSummary, +} + +func init() { + rootCmd.AddCommand(appsecCmd) + appsecCmd.AddCommand(appsecCustomRulesCmd) + appsecCmd.AddCommand(appsecExclusionFiltersCmd) + appsecCmd.AddCommand(appsecBlockedRulesCmd) + appsecBlockedRulesCmd.AddCommand(appsecBlockedRulesSummaryCmd) + + appsecCustomRulesCmd.AddCommand(appsecCustomRulesListCmd) + appsecCustomRulesCmd.AddCommand(appsecCustomRulesGetCmd) + appsecExclusionFiltersCmd.AddCommand(appsecExclusionFiltersListCmd) + appsecExclusionFiltersCmd.AddCommand(appsecExclusionFiltersGetCmd) + + appsecBlockedRulesSummaryCmd.Flags().String("query", "@appsec.blocked:true env:production service:web", "Span search query") + appsecBlockedRulesSummaryCmd.Flags().String("from", "now-7d", "Start time, e.g. now-7d or RFC3339") + appsecBlockedRulesSummaryCmd.Flags().String("to", "now", "End time, e.g. now or RFC3339") + appsecBlockedRulesSummaryCmd.Flags().Int32("limit", 200, "Maximum spans to scan") + appsecBlockedRulesSummaryCmd.Flags().String("cursor", "", "Pagination cursor from a previous response") +} + +func runAppsecCustomRulesList(cmd *cobra.Command, args []string) error { + client, err := datadogClient(cmd) + if err != nil { + return err + } + api := datadogV2.NewApplicationSecurityApi(client.API) + resp, httpResp, err := api.ListApplicationSecurityWAFCustomRules(client.Context) + if err != nil { + return appsecAPIError("appsec custom-rules list", httpResp, err) + } + count := 0 + if resp.Data != nil { + count = len(resp.Data) + } + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "appsec custom-rules list"}, + meta(client.Site, map[string]any{"count": count}, httpResp), + resp, + outputOptions(), + ) +} + +func runAppsecCustomRulesGet(cmd *cobra.Command, args []string) error { + client, err := datadogClient(cmd) + if err != nil { + return err + } + ruleID := args[0] + api := datadogV2.NewApplicationSecurityApi(client.API) + resp, httpResp, err := api.GetApplicationSecurityWafCustomRule(client.Context, ruleID) + if err != nil { + return appsecAPIError("appsec custom-rules get", httpResp, err) + } + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "appsec custom-rules get", "rule_id": ruleID}, + meta(client.Site, map[string]any{"rule_id": ruleID}, httpResp), + resp, + outputOptions(), + ) +} + +func runAppsecExclusionFiltersList(cmd *cobra.Command, args []string) error { + client, err := datadogClient(cmd) + if err != nil { + return err + } + api := datadogV2.NewApplicationSecurityApi(client.API) + resp, httpResp, err := api.ListApplicationSecurityWafExclusionFilters(client.Context) + if err != nil { + return appsecAPIError("appsec exclusion-filters list", httpResp, err) + } + count := 0 + if resp.Data != nil { + count = len(resp.Data) + } + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "appsec exclusion-filters list"}, + meta(client.Site, map[string]any{"count": count}, httpResp), + resp, + outputOptions(), + ) +} + +func runAppsecExclusionFiltersGet(cmd *cobra.Command, args []string) error { + client, err := datadogClient(cmd) + if err != nil { + return err + } + filterID := args[0] + api := datadogV2.NewApplicationSecurityApi(client.API) + resp, httpResp, err := api.GetApplicationSecurityWafExclusionFilter(client.Context, filterID) + if err != nil { + return appsecAPIError("appsec exclusion-filters get", httpResp, err) + } + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "appsec exclusion-filters get", "filter_id": filterID}, + meta(client.Site, map[string]any{"filter_id": filterID}, httpResp), + resp, + outputOptions(), + ) +} + +func runAppsecBlockedRulesSummary(cmd *cobra.Command, args []string) error { + client, err := datadogClient(cmd) + if err != nil { + return err + } + query, _ := cmd.Flags().GetString("query") + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + limit, _ := cmd.Flags().GetInt32("limit") + cursor, _ := cmd.Flags().GetString("cursor") + + spans, httpResp, nextCursor, err := listSpans(client, query, from, to, limit, cursor) + if err != nil { + return apiError("appsec blocked-rules summary", httpResp, err) + } + + summary := appsec.AggregateBlockedRules(spans) + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "appsec blocked-rules summary", "filter": query}, + meta(client.Site, map[string]any{ + "from": from, + "to": to, + "limit": limit, + "cursor": cursor, + "next_cursor": nextCursor, + "spans_scanned": summary.SpansScanned, + "unique_rules": len(summary.Rules), + }, httpResp), + summary, + outputOptions(), + ) +} + +func listSpans(client *dd.Client, query, from, to string, limit int32, cursor string) ([]datadogV2.Span, *http.Response, string, error) { + request := datadogV2.SpansListRequest{ + Data: &datadogV2.SpansListRequestData{ + Attributes: &datadogV2.SpansListRequestAttributes{ + Filter: &datadogV2.SpansQueryFilter{ + From: datadog.PtrString(from), + Query: datadog.PtrString(query), + To: datadog.PtrString(to), + }, + Page: &datadogV2.SpansListRequestPage{ + Limit: datadog.PtrInt32(limit), + }, + Sort: datadogV2.SPANSSORT_TIMESTAMP_DESCENDING.Ptr(), + }, + Type: datadogV2.SPANSLISTREQUESTTYPE_SEARCH_REQUEST.Ptr(), + }, + } + if cursor != "" { + request.Data.Attributes.Page.Cursor = datadog.PtrString(cursor) + } + + api := datadogV2.NewSpansApi(client.API) + resp, httpResp, err := api.ListSpans(client.Context, request) + if err != nil { + return nil, httpResp, "", err + } + + spans := []datadogV2.Span{} + if resp.Data != nil { + spans = resp.Data + } + + nextCursor := "" + if resp.Meta != nil && resp.Meta.Page != nil && resp.Meta.Page.After != nil { + nextCursor = *resp.Meta.Page.After + } + return spans, httpResp, nextCursor, nil +} + +func appsecAPIError(operation string, resp *http.Response, err error) error { + if err == nil { + return nil + } + if resp != nil && resp.StatusCode == 403 { + return fmt.Errorf("%s failed: %w (http 403: grant appsec_protect_read to the DD_APP_KEY owner; see ddcli scopes --command appsec)", operation, err) + } + return apiError(operation, resp, err) +} diff --git a/cmd/scopes.go b/cmd/scopes.go index 5a0b28a..e792de7 100644 --- a/cmd/scopes.go +++ b/cmd/scopes.go @@ -140,6 +140,30 @@ func requiredScopes() []scopeInfo { Permission: "apm_read", OAuthScope: "apm_read", }, + { + Command: "appsec blocked-rules summary", + API: "Spans API", + Permission: "apm_read", + OAuthScope: "apm_read", + Notes: []string{ + "Aggregates in-app WAF rule hits from @appsec.blocked spans.", + }, + }, + { + Command: "appsec custom-rules list|get", + API: "Application Security WAF custom rules API", + Permission: "appsec_protect_read", + OAuthScope: "appsec_protect_read", + }, + { + Command: "appsec exclusion-filters list|get", + API: "Application Security WAF exclusion filters API", + Permission: "appsec_protect_read", + OAuthScope: "appsec_protect_read", + Notes: []string{ + "Exclusion filters correspond to passlist entries in the Datadog UI.", + }, + }, { Command: "errors search|get", API: "Error Tracking API", diff --git a/internal/appsec/summary.go b/internal/appsec/summary.go new file mode 100644 index 0000000..8b67fca --- /dev/null +++ b/internal/appsec/summary.go @@ -0,0 +1,132 @@ +package appsec + +import ( + "sort" + + datadogV2 "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" +) + +type RuleSummary struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Category string `json:"category,omitempty"` + Module string `json:"module,omitempty"` + Count int `json:"count"` +} + +type TypeSummary struct { + Type string `json:"type"` + Count int `json:"count"` +} + +type BlockedRulesSummary struct { + SpansScanned int `json:"spans_scanned"` + Rules []RuleSummary `json:"rules"` + Types []TypeSummary `json:"types"` +} + +func AggregateBlockedRules(spans []datadogV2.Span) BlockedRulesSummary { + ruleCounts := map[string]*RuleSummary{} + typeCounts := map[string]int{} + + for _, span := range spans { + attrs, ok := span.GetAttributesOk() + if !ok || attrs == nil { + continue + } + appsec, ok := attrs.Custom["appsec"].(map[string]interface{}) + if !ok { + continue + } + + for _, typeName := range stringList(appsec["type"]) { + typeCounts[typeName]++ + } + + triggers, ok := appsec["triggers"].([]interface{}) + if !ok { + continue + } + for _, trigger := range triggers { + triggerMap, ok := trigger.(map[string]interface{}) + if !ok { + continue + } + ruleMap, ok := triggerMap["rule"].(map[string]interface{}) + if !ok { + continue + } + id, _ := ruleMap["id"].(string) + if id == "" { + continue + } + entry, exists := ruleCounts[id] + if !exists { + entry = &RuleSummary{ID: id} + if name, ok := ruleMap["name"].(string); ok { + entry.Name = name + } + if tags, ok := ruleMap["tags"].(map[string]interface{}); ok { + if typeName, ok := tags["type"].(string); ok { + entry.Type = typeName + } + if category, ok := tags["category"].(string); ok { + entry.Category = category + } + if module, ok := tags["module"].(string); ok { + entry.Module = module + } + } + ruleCounts[id] = entry + } + entry.Count++ + } + } + + rules := make([]RuleSummary, 0, len(ruleCounts)) + for _, rule := range ruleCounts { + rules = append(rules, *rule) + } + sort.Slice(rules, func(i, j int) bool { + if rules[i].Count == rules[j].Count { + return rules[i].ID < rules[j].ID + } + return rules[i].Count > rules[j].Count + }) + + types := make([]TypeSummary, 0, len(typeCounts)) + for typeName, count := range typeCounts { + types = append(types, TypeSummary{Type: typeName, Count: count}) + } + sort.Slice(types, func(i, j int) bool { + if types[i].Count == types[j].Count { + return types[i].Type < types[j].Type + } + return types[i].Count > types[j].Count + }) + + return BlockedRulesSummary{ + SpansScanned: len(spans), + Rules: rules, + Types: types, + } +} + +func stringList(value interface{}) []string { + switch typed := value.(type) { + case []interface{}: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + case string: + if typed != "" { + return []string{typed} + } + } + return nil +} diff --git a/internal/appsec/summary_test.go b/internal/appsec/summary_test.go new file mode 100644 index 0000000..2086a08 --- /dev/null +++ b/internal/appsec/summary_test.go @@ -0,0 +1,79 @@ +package appsec + +import ( + "testing" + + datadogV2 "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" +) + +func TestAggregateBlockedRules(t *testing.T) { + spans := []datadogV2.Span{ + { + Attributes: &datadogV2.SpansAttributes{ + Custom: map[string]interface{}{ + "appsec": map[string]interface{}{ + "type": []interface{}{"lfi"}, + "triggers": []interface{}{ + map[string]interface{}{ + "rule": map[string]interface{}{ + "id": "crs-930-100", + "name": "Obfuscated Path Traversal Attack (/../)", + "tags": map[string]interface{}{ + "type": "lfi", + "category": "attack_attempt", + "module": "waf", + }, + }, + }, + }, + }, + }, + }, + }, + { + Attributes: &datadogV2.SpansAttributes{ + Custom: map[string]interface{}{ + "appsec": map[string]interface{}{ + "type": []interface{}{"block_ip"}, + "triggers": []interface{}{ + map[string]interface{}{ + "rule": map[string]interface{}{ + "id": "blk-001-001", + "name": "Block IP Addresses", + "tags": map[string]interface{}{ + "type": "block_ip", + "category": "security_response", + "module": "network-acl", + }, + }, + }, + map[string]interface{}{ + "rule": map[string]interface{}{ + "id": "crs-930-100", + "name": "Obfuscated Path Traversal Attack (/../)", + "tags": map[string]interface{}{ + "type": "lfi", + }, + }, + }, + }, + }, + }, + }, + }, + } + + got := AggregateBlockedRules(spans) + if got.SpansScanned != 2 { + t.Fatalf("expected 2 spans scanned, got %d", got.SpansScanned) + } + if len(got.Rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(got.Rules)) + } + if got.Rules[0].ID != "crs-930-100" || got.Rules[0].Count != 2 { + t.Fatalf("expected crs-930-100 count 2 first, got %#v", got.Rules[0]) + } + if len(got.Types) != 2 { + t.Fatalf("expected 2 types, got %d", len(got.Types)) + } +}