diff --git a/README.md b/README.md index 513d927..8fe84f9 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ 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 security signals search --query 'team:bandzoogle' --from now-1h --to now +ddcli security signals get SIGNAL_ID ddcli cost analyze --group-by service --from now-30d --to now-2d --limit 25 ddcli cost analyze --metric aws.cost.amortized --filter 'env:prod' --group-by region ddcli cost accounts list --provider all diff --git a/bin/build b/bin/build index 3389575..58576a9 100755 --- a/bin/build +++ b/bin/build @@ -25,6 +25,7 @@ echo "==> smoke testing" "$output" appsec blocked-rules summary --help >/dev/null "$output" appsec custom-rules list --help >/dev/null "$output" errors search --help >/dev/null +"$output" security signals search --help >/dev/null "$output" cost analyze --help >/dev/null "$output" scopes --help >/dev/null diff --git a/cmd/scopes.go b/cmd/scopes.go index e792de7..f13ebb7 100644 --- a/cmd/scopes.go +++ b/cmd/scopes.go @@ -170,6 +170,12 @@ func requiredScopes() []scopeInfo { Permission: "error_tracking_read", OAuthScope: "error_tracking_read", }, + { + Command: "security signals search|get", + API: "Cloud SIEM Security Signals API", + Permission: "security_monitoring_signals_read", + OAuthScope: "security_monitoring_signals_read", + }, { Command: "cost analyze", API: "Metrics API with cloud_cost data source", diff --git a/cmd/security.go b/cmd/security.go new file mode 100644 index 0000000..4e22584 --- /dev/null +++ b/cmd/security.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "time" + + "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/output" + "github.com/bandzoogle/datadog-cli/internal/timeutil" + "github.com/spf13/cobra" +) + +var securityCmd = &cobra.Command{ + Use: "security", + Short: "Query Datadog Cloud SIEM security data", +} + +var securitySignalsCmd = &cobra.Command{ + Use: "signals", + Short: "Search and retrieve Cloud SIEM security signals", +} + +var securitySignalsSearchCmd = &cobra.Command{ + Use: "search", + Short: "Search Cloud SIEM security signals", + Example: ` ddcli security signals search --query 'team:bandzoogle' --from now-1h --to now + ddcli security signals search --query 'status:high' --limit 25 --pretty`, + PreRunE: func(cmd *cobra.Command, args []string) error { + return requireStringFlag(cmd, "query") + }, + RunE: runSecuritySignalsSearch, +} + +var securitySignalsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a Cloud SIEM security signal", + Args: cobra.ExactArgs(1), + RunE: runSecuritySignalsGet, +} + +func init() { + rootCmd.AddCommand(securityCmd) + securityCmd.AddCommand(securitySignalsCmd) + securitySignalsCmd.AddCommand(securitySignalsSearchCmd, securitySignalsGetCmd) + + securitySignalsSearchCmd.Flags().String("query", "", "Security signal search query") + securitySignalsSearchCmd.Flags().String("from", "now-1h", "Start time, e.g. now-1h or RFC3339") + securitySignalsSearchCmd.Flags().String("to", "now", "End time, e.g. now or RFC3339") + securitySignalsSearchCmd.Flags().Int32("limit", 50, "Maximum signals to return") + securitySignalsSearchCmd.Flags().String("cursor", "", "Pagination cursor from a previous response") +} + +func runSecuritySignalsSearch(cmd *cobra.Command, args []string) error { + client, err := datadogClient(cmd) + if err != nil { + return err + } + query, _ := cmd.Flags().GetString("query") + fromValue, _ := cmd.Flags().GetString("from") + toValue, _ := cmd.Flags().GetString("to") + limit, _ := cmd.Flags().GetInt32("limit") + cursor, _ := cmd.Flags().GetString("cursor") + now := time.Now() + from, err := timeutil.Parse(fromValue, now) + if err != nil { + return err + } + to, err := timeutil.Parse(toValue, now) + if err != nil { + return err + } + + body := buildSecuritySignalsSearchRequest(query, from, to, limit, cursor) + api := datadogV2.NewSecurityMonitoringApi(client.API) + resp, httpResp, err := api.SearchSecurityMonitoringSignals( + client.Context, + *datadogV2.NewSearchSecurityMonitoringSignalsOptionalParameters().WithBody(body), + ) + if err != nil { + return apiError("security signals search", httpResp, err) + } + + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "security signals search", "filter": query}, + meta(client.Site, map[string]any{ + "from": fromValue, + "to": toValue, + "limit": limit, + "cursor": cursor, + }, httpResp), + resp, + outputOptions(), + ) +} + +func buildSecuritySignalsSearchRequest(query string, from, to time.Time, limit int32, cursor string) datadogV2.SecurityMonitoringSignalListRequest { + body := *datadogV2.NewSecurityMonitoringSignalListRequest() + body.Filter = &datadogV2.SecurityMonitoringSignalListRequestFilter{ + From: datadog.PtrTime(from), + Query: datadog.PtrString(query), + To: datadog.PtrTime(to), + } + body.Page = &datadogV2.SecurityMonitoringSignalListRequestPage{ + Limit: datadog.PtrInt32(limit), + } + if cursor != "" { + body.Page.Cursor = datadog.PtrString(cursor) + } + body.Sort = datadogV2.SECURITYMONITORINGSIGNALSSORT_TIMESTAMP_DESCENDING.Ptr() + return body +} + +func runSecuritySignalsGet(cmd *cobra.Command, args []string) error { + client, err := datadogClient(cmd) + if err != nil { + return err + } + signalID := args[0] + + api := datadogV2.NewSecurityMonitoringApi(client.API) + resp, httpResp, err := api.GetSecurityMonitoringSignal(client.Context, signalID) + if err != nil { + return apiError("security signals get", httpResp, err) + } + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "security signals get", "signal_id": signalID}, + meta(client.Site, nil, httpResp), + resp, + outputOptions(), + ) +} diff --git a/cmd/security_test.go b/cmd/security_test.go new file mode 100644 index 0000000..23900d4 --- /dev/null +++ b/cmd/security_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" +) + +func TestBuildSecuritySignalsSearchRequest(t *testing.T) { + from := time.Date(2026, 7, 23, 15, 0, 0, 0, time.UTC) + to := from.Add(time.Hour) + + got := buildSecuritySignalsSearchRequest("team:bandzoogle", from, to, 25, "next-page") + + if got.Filter == nil || got.Filter.GetQuery() != "team:bandzoogle" { + t.Fatalf("unexpected filter: %#v", got.Filter) + } + if !got.Filter.GetFrom().Equal(from) || !got.Filter.GetTo().Equal(to) { + t.Fatalf("unexpected time range: %s to %s", got.Filter.GetFrom(), got.Filter.GetTo()) + } + if got.Page == nil || got.Page.GetLimit() != 25 || got.Page.GetCursor() != "next-page" { + t.Fatalf("unexpected page: %#v", got.Page) + } + if got.Sort == nil || *got.Sort != datadogV2.SECURITYMONITORINGSIGNALSSORT_TIMESTAMP_DESCENDING { + t.Fatalf("unexpected sort: %#v", got.Sort) + } +} + +func TestBuildSecuritySignalsSearchRequestOmitsEmptyCursor(t *testing.T) { + now := time.Now() + got := buildSecuritySignalsSearchRequest("*", now.Add(-time.Hour), now, 50, "") + + if got.Page == nil { + t.Fatal("expected page") + } + if got.Page.Cursor != nil { + t.Fatalf("expected nil cursor, got %q", got.Page.GetCursor()) + } +} diff --git a/projects/security-signals.md b/projects/security-signals.md new file mode 100644 index 0000000..df4d694 --- /dev/null +++ b/projects/security-signals.md @@ -0,0 +1,23 @@ +# Datadog Security Signals + +Status: Implemented and live-tested +Last Updated: 2026-07-23 +Branch: `feat/security-signals` +Worktree: `/Users/jsierles/bz/datadog-cli-security-signals` + +## Progress + +- [x] Add read-only security signal search/get commands. +- [x] Document `security_monitoring_signals_read` and usage. +- [x] Test against the reported ECS registration signal. +- [x] Confirm the signal is a real anomaly across three OpenResty instances, not a stale re-notification. + +## Next Steps + +1. Review and merge the feature branch. +2. Publish a release so Zooglerails `bin/dd` downloads the new commands. +3. Verify the ECS-agent bootstrap fix is applied to every non-ECS AMI consumer. + +## Related Resources + +- Conversation ID: `fc6de74b-840e-4af9-9e57-d318e37d7b01`