Skip to content

Commit 54cd555

Browse files
committed
feat(query): add resource selector search for config insights
Adds resource selector support for config_analysis (insights), matching how configs and config changes are already searchable. - New ConfigAnalysisQueryModel + registration in GetModelFromTable - FindConfigAnalysisByResourceSelector helpers - ConfigAnalysis wired into SearchResources request/response - HasDeletedAt flag on QueryModel so tables without a deleted_at column (config_analysis) skip the default soft-delete filter
1 parent 0e35311 commit 54cd555

5 files changed

Lines changed: 209 additions & 40 deletions

File tree

query/config_analysis.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package query
2+
3+
import (
4+
"github.com/flanksource/duty/context"
5+
"github.com/flanksource/duty/models"
6+
"github.com/flanksource/duty/types"
7+
"github.com/google/uuid"
8+
)
9+
10+
func FindConfigAnalysisByResourceSelector(ctx context.Context, limit int, resourceSelectors ...types.ResourceSelector) ([]models.ConfigAnalysis, error) {
11+
ids, err := FindConfigAnalysisIDsByResourceSelector(ctx, limit, resourceSelectors...)
12+
if err != nil {
13+
return nil, err
14+
}
15+
16+
return GetConfigAnalysisByIDs(ctx, ids)
17+
}
18+
19+
func FindConfigAnalysisIDsByResourceSelector(ctx context.Context, limit int, resourceSelectors ...types.ResourceSelector) ([]uuid.UUID, error) {
20+
return queryTableWithResourceSelectors(ctx, models.ConfigAnalysis{}.TableName(), limit, resourceSelectors...)
21+
}
22+
23+
func GetConfigAnalysisByIDs(ctx context.Context, ids []uuid.UUID) ([]models.ConfigAnalysis, error) {
24+
if len(ids) == 0 {
25+
return nil, nil
26+
}
27+
28+
var analyses []models.ConfigAnalysis
29+
if err := ctx.DB().Where("id IN ?", ids).Find(&analyses).Error; err != nil {
30+
return nil, err
31+
}
32+
33+
return analyses, nil
34+
}

