diff --git a/.mockery.yaml b/.mockery.yaml index 6cebffd0..d5e4e20e 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -33,6 +33,10 @@ packages: config: interfaces: Client: + github.com/blackstork-io/fabric/internal/elastic/kbclient: + config: + interfaces: + Client: github.com/blackstork-io/fabric/plugin/resolver: config: inpackage: true diff --git a/docs/plugins/elastic/data-sources/elastic_security_cases.md b/docs/plugins/elastic/data-sources/elastic_security_cases.md new file mode 100644 index 00000000..2d41a4f1 --- /dev/null +++ b/docs/plugins/elastic/data-sources/elastic_security_cases.md @@ -0,0 +1,68 @@ +--- +title: elastic_security_cases +plugin: + name: blackstork/elastic + description: "" + tags: [] + version: "v0.4.0" + source_github: "https://github.com/blackstork-io/fabric/tree/main/internal/elastic/" +resource: + type: data-source +type: docs +--- + +{{< breadcrumbs 2 >}} + +{{< plugin-resource-header "blackstork/elastic" "elastic" "v0.4.0" "elastic_security_cases" "data source" >}} + +## Installation + +To use `elastic_security_cases` data source, you must install the plugin `blackstork/elastic`. + +To install the plugin, add the full plugin name to the `plugin_versions` map in the Fabric global configuration block (see [Global configuration]({{< ref "configs.md#global-configuration" >}}) for more details), as shown below: + +```hcl +fabric { + plugin_versions = { + "blackstork/elastic" = ">= v0.4.0" + } +} +``` + +Note the version constraint set for the plugin. + +## Configuration + +The data source supports the following configuration parameters: + +```hcl +config data elastic_security_cases { + api_key = # optional + api_key_str = # optional + kibana_endpoint_url = # required +} +``` + +## Usage + +The data source supports the following parameters in the data blocks: + +```hcl +data elastic_security_cases { + assignees = # optional + default_search_operator = # optional + from = # optional + owner = # optional + reporters = # optional + search = # optional + search_fields = # optional + severity = # optional + size = # optional + sort_field = # optional + sort_order = # optional + space_id = # optional + status = # optional + tags = # optional + to = # optional +} +``` \ No newline at end of file diff --git a/docs/plugins/plugins.json b/docs/plugins/plugins.json index 29c062cc..d6ef7588 100644 --- a/docs/plugins/plugins.json +++ b/docs/plugins/plugins.json @@ -2,10 +2,6 @@ { "name": "blackstork/builtin", "resources": [ - { - "name": "inline", - "type": "data-source" - }, { "name": "csv", "type": "data-source", @@ -31,75 +27,79 @@ ] }, { - "name": "title", + "name": "inline", + "type": "data-source" + }, + { + "name": "text", "type": "content-provider", "arguments": [ - "value", - "absolute_size", - "relative_size" + "value" ] }, { - "name": "image", + "name": "title", "type": "content-provider", "arguments": [ - "src", - "alt" + "absolute_size", + "relative_size", + "value" ] }, { - "name": "table", + "name": "blockquote", "type": "content-provider", "arguments": [ - "columns" + "value" ] }, { - "name": "blockquote", + "name": "table", "type": "content-provider", "arguments": [ - "value" + "columns" ] }, { - "name": "list", + "name": "toc", "type": "content-provider", "arguments": [ - "item_template", - "format" + "start_level", + "end_level", + "ordered", + "scope" ] }, { - "name": "frontmatter", + "name": "code", "type": "content-provider", "arguments": [ - "format", - "content" + "value", + "language" ] }, { - "name": "toc", + "name": "image", "type": "content-provider", "arguments": [ - "scope", - "start_level", - "end_level", - "ordered" + "src", + "alt" ] }, { - "name": "text", + "name": "list", "type": "content-provider", "arguments": [ - "value" + "item_template", + "format" ] }, { - "name": "code", + "name": "frontmatter", "type": "content-provider", "arguments": [ - "language", - "value" + "format", + "content" ] } ], @@ -113,24 +113,50 @@ "name": "elasticsearch", "type": "data-source", "config_params": [ - "cloud_id", - "api_key_str", - "api_key", "basic_auth_username", "basic_auth_password", "bearer_auth", "ca_certs", - "base_url" + "base_url", + "cloud_id", + "api_key_str", + "api_key" ], "arguments": [ + "query_string", + "query", + "aggs", "only_hits", "fields", "size", "index", - "id", - "query_string", - "query", - "aggs" + "id" + ] + }, + { + "name": "elastic_security_cases", + "type": "data-source", + "config_params": [ + "kibana_endpoint_url", + "api_key_str", + "api_key" + ], + "arguments": [ + "default_search_operator", + "from", + "owner", + "sort_field", + "tags", + "space_id", + "assignees", + "reporters", + "search", + "search_fields", + "severity", + "status", + "sort_order", + "to", + "size" ] } ], @@ -147,17 +173,17 @@ "github_token" ], "arguments": [ + "labels", + "direction", + "since", "repository", - "milestone", "state", - "creator", + "assignee", "mentioned", + "milestone", + "creator", "sort", - "direction", - "since", - "limit", - "assignee", - "labels" + "limit" ] } ], @@ -171,8 +197,8 @@ "name": "graphql", "type": "data-source", "config_params": [ - "url", - "auth_token" + "auth_token", + "url" ], "arguments": [ "query" @@ -194,8 +220,8 @@ "organization_id" ], "arguments": [ - "model", - "prompt" + "prompt", + "model" ] } ], @@ -281,52 +307,52 @@ "api_token" ], "arguments": [ - "weakness_id", - "hacker_published", - "triaged_at__lt", - "size", - "program", - "id", "triaged_at__gt", + "swag_awarded_at__null", + "inbox_ids", "closed_at__lt", - "disclosed_at__lt", - "last_program_activity_at__lt", - "last_activity_at__gt", "reporter_agreed_on_going_public", - "bounty_awarded_at__null", - "swag_awarded_at__gt", - "swag_awarded_at__null", + "last_report_activity_at__gt", + "first_program_activity_at__lt", + "last_activity_at__gt", "last_public_activity_at__lt", - "keyword", - "assignee", - "swag_awarded_at__lt", - "first_program_activity_at__null", - "last_program_activity_at__gt", - "last_public_activity_at__gt", + "triaged_at__null", + "program", + "hacker_published", + "closed_at__gt", + "disclosed_at__gt", "custom_fields", - "state", + "size", + "id", + "closed_at__null", + "bounty_awarded_at__lt", + "first_program_activity_at__null", + "reporter", + "assignee", + "severity", "created_at__gt", "created_at__lt", - "disclosed_at__gt", "disclosed_at__null", - "bounty_awarded_at__lt", - "page_number", - "submitted_at__lt", - "triaged_at__null", - "closed_at__gt", "bounty_awarded_at__gt", - "first_program_activity_at__lt", - "last_activity_at__lt", - "inbox_ids", - "severity", - "submitted_at__gt", - "last_report_activity_at__gt", + "bounty_awarded_at__null", "sort", - "reporter", - "closed_at__null", + "last_public_activity_at__gt", + "keyword", "last_report_activity_at__lt", + "submitted_at__lt", + "triaged_at__lt", + "disclosed_at__lt", + "last_program_activity_at__gt", + "state", + "weakness_id", + "submitted_at__gt", + "swag_awarded_at__gt", + "swag_awarded_at__lt", "first_program_activity_at__gt", - "last_program_activity_at__null" + "last_program_activity_at__null", + "last_activity_at__lt", + "page_number", + "last_program_activity_at__lt" ] } ], @@ -343,10 +369,10 @@ "api_key" ], "arguments": [ + "user_id", "group_id", "start_date", - "end_date", - "user_id" + "end_date" ] } ], @@ -384,17 +410,17 @@ "name": "stixview", "type": "content-provider", "arguments": [ - "stix_url", - "caption", + "show_labels", + "gist_id", "show_footer", + "show_sidebar", "show_tlp_as_tags", + "show_marking_nodes", + "stix_url", + "caption", "show_idrefs", "width", - "gist_id", - "show_marking_nodes", - "show_labels", - "height", - "show_sidebar" + "height" ] } ], @@ -411,25 +437,25 @@ "api_key" ], "arguments": [ - "cvss_v3_metrics", "source_identifier", + "no_rejected", "cvss_v3_severity", - "virtual_match_string", - "is_vulnerable", - "last_mod_start_date", - "pub_start_date", - "pub_end_date", - "cve_id", - "has_cert_alerts", - "has_kev", "keyword_exact_match", - "limit", + "cvss_v3_metrics", "last_mod_end_date", + "pub_end_date", "cpe_name", + "cve_id", "cwe_id", "keyword_search", + "has_cert_alerts", + "last_mod_start_date", + "is_vulnerable", "has_cert_notes", - "no_rejected" + "virtual_match_string", + "has_kev", + "limit", + "pub_start_date" ] } ], diff --git a/internal/elastic/cmd/main.go b/internal/elastic/cmd/main.go index 32548429..3e112b27 100644 --- a/internal/elastic/cmd/main.go +++ b/internal/elastic/cmd/main.go @@ -9,6 +9,6 @@ var version string func main() { pluginapiv1.Serve( - elastic.Plugin(version), + elastic.Plugin(version, elastic.DefaultKibanaClientLoader), ) } diff --git a/internal/elastic/data_elastic_security_cases.go b/internal/elastic/data_elastic_security_cases.go new file mode 100644 index 00000000..b2f0a457 --- /dev/null +++ b/internal/elastic/data_elastic_security_cases.go @@ -0,0 +1,277 @@ +package elastic + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" + + "github.com/blackstork-io/fabric/internal/elastic/kbclient" + "github.com/blackstork-io/fabric/plugin" +) + +const ( + minSize = 1 + defaultSize = 10 +) + +func makeElasticSecurityCasesDataSource(loader KibanaClientLoaderFn) *plugin.DataSource { + return &plugin.DataSource{ + DataFunc: fetchElasticSecurityCases(loader), + Config: hcldec.ObjectSpec{ + "kibana_endpoint_url": &hcldec.AttrSpec{ + Name: "kibana_endpoint_url", + Type: cty.String, + Required: true, + }, + "api_key_str": &hcldec.AttrSpec{ + Name: "api_key_str", + Type: cty.String, + Required: false, + }, + "api_key": &hcldec.AttrSpec{ + Name: "api_key", + Type: cty.List(cty.String), + Required: false, + }, + }, + Args: hcldec.ObjectSpec{ + "space_id": &hcldec.AttrSpec{ + Name: "space_id", + Type: cty.String, + Required: false, + }, + "assignees": &hcldec.AttrSpec{ + Name: "assignees", + Type: cty.List(cty.String), + Required: false, + }, + "default_search_operator": &hcldec.AttrSpec{ + Name: "default_search_operator", + Type: cty.String, + Required: false, + }, + "from": &hcldec.AttrSpec{ + Name: "from", + Type: cty.String, + Required: false, + }, + "owner": &hcldec.AttrSpec{ + Name: "owner", + Type: cty.List(cty.String), + Required: false, + }, + "reporters": &hcldec.AttrSpec{ + Name: "reporters", + Type: cty.List(cty.String), + Required: false, + }, + "search": &hcldec.AttrSpec{ + Name: "search", + Type: cty.String, + Required: false, + }, + "search_fields": &hcldec.AttrSpec{ + Name: "search_fields", + Type: cty.List(cty.String), + Required: false, + }, + "severity": &hcldec.AttrSpec{ + Name: "severity", + Type: cty.String, + Required: false, + }, + "sort_field": &hcldec.AttrSpec{ + Name: "sort_field", + Type: cty.String, + Required: false, + }, + "sort_order": &hcldec.AttrSpec{ + Name: "sort_order", + Type: cty.String, + Required: false, + }, + "status": &hcldec.AttrSpec{ + Name: "status", + Type: cty.String, + Required: false, + }, + "tags": &hcldec.AttrSpec{ + Name: "tags", + Type: cty.List(cty.String), + Required: false, + }, + "to": &hcldec.AttrSpec{ + Name: "to", + Type: cty.String, + Required: false, + }, + "size": &hcldec.AttrSpec{ + Name: "size", + Type: cty.Number, + Required: false, + }, + }, + } +} + +func fetchElasticSecurityCases(loader KibanaClientLoaderFn) plugin.RetrieveDataFunc { + return func(ctx context.Context, params *plugin.RetrieveDataParams) (plugin.Data, hcl.Diagnostics) { + client, err := parseSecurityCasesConfig(loader, params.Config) + if err != nil { + return nil, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to parse configuration", + Detail: err.Error(), + }} + } + req, err := parseSecurityCasesArgs(params.Args) + if err != nil { + return nil, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to parse arguments", + Detail: err.Error(), + }} + } + size := defaultSize + if attr := params.Args.GetAttr("size"); !attr.IsNull() { + num, _ := attr.AsBigFloat().Int64() + size = int(num) + if size < minSize { + return nil, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Invalid size", + Detail: "size must be greater than 0", + }} + } + } + req.PerPage = size + req.Page = 1 + cases := plugin.ListData{} + for { + res, err := client.ListSecurityCases(ctx, req) + if err != nil { + return nil, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to list security cases", + Detail: err.Error(), + }} + } + for _, c := range res.Cases { + data, err := plugin.ParseDataAny(c) + if err != nil { + return nil, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to parse security case", + Detail: err.Error(), + }} + } + cases = append(cases, data) + } + if len(cases) >= size || req.Page*req.PerPage >= res.Total { + break + } + req.Page++ + } + return cases, nil + } +} + +func parseSecurityCasesArgs(args cty.Value) (*kbclient.ListSecurityCasesReq, error) { + if args.IsNull() { + return nil, fmt.Errorf("arguments are required") + } + req := &kbclient.ListSecurityCasesReq{} + if attr := args.GetAttr("space_id"); !attr.IsNull() { + req.SpaceID = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("assignees"); !attr.IsNull() { + list := []string{} + for _, v := range attr.AsValueSlice() { + list = append(list, v.AsString()) + } + req.Assignees = list + } + if attr := args.GetAttr("default_search_operator"); !attr.IsNull() { + req.DefaultSearchOperator = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("from"); !attr.IsNull() { + req.From = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("owner"); !attr.IsNull() { + list := []string{} + for _, v := range attr.AsValueSlice() { + list = append(list, v.AsString()) + } + req.Owner = list + } + if attr := args.GetAttr("reporters"); !attr.IsNull() { + list := []string{} + for _, v := range attr.AsValueSlice() { + list = append(list, v.AsString()) + } + req.Reporters = list + } + if attr := args.GetAttr("search"); !attr.IsNull() { + req.Search = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("search_fields"); !attr.IsNull() { + list := []string{} + for _, v := range attr.AsValueSlice() { + list = append(list, v.AsString()) + } + req.SearchFields = list + } + if attr := args.GetAttr("severity"); !attr.IsNull() { + req.Severity = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("sort_field"); !attr.IsNull() { + req.SortField = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("sort_order"); !attr.IsNull() { + req.SortOrder = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("status"); !attr.IsNull() { + req.Status = kbclient.String(attr.AsString()) + } + if attr := args.GetAttr("tags"); !attr.IsNull() { + list := []string{} + for _, v := range attr.AsValueSlice() { + list = append(list, v.AsString()) + } + req.Tags = list + } + if attr := args.GetAttr("to"); !attr.IsNull() { + req.To = kbclient.String(attr.AsString()) + } + return req, nil +} + +func parseSecurityCasesConfig(loader KibanaClientLoaderFn, cfg cty.Value) (kbclient.Client, error) { + if cfg.IsNull() { + return nil, fmt.Errorf("configuration is required") + } + var url string + var apiKey *string + if attr := cfg.GetAttr("kibana_endpoint_url"); attr.IsNull() { + return nil, fmt.Errorf("kibana_endpoint_url is required") + } else { + url = attr.AsString() + } + if attr := cfg.GetAttr("api_key_str"); !attr.IsNull() { + apiKey = kbclient.String(attr.AsString()) + } else { + if attr := cfg.GetAttr("api_key"); !attr.IsNull() { + list := attr.AsValueSlice() + if len(list) != 2 { + return nil, fmt.Errorf("api_key must be a list of 2 strings") + } + key := base64.RawURLEncoding.EncodeToString([]byte(list[0].AsString() + ":" + list[1].AsString())) + apiKey = kbclient.String(key) + } + } + return loader(url, apiKey), nil +} diff --git a/internal/elastic/data_elastic_security_cases_test.go b/internal/elastic/data_elastic_security_cases_test.go new file mode 100644 index 00000000..c24831f5 --- /dev/null +++ b/internal/elastic/data_elastic_security_cases_test.go @@ -0,0 +1,159 @@ +package elastic + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "github.com/zclconf/go-cty/cty" + + "github.com/blackstork-io/fabric/internal/elastic/kbclient" + kbclient_mocks "github.com/blackstork-io/fabric/mocks/internalpkg/elastic/kbclient" + "github.com/blackstork-io/fabric/plugin" +) + +type ReportsDataSourceTestSuite struct { + suite.Suite + schema *plugin.DataSource + ctx context.Context + cli *kbclient_mocks.Client + storedUrl string + storedApiKey *string +} + +func TestReportsDataSourceTestSuite(t *testing.T) { + suite.Run(t, new(ReportsDataSourceTestSuite)) +} + +func (s *ReportsDataSourceTestSuite) SetupSuite() { + s.schema = makeElasticSecurityCasesDataSource(func(url string, apiKey *string) kbclient.Client { + s.storedUrl = url + s.storedApiKey = *&apiKey + return s.cli + }) + s.ctx = context.Background() +} + +func (s *ReportsDataSourceTestSuite) SetupTest() { + s.cli = &kbclient_mocks.Client{} +} + +func (s *ReportsDataSourceTestSuite) TearDownTest() { + s.cli.AssertExpectations(s.T()) +} + +func (s *ReportsDataSourceTestSuite) TestSchema() { + s.Require().NotNil(s.schema) + s.NotNil(s.schema.Config) + s.NotNil(s.schema.Args) + s.NotNil(s.schema.DataFunc) +} + +func (s *ReportsDataSourceTestSuite) TestAuth() { + s.cli.On("ListSecurityCases", mock.Anything, &kbclient.ListSecurityCasesReq{ + Page: 1, + PerPage: 10, + }).Return(&kbclient.ListSecurityCasesRes{ + Page: 1, + PerPage: 10, + Total: 1, + Cases: []any{ + map[string]any{ + "id": "1", + }, + }, + }, nil) + res, diags := s.schema.DataFunc(s.ctx, &plugin.RetrieveDataParams{ + Config: cty.ObjectVal(map[string]cty.Value{ + "kibana_endpoint_url": cty.StringVal("test_kibana_endpoint_url"), + "api_key_str": cty.StringVal("test_api_key_str"), + "api_key": cty.NullVal(cty.List(cty.String)), + }), + Args: cty.ObjectVal(map[string]cty.Value{ + "space_id": cty.NullVal(cty.String), + "assignees": cty.NullVal(cty.List(cty.String)), + "default_search_operator": cty.NullVal(cty.String), + "from": cty.NullVal(cty.String), + "owner": cty.NullVal(cty.List(cty.String)), + "reporters": cty.NullVal(cty.List(cty.String)), + "search": cty.NullVal(cty.String), + "search_fields": cty.NullVal(cty.List(cty.String)), + "severity": cty.NullVal(cty.String), + "sort_field": cty.NullVal(cty.String), + "sort_order": cty.NullVal(cty.String), + "status": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.List(cty.String)), + "to": cty.NullVal(cty.String), + "size": cty.NullVal(cty.Number), + }), + }) + s.Equal("test_kibana_endpoint_url", s.storedUrl) + s.Equal(kbclient.String("test_api_key_str"), s.storedApiKey) + s.Len(diags, 0) + s.Equal(plugin.ListData{ + plugin.MapData{ + "id": plugin.StringData("1"), + }, + }, res) +} + +func (s *ReportsDataSourceTestSuite) TestFull() { + s.cli.On("ListSecurityCases", mock.Anything, &kbclient.ListSecurityCasesReq{ + SpaceID: nil, + Assignees: []string{"test_assignee_1", "test_assignee_2"}, + DefaultSearchOperator: nil, + From: nil, + Owner: []string{"test_owner_1", "test_owner_2"}, + Page: 1, + PerPage: 3, + Reporters: []string{"test_reporter_1", "test_reporter_2"}, + Search: kbclient.String("test_search"), + SearchFields: []string{"test_search_field_1", "test_search_field_2"}, + Severity: kbclient.String("test_severity"), + SortField: kbclient.String("test_sort_field"), + SortOrder: kbclient.String("test_sort_order"), + Status: kbclient.String("test_status"), + Tags: []string{"test_tag_1", "test_tag_2"}, + To: kbclient.String("test_to"), + }).Return(&kbclient.ListSecurityCasesRes{ + Page: 1, + Total: 2, + PerPage: 3, + Cases: []any{ + map[string]any{ + "id": "1", + }, + }, + }, nil) + res, diags := s.schema.DataFunc(s.ctx, &plugin.RetrieveDataParams{ + Config: cty.ObjectVal(map[string]cty.Value{ + "kibana_endpoint_url": cty.StringVal("test_kibana_endpoint_url"), + "api_key_str": cty.StringVal("test_api_key_str"), + "api_key": cty.NullVal(cty.List(cty.String)), + }), + Args: cty.ObjectVal(map[string]cty.Value{ + "space_id": cty.NullVal(cty.String), + "assignees": cty.ListVal([]cty.Value{cty.StringVal("test_assignee_1"), cty.StringVal("test_assignee_2")}), + "default_search_operator": cty.NullVal(cty.String), + "from": cty.NullVal(cty.String), + "owner": cty.ListVal([]cty.Value{cty.StringVal("test_owner_1"), cty.StringVal("test_owner_2")}), + "reporters": cty.ListVal([]cty.Value{cty.StringVal("test_reporter_1"), cty.StringVal("test_reporter_2")}), + "search": cty.StringVal("test_search"), + "search_fields": cty.ListVal([]cty.Value{cty.StringVal("test_search_field_1"), cty.StringVal("test_search_field_2")}), + "severity": cty.StringVal("test_severity"), + "sort_field": cty.StringVal("test_sort_field"), + "sort_order": cty.StringVal("test_sort_order"), + "status": cty.StringVal("test_status"), + "tags": cty.ListVal([]cty.Value{cty.StringVal("test_tag_1"), cty.StringVal("test_tag_2")}), + "to": cty.StringVal("test_to"), + "size": cty.NumberIntVal(3), + }), + }) + s.Len(diags, 0) + s.Equal(plugin.ListData{ + plugin.MapData{ + "id": plugin.StringData("1"), + }, + }, res) +} diff --git a/internal/elastic/data_elasticsearch.go b/internal/elastic/data_elasticsearch.go index 781dc762..3dd8b9ff 100644 --- a/internal/elastic/data_elasticsearch.go +++ b/internal/elastic/data_elasticsearch.go @@ -101,7 +101,7 @@ func makeElasticSearchDataSource() *plugin.DataSource { } func fetchElasticSearchData(ctx context.Context, params *plugin.RetrieveDataParams) (plugin.Data, hcl.Diagnostics) { - client, err := makeClient(params.Config) + client, err := makeSearchClient(params.Config) if err != nil { return nil, hcl.Diagnostics{{ Severity: hcl.DiagError, diff --git a/internal/elastic/kbclient/client.go b/internal/elastic/kbclient/client.go new file mode 100644 index 00000000..3696ef6d --- /dev/null +++ b/internal/elastic/kbclient/client.go @@ -0,0 +1,111 @@ +package kbclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/google/go-querystring/query" +) + +func String(s string) *string { + return &s +} + +func Bool(b bool) *bool { + return &b +} + +func Int(i int) *int { + return &i +} + +type ListSecurityCasesReq struct { + SpaceID *string `url:"-"` + Assignees []string `url:"assignees,omitempty"` + DefaultSearchOperator *string `url:"defaultSearchOperator,omitempty"` + From *string `url:"from,omitempty"` + Owner []string `url:"owner,omitempty"` + Page int `url:"page"` + PerPage int `url:"perPage"` + Reporters []string `url:"reporters,omitempty"` + Search *string `url:"search,omitempty"` + SearchFields []string `url:"searchFields,omitempty"` + Severity *string `url:"severity,omitempty"` + SortField *string `url:"sortField,omitempty"` + SortOrder *string `url:"sortOrder,omitempty"` + Status *string `url:"status,omitempty"` + Tags []string `url:"tags,omitempty"` + To *string `url:"to,omitempty"` +} + +type ListSecurityCasesRes struct { + Page int `json:"page"` + Total int `json:"total"` + PerPage int `json:"per_page"` + Cases []any `json:"cases"` +} + +type Client interface { + ListSecurityCases(ctx context.Context, req *ListSecurityCasesReq) (*ListSecurityCasesRes, error) +} + +type client struct { + url string + apiKey *string +} + +func New(url string, apiKey *string) Client { + return &client{ + url: url, + apiKey: apiKey, + } +} + +func (c *client) auth(r *http.Request) { + if c.apiKey != nil { + r.Header.Set("Authorization", "ApiKey "+*c.apiKey) + } +} + +func (c *client) ListSecurityCases(ctx context.Context, req *ListSecurityCasesReq) (*ListSecurityCasesRes, error) { + var u *url.URL + var err error + if req.SpaceID != nil { + u, err = url.Parse(c.url + "/s/" + *req.SpaceID + "/api/cases/_find") + } else { + u, err = url.Parse(c.url + "/api/cases/_find") + } + if err != nil { + return nil, err + } + q, err := query.Values(req) + if err != nil { + return nil, err + } + u.RawQuery = q.Encode() + r, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + c.auth(r) + client := http.Client{ + Timeout: 15 * time.Second, + } + res, err := client.Do(r) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("kbclient client returned status code: %d", res.StatusCode) + } + defer res.Body.Close() + var data ListSecurityCasesRes + if err := json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/internal/elastic/kbclient/client_test.go b/internal/elastic/kbclient/client_test.go new file mode 100644 index 00000000..0507bbed --- /dev/null +++ b/internal/elastic/kbclient/client_test.go @@ -0,0 +1,180 @@ +package kbclient + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ClientTestSuite struct { + suite.Suite + ctx context.Context + cancel context.CancelFunc +} + +func (s *ClientTestSuite) SetupTest() { + s.ctx, s.cancel = context.WithCancel(context.Background()) +} + +func (s *ClientTestSuite) TearDownTest() { + s.cancel() +} + +func TestClientTestSuite(t *testing.T) { + suite.Run(t, new(ClientTestSuite)) +} + +func (s *ClientTestSuite) mock(fn http.HandlerFunc, apiKey *string) (Client, *httptest.Server) { + srv := httptest.NewServer(fn) + cli := &client{ + url: srv.URL, + apiKey: apiKey, + } + return cli, srv +} + +func (s *ClientTestSuite) TestAuth() { + client, srv := s.mock(func(w http.ResponseWriter, r *http.Request) { + s.Equal("ApiKey test_token", r.Header.Get("Authorization")) + }, String("test_token")) + defer srv.Close() + client.ListSecurityCases(s.ctx, &ListSecurityCasesReq{}) +} + +func (s *ClientTestSuite) queryList(q url.Values, key string) []string { + list, ok := q[key] + s.Require().True(ok) + return list +} + +func (s *ClientTestSuite) TestWithSpaceID() { + client, srv := s.mock(func(w http.ResponseWriter, r *http.Request) { + s.Equal("/s/space-id-123/api/cases/_find", r.URL.Path) + s.Equal(http.MethodGet, r.Method) + w.Write([]byte(`{ + "cases": [ + { + "any": "data" + } + ] + }`)) + }, nil) + defer srv.Close() + req := ListSecurityCasesReq{ + SpaceID: String("space-id-123"), + } + result, err := client.ListSecurityCases(s.ctx, &req) + s.NoError(err) + s.Equal(&ListSecurityCasesRes{ + Cases: []any{ + map[string]any{ + "any": "data", + }, + }, + }, result) +} + +func (s *ClientTestSuite) TestWithoutSpaceID() { + client, srv := s.mock(func(w http.ResponseWriter, r *http.Request) { + s.Equal("/api/cases/_find", r.URL.Path) + s.Equal(http.MethodGet, r.Method) + w.Write([]byte(`{ + "cases": [ + { + "any": "data" + } + ] + }`)) + }, nil) + defer srv.Close() + req := ListSecurityCasesReq{} + result, err := client.ListSecurityCases(s.ctx, &req) + s.NoError(err) + s.Equal(&ListSecurityCasesRes{ + Cases: []any{ + map[string]any{ + "any": "data", + }, + }, + }, result) +} + +func (s *ClientTestSuite) TestFull() { + client, srv := s.mock(func(w http.ResponseWriter, r *http.Request) { + s.Equal("/s/space-id-123/api/cases/_find", r.URL.Path) + s.Equal(http.MethodGet, r.Method) + s.Equal([]string{"test_assignee_1", "test_assignee_2"}, s.queryList(r.URL.Query(), "assignees")) + s.Equal("test_status", r.URL.Query().Get("status")) + s.Equal([]string{"test_tag_1", "test_tag_2"}, s.queryList(r.URL.Query(), "tags")) + s.Equal("test_to", r.URL.Query().Get("to")) + s.Equal("test_search", r.URL.Query().Get("search")) + s.Equal("test_severity", r.URL.Query().Get("severity")) + s.Equal("test_sort_field", r.URL.Query().Get("sortField")) + s.Equal("test_sort_order", r.URL.Query().Get("sortOrder")) + s.Equal("test_default_search_operator", r.URL.Query().Get("defaultSearchOperator")) + s.Equal([]string{"test_search_field_1", "test_search_field_2"}, s.queryList(r.URL.Query(), "searchFields")) + s.Equal("test_from", r.URL.Query().Get("from")) + s.Equal([]string{"test_owner_1", "test_owner_2"}, s.queryList(r.URL.Query(), "owner")) + s.Equal([]string{"test_reporter_1", "test_reporter_2"}, s.queryList(r.URL.Query(), "reporters")) + w.Write([]byte(`{ + "page": 1, + "total": 2, + "per_page": 3, + "cases": [ + { + "any": "data" + } + ] + }`)) + }, nil) + defer srv.Close() + req := ListSecurityCasesReq{ + Page: 1, + PerPage: 3, + SpaceID: String("space-id-123"), + Assignees: []string{"test_assignee_1", "test_assignee_2"}, + Status: String("test_status"), + Tags: []string{"test_tag_1", "test_tag_2"}, + To: String("test_to"), + Search: String("test_search"), + Severity: String("test_severity"), + SortField: String("test_sort_field"), + SortOrder: String("test_sort_order"), + DefaultSearchOperator: String("test_default_search_operator"), + SearchFields: []string{"test_search_field_1", "test_search_field_2"}, + From: String("test_from"), + Owner: []string{"test_owner_1", "test_owner_2"}, + Reporters: []string{"test_reporter_1", "test_reporter_2"}, + } + result, err := client.ListSecurityCases(s.ctx, &req) + s.NoError(err) + s.Equal(&ListSecurityCasesRes{ + Page: 1, + Total: 2, + PerPage: 3, + Cases: []any{ + map[string]any{ + "any": "data", + }, + }, + }, result) +} + +// func (s *ClientTestSuite) TestGetAllReportsError() { +// client, srv := s.mock(func(w http.ResponseWriter, r *http.Request) { +// w.WriteHeader(http.StatusUnauthorized) +// }, "test_user", "test_token") +// defer srv.Close() +// req := GetAllReportsReq{} +// _, err := client.GetAllReports(s.ctx, &req) +// s.Error(err) +// } + +// func (s *ClientTestSuite) TestDefaultClientURL() { +// cli := New("test_user", "test_token") +// s.Equal("https://api.hackerone.com", cli.(*client).url) +// } diff --git a/internal/elastic/plugin.go b/internal/elastic/plugin.go index b63858dd..107388ed 100644 --- a/internal/elastic/plugin.go +++ b/internal/elastic/plugin.go @@ -11,6 +11,7 @@ import ( "github.com/elastic/go-elasticsearch/v8/esapi" "github.com/zclconf/go-cty/cty" + "github.com/blackstork-io/fabric/internal/elastic/kbclient" "github.com/blackstork-io/fabric/plugin" ) @@ -19,17 +20,25 @@ const ( defaultUsername = "elastic" ) -func Plugin(version string) *plugin.Schema { +type KibanaClientLoaderFn func(url string, apiKey *string) kbclient.Client + +var DefaultKibanaClientLoader KibanaClientLoaderFn = kbclient.New + +func Plugin(version string, loader KibanaClientLoaderFn) *plugin.Schema { + if loader == nil { + loader = DefaultKibanaClientLoader + } return &plugin.Schema{ Name: "blackstork/elastic", Version: version, DataSources: plugin.DataSources{ - "elasticsearch": makeElasticSearchDataSource(), + "elasticsearch": makeElasticSearchDataSource(), + "elastic_security_cases": makeElasticSecurityCasesDataSource(loader), }, } } -func makeClient(pcfg cty.Value) (*es.Client, error) { +func makeSearchClient(pcfg cty.Value) (*es.Client, error) { cfg := &es.Config{ Addresses: []string{defaultBaseURL}, Username: defaultUsername, diff --git a/internal/elastic/plugin_test.go b/internal/elastic/plugin_test.go index ca79a5f2..9ee1f1e2 100644 --- a/internal/elastic/plugin_test.go +++ b/internal/elastic/plugin_test.go @@ -7,8 +7,9 @@ import ( ) func TestPlugin_Schema(t *testing.T) { - schema := Plugin("1.2.3") + schema := Plugin("1.2.3", nil) assert.Equal(t, "blackstork/elastic", schema.Name) assert.Equal(t, "1.2.3", schema.Version) assert.NotNil(t, schema.DataSources["elasticsearch"]) + assert.NotNil(t, schema.DataSources["elastic_security_cases"]) } diff --git a/internal/plugin_validity_test.go b/internal/plugin_validity_test.go index b4549e7f..c67c115c 100644 --- a/internal/plugin_validity_test.go +++ b/internal/plugin_validity_test.go @@ -30,7 +30,7 @@ func TestAllPluginSchemaValidity(t *testing.T) { ver := "1.2.3" plugins := []*plugin.Schema{ builtin.Plugin(ver), - elastic.Plugin(ver), + elastic.Plugin(ver, nil), github.Plugin(ver, nil), graphql.Plugin(ver), openai.Plugin(ver, nil), diff --git a/mocks/internalpkg/elastic/kbclient/client.go b/mocks/internalpkg/elastic/kbclient/client.go new file mode 100644 index 00000000..c368e42f --- /dev/null +++ b/mocks/internalpkg/elastic/kbclient/client.go @@ -0,0 +1,96 @@ +// Code generated by mockery v2.42.1. DO NOT EDIT. + +package kbclient_mocks + +import ( + context "context" + + kbclient "github.com/blackstork-io/fabric/internal/elastic/kbclient" + mock "github.com/stretchr/testify/mock" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +type Client_Expecter struct { + mock *mock.Mock +} + +func (_m *Client) EXPECT() *Client_Expecter { + return &Client_Expecter{mock: &_m.Mock} +} + +// ListSecurityCases provides a mock function with given fields: ctx, req +func (_m *Client) ListSecurityCases(ctx context.Context, req *kbclient.ListSecurityCasesReq) (*kbclient.ListSecurityCasesRes, error) { + ret := _m.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for ListSecurityCases") + } + + var r0 *kbclient.ListSecurityCasesRes + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *kbclient.ListSecurityCasesReq) (*kbclient.ListSecurityCasesRes, error)); ok { + return rf(ctx, req) + } + if rf, ok := ret.Get(0).(func(context.Context, *kbclient.ListSecurityCasesReq) *kbclient.ListSecurityCasesRes); ok { + r0 = rf(ctx, req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*kbclient.ListSecurityCasesRes) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *kbclient.ListSecurityCasesReq) error); ok { + r1 = rf(ctx, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Client_ListSecurityCases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecurityCases' +type Client_ListSecurityCases_Call struct { + *mock.Call +} + +// ListSecurityCases is a helper method to define mock.On call +// - ctx context.Context +// - req *kbclient.ListSecurityCasesReq +func (_e *Client_Expecter) ListSecurityCases(ctx interface{}, req interface{}) *Client_ListSecurityCases_Call { + return &Client_ListSecurityCases_Call{Call: _e.mock.On("ListSecurityCases", ctx, req)} +} + +func (_c *Client_ListSecurityCases_Call) Run(run func(ctx context.Context, req *kbclient.ListSecurityCasesReq)) *Client_ListSecurityCases_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*kbclient.ListSecurityCasesReq)) + }) + return _c +} + +func (_c *Client_ListSecurityCases_Call) Return(_a0 *kbclient.ListSecurityCasesRes, _a1 error) *Client_ListSecurityCases_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Client_ListSecurityCases_Call) RunAndReturn(run func(context.Context, *kbclient.ListSecurityCasesReq) (*kbclient.ListSecurityCasesRes, error)) *Client_ListSecurityCases_Call { + _c.Call.Return(run) + return _c +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/tools/docgen/main.go b/tools/docgen/main.go index cfdc46aa..c6220a98 100644 --- a/tools/docgen/main.go +++ b/tools/docgen/main.go @@ -203,7 +203,7 @@ func main() { // load all plugins plugins := []*plugin.Schema{ builtin.Plugin(version), - elastic.Plugin(version), + elastic.Plugin(version, nil), github.Plugin(version, nil), graphql.Plugin(version), openai.Plugin(version, nil),