From 164231ba9e0b9219965a2f0ebc07271791a2d8ab Mon Sep 17 00:00:00 2001 From: ivanlysiuk-sysdig Date: Fri, 15 May 2026 14:46:38 -0700 Subject: [PATCH] feat(events): add count, timeseries, and field-value-discovery tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new MCP tools so that end-to-end runtime-event investigations can be done in a few tool calls instead of paginating event bodies: - count_runtime_events: returns a 16-category × 8-severity histogram for any filter and time window in a single call. No pagination, no truncation. Backed by GET /api/v1/secureEvents/count. - runtime_events_timeseries: buckets event counts over time, grouped by a categorical field (default "severity"). Server picks the coarsest bucket size that fits the rows cap; minimum bucket is 1 minute. Lets the model find when a burst started/ended in two calls (coarse pass + zoom). Backed by GET /api/v1/secureEvents/timeseriesBy. - discover_runtime_event_field_values: enumerates the distinct values of a runtime-events field present in a window, split into "suggested" (active in window) and "other" (known but inactive). Lets the model learn real cluster/rule/image names before writing a filter instead of guessing. Backed by GET /secure/events/v2/eventFields/{field}. Also: - Extracts the runtime-events baseline filter ("not originator in (benchmarks, compliance, cloudsec, scanning, hostscanning)") into a shared helper used by all four runtime-event tools. - Shares the filter-expression DSL documentation across the four tools so the LLM applies identical filter intuition everywhere. - Fixes two filter-DSL examples in list_runtime_events whose syntax was rejected by the live API: 'host.hostName startsWith "web-"' is not accepted (correct form: 'host.hostName starts with "web-"'), and 'container.imageName' is not a valid field (correct forms: 'container.image.repo' and 'container.image.tag'). All three new tools require policy-events.read, the same permission as list_runtime_events and get_event_info. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 ++ cmd/server/main.go | 3 + .../infra/mcp/tools/secure_events_common.go | 38 ++++ .../mcp/tools/tool_count_runtime_events.go | 77 +++++++ .../tools/tool_count_runtime_events_test.go | 145 ++++++++++++++ ...ool_discover_runtime_event_field_values.go | 91 +++++++++ ...iscover_runtime_event_field_values_test.go | 148 ++++++++++++++ .../mcp/tools/tool_list_runtime_events.go | 30 +-- .../tools/tool_runtime_events_timeseries.go | 91 +++++++++ .../tool_runtime_events_timeseries_test.go | 134 +++++++++++++ internal/infra/sysdig/client_event_fields.go | 109 ++++++++++ internal/infra/sysdig/client_extension.go | 3 + internal/infra/sysdig/client_secure_events.go | 189 ++++++++++++++++++ .../infra/sysdig/mocks/client_extension.go | 60 ++++++ 14 files changed, 1108 insertions(+), 25 deletions(-) create mode 100644 internal/infra/mcp/tools/secure_events_common.go create mode 100644 internal/infra/mcp/tools/tool_count_runtime_events.go create mode 100644 internal/infra/mcp/tools/tool_count_runtime_events_test.go create mode 100644 internal/infra/mcp/tools/tool_discover_runtime_event_field_values.go create mode 100644 internal/infra/mcp/tools/tool_discover_runtime_event_field_values_test.go create mode 100644 internal/infra/mcp/tools/tool_runtime_events_timeseries.go create mode 100644 internal/infra/mcp/tools/tool_runtime_events_timeseries_test.go create mode 100644 internal/infra/sysdig/client_event_fields.go create mode 100644 internal/infra/sysdig/client_secure_events.go diff --git a/README.md b/README.md index 79a5697..937d224 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,21 @@ The server dynamically filters the available tools based on the permissions asso - **Required Permission**: `policy-events.read` - **Sample Prompt**: "Get the process tree for event ID abc123" +- **`count_runtime_events`** + - **Description**: Count runtime security events matching a filter expression in the last N hours, without paginating event bodies. Returns a histogram across 16 event categories, each bucketed by severity codes "0"-"7". + - **Required Permission**: `policy-events.read` + - **Sample Prompt**: "How many high-severity runtime events fired in cluster 'prod-gke' in the last 24 hours?" + +- **`runtime_events_timeseries`** + - **Description**: Bucket runtime security event counts over time, grouped by a categorical field (default `severity`). Use to find when a burst started or ended without paginating; minimum bucket width is 1 minute. + - **Required Permission**: `policy-events.read` + - **Sample Prompt**: "When did the spike in Suspicious Outbound Connection events on cluster 'prod-gke' start and stop?" + +- **`discover_runtime_event_field_values`** + - **Description**: Discover the distinct values of a runtime-events field present in a time window. Returns `suggested` (values active in the window) and `other` (values known to the tenant but inactive). Use BEFORE writing filters to avoid guessing cluster, rule, or image names. + - **Required Permission**: `policy-events.read` + - **Sample Prompt**: "Which clusters produced any runtime events in the last hour?" or "What rule names are firing right now?" + - **`run_sysql`** - **Description**: Execute a pre-written SysQL query directly (use only when user provides explicit query). - **Required Permission**: `sage.exec`, `risks.read` diff --git a/cmd/server/main.go b/cmd/server/main.go index ff7a4ba..5240ac1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -116,6 +116,9 @@ func setupHandler(sysdigClient sysdig.ExtendedClientWithResponsesInterface) *mcp tools.NewToolListRuntimeEvents(sysdigClient, systemClock), tools.NewToolGetEventInfo(sysdigClient), tools.NewToolGetEventProcessTree(sysdigClient), + tools.NewToolCountRuntimeEvents(sysdigClient, systemClock), + tools.NewToolRuntimeEventsTimeseries(sysdigClient, systemClock), + tools.NewToolDiscoverRuntimeEventFieldValues(sysdigClient, systemClock), tools.NewToolRunSysql(sysdigClient), tools.NewToolGenerateSysql(sysdigClient), diff --git a/internal/infra/mcp/tools/secure_events_common.go b/internal/infra/mcp/tools/secure_events_common.go new file mode 100644 index 0000000..4689d81 --- /dev/null +++ b/internal/infra/mcp/tools/secure_events_common.go @@ -0,0 +1,38 @@ +package tools + +// secureEventsBaseFilter is the filter prefix applied to every runtime-events +// query the server makes on behalf of an LLM. It hides classes of events that +// are noisy at investigation time (benchmarks, posture findings, scanning +// activity) so that user-supplied filters target the runtime signal. +const secureEventsBaseFilter = `not originator in ("benchmarks","compliance","cloudsec","scanning","hostscanning")` + +// composeSecureEventsFilter merges the user-supplied filter expression with +// the baseline. An empty userFilter returns the baseline unchanged. +func composeSecureEventsFilter(userFilter string) string { + if userFilter == "" { + return secureEventsBaseFilter + } + return secureEventsBaseFilter + " and " + userFilter +} + +// secureEventsFilterDSL is the shared filter-expression description used by +// list_runtime_events, count_runtime_events, runtime_events_timeseries, and +// discover_runtime_event_field_values. Keeping the prose in one place lets the +// LLM apply identical filter intuition across all four tools. +const secureEventsFilterDSL = `Logical filter expression to select runtime security events. +Supports operators: =, !=, in, contains, starts with, exists. +Combine with and/or/not. +Key attributes include: severity (codes "0"-"7"), originator, sourceType, ruleName, rawEventCategory, engine, source, category, kubernetes.cluster.name, host.hostName, container.image.repo, container.image.tag, aws.accountId, azure.subscriptionId, gcp.projectId, policyId, trigger. + +To find machine learning (ML) detections (e.g. crypto mining, anomalous logins), use engine or source filters: +- All ML events: 'engine = "machineLearning"' +- AWS ML detections: 'source = "agentless-aws-ml"' +- Okta ML detections: 'source = "agentless-okta-ml"' +- By category: 'category = "machine-learning"' + +You can specify the severity of the events based on the following cases: +- high-severity: 'severity in ("0","1","2","3")' +- medium: 'severity in ("4","5")' +- low: 'severity in ("6")' +- info: 'severity in ("7")' +` diff --git a/internal/infra/mcp/tools/tool_count_runtime_events.go b/internal/infra/mcp/tools/tool_count_runtime_events.go new file mode 100644 index 0000000..731e939 --- /dev/null +++ b/internal/infra/mcp/tools/tool_count_runtime_events.go @@ -0,0 +1,77 @@ +package tools + +import ( + "context" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/clock" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" +) + +type ToolCountRuntimeEvents struct { + sysdigClient sysdig.ExtendedClientWithResponsesInterface + clock clock.Clock +} + +func NewToolCountRuntimeEvents(client sysdig.ExtendedClientWithResponsesInterface, clock clock.Clock) *ToolCountRuntimeEvents { + return &ToolCountRuntimeEvents{ + sysdigClient: client, + clock: clock, + } +} + +func (h *ToolCountRuntimeEvents) handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + params := toolRequestToCountParams(request, h.clock) + + response, err := h.sysdigClient.GetSecureEventsCountWithResponse(ctx, params) + if err != nil { + return mcp.NewToolResultErrorFromErr("error triggering request", err), nil + } + if response.StatusCode() >= 400 { + return mcp.NewToolResultErrorf("error counting events, status code: %d, response: %s", response.StatusCode(), response.Body), nil + } + + return mcp.NewToolResultJSON(response.JSON200) +} + +func toolRequestToCountParams(request mcp.CallToolRequest, clock clock.Clock) *sysdig.GetSecureEventsCountParams { + scopeHours := request.GetInt("scope_hours", 1) + to := clock.Now() + from := to.Add(-time.Duration(scopeHours) * time.Hour) + + filter := composeSecureEventsFilter(request.GetString("filter_expr", "")) + + return &sysdig.GetSecureEventsCountParams{ + From: from.UnixNano(), + To: to.UnixNano(), + Filter: &filter, + } +} + +func (h *ToolCountRuntimeEvents) RegisterInServer(s *server.MCPServer) { + tool := mcp.NewTool("count_runtime_events", + mcp.WithDescription("Count runtime security events matching a filter expression in the last N hours, without paginating event bodies. Returns a histogram across 16 event categories (policyEvents, scanningEvents, cloudTrailEvents, mlCloudEvents, oktaEvents, githubEvents, gcpEvents, falcoCloudEvents, admissionControllerEvents, profilingDetectionEvents, awsMlConsoleLoginEvents, hostScanningEvents, benchmarkEvents, complianceEvents, cloudsecEvents, statefulDetectionEvents) where each category carries a `countBySeverity` map keyed \"0\" (highest) through \"7\" (info). Use this when the question is \"how many\" rather than \"which ones\" — it is one call regardless of result size."), + mcp.WithNumber("scope_hours", + mcp.Description("Number of hours back from now to count events over. Maximum 336 (14 days) — the backend rejects wider windows. Default 1."), + mcp.DefaultNumber(1), + ), + mcp.WithString("filter_expr", + mcp.Description(secureEventsFilterDSL), + Examples( + `severity in ("0","1","2","3")`, + `ruleName = "Malware Detection"`, + `kubernetes.cluster.name = "cluster1" and severity in ("0","1","2","3")`, + `engine = "machineLearning"`, + `aws.accountId = "123456789012"`, + ), + ), + mcp.WithOutputSchema[map[string]any](), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + WithRequiredPermissions("policy-events.read"), + ) + + s.AddTool(tool, h.handle) +} diff --git a/internal/infra/mcp/tools/tool_count_runtime_events_test.go b/internal/infra/mcp/tools/tool_count_runtime_events_test.go new file mode 100644 index 0000000..0fe68a2 --- /dev/null +++ b/internal/infra/mcp/tools/tool_count_runtime_events_test.go @@ -0,0 +1,145 @@ +package tools_test + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + + mocks_clock "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/clock/mocks" + inframcp "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp/tools" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig/mocks" +) + +var _ = Describe("ToolCountRuntimeEvents", func() { + var ( + mockClient *mocks.MockExtendedClientWithResponsesInterface + mockClock *mocks_clock.MockClock + tool *tools.ToolCountRuntimeEvents + ctrl *gomock.Controller + handler *inframcp.Handler + mcpClient *client.Client + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + mockClient = mocks.NewMockExtendedClientWithResponsesInterface(ctrl) + mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any(), gomock.Any()).Return(&sysdig.GetMyPermissionsResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &sysdig.UserPermissions{ + Permissions: []string{"policy-events.read"}, + }, + }, nil).AnyTimes() + mockClock = mocks_clock.NewMockClock(ctrl) + mockClock.EXPECT().Now().AnyTimes().Return(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)) + tool = tools.NewToolCountRuntimeEvents(mockClient, mockClock) + handler = inframcp.NewHandler("dev", mockClient) + handler.RegisterTools(tool) + + var err error + mcpClient, err = handler.ServeInProcessClient() + Expect(err).NotTo(HaveOccurred()) + + _, err = mcpClient.Initialize(context.Background(), mcp.InitializeRequest{}) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + ctrl.Finish() + }) + + It("converts a request into count params with baseline filter prepended", func(ctx SpecContext) { + mockClient.EXPECT().GetSecureEventsCountWithResponse(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params *sysdig.GetSecureEventsCountParams, _ ...sysdig.RequestEditorFn) (*sysdig.GetSecureEventsCountResponse, error) { + Expect(params.From).To(Equal(int64(946677600000000000))) // 2000-01-01 minus 2h + Expect(params.To).To(Equal(int64(946684800000000000))) // 2000-01-01 00:00:00 UTC + Expect(*params.Filter).To(ContainSubstring(`not originator in ("benchmarks","compliance","cloudsec","scanning","hostscanning")`)) + Expect(*params.Filter).To(ContainSubstring(`severity = 4`)) + + body := map[string]any{ + "policyEvents": map[string]any{"countBySeverity": map[string]any{"0": 1.0}}, + } + return &sysdig.GetSecureEventsCountResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &body, + }, nil + }) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "count_runtime_events", + Arguments: map[string]any{ + "scope_hours": 2, + "filter_expr": "severity = 4", + }, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + }) + + It("uses defaults (1h window, baseline filter only) when no args provided", func(ctx SpecContext) { + mockClient.EXPECT().GetSecureEventsCountWithResponse(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params *sysdig.GetSecureEventsCountParams, _ ...sysdig.RequestEditorFn) (*sysdig.GetSecureEventsCountResponse, error) { + Expect(params.From).To(Equal(int64(946681200000000000))) // 2000-01-01 minus 1h + Expect(params.To).To(Equal(int64(946684800000000000))) + Expect(*params.Filter).To(Equal(`not originator in ("benchmarks","compliance","cloudsec","scanning","hostscanning")`)) + + body := map[string]any{} + return &sysdig.GetSecureEventsCountResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &body, + }, nil + }) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "count_runtime_events", + Arguments: map[string]any{}, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + }) + + It("surfaces a client error as a tool error", func(ctx SpecContext) { + mockClient.EXPECT().GetSecureEventsCountWithResponse(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("client error")) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "count_runtime_events", + Arguments: map[string]any{}, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + }) + + It("surfaces a non-2xx HTTP response as a tool error", func(ctx SpecContext) { + mockClient.EXPECT().GetSecureEventsCountWithResponse(gomock.Any(), gomock.Any()).Return(&sysdig.GetSecureEventsCountResponse{ + HTTPResponse: &http.Response{StatusCode: 401}, + Body: []byte("Unauthorized"), + }, nil) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "count_runtime_events", + Arguments: map[string]any{}, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + }) +}) diff --git a/internal/infra/mcp/tools/tool_discover_runtime_event_field_values.go b/internal/infra/mcp/tools/tool_discover_runtime_event_field_values.go new file mode 100644 index 0000000..cc31162 --- /dev/null +++ b/internal/infra/mcp/tools/tool_discover_runtime_event_field_values.go @@ -0,0 +1,91 @@ +package tools + +import ( + "context" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/clock" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" +) + +type ToolDiscoverRuntimeEventFieldValues struct { + sysdigClient sysdig.ExtendedClientWithResponsesInterface + clock clock.Clock +} + +func NewToolDiscoverRuntimeEventFieldValues(client sysdig.ExtendedClientWithResponsesInterface, clock clock.Clock) *ToolDiscoverRuntimeEventFieldValues { + return &ToolDiscoverRuntimeEventFieldValues{ + sysdigClient: client, + clock: clock, + } +} + +func (h *ToolDiscoverRuntimeEventFieldValues) handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + field := request.GetString("field", "") + if field == "" { + return mcp.NewToolResultErrorf("field is required"), nil + } + + scopeHours := request.GetInt("scope_hours", 1) + to := h.clock.Now() + from := to.Add(-time.Duration(scopeHours) * time.Hour) + + params := &sysdig.GetEventFieldValuesParams{ + Field: field, + From: from.UnixNano(), + To: to.UnixNano(), + } + if filterExpr := request.GetString("filter_expr", ""); filterExpr != "" { + params.Filter = &filterExpr + } + + response, err := h.sysdigClient.GetEventFieldValuesWithResponse(ctx, params) + if err != nil { + return mcp.NewToolResultErrorFromErr("error triggering request", err), nil + } + if response.StatusCode() >= 400 { + return mcp.NewToolResultErrorf("error discovering field values, status code: %d, response: %s", response.StatusCode(), response.Body), nil + } + + return mcp.NewToolResultJSON(response.JSON200) +} + +func (h *ToolDiscoverRuntimeEventFieldValues) RegisterInServer(s *server.MCPServer) { + tool := mcp.NewTool("discover_runtime_event_field_values", + mcp.WithDescription("Discover the distinct values of a runtime-events field present in a time window. Returns two buckets: `suggested` = values producing events in the window (fire order — what's actually happening); `other` = values known to the tenant but inactive in the window (catalog — what's possible). Use BEFORE writing a filter to learn which cluster / rule / image / namespace names are real, instead of guessing and getting empty results. Common fields to discover: kubernetes.cluster.name, kubernetes.namespace.name, ruleName, container.image.repo, host.hostName, aws.accountId, source, engine."), + mcp.WithString("field", + mcp.Description("Field whose distinct values to enumerate. Examples: kubernetes.cluster.name, ruleName, container.image.repo, host.hostName, severity, source, engine."), + mcp.Required(), + Examples( + "kubernetes.cluster.name", + "ruleName", + "container.image.repo", + "host.hostName", + "severity", + "source", + "engine", + "aws.accountId", + ), + ), + mcp.WithNumber("scope_hours", + mcp.Description("Number of hours back from now to scan. Maximum 336 (14 days). Default 1."), + mcp.DefaultNumber(1), + ), + mcp.WithString("filter_expr", + mcp.Description("Optional filter expression to scope the search before enumerating values. Same DSL as other runtime-event tools. Without a filter, the enumeration spans all categories of events in the window."), + Examples( + `kubernetes.cluster.name = "production-gke"`, + `engine = "machineLearning"`, + `severity in ("0","1","2","3")`, + ), + ), + mcp.WithOutputSchema[map[string]any](), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + WithRequiredPermissions("policy-events.read"), + ) + + s.AddTool(tool, h.handle) +} diff --git a/internal/infra/mcp/tools/tool_discover_runtime_event_field_values_test.go b/internal/infra/mcp/tools/tool_discover_runtime_event_field_values_test.go new file mode 100644 index 0000000..e69d03c --- /dev/null +++ b/internal/infra/mcp/tools/tool_discover_runtime_event_field_values_test.go @@ -0,0 +1,148 @@ +package tools_test + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + + mocks_clock "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/clock/mocks" + inframcp "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp/tools" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig/mocks" +) + +var _ = Describe("ToolDiscoverRuntimeEventFieldValues", func() { + var ( + mockClient *mocks.MockExtendedClientWithResponsesInterface + mockClock *mocks_clock.MockClock + tool *tools.ToolDiscoverRuntimeEventFieldValues + ctrl *gomock.Controller + handler *inframcp.Handler + mcpClient *client.Client + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + mockClient = mocks.NewMockExtendedClientWithResponsesInterface(ctrl) + mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any(), gomock.Any()).Return(&sysdig.GetMyPermissionsResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &sysdig.UserPermissions{ + Permissions: []string{"policy-events.read"}, + }, + }, nil).AnyTimes() + mockClock = mocks_clock.NewMockClock(ctrl) + mockClock.EXPECT().Now().AnyTimes().Return(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)) + tool = tools.NewToolDiscoverRuntimeEventFieldValues(mockClient, mockClock) + handler = inframcp.NewHandler("dev", mockClient) + handler.RegisterTools(tool) + + var err error + mcpClient, err = handler.ServeInProcessClient() + Expect(err).NotTo(HaveOccurred()) + + _, err = mcpClient.Initialize(context.Background(), mcp.InitializeRequest{}) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + ctrl.Finish() + }) + + It("passes field, scope_hours, and filter through to the client", func(ctx SpecContext) { + mockClient.EXPECT().GetEventFieldValuesWithResponse(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params *sysdig.GetEventFieldValuesParams, _ ...sysdig.RequestEditorFn) (*sysdig.GetEventFieldValuesResponse, error) { + Expect(params.Field).To(Equal("kubernetes.cluster.name")) + Expect(params.From).To(Equal(int64(946677600000000000))) + Expect(params.To).To(Equal(int64(946684800000000000))) + Expect(params.Filter).NotTo(BeNil()) + Expect(*params.Filter).To(Equal(`severity in ("0","1","2","3")`)) + + body := map[string]any{ + "data": []any{ + map[string]any{"label": "suggested", "options": []any{"prod-gke", "stage-gke"}}, + map[string]any{"label": "other", "options": []any{}}, + }, + } + return &sysdig.GetEventFieldValuesResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &body, + }, nil + }) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "discover_runtime_event_field_values", + Arguments: map[string]any{ + "field": "kubernetes.cluster.name", + "scope_hours": 2, + "filter_expr": `severity in ("0","1","2","3")`, + }, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + }) + + It("does not set a filter when filter_expr is omitted (no baseline applied to value discovery)", func(ctx SpecContext) { + mockClient.EXPECT().GetEventFieldValuesWithResponse(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params *sysdig.GetEventFieldValuesParams, _ ...sysdig.RequestEditorFn) (*sysdig.GetEventFieldValuesResponse, error) { + Expect(params.Field).To(Equal("ruleName")) + Expect(params.Filter).To(BeNil()) + + body := map[string]any{} + return &sysdig.GetEventFieldValuesResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &body, + }, nil + }) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "discover_runtime_event_field_values", + Arguments: map[string]any{ + "field": "ruleName", + }, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + }) + + It("returns a tool error when field is missing", func(ctx SpecContext) { + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "discover_runtime_event_field_values", + Arguments: map[string]any{}, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + }) + + It("surfaces a client error as a tool error", func(ctx SpecContext) { + mockClient.EXPECT().GetEventFieldValuesWithResponse(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("client error")) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "discover_runtime_event_field_values", + Arguments: map[string]any{ + "field": "ruleName", + }, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + }) +}) diff --git a/internal/infra/mcp/tools/tool_list_runtime_events.go b/internal/infra/mcp/tools/tool_list_runtime_events.go index 9f1eb3b..afc566a 100644 --- a/internal/infra/mcp/tools/tool_list_runtime_events.go +++ b/internal/infra/mcp/tools/tool_list_runtime_events.go @@ -10,8 +10,6 @@ import ( "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" ) -const baseFilter = `not originator in ("benchmarks","compliance","cloudsec","scanning","hostscanning")` - type ToolListRuntimeEvents struct { sysdigClient sysdig.ExtendedClientWithResponsesInterface clock clock.Clock @@ -53,10 +51,8 @@ func toolRequestToEventsV1Params(request mcp.CallToolRequest, clock clock.Clock) params.From = new(from.UnixNano()) } - params.Filter = new(baseFilter) - if filterExpr := request.GetString("filter_expr", ""); filterExpr != "" { - params.Filter = new(baseFilter + " and " + filterExpr) - } + filter := composeSecureEventsFilter(request.GetString("filter_expr", "")) + params.Filter = &filter return params } @@ -76,30 +72,14 @@ func (h *ToolListRuntimeEvents) RegisterInServer(s *server.MCPServer) { mcp.DefaultNumber(50), ), mcp.WithString("filter_expr", - mcp.Description(`Logical filter expression to select runtime security events. -Supports operators: =, !=, in, contains, startsWith, exists. -Combine with and/or/not. -Key attributes include: severity (codes "0"-"7"), originator, sourceType, ruleName, rawEventCategory, engine, source, category, kubernetes.cluster.name, host.hostName, container.imageName, aws.accountId, azure.subscriptionId, gcp.projectId, policyId, trigger. - -To find machine learning (ML) detections (e.g. crypto mining, anomalous logins), use engine or source filters: -- All ML events: 'engine = "machineLearning"' -- AWS ML detections: 'source = "agentless-aws-ml"' -- Okta ML detections: 'source = "agentless-okta-ml"' -- By category: 'category = "machine-learning"' - -You can specify the severity of the events based on the following cases: -- high-severity: 'severity in ("0","1","2","3")' -- medium: 'severity in ("4","5")' -- low: 'severity in ("6")' -- info: 'severity in ("7")' -`), + mcp.Description(secureEventsFilterDSL), Examples( `originator in ("awsCloudConnector","gcp") and not sourceType = "auditTrail"`, `ruleName contains "Login"`, `severity in ("0","1","2","3")`, `kubernetes.cluster.name = "cluster1"`, - `host.hostName startsWith "web-"`, - `container.imageName = "nginx:latest" and originator = "hostscanning"`, + `host.hostName starts with "web-"`, + `container.image.repo = "nginx" and container.image.tag = "latest"`, `aws.accountId = "123456789012"`, `policyId = "CIS_Docker_Benchmark"`, `engine = "machineLearning"`, diff --git a/internal/infra/mcp/tools/tool_runtime_events_timeseries.go b/internal/infra/mcp/tools/tool_runtime_events_timeseries.go new file mode 100644 index 0000000..5291ef7 --- /dev/null +++ b/internal/infra/mcp/tools/tool_runtime_events_timeseries.go @@ -0,0 +1,91 @@ +package tools + +import ( + "context" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/clock" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" +) + +type ToolRuntimeEventsTimeseries struct { + sysdigClient sysdig.ExtendedClientWithResponsesInterface + clock clock.Clock +} + +func NewToolRuntimeEventsTimeseries(client sysdig.ExtendedClientWithResponsesInterface, clock clock.Clock) *ToolRuntimeEventsTimeseries { + return &ToolRuntimeEventsTimeseries{ + sysdigClient: client, + clock: clock, + } +} + +func (h *ToolRuntimeEventsTimeseries) handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + params := toolRequestToTimeseriesParams(request, h.clock) + + response, err := h.sysdigClient.GetSecureEventsTimeseriesByWithResponse(ctx, params) + if err != nil { + return mcp.NewToolResultErrorFromErr("error triggering request", err), nil + } + if response.StatusCode() >= 400 { + return mcp.NewToolResultErrorf("error retrieving event timeseries, status code: %d, response: %s", response.StatusCode(), response.Body), nil + } + + return mcp.NewToolResultJSON(response.JSON200) +} + +func toolRequestToTimeseriesParams(request mcp.CallToolRequest, clock clock.Clock) *sysdig.GetSecureEventsTimeseriesByParams { + scopeHours := request.GetInt("scope_hours", 1) + to := clock.Now() + from := to.Add(-time.Duration(scopeHours) * time.Hour) + + filter := composeSecureEventsFilter(request.GetString("filter_expr", "")) + + return &sysdig.GetSecureEventsTimeseriesByParams{ + From: from.UnixNano(), + To: to.UnixNano(), + Field: request.GetString("field", "severity"), + Rows: int32(request.GetInt("rows", 60)), + Limit: int32(request.GetInt("limit", 50)), + Filter: &filter, + } +} + +func (h *ToolRuntimeEventsTimeseries) RegisterInServer(s *server.MCPServer) { + tool := mcp.NewTool("runtime_events_timeseries", + mcp.WithDescription("Bucket runtime security event counts over time, grouped by a categorical field (default \"severity\"). Use to locate when a burst started or ended without paginating event bodies. Returns a nested structure: data.subCounts[].subCounts.timestamp.subCounts[].count, plus a top-level `step` giving the bucket width in nanoseconds. The server picks the coarsest bucket size that fits the `rows` upper bound; minimum bucket size is 1 minute. Two-call boundary-finding pattern: first call with rows=1000 over a wide window to bracket the burst, second call with rows=3600 over the bracketed range to drill to 1-minute resolution."), + mcp.WithNumber("scope_hours", + mcp.Description("Number of hours back from now to bucket events over. Maximum 336 (14 days). Default 1."), + mcp.DefaultNumber(1), + ), + mcp.WithString("field", + mcp.Description("Categorical field to group by. \"severity\" is the useful default for triage; other categorical fields are also accepted by the backend."), + mcp.DefaultString("severity"), + ), + mcp.WithNumber("rows", + mcp.Description("Upper bound on the number of time buckets. Server picks the coarsest step (minimum 1 minute) that yields <= rows buckets. Use ~1000 for a coarse pass over a wide window, then ~3600 over a bracketed range to force 1-minute buckets."), + mcp.DefaultNumber(60), + ), + mcp.WithNumber("limit", + mcp.Description("Maximum number of distinct values reported under the chosen field. The backend requires it; 50 is the canonical default and is never a bottleneck for severity (8 codes)."), + mcp.DefaultNumber(50), + ), + mcp.WithString("filter_expr", + mcp.Description(secureEventsFilterDSL), + Examples( + `severity in ("0","1","2","3")`, + `ruleName = "Suspicious Outbound Connection"`, + `kubernetes.cluster.name = "cluster1"`, + `engine = "machineLearning"`, + ), + ), + mcp.WithOutputSchema[map[string]any](), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + WithRequiredPermissions("policy-events.read"), + ) + + s.AddTool(tool, h.handle) +} diff --git a/internal/infra/mcp/tools/tool_runtime_events_timeseries_test.go b/internal/infra/mcp/tools/tool_runtime_events_timeseries_test.go new file mode 100644 index 0000000..f62cee2 --- /dev/null +++ b/internal/infra/mcp/tools/tool_runtime_events_timeseries_test.go @@ -0,0 +1,134 @@ +package tools_test + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + + mocks_clock "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/clock/mocks" + inframcp "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp/tools" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig" + "github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig/mocks" +) + +var _ = Describe("ToolRuntimeEventsTimeseries", func() { + var ( + mockClient *mocks.MockExtendedClientWithResponsesInterface + mockClock *mocks_clock.MockClock + tool *tools.ToolRuntimeEventsTimeseries + ctrl *gomock.Controller + handler *inframcp.Handler + mcpClient *client.Client + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + mockClient = mocks.NewMockExtendedClientWithResponsesInterface(ctrl) + mockClient.EXPECT().GetMyPermissionsWithResponse(gomock.Any(), gomock.Any()).Return(&sysdig.GetMyPermissionsResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &sysdig.UserPermissions{ + Permissions: []string{"policy-events.read"}, + }, + }, nil).AnyTimes() + mockClock = mocks_clock.NewMockClock(ctrl) + mockClock.EXPECT().Now().AnyTimes().Return(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)) + tool = tools.NewToolRuntimeEventsTimeseries(mockClient, mockClock) + handler = inframcp.NewHandler("dev", mockClient) + handler.RegisterTools(tool) + + var err error + mcpClient, err = handler.ServeInProcessClient() + Expect(err).NotTo(HaveOccurred()) + + _, err = mcpClient.Initialize(context.Background(), mcp.InitializeRequest{}) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + ctrl.Finish() + }) + + It("converts a request into timeseries params with field/rows/limit and baseline filter", func(ctx SpecContext) { + mockClient.EXPECT().GetSecureEventsTimeseriesByWithResponse(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params *sysdig.GetSecureEventsTimeseriesByParams, _ ...sysdig.RequestEditorFn) (*sysdig.GetSecureEventsTimeseriesByResponse, error) { + Expect(params.From).To(Equal(int64(946677600000000000))) + Expect(params.To).To(Equal(int64(946684800000000000))) + Expect(params.Field).To(Equal("ruleName")) + Expect(params.Rows).To(Equal(int32(1000))) + Expect(params.Limit).To(Equal(int32(50))) + Expect(*params.Filter).To(ContainSubstring(`kubernetes.cluster.name = "cluster1"`)) + + body := map[string]any{"step": 60000000000.0, "data": map[string]any{}} + return &sysdig.GetSecureEventsTimeseriesByResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &body, + }, nil + }) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "runtime_events_timeseries", + Arguments: map[string]any{ + "scope_hours": 2, + "field": "ruleName", + "rows": 1000, + "limit": 50, + "filter_expr": `kubernetes.cluster.name = "cluster1"`, + }, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + }) + + It("uses defaults (1h, severity, rows=60, limit=50, baseline only) when no args provided", func(ctx SpecContext) { + mockClient.EXPECT().GetSecureEventsTimeseriesByWithResponse(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params *sysdig.GetSecureEventsTimeseriesByParams, _ ...sysdig.RequestEditorFn) (*sysdig.GetSecureEventsTimeseriesByResponse, error) { + Expect(params.From).To(Equal(int64(946681200000000000))) + Expect(params.To).To(Equal(int64(946684800000000000))) + Expect(params.Field).To(Equal("severity")) + Expect(params.Rows).To(Equal(int32(60))) + Expect(params.Limit).To(Equal(int32(50))) + Expect(*params.Filter).To(Equal(`not originator in ("benchmarks","compliance","cloudsec","scanning","hostscanning")`)) + + body := map[string]any{} + return &sysdig.GetSecureEventsTimeseriesByResponse{ + HTTPResponse: &http.Response{StatusCode: 200}, + JSON200: &body, + }, nil + }) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "runtime_events_timeseries", + Arguments: map[string]any{}, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + }) + + It("surfaces a client error as a tool error", func(ctx SpecContext) { + mockClient.EXPECT().GetSecureEventsTimeseriesByWithResponse(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("client error")) + + result, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "runtime_events_timeseries", + Arguments: map[string]any{}, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + }) +}) diff --git a/internal/infra/sysdig/client_event_fields.go b/internal/infra/sysdig/client_event_fields.go new file mode 100644 index 0000000..7831105 --- /dev/null +++ b/internal/infra/sysdig/client_event_fields.go @@ -0,0 +1,109 @@ +package sysdig + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/oapi-codegen/runtime" +) + +// GetEventFieldValuesParams models the query parameters for +// GET /secure/events/v2/eventFields/{field}. The field name is part of the +// URL path, not a query parameter. +type GetEventFieldValuesParams struct { + Field string + From int64 + To int64 + Filter *string +} + +// GetEventFieldValuesResponse is the typed response from +// GET /secure/events/v2/eventFields/{field}. The body has the shape +// {data: [{label: "suggested", options: [...]}, {label: "other", options: [...]}]} +// where "suggested" values are observed in the window and "other" values +// are known in the tenant but inactive in the window. +type GetEventFieldValuesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *map[string]any +} + +func ParseGetEventFieldValuesResponse(rsp *http.Response) (*GetEventFieldValuesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetEventFieldValuesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + if rsp.StatusCode == http.StatusOK { + var dest map[string]any + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + } + return response, nil +} + +func (r *GetEventFieldValuesResponse) StatusCode() int { return r.HTTPResponse.StatusCode } + +func NewGetEventFieldValuesRequest(server string, params *GetEventFieldValuesParams) (*http.Request, error) { + if params.Field == "" { + return nil, fmt.Errorf("field is required") + } + + pathParam, err := runtime.StyleParamWithLocation("simple", false, "field", runtime.ParamLocationPath, params.Field) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + queryURL, err := serverURL.Parse(fmt.Sprintf("./secure/events/v2/eventFields/%s", pathParam)) + if err != nil { + return nil, err + } + + q := queryURL.Query() + q.Set("from", strconv.FormatInt(params.From, 10)) + q.Set("to", strconv.FormatInt(params.To, 10)) + if params.Filter != nil { + q.Set("filter", *params.Filter) + } + queryURL.RawQuery = q.Encode() + + return http.NewRequest("GET", queryURL.String(), nil) +} + +func (c *Client) GetEventFieldValues(ctx context.Context, params *GetEventFieldValuesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetEventFieldValuesRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *ClientWithResponses) GetEventFieldValuesWithResponse(ctx context.Context, params *GetEventFieldValuesParams, reqEditors ...RequestEditorFn) (*GetEventFieldValuesResponse, error) { + rsp, err := c.ClientInterface.(*Client).GetEventFieldValues(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetEventFieldValuesResponse(rsp) +} diff --git a/internal/infra/sysdig/client_extension.go b/internal/infra/sysdig/client_extension.go index 7d89e34..0406267 100644 --- a/internal/infra/sysdig/client_extension.go +++ b/internal/infra/sysdig/client_extension.go @@ -11,4 +11,7 @@ type ExtendedClientWithResponsesInterface interface { GetProcessTreeTreesWithResponse(ctx context.Context, eventID string, reqEditors ...RequestEditorFn) (*GetProcessTreeTreesResponse, error) GetMyPermissionsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetMyPermissionsResponse, error) GenerateSysqlWithResponse(ctx context.Context, question string, reqEditors ...RequestEditorFn) (*GenerateSysqlResponse, error) + GetSecureEventsCountWithResponse(ctx context.Context, params *GetSecureEventsCountParams, reqEditors ...RequestEditorFn) (*GetSecureEventsCountResponse, error) + GetSecureEventsTimeseriesByWithResponse(ctx context.Context, params *GetSecureEventsTimeseriesByParams, reqEditors ...RequestEditorFn) (*GetSecureEventsTimeseriesByResponse, error) + GetEventFieldValuesWithResponse(ctx context.Context, params *GetEventFieldValuesParams, reqEditors ...RequestEditorFn) (*GetEventFieldValuesResponse, error) } diff --git a/internal/infra/sysdig/client_secure_events.go b/internal/infra/sysdig/client_secure_events.go new file mode 100644 index 0000000..16fe24f --- /dev/null +++ b/internal/infra/sysdig/client_secure_events.go @@ -0,0 +1,189 @@ +package sysdig + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +// GetSecureEventsCountParams models the query parameters for +// GET /api/v1/secureEvents/count. +type GetSecureEventsCountParams struct { + From int64 + To int64 + Filter *string +} + +// GetSecureEventsCountResponse is the typed response from +// GET /api/v1/secureEvents/count. The endpoint returns 16 event categories +// (policyEvents, scanningEvents, cloudTrailEvents, etc.), each with a +// severity histogram keyed "0"-"7". +type GetSecureEventsCountResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *map[string]any +} + +func ParseGetSecureEventsCountResponse(rsp *http.Response) (*GetSecureEventsCountResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetSecureEventsCountResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + if rsp.StatusCode == http.StatusOK { + var dest map[string]any + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + } + return response, nil +} + +func (r *GetSecureEventsCountResponse) StatusCode() int { return r.HTTPResponse.StatusCode } + +func NewGetSecureEventsCountRequest(server string, params *GetSecureEventsCountParams) (*http.Request, error) { + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + queryURL, err := serverURL.Parse("./api/v1/secureEvents/count") + if err != nil { + return nil, err + } + + q := queryURL.Query() + q.Set("from", strconv.FormatInt(params.From, 10)) + q.Set("to", strconv.FormatInt(params.To, 10)) + if params.Filter != nil { + q.Set("filter", *params.Filter) + } + queryURL.RawQuery = q.Encode() + + return http.NewRequest("GET", queryURL.String(), nil) +} + +// GetSecureEventsTimeseriesByParams models the query parameters for +// GET /api/v1/secureEvents/timeseriesBy. +type GetSecureEventsTimeseriesByParams struct { + From int64 + To int64 + Field string // categorical field to group by (e.g. "severity") + Rows int32 // upper bound on bucket count; server picks the coarsest step <= rows + Limit int32 // max distinct values reported under Field; server requires it + Filter *string +} + +// GetSecureEventsTimeseriesByResponse is the typed response from +// GET /api/v1/secureEvents/timeseriesBy. Returns a nested subCount tree +// indexed by group-value -> "timestamp" -> bucket-ns -> count, plus a +// top-level `step` (bucket width in nanoseconds). +type GetSecureEventsTimeseriesByResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *map[string]any +} + +func ParseGetSecureEventsTimeseriesByResponse(rsp *http.Response) (*GetSecureEventsTimeseriesByResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetSecureEventsTimeseriesByResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + if rsp.StatusCode == http.StatusOK { + var dest map[string]any + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + } + return response, nil +} + +func (r *GetSecureEventsTimeseriesByResponse) StatusCode() int { return r.HTTPResponse.StatusCode } + +func NewGetSecureEventsTimeseriesByRequest(server string, params *GetSecureEventsTimeseriesByParams) (*http.Request, error) { + if params.Field == "" { + return nil, fmt.Errorf("field is required") + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + queryURL, err := serverURL.Parse("./api/v1/secureEvents/timeseriesBy") + if err != nil { + return nil, err + } + + q := queryURL.Query() + q.Set("from", strconv.FormatInt(params.From, 10)) + q.Set("to", strconv.FormatInt(params.To, 10)) + q.Set("field", params.Field) + q.Set("rows", strconv.FormatInt(int64(params.Rows), 10)) + q.Set("limit", strconv.FormatInt(int64(params.Limit), 10)) + if params.Filter != nil { + q.Set("filter", *params.Filter) + } + queryURL.RawQuery = q.Encode() + + return http.NewRequest("GET", queryURL.String(), nil) +} + +func (c *Client) GetSecureEventsCount(ctx context.Context, params *GetSecureEventsCountParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetSecureEventsCountRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetSecureEventsTimeseriesBy(ctx context.Context, params *GetSecureEventsTimeseriesByParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetSecureEventsTimeseriesByRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *ClientWithResponses) GetSecureEventsCountWithResponse(ctx context.Context, params *GetSecureEventsCountParams, reqEditors ...RequestEditorFn) (*GetSecureEventsCountResponse, error) { + rsp, err := c.ClientInterface.(*Client).GetSecureEventsCount(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetSecureEventsCountResponse(rsp) +} + +func (c *ClientWithResponses) GetSecureEventsTimeseriesByWithResponse(ctx context.Context, params *GetSecureEventsTimeseriesByParams, reqEditors ...RequestEditorFn) (*GetSecureEventsTimeseriesByResponse, error) { + rsp, err := c.ClientInterface.(*Client).GetSecureEventsTimeseriesBy(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetSecureEventsTimeseriesByResponse(rsp) +} diff --git a/internal/infra/sysdig/mocks/client_extension.go b/internal/infra/sysdig/mocks/client_extension.go index 08e83ca..ce51fbc 100644 --- a/internal/infra/sysdig/mocks/client_extension.go +++ b/internal/infra/sysdig/mocks/client_extension.go @@ -3823,6 +3823,26 @@ func (mr *MockExtendedClientWithResponsesInterfaceMockRecorder) GetEFOIntegratio return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEFOIntegrationByIdV2WithResponse", reflect.TypeOf((*MockExtendedClientWithResponsesInterface)(nil).GetEFOIntegrationByIdV2WithResponse), varargs...) } +// GetEventFieldValuesWithResponse mocks base method. +func (m *MockExtendedClientWithResponsesInterface) GetEventFieldValuesWithResponse(ctx context.Context, params *sysdig.GetEventFieldValuesParams, reqEditors ...sysdig.RequestEditorFn) (*sysdig.GetEventFieldValuesResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, params} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetEventFieldValuesWithResponse", varargs...) + ret0, _ := ret[0].(*sysdig.GetEventFieldValuesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEventFieldValuesWithResponse indicates an expected call of GetEventFieldValuesWithResponse. +func (mr *MockExtendedClientWithResponsesInterfaceMockRecorder) GetEventFieldValuesWithResponse(ctx, params any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, params}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEventFieldValuesWithResponse", reflect.TypeOf((*MockExtendedClientWithResponsesInterface)(nil).GetEventFieldValuesWithResponse), varargs...) +} + // GetEventV1 mocks base method. func (m *MockExtendedClientWithResponsesInterface) GetEventV1(ctx context.Context, eventId sysdig.EventId, reqEditors ...sysdig.RequestEditorFn) (*http.Response, error) { m.ctrl.T.Helper() @@ -5603,6 +5623,46 @@ func (mr *MockExtendedClientWithResponsesInterfaceMockRecorder) GetSBOMV1beta1Wi return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSBOMV1beta1WithResponse", reflect.TypeOf((*MockExtendedClientWithResponsesInterface)(nil).GetSBOMV1beta1WithResponse), varargs...) } +// GetSecureEventsCountWithResponse mocks base method. +func (m *MockExtendedClientWithResponsesInterface) GetSecureEventsCountWithResponse(ctx context.Context, params *sysdig.GetSecureEventsCountParams, reqEditors ...sysdig.RequestEditorFn) (*sysdig.GetSecureEventsCountResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, params} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetSecureEventsCountWithResponse", varargs...) + ret0, _ := ret[0].(*sysdig.GetSecureEventsCountResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSecureEventsCountWithResponse indicates an expected call of GetSecureEventsCountWithResponse. +func (mr *MockExtendedClientWithResponsesInterfaceMockRecorder) GetSecureEventsCountWithResponse(ctx, params any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, params}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecureEventsCountWithResponse", reflect.TypeOf((*MockExtendedClientWithResponsesInterface)(nil).GetSecureEventsCountWithResponse), varargs...) +} + +// GetSecureEventsTimeseriesByWithResponse mocks base method. +func (m *MockExtendedClientWithResponsesInterface) GetSecureEventsTimeseriesByWithResponse(ctx context.Context, params *sysdig.GetSecureEventsTimeseriesByParams, reqEditors ...sysdig.RequestEditorFn) (*sysdig.GetSecureEventsTimeseriesByResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, params} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetSecureEventsTimeseriesByWithResponse", varargs...) + ret0, _ := ret[0].(*sysdig.GetSecureEventsTimeseriesByResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSecureEventsTimeseriesByWithResponse indicates an expected call of GetSecureEventsTimeseriesByWithResponse. +func (mr *MockExtendedClientWithResponsesInterfaceMockRecorder) GetSecureEventsTimeseriesByWithResponse(ctx, params any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, params}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecureEventsTimeseriesByWithResponse", reflect.TypeOf((*MockExtendedClientWithResponsesInterface)(nil).GetSecureEventsTimeseriesByWithResponse), varargs...) +} + // GetSecureVulnerabilityV1Bundles mocks base method. func (m *MockExtendedClientWithResponsesInterface) GetSecureVulnerabilityV1Bundles(ctx context.Context, params *sysdig.GetSecureVulnerabilityV1BundlesParams, reqEditors ...sysdig.RequestEditorFn) (*http.Response, error) { m.ctrl.T.Helper()