query/models.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ type QueryModel struct {
192192
// True when the table has properties column
193193
HasProperties bool
194194

195+
// True when the table has a "deleted_at" column.
196+
// When false, the default `deleted_at IS NULL` filter is not applied.
197+
HasDeletedAt bool
198+
195199
// FieldMapper maps the value of these fields
196200
FieldMapper map[string]func(ctx context.Context, id string) (any, error)
197201
}
@@ -211,6 +215,7 @@ var ConfigItemQueryModel = QueryModel{
211215
HasTags: true,
212216
HasAgents: true,
213217
HasLabels: true,
218+
HasDeletedAt: true,
214219
Aliases: map[string]string{
215220
"created": "created_at",
216221
"updated": "updated_at",
@@ -243,6 +248,7 @@ var ConfigItemSummaryQueryModel = QueryModel{
243248
HasAgents: true,
244249
HasLabels: true,
245250
HasProperties: true,
251+
HasDeletedAt: true,
246252
Aliases: map[string]string{
247253
"created": "created_at",
248254
"updated": "updated_at",
@@ -293,6 +299,7 @@ var ComponentQueryModel = QueryModel{
293299
HasProperties: true,
294300
HasAgents: true,
295301
HasLabels: true,
302+
HasDeletedAt: true,
296303
FieldMapper: map[string]func(ctx context.Context, id string) (any, error){
297304
"agent_id": AgentMapper,
298305
"created_at": DateMapper,
@@ -316,8 +323,9 @@ var CheckQueryModel = QueryModel{
316323
"health": "status",
317324
"check_type": "type",
318325
},
319-
HasAgents: true,
320-
HasLabels: true,
326+
HasAgents: true,
327+
HasLabels: true,
328+
HasDeletedAt: true,
321329
FieldMapper: map[string]func(ctx context.Context, id string) (any, error){
322330
"agent_id": AgentMapper,
323331
"created_at": DateMapper,
@@ -327,9 +335,10 @@ var CheckQueryModel = QueryModel{
327335
}
328336

329337
var PlaybookQueryModel = QueryModel{
330-
Table: models.Playbook{}.TableName(),
331-
HasTags: true,
332-
Columns: []string{"id", "name", "namespace", "created_at", "updated_at", "deleted_at"},
338+
Table: models.Playbook{}.TableName(),
339+
HasTags: true,
340+
HasDeletedAt: true,
341+
Columns: []string{"id", "name", "namespace", "created_at", "updated_at", "deleted_at"},
333342
Aliases: map[string]string{
334343
"created": "created_at",
335344
"updated": "updated_at",
@@ -343,8 +352,9 @@ var PlaybookQueryModel = QueryModel{
343352
}
344353

345354
var ConnectionQueryModel = QueryModel{
346-
Table: models.Connection{}.TableName(),
347-
Columns: []string{"id", "name", "namespace", "type"},
355+
Table: models.Connection{}.TableName(),
356+
Columns: []string{"id", "name", "namespace", "type"},
357+
HasDeletedAt: true,
348358
}
349359

350360
var ConfigChangeQueryModel = QueryModel{
@@ -356,6 +366,7 @@ var ConfigChangeQueryModel = QueryModel{
356366
JSONMapColumns: []string{"tags", "details"},
357367
HasAgents: true,
358368
HasTags: true,
369+
HasDeletedAt: true,
359370
Aliases: map[string]string{
360371
"created": "created_at",
361372
"first_observed": "first_observed",
@@ -374,6 +385,7 @@ var ViewQueryModel = QueryModel{
374385
Columns: []string{"name", "namespace"},
375386
JSONMapColumns: []string{"labels"},
376387
HasLabels: true,
388+
HasDeletedAt: true,
377389
}
378390

379391
var CanaryQueryModel = QueryModel{
@@ -385,6 +397,7 @@ var CanaryQueryModel = QueryModel{
385397
JSONMapColumns: []string{"labels", "spec"},
386398
HasLabels: true,
387399
HasAgents: true,
400+
HasDeletedAt: true,
388401
Aliases: map[string]string{
389402
"created": "created_at",
390403
"updated": "updated_at",
@@ -399,6 +412,30 @@ var CanaryQueryModel = QueryModel{
399412
},
400413
}
401414

415+
// ConfigAnalysisQueryModel powers resource selector search for config insights
416+
// (the config_analysis table). The table has neither name/namespace nor
417+
// deleted_at/agent_id/tags/labels columns, so those features stay disabled.
418+
var ConfigAnalysisQueryModel = QueryModel{
419+
Table: models.ConfigAnalysis{}.TableName(),
420+
Columns: []string{
421+
"id", "config_id", "scraper_id", "source", "analyzer", "analysis_type",
422+
"severity", "status", "summary", "message", "first_observed", "last_observed",
423+
},
424+
JSONMapColumns: []string{"analysis"},
425+
HasProperties: true,
426+
Aliases: map[string]string{
427+
"type": "analysis_type",
428+
"analyzer_type": "analysis_type",
429+
"config": "config_id",
430+
"first_observed": "first_observed",
431+
"last_observed": "last_observed",
432+
},
433+
FieldMapper: map[string]func(ctx context.Context, id string) (any, error){
434+
"first_observed": DateMapper,
435+
"last_observed": DateMapper,
436+
},
437+
}
438+
402439
func GetModelFromTable(table string) (QueryModel, error) {
403440
switch table {
404441
case models.ConfigItem{}.TableName():
@@ -419,6 +456,8 @@ func GetModelFromTable(table string) (QueryModel, error) {
419456
return ConfigItemSummaryQueryModel, nil
420457
case models.View{}.TableName():
421458
return ViewQueryModel, nil
459+
case models.ConfigAnalysis{}.TableName():
460+
return ConfigAnalysisQueryModel, nil
422461
default:
423462
return QueryModel{}, fmt.Errorf("invalid table")
424463
}

query/resource_selector.go

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,25 @@ type SearchResourcesRequest struct {
3030
// Limit the number of results returned per resource type
3131
Limit int `json:"limit"`
3232

33-
Canaries []types.ResourceSelector `json:"canaries"`
34-
Checks []types.ResourceSelector `json:"checks"`
35-
Components []types.ResourceSelector `json:"components"`
36-
Configs []types.ResourceSelector `json:"configs"`
37-
ConfigChanges []types.ResourceSelector `json:"config_changes"`
38-
Playbooks []types.ResourceSelector `json:"playbooks"`
39-
Connections []types.ResourceSelector `json:"connections"`
33+
Canaries []types.ResourceSelector `json:"canaries"`
34+
Checks []types.ResourceSelector `json:"checks"`
35+
Components []types.ResourceSelector `json:"components"`
36+
Configs []types.ResourceSelector `json:"configs"`
37+
ConfigChanges []types.ResourceSelector `json:"config_changes"`
38+
ConfigAnalysis []types.ResourceSelector `json:"config_analysis"`
39+
Playbooks []types.ResourceSelector `json:"playbooks"`
40+
Connections []types.ResourceSelector `json:"connections"`
4041
}
4142

4243
type SearchResourcesResponse struct {
43-
Canaries []SelectedResource `json:"canaries,omitempty"`
44-
Checks []SelectedResource `json:"checks,omitempty"`
45-
Components []SelectedResource `json:"components,omitempty"`
46-
Configs []SelectedResource `json:"configs,omitempty"`
47-
ConfigChanges []SelectedResource `json:"config_changes,omitempty"`
48-
Playbooks []SelectedResource `json:"playbooks,omitempty"`
49-
Connections []SelectedResource `json:"connections,omitempty"`
44+
Canaries []SelectedResource `json:"canaries,omitempty"`
45+
Checks []SelectedResource `json:"checks,omitempty"`
46+
Components []SelectedResource `json:"components,omitempty"`
47+
Configs []SelectedResource `json:"configs,omitempty"`
48+
ConfigChanges []SelectedResource `json:"config_changes,omitempty"`
49+
ConfigAnalysis []SelectedResource `json:"config_analysis,omitempty"`
50+
Playbooks []SelectedResource `json:"playbooks,omitempty"`
51+
Connections []SelectedResource `json:"connections,omitempty"`
5052
}
5153

5254
func (r *SearchResourcesResponse) GetIDs() []string {
@@ -56,6 +58,7 @@ func (r *SearchResourcesResponse) GetIDs() []string {
5658
ids = append(ids, lo.Map(r.Configs, func(c SelectedResource, _ int) string { return c.ID })...)
5759
ids = append(ids, lo.Map(r.Components, func(c SelectedResource, _ int) string { return c.ID })...)
5860
ids = append(ids, lo.Map(r.ConfigChanges, func(c SelectedResource, _ int) string { return c.ID })...)
61+
ids = append(ids, lo.Map(r.ConfigAnalysis, func(c SelectedResource, _ int) string { return c.ID })...)
5962
ids = append(ids, lo.Map(r.Playbooks, func(c SelectedResource, _ int) string { return c.ID })...)
6063
ids = append(ids, lo.Map(r.Connections, func(c SelectedResource, _ int) string { return c.ID })...)
6164
return ids
@@ -189,6 +192,23 @@ func SearchResources(ctx context.Context, req SearchResourcesRequest) (*SearchRe
189192
return nil
190193
})
191194

195+
eg.Go(func() error {
196+
if items, err := FindConfigAnalysisByResourceSelector(ctx, req.Limit, req.ConfigAnalysis...); err != nil {
197+
return err
198+
} else {
199+
for i := range items {
200+
output.ConfigAnalysis = append(output.ConfigAnalysis, SelectedResource{
201+
ID: items[i].ID.String(),
202+
Name: items[i].Analyzer,
203+
Type: string(items[i].AnalysisType),
204+
Status: items[i].Status,
205+
})
206+
}
207+
}
208+
209+
return nil
210+
})
211+
192212
eg.Go(func() error {
193213
if items, err := FindPlaybooksByResourceSelector(ctx, req.Limit, req.Playbooks...); err != nil {
194214
return err
@@ -288,7 +308,7 @@ func SetResourceSelectorClause(
288308
query = query.Clauses(clauses...)
289309
}
290310

291-
if !resourceSelector.IncludeDeleted && !searchSetDeleted {
311+
if !resourceSelector.IncludeDeleted && !searchSetDeleted && qm.HasDeletedAt {
292312
query = query.Where("deleted_at IS NULL")
293313
}
294314

tests/fixtures/dummy/config_analysis.go

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,31 @@ import (
55
"github.com/google/uuid"
66
)
77

8+
var LogisticsDBRDSAnalysis = models.ConfigAnalysis{
9+
ID: uuid.New(),
10+
ConfigID: LogisticsDBRDS.ID,
11+
Analyzer: "rds-port-exposed",
12+
AnalysisType: models.AnalysisTypeSecurity,
13+
Severity: models.SeverityCritical,
14+
Message: "Port exposed to public",
15+
FirstObserved: &CurrentTime,
16+
Status: models.AnalysisStatusOpen,
17+
}
18+
19+
var EC2InstanceBAnalysis = models.ConfigAnalysis{
20+
ID: uuid.New(),
21+
ConfigID: EC2InstanceB.ID,
22+
Analyzer: "ec2-ssh-key-not-rotated",
23+
AnalysisType: models.AnalysisTypeSecurity,
24+
Severity: models.SeverityCritical,
25+
Message: "SSH key not rotated",
26+
FirstObserved: &CurrentTime,
27+
Status: models.AnalysisStatusOpen,
28+
}
29+
830
func AllDummyConfigAnalysis() []models.ConfigAnalysis {
931
return []models.ConfigAnalysis{
10-
{
11-
ID: uuid.New(),
12-
ConfigID: LogisticsDBRDS.ID,
13-
AnalysisType: models.AnalysisTypeSecurity,
14-
Severity: "critical",
15-
Message: "Port exposed to public",
16-
FirstObserved: &CurrentTime,
17-
Status: models.AnalysisStatusOpen,
18-
},
19-
{
20-
ID: uuid.New(),
21-
ConfigID: EC2InstanceB.ID,
22-
AnalysisType: models.AnalysisTypeSecurity,
23-
Severity: "critical",
24-
Message: "SSH key not rotated",
25-
FirstObserved: &CurrentTime,
26-
Status: models.AnalysisStatusOpen,
27-
},
32+
LogisticsDBRDSAnalysis,
33+
EC2InstanceBAnalysis,
2834
}
2935
}

tests/query_resource_selector_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,76 @@ var _ = ginkgo.Describe("View Resource Selector", func() {
724724
})
725725
})
726726

727+
var _ = ginkgo.Describe("Config Analysis Resource Selector", func() {
728+
// Other suites insert additional config_analysis rows that are never cleaned
729+
// up, so the severity/status/type cases are scoped by config_id to stay
730+
// deterministic.
731+
logisticsConfigID := dummy.LogisticsDBRDSAnalysis.ConfigID.String()
732+
ec2ConfigID := dummy.EC2InstanceBAnalysis.ConfigID.String()
733+
734+
testData := []struct {
735+
description string
736+
resourceSelector types.ResourceSelector
737+
expectedIDs []uuid.UUID
738+
}{
739+
{
740+
description: "by config_id",
741+
resourceSelector: types.ResourceSelector{Search: "config_id=" + logisticsConfigID},
742+
expectedIDs: []uuid.UUID{dummy.LogisticsDBRDSAnalysis.ID},
743+
},
744+
{
745+
description: "by analyzer",
746+
resourceSelector: types.ResourceSelector{Search: "analyzer=ec2-ssh-key-not-rotated"},
747+
expectedIDs: []uuid.UUID{dummy.EC2InstanceBAnalysis.ID},
748+
},
749+
{
750+
description: "by analyzer prefix",
751+
resourceSelector: types.ResourceSelector{Search: "analyzer=rds*"},
752+
expectedIDs: []uuid.UUID{dummy.LogisticsDBRDSAnalysis.ID},
753+
},
754+
{
755+
description: "by analysis_type alias (type)",
756+
resourceSelector: types.ResourceSelector{Search: "type=security config_id=" + ec2ConfigID},
757+
expectedIDs: []uuid.UUID{dummy.EC2InstanceBAnalysis.ID},
758+
},
759+
{
760+
description: "by severity",
761+
resourceSelector: types.ResourceSelector{Search: "severity=critical config_id=" + logisticsConfigID},
762+
expectedIDs: []uuid.UUID{dummy.LogisticsDBRDSAnalysis.ID},
763+
},
764+
{
765+
description: "by status",
766+
resourceSelector: types.ResourceSelector{Search: "status=open config_id=" + ec2ConfigID},
767+
expectedIDs: []uuid.UUID{dummy.EC2InstanceBAnalysis.ID},
768+
},
769+
{
770+
description: "no match when filtering deleted (table has no deleted_at)",
771+
resourceSelector: types.ResourceSelector{Search: "analyzer=does-not-exist"},
772+
expectedIDs: nil,
773+
},
774+
}
775+
776+
for _, test := range testData {
777+
ginkgo.It(test.description, func() {
778+
analyses, err := query.FindConfigAnalysisByResourceSelector(DefaultContext, -1, test.resourceSelector)
779+
Expect(err).To(BeNil())
780+
gotIDs := lo.Map(analyses, func(a models.ConfigAnalysis, _ int) uuid.UUID { return a.ID })
781+
Expect(gotIDs).To(ConsistOf(test.expectedIDs))
782+
})
783+
}
784+
785+
ginkgo.It("flows through SearchResources", func() {
786+
response, err := query.SearchResources(DefaultContext, query.SearchResourcesRequest{
787+
ConfigAnalysis: []types.ResourceSelector{{Search: "analyzer=rds-port-exposed"}},
788+
})
789+
Expect(err).To(BeNil())
790+
Expect(response.ConfigAnalysis).To(HaveLen(1))
791+
Expect(response.ConfigAnalysis[0].ID).To(Equal(dummy.LogisticsDBRDSAnalysis.ID.String()))
792+
Expect(response.ConfigAnalysis[0].Name).To(Equal("rds-port-exposed"))
793+
Expect(response.ConfigAnalysis[0].Type).To(Equal(string(models.AnalysisTypeSecurity)))
794+
})
795+
})
796+
727797
var _ = ginkgo.Describe("Resoure Selector with PEG", ginkgo.Ordered, func() {
728798
ginkgo.BeforeAll(func() {
729799
_ = query.SyncConfigCache(DefaultContext)

0 commit comments

Comments
 (0)