Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bin/build
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions cmd/scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
131 changes: 131 additions & 0 deletions cmd/security.go
Original file line number Diff line number Diff line change
@@ -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 <signal-id>",
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(),
)
}
40 changes: 40 additions & 0 deletions cmd/security_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
23 changes: 23 additions & 0 deletions projects/security-signals.md
Original file line number Diff line number Diff line change
@@ -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`
Loading