diff --git a/docs/docs.go b/docs/docs.go index 55c577f3..2bd20ea2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -36972,18 +36972,36 @@ const docTemplate = `{ "templates.batchRiskTemplateItem": { "type": "object", "properties": { + "dedupe-label-keys": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, "impact-hint": { "type": "string" }, + "impact-hint-template": { + "type": "string" + }, "is-active": { "type": "boolean" }, + "label-schema": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.labelSchemaFieldRequest" + } + }, "likelihood-hint": { "type": "string" }, + "likelihood-hint-template": { + "type": "string" + }, "name": { "type": "string" }, @@ -36993,6 +37011,9 @@ const docTemplate = `{ "statement": { "type": "string" }, + "statement-template": { + "type": "string" + }, "threat-ids": { "type": "array", "items": { @@ -37002,6 +37023,9 @@ const docTemplate = `{ "title": { "type": "string" }, + "title-template": { + "type": "string" + }, "violation-ids": { "type": "array", "items": { @@ -37181,6 +37205,28 @@ const docTemplate = `{ } } }, + "templates.labelSchemaFieldRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, + "templates.labelSchemaFieldResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, "templates.remediationTaskRequest": { "type": "object", "properties": { @@ -37257,18 +37303,36 @@ const docTemplate = `{ "created-at": { "type": "string" }, + "dedupe-label-keys": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, "impact-hint": { "type": "string" }, + "impact-hint-template": { + "type": "string" + }, "is-active": { "type": "boolean" }, + "label-schema": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.labelSchemaFieldResponse" + } + }, "likelihood-hint": { "type": "string" }, + "likelihood-hint-template": { + "type": "string" + }, "name": { "type": "string" }, @@ -37284,6 +37348,9 @@ const docTemplate = `{ "statement": { "type": "string" }, + "statement-template": { + "type": "string" + }, "threat-ids": { "type": "array", "items": { @@ -37293,6 +37360,9 @@ const docTemplate = `{ "title": { "type": "string" }, + "title-template": { + "type": "string" + }, "updated-at": { "type": "string" }, @@ -37458,15 +37528,33 @@ const docTemplate = `{ "templates.upsertRiskTemplateRequest": { "type": "object", "properties": { + "dedupe-label-keys": { + "type": "array", + "items": { + "type": "string" + } + }, "impact-hint": { "type": "string" }, + "impact-hint-template": { + "type": "string" + }, "is-active": { "type": "boolean" }, + "label-schema": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.labelSchemaFieldRequest" + } + }, "likelihood-hint": { "type": "string" }, + "likelihood-hint-template": { + "type": "string" + }, "name": { "type": "string" }, @@ -37482,6 +37570,9 @@ const docTemplate = `{ "statement": { "type": "string" }, + "statement-template": { + "type": "string" + }, "threat-ids": { "type": "array", "items": { @@ -37491,6 +37582,9 @@ const docTemplate = `{ "title": { "type": "string" }, + "title-template": { + "type": "string" + }, "violation-ids": { "type": "array", "items": { diff --git a/docs/swagger.json b/docs/swagger.json index 813a94a5..65beac6a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -36966,18 +36966,36 @@ "templates.batchRiskTemplateItem": { "type": "object", "properties": { + "dedupe-label-keys": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, "impact-hint": { "type": "string" }, + "impact-hint-template": { + "type": "string" + }, "is-active": { "type": "boolean" }, + "label-schema": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.labelSchemaFieldRequest" + } + }, "likelihood-hint": { "type": "string" }, + "likelihood-hint-template": { + "type": "string" + }, "name": { "type": "string" }, @@ -36987,6 +37005,9 @@ "statement": { "type": "string" }, + "statement-template": { + "type": "string" + }, "threat-ids": { "type": "array", "items": { @@ -36996,6 +37017,9 @@ "title": { "type": "string" }, + "title-template": { + "type": "string" + }, "violation-ids": { "type": "array", "items": { @@ -37175,6 +37199,28 @@ } } }, + "templates.labelSchemaFieldRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, + "templates.labelSchemaFieldResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, "templates.remediationTaskRequest": { "type": "object", "properties": { @@ -37251,18 +37297,36 @@ "created-at": { "type": "string" }, + "dedupe-label-keys": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, "impact-hint": { "type": "string" }, + "impact-hint-template": { + "type": "string" + }, "is-active": { "type": "boolean" }, + "label-schema": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.labelSchemaFieldResponse" + } + }, "likelihood-hint": { "type": "string" }, + "likelihood-hint-template": { + "type": "string" + }, "name": { "type": "string" }, @@ -37278,6 +37342,9 @@ "statement": { "type": "string" }, + "statement-template": { + "type": "string" + }, "threat-ids": { "type": "array", "items": { @@ -37287,6 +37354,9 @@ "title": { "type": "string" }, + "title-template": { + "type": "string" + }, "updated-at": { "type": "string" }, @@ -37452,15 +37522,33 @@ "templates.upsertRiskTemplateRequest": { "type": "object", "properties": { + "dedupe-label-keys": { + "type": "array", + "items": { + "type": "string" + } + }, "impact-hint": { "type": "string" }, + "impact-hint-template": { + "type": "string" + }, "is-active": { "type": "boolean" }, + "label-schema": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.labelSchemaFieldRequest" + } + }, "likelihood-hint": { "type": "string" }, + "likelihood-hint-template": { + "type": "string" + }, "name": { "type": "string" }, @@ -37476,6 +37564,9 @@ "statement": { "type": "string" }, + "statement-template": { + "type": "string" + }, "threat-ids": { "type": "array", "items": { @@ -37485,6 +37576,9 @@ "title": { "type": "string" }, + "title-template": { + "type": "string" + }, "violation-ids": { "type": "array", "items": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1b76704a..37ffd2ac 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -7428,26 +7428,42 @@ definitions: type: object templates.batchRiskTemplateItem: properties: + dedupe-label-keys: + items: + type: string + type: array id: type: string impact-hint: type: string + impact-hint-template: + type: string is-active: type: boolean + label-schema: + items: + $ref: '#/definitions/templates.labelSchemaFieldRequest' + type: array likelihood-hint: type: string + likelihood-hint-template: + type: string name: type: string remediation-template: $ref: '#/definitions/templates.remediationTemplateRequest' statement: type: string + statement-template: + type: string threat-ids: items: $ref: '#/definitions/templates.threatIDRequest' type: array title: type: string + title-template: + type: string violation-ids: items: type: string @@ -7565,6 +7581,20 @@ definitions: data: $ref: '#/definitions/templates.batchUpsertSubjectTemplatesData' type: object + templates.labelSchemaFieldRequest: + properties: + description: + type: string + key: + type: string + type: object + templates.labelSchemaFieldResponse: + properties: + description: + type: string + key: + type: string + type: object templates.remediationTaskRequest: properties: order-index: @@ -7614,14 +7644,26 @@ definitions: properties: created-at: type: string + dedupe-label-keys: + items: + type: string + type: array id: type: string impact-hint: type: string + impact-hint-template: + type: string is-active: type: boolean + label-schema: + items: + $ref: '#/definitions/templates.labelSchemaFieldResponse' + type: array likelihood-hint: type: string + likelihood-hint-template: + type: string name: type: string plugin-id: @@ -7632,12 +7674,16 @@ definitions: $ref: '#/definitions/templates.remediationTemplateResponse' statement: type: string + statement-template: + type: string threat-ids: items: $ref: '#/definitions/templates.threatIDResponse' type: array title: type: string + title-template: + type: string updated-at: type: string violation-ids: @@ -7745,12 +7791,24 @@ definitions: type: object templates.upsertRiskTemplateRequest: properties: + dedupe-label-keys: + items: + type: string + type: array impact-hint: type: string + impact-hint-template: + type: string is-active: type: boolean + label-schema: + items: + $ref: '#/definitions/templates.labelSchemaFieldRequest' + type: array likelihood-hint: type: string + likelihood-hint-template: + type: string name: type: string plugin-id: @@ -7761,12 +7819,16 @@ definitions: $ref: '#/definitions/templates.remediationTemplateRequest' statement: type: string + statement-template: + type: string threat-ids: items: $ref: '#/definitions/templates.threatIDRequest' type: array title: type: string + title-template: + type: string violation-ids: items: type: string diff --git a/internal/api/handler/templates/risk_template.go b/internal/api/handler/templates/risk_template.go index 9922ce89..20864526 100644 --- a/internal/api/handler/templates/risk_template.go +++ b/internal/api/handler/templates/risk_template.go @@ -60,18 +60,29 @@ type remediationTemplateRequest struct { Tasks []remediationTaskRequest `json:"tasks"` } +type labelSchemaFieldRequest struct { + Key string `json:"key"` + Description *string `json:"description"` +} + type upsertRiskTemplateRequest struct { - PluginID string `json:"plugin-id"` - PolicyPackage string `json:"policy-package"` - Name string `json:"name"` - Title string `json:"title"` - Statement string `json:"statement"` - LikelihoodHint *string `json:"likelihood-hint"` - ImpactHint *string `json:"impact-hint"` - ViolationIDs []string `json:"violation-ids"` - ThreatIDs []threatIDRequest `json:"threat-ids"` - Remediation *remediationTemplateRequest `json:"remediation-template"` - IsActive *bool `json:"is-active"` + PluginID string `json:"plugin-id"` + PolicyPackage string `json:"policy-package"` + Name string `json:"name"` + Title string `json:"title"` + Statement string `json:"statement"` + LikelihoodHint *string `json:"likelihood-hint"` + ImpactHint *string `json:"impact-hint"` + TitleTemplate *string `json:"title-template"` + StatementTemplate *string `json:"statement-template"` + LikelihoodHintTemplate *string `json:"likelihood-hint-template"` + ImpactHintTemplate *string `json:"impact-hint-template"` + DedupeLabelKeys []string `json:"dedupe-label-keys"` + LabelSchema []labelSchemaFieldRequest `json:"label-schema"` + ViolationIDs []string `json:"violation-ids"` + ThreatIDs []threatIDRequest `json:"threat-ids"` + Remediation *remediationTemplateRequest `json:"remediation-template"` + IsActive *bool `json:"is-active"` } type threatIDResponse struct { @@ -94,21 +105,32 @@ type remediationTemplateResponse struct { Tasks []remediationTaskResponse `json:"tasks"` } +type labelSchemaFieldResponse struct { + Key string `json:"key"` + Description *string `json:"description,omitempty"` +} + type riskTemplateResponse struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created-at"` - UpdatedAt time.Time `json:"updated-at"` - PluginID string `json:"plugin-id"` - PolicyPackage string `json:"policy-package"` - Name string `json:"name"` - Title string `json:"title"` - Statement string `json:"statement"` - LikelihoodHint *string `json:"likelihood-hint"` - ImpactHint *string `json:"impact-hint"` - ViolationIDs []string `json:"violation-ids"` - ThreatIDs []threatIDResponse `json:"threat-ids"` - Remediation *remediationTemplateResponse `json:"remediation-template,omitempty"` - IsActive bool `json:"is-active"` + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created-at"` + UpdatedAt time.Time `json:"updated-at"` + PluginID string `json:"plugin-id"` + PolicyPackage string `json:"policy-package"` + Name string `json:"name"` + Title string `json:"title"` + Statement string `json:"statement"` + LikelihoodHint *string `json:"likelihood-hint"` + ImpactHint *string `json:"impact-hint"` + TitleTemplate *string `json:"title-template,omitempty"` + StatementTemplate *string `json:"statement-template,omitempty"` + LikelihoodHintTemplate *string `json:"likelihood-hint-template,omitempty"` + ImpactHintTemplate *string `json:"impact-hint-template,omitempty"` + DedupeLabelKeys []string `json:"dedupe-label-keys,omitempty"` + LabelSchema []labelSchemaFieldResponse `json:"label-schema,omitempty"` + ViolationIDs []string `json:"violation-ids"` + ThreatIDs []threatIDResponse `json:"threat-ids"` + Remediation *remediationTemplateResponse `json:"remediation-template,omitempty"` + IsActive bool `json:"is-active"` } type riskTemplateDataResponse struct { @@ -289,16 +311,22 @@ func (h *RiskTemplateHandler) Delete(ctx echo.Context) error { func mapRequestToPayload(req upsertRiskTemplateRequest) templaterel.RiskTemplatePayload { payload := templaterel.RiskTemplatePayload{ - PluginID: req.PluginID, - PolicyPackage: req.PolicyPackage, - Name: req.Name, - Title: req.Title, - Statement: req.Statement, - LikelihoodHint: req.LikelihoodHint, - ImpactHint: req.ImpactHint, - ViolationIDs: req.ViolationIDs, - IsActive: req.IsActive, - ThreatRefs: make([]templaterel.ThreatRefInput, 0, len(req.ThreatIDs)), + PluginID: req.PluginID, + PolicyPackage: req.PolicyPackage, + Name: req.Name, + Title: req.Title, + Statement: req.Statement, + LikelihoodHint: req.LikelihoodHint, + ImpactHint: req.ImpactHint, + TitleTemplate: req.TitleTemplate, + StatementTemplate: req.StatementTemplate, + LikelihoodHintTemplate: req.LikelihoodHintTemplate, + ImpactHintTemplate: req.ImpactHintTemplate, + DedupeLabelKeys: req.DedupeLabelKeys, + ViolationIDs: req.ViolationIDs, + IsActive: req.IsActive, + ThreatRefs: make([]templaterel.ThreatRefInput, 0, len(req.ThreatIDs)), + LabelSchema: make([]templaterel.RiskTemplateLabelSchemaFieldInput, 0, len(req.LabelSchema)), } for _, ref := range req.ThreatIDs { @@ -310,6 +338,13 @@ func mapRequestToPayload(req upsertRiskTemplateRequest) templaterel.RiskTemplate }) } + for _, f := range req.LabelSchema { + payload.LabelSchema = append(payload.LabelSchema, templaterel.RiskTemplateLabelSchemaFieldInput{ + Key: f.Key, + Description: f.Description, + }) + } + if req.Remediation != nil { remediation := templaterel.RemediationTemplateInput{ Title: req.Remediation.Title, @@ -329,16 +364,22 @@ func mapRequestToPayload(req upsertRiskTemplateRequest) templaterel.RiskTemplate } type batchRiskTemplateItem struct { - ID string `json:"id"` - Name string `json:"name"` - Title string `json:"title"` - Statement string `json:"statement"` - LikelihoodHint *string `json:"likelihood-hint"` - ImpactHint *string `json:"impact-hint"` - ViolationIDs []string `json:"violation-ids"` - ThreatIDs []threatIDRequest `json:"threat-ids"` - Remediation *remediationTemplateRequest `json:"remediation-template"` - IsActive *bool `json:"is-active"` + ID string `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Statement string `json:"statement"` + LikelihoodHint *string `json:"likelihood-hint"` + ImpactHint *string `json:"impact-hint"` + TitleTemplate *string `json:"title-template"` + StatementTemplate *string `json:"statement-template"` + LikelihoodHintTemplate *string `json:"likelihood-hint-template"` + ImpactHintTemplate *string `json:"impact-hint-template"` + DedupeLabelKeys []string `json:"dedupe-label-keys"` + LabelSchema []labelSchemaFieldRequest `json:"label-schema"` + ViolationIDs []string `json:"violation-ids"` + ThreatIDs []threatIDRequest `json:"threat-ids"` + Remediation *remediationTemplateRequest `json:"remediation-template"` + IsActive *bool `json:"is-active"` } type batchUpsertRiskTemplatesRequest struct { @@ -393,15 +434,21 @@ func (h *RiskTemplateHandler) BatchUpsert(ctx echo.Context) error { return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("item %d: invalid id %q: %w", len(items), item.ID, err))) } svcItem := templaterel.BatchRiskTemplateItem{ - ID: parsedID, - Name: item.Name, - Title: item.Title, - Statement: item.Statement, - LikelihoodHint: item.LikelihoodHint, - ImpactHint: item.ImpactHint, - ViolationIDs: item.ViolationIDs, - IsActive: item.IsActive, - ThreatRefs: make([]templaterel.ThreatRefInput, 0, len(item.ThreatIDs)), + ID: parsedID, + Name: item.Name, + Title: item.Title, + Statement: item.Statement, + LikelihoodHint: item.LikelihoodHint, + ImpactHint: item.ImpactHint, + TitleTemplate: item.TitleTemplate, + StatementTemplate: item.StatementTemplate, + LikelihoodHintTemplate: item.LikelihoodHintTemplate, + ImpactHintTemplate: item.ImpactHintTemplate, + DedupeLabelKeys: item.DedupeLabelKeys, + ViolationIDs: item.ViolationIDs, + IsActive: item.IsActive, + ThreatRefs: make([]templaterel.ThreatRefInput, 0, len(item.ThreatIDs)), + LabelSchema: make([]templaterel.RiskTemplateLabelSchemaFieldInput, 0, len(item.LabelSchema)), } for _, ref := range item.ThreatIDs { svcItem.ThreatRefs = append(svcItem.ThreatRefs, templaterel.ThreatRefInput{ @@ -411,6 +458,12 @@ func (h *RiskTemplateHandler) BatchUpsert(ctx echo.Context) error { URL: ref.URL, }) } + for _, f := range item.LabelSchema { + svcItem.LabelSchema = append(svcItem.LabelSchema, templaterel.RiskTemplateLabelSchemaFieldInput{ + Key: f.Key, + Description: f.Description, + }) + } if item.Remediation != nil { rem := templaterel.RemediationTemplateInput{ Title: item.Remediation.Title, @@ -451,19 +504,34 @@ func (h *RiskTemplateHandler) BatchUpsert(ctx echo.Context) error { func mapRiskTemplateToResponse(row templaterel.RiskTemplate) riskTemplateResponse { resp := riskTemplateResponse{ - ID: *row.ID, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - PluginID: row.PluginID, - PolicyPackage: row.PolicyPackage, - Name: row.Name, - Title: row.Title, - Statement: row.Statement, - LikelihoodHint: riskrel.NormalizeRiskLevelPtr(row.LikelihoodHint), - ImpactHint: riskrel.NormalizeRiskLevelPtr(row.ImpactHint), - ViolationIDs: append([]string{}, row.ViolationIDs...), - ThreatIDs: make([]threatIDResponse, 0, len(row.ThreatRefs)), - IsActive: row.IsActive, + ID: *row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + PluginID: row.PluginID, + PolicyPackage: row.PolicyPackage, + Name: row.Name, + Title: row.Title, + Statement: row.Statement, + LikelihoodHint: riskrel.NormalizeRiskLevelPtr(row.LikelihoodHint), + ImpactHint: riskrel.NormalizeRiskLevelPtr(row.ImpactHint), + TitleTemplate: row.TitleTemplate, + StatementTemplate: row.StatementTemplate, + LikelihoodHintTemplate: row.LikelihoodHintTemplate, + ImpactHintTemplate: row.ImpactHintTemplate, + DedupeLabelKeys: append([]string{}, row.DedupeLabelKeys...), + ViolationIDs: append([]string{}, row.ViolationIDs...), + ThreatIDs: make([]threatIDResponse, 0, len(row.ThreatRefs)), + IsActive: row.IsActive, + } + + if len(row.LabelSchema) > 0 { + resp.LabelSchema = make([]labelSchemaFieldResponse, 0, len(row.LabelSchema)) + for _, f := range row.LabelSchema { + resp.LabelSchema = append(resp.LabelSchema, labelSchemaFieldResponse{ + Key: f.Key, + Description: f.Description, + }) + } } for _, ref := range row.ThreatRefs { diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 5e324212..1d006a36 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -103,6 +103,7 @@ func MigrateUp(db *gorm.DB) error { &riskrel.ComponentDefinitionLabel{}, &templaterel.RiskTemplate{}, &templaterel.RiskTemplateThreatRef{}, + &templaterel.RiskTemplateLabelSchemaField{}, &templaterel.RemediationTemplate{}, &templaterel.RemediationTask{}, &templaterel.SubjectTemplate{}, @@ -328,6 +329,7 @@ func MigrateDown(db *gorm.DB) error { &riskrel.ComponentDefinitionLabel{}, &templaterel.RiskTemplate{}, &templaterel.RiskTemplateThreatRef{}, + &templaterel.RiskTemplateLabelSchemaField{}, &templaterel.RemediationTemplate{}, &templaterel.RemediationTask{}, &templaterel.AssessmentSubjectIdentity{}, diff --git a/internal/service/relational/templates/models.go b/internal/service/relational/templates/models.go index 966edd1f..a622e6b8 100644 --- a/internal/service/relational/templates/models.go +++ b/internal/service/relational/templates/models.go @@ -21,13 +21,21 @@ type RiskTemplate struct { LikelihoodHint *string `json:"likelihoodHint" gorm:"type:varchar(32)"` ImpactHint *string `json:"impactHint" gorm:"type:varchar(32)"` + TitleTemplate *string `json:"titleTemplate" gorm:"type:text"` + StatementTemplate *string `json:"statementTemplate" gorm:"type:text"` + LikelihoodHintTemplate *string `json:"likelihoodHintTemplate" gorm:"type:text"` + ImpactHintTemplate *string `json:"impactHintTemplate" gorm:"type:text"` + + DedupeLabelKeys datatypes.JSONSlice[string] `json:"dedupeLabelKeys" gorm:"type:jsonb"` + RemediationTemplateID *uuid.UUID `json:"remediationTemplateId" gorm:"type:uuid;index"` RemediationTemplate *RemediationTemplate `json:"remediationTemplate,omitempty" gorm:"foreignKey:RemediationTemplateID;references:ID"` ViolationIDs datatypes.JSONSlice[string] `json:"violationIds" gorm:"type:jsonb"` IsActive bool `json:"isActive" gorm:"not null;default:false;index"` - ThreatRefs []RiskTemplateThreatRef `json:"threatRefs,omitempty" gorm:"foreignKey:RiskTemplateID;constraint:OnDelete:CASCADE"` + ThreatRefs []RiskTemplateThreatRef `json:"threatRefs,omitempty" gorm:"foreignKey:RiskTemplateID;constraint:OnDelete:CASCADE"` + LabelSchema []RiskTemplateLabelSchemaField `json:"labelSchema,omitempty" gorm:"foreignKey:RiskTemplateID;constraint:OnDelete:CASCADE"` } func (RiskTemplate) TableName() string { @@ -48,6 +56,18 @@ func (RiskTemplateThreatRef) TableName() string { return "risk_template_threat_refs" } +type RiskTemplateLabelSchemaField struct { + relational.UUIDModel + RiskTemplateID uuid.UUID `json:"riskTemplateId" gorm:"type:uuid;not null;uniqueIndex:idx_risk_template_label_schema_fields_template_key,priority:1"` + + Key string `json:"key" gorm:"type:text;not null;uniqueIndex:idx_risk_template_label_schema_fields_template_key,priority:2"` + Description *string `json:"description" gorm:"type:text"` +} + +func (RiskTemplateLabelSchemaField) TableName() string { + return "risk_template_label_schema_fields" +} + type RemediationTemplate struct { relational.UUIDModel CreatedAt time.Time `json:"createdAt"` diff --git a/internal/service/relational/templates/risk_template_service.go b/internal/service/relational/templates/risk_template_service.go index 9078f7b3..9b340fb8 100644 --- a/internal/service/relational/templates/risk_template_service.go +++ b/internal/service/relational/templates/risk_template_service.go @@ -15,10 +15,12 @@ import ( ) const ( - maxRiskTemplateFieldLength = 1000 - maxThreatRefsPerTemplate = 50 - maxViolationIDsPerTemplate = 100 - maxRemediationTasks = 100 + maxRiskTemplateFieldLength = 1000 + maxThreatRefsPerTemplate = 50 + maxViolationIDsPerTemplate = 100 + maxRemediationTasks = 100 + maxRiskTemplateLabelSchemaItems = 100 + maxRiskTemplateDedupeLabelKeys = 20 ) type ValidationError struct { @@ -76,6 +78,11 @@ type RemediationTemplateInput struct { Tasks []RemediationTaskInput } +type RiskTemplateLabelSchemaFieldInput struct { + Key string + Description *string +} + type RiskTemplatePayload struct { PluginID string PolicyPackage string @@ -88,6 +95,13 @@ type RiskTemplatePayload struct { IsActive *bool ThreatRefs []ThreatRefInput + TitleTemplate *string + StatementTemplate *string + LikelihoodHintTemplate *string + ImpactHintTemplate *string + DedupeLabelKeys []string + LabelSchema []RiskTemplateLabelSchemaFieldInput + // Optional: nil means "no remediation template". RemediationTemplate *RemediationTemplateInput } @@ -119,6 +133,7 @@ func (s *RiskTemplateService) List(params RiskTemplateListParams) ([]RiskTemplat var rows []RiskTemplate if err := query. Preload("ThreatRefs", preloadThreatRefs). + Preload("LabelSchema", preloadRiskTemplateLabelSchema). Preload("RemediationTemplate"). Preload("RemediationTemplate.Tasks", preloadRemediationTasks). Order("created_at desc"). @@ -157,15 +172,20 @@ func (s *RiskTemplateService) Create(payload RiskTemplatePayload) (*RiskTemplate } row := RiskTemplate{ - PluginID: payload.PluginID, - PolicyPackage: payload.PolicyPackage, - Name: payload.Name, - Title: payload.Title, - Statement: payload.Statement, - LikelihoodHint: payload.LikelihoodHint, - ImpactHint: payload.ImpactHint, - ViolationIDs: datatypes.NewJSONSlice(payload.ViolationIDs), - IsActive: true, + PluginID: payload.PluginID, + PolicyPackage: payload.PolicyPackage, + Name: payload.Name, + Title: payload.Title, + Statement: payload.Statement, + LikelihoodHint: payload.LikelihoodHint, + ImpactHint: payload.ImpactHint, + TitleTemplate: payload.TitleTemplate, + StatementTemplate: payload.StatementTemplate, + LikelihoodHintTemplate: payload.LikelihoodHintTemplate, + ImpactHintTemplate: payload.ImpactHintTemplate, + DedupeLabelKeys: datatypes.NewJSONSlice(payload.DedupeLabelKeys), + ViolationIDs: datatypes.NewJSONSlice(payload.ViolationIDs), + IsActive: true, } if payload.IsActive != nil { row.IsActive = *payload.IsActive @@ -185,6 +205,11 @@ func (s *RiskTemplateService) Create(payload RiskTemplatePayload) (*RiskTemplate "Statement", "LikelihoodHint", "ImpactHint", + "TitleTemplate", + "StatementTemplate", + "LikelihoodHintTemplate", + "ImpactHintTemplate", + "DedupeLabelKeys", "ViolationIDs", "IsActive", "RemediationTemplateID", @@ -206,6 +231,11 @@ func (s *RiskTemplateService) Create(payload RiskTemplatePayload) (*RiskTemplate return nil, err } + if err := replaceRiskTemplateLabelSchema(tx, *row.ID, payload.LabelSchema); err != nil { + tx.Rollback() + return nil, err + } + created, err := fetchRiskTemplateByID(tx, *row.ID) if err != nil { tx.Rollback() @@ -243,6 +273,11 @@ func (s *RiskTemplateService) Update(id uuid.UUID, payload RiskTemplatePayload) existing.Statement = payload.Statement existing.LikelihoodHint = payload.LikelihoodHint existing.ImpactHint = payload.ImpactHint + existing.TitleTemplate = payload.TitleTemplate + existing.StatementTemplate = payload.StatementTemplate + existing.LikelihoodHintTemplate = payload.LikelihoodHintTemplate + existing.ImpactHintTemplate = payload.ImpactHintTemplate + existing.DedupeLabelKeys = datatypes.NewJSONSlice(payload.DedupeLabelKeys) existing.ViolationIDs = datatypes.NewJSONSlice(payload.ViolationIDs) if payload.IsActive != nil { existing.IsActive = *payload.IsActive @@ -264,7 +299,7 @@ func (s *RiskTemplateService) Update(id uuid.UUID, payload RiskTemplatePayload) existing.RemediationTemplate = nil } - if err := tx.Omit("ThreatRefs", "RemediationTemplate").Save(&existing).Error; err != nil { + if err := tx.Omit("ThreatRefs", "LabelSchema", "RemediationTemplate").Save(&existing).Error; err != nil { tx.Rollback() return nil, err } @@ -274,6 +309,11 @@ func (s *RiskTemplateService) Update(id uuid.UUID, payload RiskTemplatePayload) return nil, err } + if err := replaceRiskTemplateLabelSchema(tx, *existing.ID, payload.LabelSchema); err != nil { + tx.Rollback() + return nil, err + } + updated, err := fetchRiskTemplateByID(tx, *existing.ID) if err != nil { tx.Rollback() @@ -305,6 +345,11 @@ func (s *RiskTemplateService) Delete(id uuid.UUID) error { return err } + if err := tx.Delete(&RiskTemplateLabelSchemaField{}, "risk_template_id = ?", id).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Delete(&RiskTemplate{}, "id = ?", id).Error; err != nil { tx.Rollback() return err @@ -358,6 +403,27 @@ func replaceThreatRefs(tx *gorm.DB, riskTemplateID uuid.UUID, refs []ThreatRefIn return tx.Create(&rows).Error } +func replaceRiskTemplateLabelSchema(tx *gorm.DB, riskTemplateID uuid.UUID, fields []RiskTemplateLabelSchemaFieldInput) error { + if err := tx.Delete(&RiskTemplateLabelSchemaField{}, "risk_template_id = ?", riskTemplateID).Error; err != nil { + return err + } + + rows := make([]RiskTemplateLabelSchemaField, 0, len(fields)) + for _, f := range fields { + rows = append(rows, RiskTemplateLabelSchemaField{ + RiskTemplateID: riskTemplateID, + Key: f.Key, + Description: f.Description, + }) + } + + if len(rows) == 0 { + return nil + } + + return tx.Create(&rows).Error +} + func upsertRemediationTemplate(tx *gorm.DB, remediationTemplateID *uuid.UUID, input *RemediationTemplateInput) (*RemediationTemplate, error) { if input == nil { return nil, newValidationError("remediationTemplate is required") @@ -430,6 +496,126 @@ func validateRiskTemplatePayload(payload *RiskTemplatePayload) error { if err := validateRemediationTemplate(payload.RemediationTemplate); err != nil { return err } + if err := validateRiskTemplateLabelSchema(payload.LabelSchema); err != nil { + return err + } + if err := validateRiskTemplateDedupeLabelKeys(payload.DedupeLabelKeys, payload.LabelSchema); err != nil { + return err + } + if err := validateRiskTemplateTemplateFields(payload); err != nil { + return err + } + + return nil +} + +func validateRiskTemplateLabelSchema(schema []RiskTemplateLabelSchemaFieldInput) error { + if err := validateMaxItems("labelSchema", len(schema), maxRiskTemplateLabelSchemaItems); err != nil { + return err + } + + seen := make(map[string]struct{}, len(schema)) + for i, field := range schema { + key := strings.TrimSpace(field.Key) + if key == "" { + return newValidationError(fmt.Sprintf("labelSchema[%d].key must not be empty", i)) + } + if err := validateTextLength(fmt.Sprintf("labelSchema[%d].key", i), key); err != nil { + return err + } + if _, exists := seen[key]; exists { + return newValidationError(fmt.Sprintf("labelSchema has duplicate key %q", key)) + } + seen[key] = struct{}{} + if err := validateOptionalText(fmt.Sprintf("labelSchema[%d].description", i), field.Description); err != nil { + return err + } + } + + return nil +} + +func validateRiskTemplateDedupeLabelKeys(keys []string, schema []RiskTemplateLabelSchemaFieldInput) error { + if len(keys) == 0 { + return nil + } + + if err := validateMaxItems("dedupeLabelKeys", len(keys), maxRiskTemplateDedupeLabelKeys); err != nil { + return err + } + + if len(schema) == 0 { + return newValidationError("dedupeLabelKeys requires a non-empty labelSchema") + } + + schemaKeys := make(map[string]struct{}, len(schema)) + for _, f := range schema { + schemaKeys[strings.TrimSpace(f.Key)] = struct{}{} + } + + seen := make(map[string]struct{}, len(keys)) + for i, key := range keys { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return newValidationError(fmt.Sprintf("dedupeLabelKeys[%d] must not be empty", i)) + } + if _, exists := seen[trimmed]; exists { + return newValidationError(fmt.Sprintf("dedupeLabelKeys has duplicate key %q", trimmed)) + } + seen[trimmed] = struct{}{} + + if _, inSchema := schemaKeys[trimmed]; !inSchema { + return newValidationError(fmt.Sprintf("dedupeLabelKeys key %q is not defined in labelSchema", trimmed)) + } + } + + return nil +} + +func validateRiskTemplateTemplateFields(payload *RiskTemplatePayload) error { + if len(payload.LabelSchema) == 0 { + // No label schema means no template fields should be set. + if payload.TitleTemplate != nil || payload.StatementTemplate != nil || + payload.LikelihoodHintTemplate != nil || payload.ImpactHintTemplate != nil { + return newValidationError("template fields require a non-empty labelSchema") + } + return nil + } + + if err := validateOptionalText("titleTemplate", payload.TitleTemplate); err != nil { + return err + } + if err := validateOptionalText("statementTemplate", payload.StatementTemplate); err != nil { + return err + } + if err := validateOptionalText("likelihoodHintTemplate", payload.LikelihoodHintTemplate); err != nil { + return err + } + if err := validateOptionalText("impactHintTemplate", payload.ImpactHintTemplate); err != nil { + return err + } + + // Build SubjectTemplateLabelSchemaField slice for reuse of validateTemplateAgainstSchema. + schemaFields := make([]SubjectTemplateLabelSchemaField, 0, len(payload.LabelSchema)) + for _, f := range payload.LabelSchema { + schemaFields = append(schemaFields, SubjectTemplateLabelSchemaField{ + Key: f.Key, + Description: f.Description, + }) + } + + if err := validateTemplateAgainstSchema(payload.TitleTemplate, schemaFields); err != nil { + return newValidationError(fmt.Sprintf("titleTemplate: %s", err.Error())) + } + if err := validateTemplateAgainstSchema(payload.StatementTemplate, schemaFields); err != nil { + return newValidationError(fmt.Sprintf("statementTemplate: %s", err.Error())) + } + if err := validateTemplateAgainstSchema(payload.LikelihoodHintTemplate, schemaFields); err != nil { + return newValidationError(fmt.Sprintf("likelihoodHintTemplate: %s", err.Error())) + } + if err := validateTemplateAgainstSchema(payload.ImpactHintTemplate, schemaFields); err != nil { + return newValidationError(fmt.Sprintf("impactHintTemplate: %s", err.Error())) + } return nil } @@ -462,6 +648,33 @@ func normalizeRiskTemplatePayload(payload *RiskTemplatePayload) { } } + for i := range payload.LabelSchema { + payload.LabelSchema[i].Key = strings.TrimSpace(payload.LabelSchema[i].Key) + if payload.LabelSchema[i].Description != nil { + normalizedDescription := strings.TrimSpace(*payload.LabelSchema[i].Description) + payload.LabelSchema[i].Description = &normalizedDescription + } + } + for i := range payload.DedupeLabelKeys { + payload.DedupeLabelKeys[i] = strings.TrimSpace(payload.DedupeLabelKeys[i]) + } + if payload.TitleTemplate != nil { + normalizedTitleTemplate := strings.TrimSpace(*payload.TitleTemplate) + payload.TitleTemplate = &normalizedTitleTemplate + } + if payload.StatementTemplate != nil { + normalizedStatementTemplate := strings.TrimSpace(*payload.StatementTemplate) + payload.StatementTemplate = &normalizedStatementTemplate + } + if payload.LikelihoodHintTemplate != nil { + normalizedLikelihoodHintTemplate := strings.TrimSpace(*payload.LikelihoodHintTemplate) + payload.LikelihoodHintTemplate = &normalizedLikelihoodHintTemplate + } + if payload.ImpactHintTemplate != nil { + normalizedImpactHintTemplate := strings.TrimSpace(*payload.ImpactHintTemplate) + payload.ImpactHintTemplate = &normalizedImpactHintTemplate + } + if payload.RemediationTemplate == nil { return } @@ -654,6 +867,7 @@ func fetchRiskTemplateByID(db *gorm.DB, id uuid.UUID) (*RiskTemplate, error) { var row RiskTemplate if err := db. Preload("ThreatRefs", preloadThreatRefs). + Preload("LabelSchema", preloadRiskTemplateLabelSchema). Preload("RemediationTemplate"). Preload("RemediationTemplate.Tasks", preloadRemediationTasks). First(&row, "id = ?", id).Error; err != nil { @@ -666,6 +880,10 @@ func preloadThreatRefs(db *gorm.DB) *gorm.DB { return db.Order("system ASC, external_id ASC") } +func preloadRiskTemplateLabelSchema(db *gorm.DB) *gorm.DB { + return db.Order("key ASC") +} + func preloadRemediationTasks(db *gorm.DB) *gorm.DB { return db.Order("order_index ASC") } @@ -674,16 +892,22 @@ func preloadRemediationTasks(db *gorm.DB) *gorm.DB { // PluginID and PolicyPackage are inherited from the batch-level scope and must not be set here. // ID is mandatory and must be provided by the caller (agent-side UUID generation). type BatchRiskTemplateItem struct { - ID uuid.UUID - Name string - Title string - Statement string - LikelihoodHint *string - ImpactHint *string - ViolationIDs []string - IsActive *bool - ThreatRefs []ThreatRefInput - RemediationTemplate *RemediationTemplateInput + ID uuid.UUID + Name string + Title string + Statement string + LikelihoodHint *string + ImpactHint *string + ViolationIDs []string + IsActive *bool + ThreatRefs []ThreatRefInput + RemediationTemplate *RemediationTemplateInput + TitleTemplate *string + StatementTemplate *string + LikelihoodHintTemplate *string + ImpactHintTemplate *string + DedupeLabelKeys []string + LabelSchema []RiskTemplateLabelSchemaFieldInput } // BatchUpsertRiskTemplatesResult is the result of a RiskTemplateService.BatchUpsert call. @@ -753,6 +977,7 @@ func (s *RiskTemplateService) BatchUpsert(pluginID, policyPackage string, items if err := tx. Where("plugin_id = ? AND policy_package = ?", pluginID, policyPackage). Preload("ThreatRefs", preloadThreatRefs). + Preload("LabelSchema", preloadRiskTemplateLabelSchema). Preload("RemediationTemplate"). Preload("RemediationTemplate.Tasks", preloadRemediationTasks). Find(&existingRows).Error; err != nil { @@ -826,6 +1051,10 @@ func (s *RiskTemplateService) BatchUpsert(pluginID, policyPackage string, items tx.Rollback() return nil, fmt.Errorf("delete threat refs for risk template %s: %w", id, err) } + if err := tx.Delete(&RiskTemplateLabelSchemaField{}, "risk_template_id = ?", id).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("delete label schema for risk template %s: %w", id, err) + } if err := tx.Delete(&RiskTemplate{}, "id = ?", id).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("delete risk template %s: %w", id, err) @@ -850,17 +1079,23 @@ func (s *RiskTemplateService) BatchUpsert(pluginID, policyPackage string, items // The returned payload is NOT yet validated or normalised; call validateRiskTemplatePayload first. func batchItemToPayload(pluginID, policyPackage string, item BatchRiskTemplateItem) RiskTemplatePayload { return RiskTemplatePayload{ - PluginID: pluginID, - PolicyPackage: policyPackage, - Name: item.Name, - Title: item.Title, - Statement: item.Statement, - LikelihoodHint: item.LikelihoodHint, - ImpactHint: item.ImpactHint, - ViolationIDs: append([]string{}, item.ViolationIDs...), - IsActive: item.IsActive, - ThreatRefs: append([]ThreatRefInput{}, item.ThreatRefs...), - RemediationTemplate: item.RemediationTemplate, + PluginID: pluginID, + PolicyPackage: policyPackage, + Name: item.Name, + Title: item.Title, + Statement: item.Statement, + LikelihoodHint: item.LikelihoodHint, + ImpactHint: item.ImpactHint, + ViolationIDs: append([]string{}, item.ViolationIDs...), + IsActive: item.IsActive, + ThreatRefs: append([]ThreatRefInput{}, item.ThreatRefs...), + RemediationTemplate: item.RemediationTemplate, + TitleTemplate: item.TitleTemplate, + StatementTemplate: item.StatementTemplate, + LikelihoodHintTemplate: item.LikelihoodHintTemplate, + ImpactHintTemplate: item.ImpactHintTemplate, + DedupeLabelKeys: append([]string{}, item.DedupeLabelKeys...), + LabelSchema: append([]RiskTemplateLabelSchemaFieldInput{}, item.LabelSchema...), } } @@ -876,6 +1111,12 @@ func batchItemFromPayload(item BatchRiskTemplateItem, payload RiskTemplatePayloa item.IsActive = payload.IsActive item.ThreatRefs = payload.ThreatRefs item.RemediationTemplate = payload.RemediationTemplate + item.TitleTemplate = payload.TitleTemplate + item.StatementTemplate = payload.StatementTemplate + item.LikelihoodHintTemplate = payload.LikelihoodHintTemplate + item.ImpactHintTemplate = payload.ImpactHintTemplate + item.DedupeLabelKeys = payload.DedupeLabelKeys + item.LabelSchema = payload.LabelSchema return item } @@ -891,15 +1132,20 @@ func createRiskTemplateInTx(tx *gorm.DB, id uuid.UUID, payload RiskTemplatePaylo } row := RiskTemplate{ - PluginID: payload.PluginID, - PolicyPackage: payload.PolicyPackage, - Name: payload.Name, - Title: payload.Title, - Statement: payload.Statement, - LikelihoodHint: payload.LikelihoodHint, - ImpactHint: payload.ImpactHint, - ViolationIDs: datatypes.NewJSONSlice(payload.ViolationIDs), - IsActive: true, + PluginID: payload.PluginID, + PolicyPackage: payload.PolicyPackage, + Name: payload.Name, + Title: payload.Title, + Statement: payload.Statement, + LikelihoodHint: payload.LikelihoodHint, + ImpactHint: payload.ImpactHint, + TitleTemplate: payload.TitleTemplate, + StatementTemplate: payload.StatementTemplate, + LikelihoodHintTemplate: payload.LikelihoodHintTemplate, + ImpactHintTemplate: payload.ImpactHintTemplate, + DedupeLabelKeys: datatypes.NewJSONSlice(payload.DedupeLabelKeys), + ViolationIDs: datatypes.NewJSONSlice(payload.ViolationIDs), + IsActive: true, } row.ID = &id if payload.IsActive != nil { @@ -920,6 +1166,11 @@ func createRiskTemplateInTx(tx *gorm.DB, id uuid.UUID, payload RiskTemplatePaylo "Statement", "LikelihoodHint", "ImpactHint", + "TitleTemplate", + "StatementTemplate", + "LikelihoodHintTemplate", + "ImpactHintTemplate", + "DedupeLabelKeys", "ViolationIDs", "IsActive", "RemediationTemplateID", @@ -929,6 +1180,9 @@ func createRiskTemplateInTx(tx *gorm.DB, id uuid.UUID, payload RiskTemplatePaylo if err := replaceThreatRefs(tx, id, payload.ThreatRefs); err != nil { return nil, err } + if err := replaceRiskTemplateLabelSchema(tx, id, payload.LabelSchema); err != nil { + return nil, err + } return fetchRiskTemplateByID(tx, id) } @@ -947,6 +1201,11 @@ func updateRiskTemplateInTx(tx *gorm.DB, id uuid.UUID, payload RiskTemplatePaylo existing.Statement = payload.Statement existing.LikelihoodHint = payload.LikelihoodHint existing.ImpactHint = payload.ImpactHint + existing.TitleTemplate = payload.TitleTemplate + existing.StatementTemplate = payload.StatementTemplate + existing.LikelihoodHintTemplate = payload.LikelihoodHintTemplate + existing.ImpactHintTemplate = payload.ImpactHintTemplate + existing.DedupeLabelKeys = datatypes.NewJSONSlice(payload.DedupeLabelKeys) existing.ViolationIDs = datatypes.NewJSONSlice(payload.ViolationIDs) if payload.IsActive != nil { existing.IsActive = *payload.IsActive @@ -966,13 +1225,16 @@ func updateRiskTemplateInTx(tx *gorm.DB, id uuid.UUID, payload RiskTemplatePaylo existing.RemediationTemplate = nil } - if err := tx.Omit("ThreatRefs", "RemediationTemplate").Save(&existing).Error; err != nil { + if err := tx.Omit("ThreatRefs", "LabelSchema", "RemediationTemplate").Save(&existing).Error; err != nil { return nil, err } if err := replaceThreatRefs(tx, *existing.ID, payload.ThreatRefs); err != nil { return nil, err } + if err := replaceRiskTemplateLabelSchema(tx, *existing.ID, payload.LabelSchema); err != nil { + return nil, err + } return fetchRiskTemplateByID(tx, *existing.ID) } @@ -980,15 +1242,26 @@ func updateRiskTemplateInTx(tx *gorm.DB, id uuid.UUID, payload RiskTemplatePaylo // riskTemplateFP is an unexported fingerprint struct used to detect whether a batch // payload differs from a stored template, avoiding unnecessary UPDATE statements. type riskTemplateFP struct { - Name string `json:"n"` - Title string `json:"t"` - Statement string `json:"s"` - LikelihoodHint *string `json:"lh,omitempty"` - ImpactHint *string `json:"ih,omitempty"` - IsActive bool `json:"ia"` - ViolationIDs []string `json:"v"` - ThreatRefs []threatFP `json:"tr"` - Remediation *remFP `json:"r,omitempty"` + Name string `json:"n"` + Title string `json:"t"` + Statement string `json:"s"` + LikelihoodHint *string `json:"lh,omitempty"` + ImpactHint *string `json:"ih,omitempty"` + TitleTemplate *string `json:"tt,omitempty"` + StatementTemplate *string `json:"st,omitempty"` + LikelihoodHintTemplate *string `json:"lht,omitempty"` + ImpactHintTemplate *string `json:"iht,omitempty"` + DedupeLabelKeys []string `json:"dlk,omitempty"` + IsActive bool `json:"ia"` + ViolationIDs []string `json:"v"` + ThreatRefs []threatFP `json:"tr"` + LabelSchema []labelSchemaFP `json:"ls,omitempty"` + Remediation *remFP `json:"r,omitempty"` +} + +type labelSchemaFP struct { + Key string `json:"k"` + Description *string `json:"d,omitempty"` } type threatFP struct { @@ -1041,15 +1314,31 @@ func riskTemplateFPFromExisting(t RiskTemplate) riskTemplateFP { return refs[i].ExternalID < refs[j].ExternalID }) + dedupeLabelKeys := make([]string, len(t.DedupeLabelKeys)) + copy(dedupeLabelKeys, t.DedupeLabelKeys) + sort.Strings(dedupeLabelKeys) + + labelSchema := make([]labelSchemaFP, 0, len(t.LabelSchema)) + for _, f := range t.LabelSchema { + labelSchema = append(labelSchema, labelSchemaFP{Key: f.Key, Description: f.Description}) + } + sort.Slice(labelSchema, func(i, j int) bool { return labelSchema[i].Key < labelSchema[j].Key }) + fp := riskTemplateFP{ - Name: t.Name, - Title: t.Title, - Statement: t.Statement, - LikelihoodHint: t.LikelihoodHint, - ImpactHint: t.ImpactHint, - IsActive: t.IsActive, - ViolationIDs: violations, - ThreatRefs: refs, + Name: t.Name, + Title: t.Title, + Statement: t.Statement, + LikelihoodHint: t.LikelihoodHint, + ImpactHint: t.ImpactHint, + TitleTemplate: t.TitleTemplate, + StatementTemplate: t.StatementTemplate, + LikelihoodHintTemplate: t.LikelihoodHintTemplate, + ImpactHintTemplate: t.ImpactHintTemplate, + DedupeLabelKeys: dedupeLabelKeys, + IsActive: t.IsActive, + ViolationIDs: violations, + ThreatRefs: refs, + LabelSchema: labelSchema, } if t.RemediationTemplate != nil { tasks := make([]taskFP, 0, len(t.RemediationTemplate.Tasks)) @@ -1087,15 +1376,31 @@ func riskTemplateFPFromPayload(payload RiskTemplatePayload) riskTemplateFP { return refs[i].ExternalID < refs[j].ExternalID }) + dedupeLabelKeys := make([]string, len(payload.DedupeLabelKeys)) + copy(dedupeLabelKeys, payload.DedupeLabelKeys) + sort.Strings(dedupeLabelKeys) + + labelSchema := make([]labelSchemaFP, 0, len(payload.LabelSchema)) + for _, f := range payload.LabelSchema { + labelSchema = append(labelSchema, labelSchemaFP(f)) + } + sort.Slice(labelSchema, func(i, j int) bool { return labelSchema[i].Key < labelSchema[j].Key }) + fp := riskTemplateFP{ - Name: payload.Name, - Title: payload.Title, - Statement: payload.Statement, - LikelihoodHint: payload.LikelihoodHint, - ImpactHint: payload.ImpactHint, - IsActive: isActive, - ViolationIDs: violations, - ThreatRefs: refs, + Name: payload.Name, + Title: payload.Title, + Statement: payload.Statement, + LikelihoodHint: payload.LikelihoodHint, + ImpactHint: payload.ImpactHint, + TitleTemplate: payload.TitleTemplate, + StatementTemplate: payload.StatementTemplate, + LikelihoodHintTemplate: payload.LikelihoodHintTemplate, + ImpactHintTemplate: payload.ImpactHintTemplate, + DedupeLabelKeys: dedupeLabelKeys, + IsActive: isActive, + ViolationIDs: violations, + ThreatRefs: refs, + LabelSchema: labelSchema, } if payload.RemediationTemplate != nil { tasks := make([]taskFP, 0, len(payload.RemediationTemplate.Tasks)) diff --git a/internal/service/relational/templates/risk_template_service_test.go b/internal/service/relational/templates/risk_template_service_test.go index e84df6bd..cd46bb72 100644 --- a/internal/service/relational/templates/risk_template_service_test.go +++ b/internal/service/relational/templates/risk_template_service_test.go @@ -688,6 +688,7 @@ func newRiskTemplateTestDB(t *testing.T) *gorm.DB { require.NoError(t, db.AutoMigrate( &RiskTemplate{}, &RiskTemplateThreatRef{}, + &RiskTemplateLabelSchemaField{}, &RemediationTemplate{}, &RemediationTask{}, )) @@ -721,3 +722,287 @@ func validRiskTemplatePayload() RiskTemplatePayload { }, } } + +func TestRiskTemplateService_CreateWithLabelSchemaAndTemplateFields(t *testing.T) { + db := newRiskTemplateTestDB(t) + svc := NewRiskTemplateService(db) + + titleTmpl := " Vulnerability {{.cve_id}} found in {{.repo_name}} " + stmtTmpl := "CVE {{.cve_id}} with severity {{.severity}} detected." + desc := " The CVE identifier " + + created, err := svc.Create(RiskTemplatePayload{ + PluginID: "vuln-scanner", + PolicyPackage: "compliance_framework.vulnerability_scan", + Name: "CVE risk template", + Title: "Vulnerability found", + Statement: "A vulnerability was detected.", + IsActive: boolPtr(true), + LabelSchema: []RiskTemplateLabelSchemaFieldInput{ + {Key: "cve_id", Description: &desc}, + {Key: "repo_name"}, + {Key: "severity"}, + }, + TitleTemplate: &titleTmpl, + StatementTemplate: &stmtTmpl, + DedupeLabelKeys: []string{"cve_id"}, + ThreatRefs: []ThreatRefInput{ + {System: "https://cve.mitre.org", ExternalID: "CVE-2024-0001", Title: "Test CVE"}, + }, + }) + require.NoError(t, err) + require.NotNil(t, created) + + // Verify label schema persisted + require.Len(t, created.LabelSchema, 3) + require.Equal(t, "cve_id", created.LabelSchema[0].Key) // sorted by key ASC + require.NotNil(t, created.LabelSchema[0].Description) + require.Equal(t, "The CVE identifier", *created.LabelSchema[0].Description) + require.Equal(t, "repo_name", created.LabelSchema[1].Key) + require.Equal(t, "severity", created.LabelSchema[2].Key) + + // Verify template fields persisted + require.NotNil(t, created.TitleTemplate) + require.Equal(t, "Vulnerability {{.cve_id}} found in {{.repo_name}}", *created.TitleTemplate) + require.NotNil(t, created.StatementTemplate) + require.Equal(t, stmtTmpl, *created.StatementTemplate) + + // Verify dedupe label keys persisted + require.Len(t, created.DedupeLabelKeys, 1) + require.Equal(t, "cve_id", created.DedupeLabelKeys[0]) + + // Verify round-trip via GetByID + got, err := svc.GetByID(*created.ID) + require.NoError(t, err) + require.Len(t, got.LabelSchema, 3) + require.NotNil(t, got.TitleTemplate) + require.Len(t, got.DedupeLabelKeys, 1) + + // Update: replace label schema and template fields + newTitleTmpl := "Issue {{.issue_id}} in {{.repo_name}}" + updated, err := svc.Update(*created.ID, RiskTemplatePayload{ + PluginID: "vuln-scanner", + PolicyPackage: "compliance_framework.vulnerability_scan", + Name: "CVE risk template (updated)", + Title: "Vulnerability found", + Statement: "A vulnerability was detected.", + IsActive: boolPtr(true), + LabelSchema: []RiskTemplateLabelSchemaFieldInput{ + {Key: "issue_id"}, + {Key: "repo_name"}, + }, + TitleTemplate: &newTitleTmpl, + DedupeLabelKeys: []string{"issue_id"}, + ThreatRefs: []ThreatRefInput{ + {System: "https://cve.mitre.org", ExternalID: "CVE-2024-0001", Title: "Test CVE"}, + }, + }) + require.NoError(t, err) + require.Len(t, updated.LabelSchema, 2) + require.Equal(t, "issue_id", updated.LabelSchema[0].Key) + require.Equal(t, "repo_name", updated.LabelSchema[1].Key) + require.NotNil(t, updated.TitleTemplate) + require.Equal(t, newTitleTmpl, *updated.TitleTemplate) + require.Nil(t, updated.StatementTemplate) // removed + require.Len(t, updated.DedupeLabelKeys, 1) + require.Equal(t, "issue_id", updated.DedupeLabelKeys[0]) + + // Verify old label schema fields are gone from DB + var oldSchemaCount int64 + require.NoError(t, db.Model(&RiskTemplateLabelSchemaField{}). + Where("risk_template_id = ? AND key = ?", *created.ID, "cve_id"). + Count(&oldSchemaCount).Error) + require.Equal(t, int64(0), oldSchemaCount) +} + +func TestRiskTemplateService_TemplateFieldValidation(t *testing.T) { + db := newRiskTemplateTestDB(t) + svc := NewRiskTemplateService(db) + + tests := []struct { + name string + mutate func(payload *RiskTemplatePayload) + message string + }{ + { + name: "template fields without label schema", + mutate: func(payload *RiskTemplatePayload) { + tmpl := "{{.some_key}}" + payload.TitleTemplate = &tmpl + }, + message: "template fields require a non-empty labelSchema", + }, + { + name: "template field referencing undefined key", + mutate: func(payload *RiskTemplatePayload) { + tmpl := "{{.undefined_key}}" + payload.TitleTemplate = &tmpl + payload.LabelSchema = []RiskTemplateLabelSchemaFieldInput{ + {Key: "defined_key"}, + } + }, + message: `titleTemplate: template references undefined label key: "undefined_key" (not in label schema)`, + }, + { + name: "dedupe label keys without label schema", + mutate: func(payload *RiskTemplatePayload) { + payload.DedupeLabelKeys = []string{"some_key"} + }, + message: "dedupeLabelKeys requires a non-empty labelSchema", + }, + { + name: "dedupe label key not in label schema", + mutate: func(payload *RiskTemplatePayload) { + payload.LabelSchema = []RiskTemplateLabelSchemaFieldInput{ + {Key: "defined_key"}, + } + payload.DedupeLabelKeys = []string{"undefined_key"} + }, + message: `dedupeLabelKeys key "undefined_key" is not defined in labelSchema`, + }, + { + name: "duplicate label schema keys", + mutate: func(payload *RiskTemplatePayload) { + payload.LabelSchema = []RiskTemplateLabelSchemaFieldInput{ + {Key: "dup_key"}, + {Key: "dup_key"}, + } + }, + message: `labelSchema has duplicate key "dup_key"`, + }, + { + name: "duplicate dedupe label keys", + mutate: func(payload *RiskTemplatePayload) { + payload.LabelSchema = []RiskTemplateLabelSchemaFieldInput{ + {Key: "key_a"}, + } + payload.DedupeLabelKeys = []string{"key_a", "key_a"} + }, + message: `dedupeLabelKeys has duplicate key "key_a"`, + }, + { + name: "label schema key over max length", + mutate: func(payload *RiskTemplatePayload) { + payload.LabelSchema = []RiskTemplateLabelSchemaFieldInput{ + {Key: strings.Repeat("k", maxRiskTemplateFieldLength+1)}, + } + }, + message: "labelSchema[0].key must be at most 1000 characters", + }, + { + name: "label schema description over max length", + mutate: func(payload *RiskTemplatePayload) { + description := strings.Repeat("d", maxRiskTemplateFieldLength+1) + payload.LabelSchema = []RiskTemplateLabelSchemaFieldInput{ + {Key: "defined_key", Description: &description}, + } + }, + message: "labelSchema[0].description must be at most 1000 characters", + }, + { + name: "template field over max length", + mutate: func(payload *RiskTemplatePayload) { + tmpl := strings.Repeat("t", maxRiskTemplateFieldLength+1) + payload.TitleTemplate = &tmpl + payload.LabelSchema = []RiskTemplateLabelSchemaFieldInput{ + {Key: "defined_key"}, + } + }, + message: "titleTemplate must be at most 1000 characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := validRiskTemplatePayload() + tt.mutate(&payload) + + _, err := svc.Create(payload) + require.Error(t, err) + require.True(t, IsValidationError(err)) + require.Equal(t, tt.message, err.Error()) + }) + } +} + +func TestRiskTemplateService_BatchUpsertWithTemplateFields(t *testing.T) { + db := newRiskTemplateTestDB(t) + svc := NewRiskTemplateService(db) + + pluginID := "batch-tmpl-plugin" + policy := "compliance_framework.batch_tmpl_test" + + titleTmpl := "CVE {{.cve_id}} in {{.repo}}" + stmtTmpl := "Severity: {{.severity}}" + id1 := uuid.New() + + // Round 1: create with template fields + result, err := svc.BatchUpsert(pluginID, policy, []BatchRiskTemplateItem{ + { + ID: id1, + Name: "Templated batch", + Title: "Fallback title", + Statement: "Fallback statement", + TitleTemplate: &titleTmpl, + StatementTemplate: &stmtTmpl, + LabelSchema: []RiskTemplateLabelSchemaFieldInput{ + {Key: "cve_id"}, + {Key: "repo"}, + {Key: "severity"}, + }, + DedupeLabelKeys: []string{"cve_id"}, + }, + }) + require.NoError(t, err) + require.Len(t, result.Created, 1) + require.Len(t, result.Created[0].LabelSchema, 3) + require.NotNil(t, result.Created[0].TitleTemplate) + require.Len(t, result.Created[0].DedupeLabelKeys, 1) + + // Round 2: same payload — should be unchanged + result2, err := svc.BatchUpsert(pluginID, policy, []BatchRiskTemplateItem{ + { + ID: id1, + Name: "Templated batch", + Title: "Fallback title", + Statement: "Fallback statement", + TitleTemplate: &titleTmpl, + StatementTemplate: &stmtTmpl, + LabelSchema: []RiskTemplateLabelSchemaFieldInput{ + {Key: "cve_id"}, + {Key: "repo"}, + {Key: "severity"}, + }, + DedupeLabelKeys: []string{"cve_id"}, + }, + }) + require.NoError(t, err) + require.Empty(t, result2.Created) + require.Empty(t, result2.Updated) + require.Empty(t, result2.Deleted) + require.Len(t, result2.Unchanged, 1) + require.Equal(t, id1, result2.Unchanged[0]) + + // Round 3: change template field — should be updated + newTitleTmpl := "Issue {{.cve_id}}" + result3, err := svc.BatchUpsert(pluginID, policy, []BatchRiskTemplateItem{ + { + ID: id1, + Name: "Templated batch", + Title: "Fallback title", + Statement: "Fallback statement", + TitleTemplate: &newTitleTmpl, + StatementTemplate: &stmtTmpl, + LabelSchema: []RiskTemplateLabelSchemaFieldInput{ + {Key: "cve_id"}, + {Key: "repo"}, + {Key: "severity"}, + }, + DedupeLabelKeys: []string{"cve_id"}, + }, + }) + require.NoError(t, err) + require.Len(t, result3.Updated, 1) + require.NotNil(t, result3.Updated[0].TitleTemplate) + require.Equal(t, newTitleTmpl, *result3.Updated[0].TitleTemplate) +} diff --git a/internal/service/relational/templates/template_renderer.go b/internal/service/relational/templates/template_renderer.go index 7a60e584..288d536f 100644 --- a/internal/service/relational/templates/template_renderer.go +++ b/internal/service/relational/templates/template_renderer.go @@ -7,6 +7,11 @@ import ( "text/template/parse" ) +// RenderTemplate executes a Go template string with the provided label data. +func RenderTemplate(tmplStr string, labels map[string]string) (string, error) { + return renderTemplate(tmplStr, labels) +} + // renderTemplate executes a Go template string with the provided label data. // Returns the rendered string or an error if the template is invalid or execution fails. func renderTemplate(tmplStr string, labels map[string]string) (string, error) { diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index 6041e549..06f0ee01 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/url" "sort" "strings" "time" @@ -290,6 +291,7 @@ func (w *RiskEvidenceWorker) loadRiskTemplates(ctx context.Context, evidenceLabe err := w.db.WithContext(ctx). Where("policy_package IN ? AND is_active = ?", policyPackageList, true). Preload("ThreatRefs", func(db *gorm.DB) *gorm.DB { return db.Order("system ASC, external_id ASC") }). + Preload("LabelSchema", func(db *gorm.DB) *gorm.DB { return db.Order("key ASC") }). Preload("RemediationTemplate"). Preload("RemediationTemplate.Tasks", func(db *gorm.DB) *gorm.DB { return db.Order("order_index ASC") }). Find(&riskTemplates).Error @@ -374,8 +376,8 @@ func (w *RiskEvidenceWorker) violationMatches(templateViolationIDs, evidenceViol func (w *RiskEvidenceWorker) createOrUpdateRisksForSSPs(ctx context.Context, riskTemplate templates.RiskTemplate, evidence *relational.Evidence, sspInfos []resolvedSSPInfo) error { var errs []error for _, sspInfo := range sspInfos { - // Compute dedupe key: ssp_id + risk_template_id - dedupeKey := w.computeDedupeKeyForSSP(riskTemplate, sspInfo.SSPID) + // Compute dedupe key: ssp_id + risk_template_id (+ optional label-based suffix) + dedupeKey := w.computeDedupeKeyForSSP(riskTemplate, sspInfo.SSPID, evidence.Labels) // Look for existing active risk with this dedupe key var existingRisk risks.Risk @@ -410,9 +412,35 @@ func (w *RiskEvidenceWorker) createOrUpdateRisksForSSPs(ctx context.Context, ris } // computeDedupeKeyForSSP computes the dedupe key for a risk. -// Format: ssp_id:risk_template_id -func (w *RiskEvidenceWorker) computeDedupeKeyForSSP(riskTemplate templates.RiskTemplate, sspID uuid.UUID) string { - return fmt.Sprintf("%s:%s", sspID.String(), riskTemplate.ID.String()) +// Base format: ssp_id:risk_template_id +// When the template declares DedupeLabelKeys, the corresponding evidence label values +// are appended as sorted key=value pairs, allowing the same template to produce +// distinct risks per unique combination (e.g. per-CVE). +func (w *RiskEvidenceWorker) computeDedupeKeyForSSP(riskTemplate templates.RiskTemplate, sspID uuid.UUID, evidenceLabels []relational.Labels) string { + base := fmt.Sprintf("%s:%s", sspID.String(), riskTemplate.ID.String()) + + if len(riskTemplate.DedupeLabelKeys) == 0 { + return base + } + + labelValues := collectSortedUniqueEvidenceLabelValues(evidenceLabels) + + // Collect key=value pairs in sorted dedupe key order + keys := make([]string, len(riskTemplate.DedupeLabelKeys)) + copy(keys, riskTemplate.DedupeLabelKeys) + sort.Strings(keys) + + var parts []string + for _, key := range keys { + values := labelValues[key] + encodedValues := make([]string, len(values)) + for i, val := range values { + encodedValues[i] = url.QueryEscape(val) + } + parts = append(parts, fmt.Sprintf("%s=%s", url.QueryEscape(key), strings.Join(encodedValues, "&"))) + } + + return base + ":" + strings.Join(parts, ",") } // updateExistingRisk updates an existing risk with new evidence. @@ -485,9 +513,12 @@ func (w *RiskEvidenceWorker) updateExistingRisk(ctx context.Context, existingRis func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTemplate templates.RiskTemplate, evidence *relational.Evidence, sspID uuid.UUID, dedupeKey string, controlLinks []controlLinkInfo) error { now := time.Now().UTC() + // Resolve template fields if present, falling back to static values. + title, statement, likelihoodHint, impactHint := w.resolveRiskTemplateFields(riskTemplate, evidence.Labels) + newRisk := risks.Risk{ - Title: riskTemplate.Title, - Description: riskTemplate.Statement, + Title: title, + Description: statement, Status: string(risks.RiskStatusOpen), SSPID: sspID, RiskTemplateID: riskTemplate.ID, @@ -497,12 +528,12 @@ func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTempla LastSeenAt: now, } - // Set likelihood and impact from template hints if available - if riskTemplate.LikelihoodHint != nil { - newRisk.Likelihood = riskTemplate.LikelihoodHint + // Set likelihood and impact from resolved values if available + if likelihoodHint != nil { + newRisk.Likelihood = likelihoodHint } - if riskTemplate.ImpactHint != nil { - newRisk.Impact = riskTemplate.ImpactHint + if impactHint != nil { + newRisk.Impact = impactHint } err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -873,6 +904,131 @@ func (w *RiskEvidenceWorker) resolveRiskEvidenceLink(ctx context.Context, risk * }) } +// resolveRiskTemplateFields renders the template fields (title, statement, likelihood, impact) +// using evidence labels. If a template string is set it is rendered; otherwise the static value +// is returned. Rendering errors are logged but non-fatal — the static fallback is used instead. +func (w *RiskEvidenceWorker) resolveRiskTemplateFields(rt templates.RiskTemplate, evidenceLabels []relational.Labels) (title string, statement string, likelihoodHint *string, impactHint *string) { + title = rt.Title + statement = rt.Statement + likelihoodHint = normalizeRenderedRiskLevel(rt.LikelihoodHint) + impactHint = normalizeRenderedRiskLevel(rt.ImpactHint) + + // If no template fields are set, return static values. + if rt.TitleTemplate == nil && rt.StatementTemplate == nil && rt.LikelihoodHintTemplate == nil && rt.ImpactHintTemplate == nil { + return + } + + labelMap := collapseEvidenceLabelValues(collectSortedUniqueEvidenceLabelValues(evidenceLabels)) + + if rt.TitleTemplate != nil { + rendered, err := templates.RenderTemplate(*rt.TitleTemplate, labelMap) + if err != nil { + w.logger.Warnw("Failed to render title template, using static title", + "error", err, "risk_template_id", rt.ID) + } else if rendered != "" { + title = rendered + } + } + + if rt.StatementTemplate != nil { + rendered, err := templates.RenderTemplate(*rt.StatementTemplate, labelMap) + if err != nil { + w.logger.Warnw("Failed to render statement template, using static statement", + "error", err, "risk_template_id", rt.ID) + } else if rendered != "" { + statement = rendered + } + } + + if rt.LikelihoodHintTemplate != nil { + rendered, err := templates.RenderTemplate(*rt.LikelihoodHintTemplate, labelMap) + if err != nil { + w.logger.Warnw("Failed to render likelihood hint template, using static hint", + "error", err, "risk_template_id", rt.ID) + } else if rendered != "" { + normalized := normalizeRenderedRiskLevel(&rendered) + if normalized == nil { + w.logger.Warnw("Rendered likelihood hint template produced invalid risk level, using static hint", + "risk_template_id", rt.ID, "rendered_value", rendered) + } else { + likelihoodHint = normalized + } + } + } + + if rt.ImpactHintTemplate != nil { + rendered, err := templates.RenderTemplate(*rt.ImpactHintTemplate, labelMap) + if err != nil { + w.logger.Warnw("Failed to render impact hint template, using static hint", + "error", err, "risk_template_id", rt.ID) + } else if rendered != "" { + normalized := normalizeRenderedRiskLevel(&rendered) + if normalized == nil { + w.logger.Warnw("Rendered impact hint template produced invalid risk level, using static hint", + "risk_template_id", rt.ID, "rendered_value", rendered) + } else { + impactHint = normalized + } + } + } + + return +} + +// collectSortedUniqueEvidenceLabelValues groups evidence labels by name, sorts each value list, +// and removes duplicates so callers can deterministically consume multi-valued labels. +func collectSortedUniqueEvidenceLabelValues(evidenceLabels []relational.Labels) map[string][]string { + labelValues := make(map[string][]string, len(evidenceLabels)) + for _, l := range evidenceLabels { + labelValues[l.Name] = append(labelValues[l.Name], l.Value) + } + + for name, values := range labelValues { + sort.Strings(values) + + unique := values[:0] + var previous string + for i, value := range values { + if i == 0 || value != previous { + unique = append(unique, value) + previous = value + } + } + + labelValues[name] = unique + } + + return labelValues +} + +// collapseEvidenceLabelValues joins each sorted unique value set into a deterministic string so +// string-based template rendering can still expose every value for a multi-valued label. +func collapseEvidenceLabelValues(labelValues map[string][]string) map[string]string { + labelMap := make(map[string]string, len(labelValues)) + for name, values := range labelValues { + if len(values) == 0 { + continue + } + labelMap[name] = strings.Join(values, ", ") + } + + return labelMap +} + +func normalizeRenderedRiskLevel(level *string) *string { + if level == nil { + return nil + } + + normalized := risks.NormalizeRiskLevel(*level) + if normalized == "" || !normalized.IsValid() { + return nil + } + + value := string(normalized) + return &value +} + // emitRiskEvent creates a risk event record using the provided DB handle. // Accepts a *gorm.DB so the caller can pass a transaction handle. func (w *RiskEvidenceWorker) emitRiskEvent(ctx context.Context, db *gorm.DB, riskID uuid.UUID, eventType string, payload map[string]interface{}) error { diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index dd80486c..f929538f 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "time" @@ -42,6 +43,7 @@ func newRiskEvidenceWorkerTestDB(t *testing.T) *gorm.DB { &relational.ImplementedRequirement{}, &templates.RiskTemplate{}, &templates.RiskTemplateThreatRef{}, + &templates.RiskTemplateLabelSchemaField{}, &templates.RemediationTemplate{}, &templates.RemediationTask{}, &risks.Risk{}, @@ -1686,3 +1688,279 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters_MultipleSSPs(t *testing.T) { assert.True(t, sspIDs[*ssp1.ID]) assert.True(t, sspIDs[*ssp2.ID]) } + +func TestComputeDedupeKeyForSSP_WithDedupeLabelKeys(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + sspID := uuid.New() + templateID := uuid.New() + + baseTemplate := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + } + + t.Run("no dedupe keys returns base key only", func(t *testing.T) { + rt := baseTemplate + rt.DedupeLabelKeys = nil + + key := worker.computeDedupeKeyForSSP(rt, sspID, []relational.Labels{ + {Name: "env", Value: "prod"}, + }) + require.Equal(t, fmt.Sprintf("%s:%s", sspID, templateID), key) + }) + + t.Run("dedupe keys appends sorted label values", func(t *testing.T) { + rt := baseTemplate + rt.DedupeLabelKeys = []string{"cve_id", "repo"} + + key := worker.computeDedupeKeyForSSP(rt, sspID, []relational.Labels{ + {Name: "repo", Value: "my-repo"}, + {Name: "cve_id", Value: "CVE-2024-1234"}, + {Name: "extra", Value: "ignored"}, + }) + expected := fmt.Sprintf("%s:%s:cve_id=CVE-2024-1234,repo=my-repo", sspID, templateID) + require.Equal(t, expected, key) + }) + + t.Run("missing evidence labels produce empty values in key", func(t *testing.T) { + rt := baseTemplate + rt.DedupeLabelKeys = []string{"cve_id", "repo"} + + key := worker.computeDedupeKeyForSSP(rt, sspID, []relational.Labels{ + {Name: "cve_id", Value: "CVE-2024-5678"}, + }) + expected := fmt.Sprintf("%s:%s:cve_id=CVE-2024-5678,repo=", sspID, templateID) + require.Equal(t, expected, key) + }) + + t.Run("dedupe key escapes delimiter characters in label values", func(t *testing.T) { + rt := baseTemplate + rt.DedupeLabelKeys = []string{"artifact", "repo"} + + key := worker.computeDedupeKeyForSSP(rt, sspID, []relational.Labels{ + {Name: "repo", Value: "payments:api,worker"}, + {Name: "artifact", Value: "cve=id=1,part:2"}, + }) + expected := fmt.Sprintf("%s:%s:%s", sspID, templateID, "artifact=cve%3Did%3D1%2Cpart%3A2,repo=payments%3Aapi%2Cworker") + require.Equal(t, expected, key) + }) + + t.Run("dedupe key includes sorted unique values for duplicate label names", func(t *testing.T) { + rt := baseTemplate + rt.DedupeLabelKeys = []string{"repo", "team"} + + key := worker.computeDedupeKeyForSSP(rt, sspID, []relational.Labels{ + {Name: "repo", Value: "worker"}, + {Name: "repo", Value: "api"}, + {Name: "repo", Value: "api"}, + {Name: "team", Value: "platform"}, + {Name: "team", Value: "security"}, + }) + expected := fmt.Sprintf("%s:%s:%s", sspID, templateID, "repo=api&worker,team=platform&security") + require.Equal(t, expected, key) + }) +} + +func TestResolveRiskTemplateFields(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + templateID := uuid.New() + + t.Run("no template fields returns static values", func(t *testing.T) { + rt := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + Title: "Static Title", + Statement: "Static Statement", + } + + title, statement, likelihood, impact := worker.resolveRiskTemplateFields(rt, []relational.Labels{ + {Name: "foo", Value: "bar"}, + }) + require.Equal(t, "Static Title", title) + require.Equal(t, "Static Statement", statement) + require.Nil(t, likelihood) + require.Nil(t, impact) + }) + + t.Run("template fields render with evidence labels", func(t *testing.T) { + titleTmpl := "CVE {{.cve_id}} in {{.repo}}" + stmtTmpl := "Severity: {{.severity}}" + rt := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + Title: "Fallback Title", + Statement: "Fallback Statement", + TitleTemplate: &titleTmpl, + StatementTemplate: &stmtTmpl, + } + + title, statement, _, _ := worker.resolveRiskTemplateFields(rt, []relational.Labels{ + {Name: "cve_id", Value: "CVE-2024-1234"}, + {Name: "repo", Value: "my-repo"}, + {Name: "severity", Value: "critical"}, + }) + require.Equal(t, "CVE CVE-2024-1234 in my-repo", title) + require.Equal(t, "Severity: critical", statement) + }) + + t.Run("invalid template falls back to static value", func(t *testing.T) { + badTmpl := "{{.invalid template syntax" + rt := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + Title: "Static Title", + Statement: "Static Statement", + TitleTemplate: &badTmpl, + } + + title, statement, _, _ := worker.resolveRiskTemplateFields(rt, []relational.Labels{}) + require.Equal(t, "Static Title", title) + require.Equal(t, "Static Statement", statement) + }) + + t.Run("missing label renders with empty string via missingkey=zero", func(t *testing.T) { + titleTmpl := "Issue {{.cve_id}} in {{.repo}}" + rt := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + Title: "Fallback", + TitleTemplate: &titleTmpl, + Statement: "Fallback statement", + } + + title, _, _, _ := worker.resolveRiskTemplateFields(rt, []relational.Labels{ + {Name: "cve_id", Value: "CVE-2024-9999"}, + // "repo" label is missing + }) + require.Equal(t, "Issue CVE-2024-9999 in ", title) + }) + + t.Run("templated risk levels are normalized before use", func(t *testing.T) { + likelihoodTmpl := "{{.likelihood}}" + impactTmpl := "{{.impact}}" + rt := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + LikelihoodHintTemplate: &likelihoodTmpl, + ImpactHintTemplate: &impactTmpl, + } + + _, _, likelihood, impact := worker.resolveRiskTemplateFields(rt, []relational.Labels{ + {Name: "likelihood", Value: "Medium"}, + {Name: "impact", Value: " HIGH "}, + }) + require.NotNil(t, likelihood) + require.Equal(t, "moderate", *likelihood) + require.NotNil(t, impact) + require.Equal(t, "high", *impact) + }) + + t.Run("template fields deterministically include all values for duplicate label names", func(t *testing.T) { + titleTmpl := "Repos: {{.repo}}" + stmtTmpl := "Teams: {{.team}}" + rt := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + Title: "Fallback Title", + Statement: "Fallback Statement", + TitleTemplate: &titleTmpl, + StatementTemplate: &stmtTmpl, + } + + title, statement, _, _ := worker.resolveRiskTemplateFields(rt, []relational.Labels{ + {Name: "repo", Value: "worker"}, + {Name: "repo", Value: "api"}, + {Name: "repo", Value: "api"}, + {Name: "team", Value: "security"}, + {Name: "team", Value: "platform"}, + }) + require.Equal(t, "Repos: api, worker", title) + require.Equal(t, "Teams: platform, security", statement) + }) + + t.Run("invalid templated risk levels fall back to static hints", func(t *testing.T) { + likelihoodTmpl := "{{.likelihood}}" + impactTmpl := "{{.impact}}" + rt := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + LikelihoodHint: stringPtr("medium"), + ImpactHint: stringPtr("high"), + LikelihoodHintTemplate: &likelihoodTmpl, + ImpactHintTemplate: &impactTmpl, + } + + _, _, likelihood, impact := worker.resolveRiskTemplateFields(rt, []relational.Labels{ + {Name: "likelihood", Value: strings.Repeat("x", 32)}, + {Name: "impact", Value: "definitely-not-a-risk-level"}, + }) + require.NotNil(t, likelihood) + require.Equal(t, "moderate", *likelihood) + require.NotNil(t, impact) + require.Equal(t, "high", *impact) + }) +} + +func TestCreateNewRiskForSSP_WithTemplateResolution(t *testing.T) { + t.Parallel() + + db := newRiskEvidenceWorkerTestDB(t) + logger := zap.NewNop().Sugar() + worker := NewRiskEvidenceWorker(db, logger) + + // Create SSP + ssp := createTestSSP(t, db) + + // Create evidence with labels for template rendering + evidenceID := uuid.New() + evidence := &relational.Evidence{ + UUIDModel: relational.UUIDModel{ID: &evidenceID}, + UUID: evidenceID, + Title: "Test Evidence", + Start: time.Now().Add(-1 * time.Hour), + End: time.Now(), + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "not-satisfied"}), + Labels: []relational.Labels{ + {Name: "cve_id", Value: "CVE-2024-1234"}, + {Name: "repo", Value: "my-repo"}, + {Name: "severity", Value: "critical"}, + {Name: "_policy", Value: "compliance_framework.vuln_scan"}, + }, + Props: datatypes.NewJSONSlice([]relational.Prop{ + {Name: "violation_id", Value: "vuln_detected"}, + }), + } + require.NoError(t, db.Create(evidence).Error) + + // Create risk template with template fields + templateID := uuid.New() + titleTmpl := "CVE {{.cve_id}} in {{.repo}}" + stmtTmpl := "Severity: {{.severity}}" + riskTemplate := templates.RiskTemplate{ + UUIDModel: relational.UUIDModel{ID: &templateID}, + PluginID: "vuln-scanner", + PolicyPackage: "compliance_framework.vuln_scan", + Name: "CVE template", + Title: "Fallback title", + Statement: "Fallback statement", + TitleTemplate: &titleTmpl, + StatementTemplate: &stmtTmpl, + LikelihoodHint: stringPtr("high"), + ImpactHint: stringPtr("high"), + IsActive: true, + ViolationIDs: []string{"vuln_detected"}, + DedupeLabelKeys: []string{"cve_id"}, + } + require.NoError(t, db.Create(&riskTemplate).Error) + + dedupeKey := worker.computeDedupeKeyForSSP(riskTemplate, *ssp.ID, evidence.Labels) + + ctx := context.Background() + err := worker.createNewRiskForSSP(ctx, riskTemplate, evidence, *ssp.ID, dedupeKey, nil) + require.NoError(t, err) + + // Verify the created risk has rendered template values + var createdRisk risks.Risk + require.NoError(t, db.Where("dedupe_key = ?", dedupeKey).First(&createdRisk).Error) + + require.Equal(t, "CVE CVE-2024-1234 in my-repo", createdRisk.Title) + require.Equal(t, "Severity: critical", createdRisk.Description) + require.Equal(t, *ssp.ID, createdRisk.SSPID) + require.Equal(t, &templateID, createdRisk.RiskTemplateID) +} diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index ef023228..7dbad2b1 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -134,6 +134,7 @@ func (t *TestMigrator) Up() error { &riskrel.ComponentDefinitionLabel{}, &templaterel.RiskTemplate{}, &templaterel.RiskTemplateThreatRef{}, + &templaterel.RiskTemplateLabelSchemaField{}, &templaterel.RemediationTemplate{}, &templaterel.RemediationTask{}, &templaterel.SubjectTemplate{}, @@ -338,6 +339,7 @@ func (t *TestMigrator) Down() error { &riskrel.ComponentDefinitionLabel{}, &templaterel.RiskTemplate{}, &templaterel.RiskTemplateThreatRef{}, + &templaterel.RiskTemplateLabelSchemaField{}, &templaterel.RemediationTemplate{}, &templaterel.RemediationTask{}, &templaterel.AssessmentSubjectIdentity{}, diff --git a/risk-worker-redesign.md b/risk-worker-redesign.md deleted file mode 100644 index e711ddf1..00000000 --- a/risk-worker-redesign.md +++ /dev/null @@ -1,186 +0,0 @@ -# Plan: Filter-Based Risk SSP Resolution - -## Context - -Currently, risks are only created after a user manually creates System Components (via apply-suggestion). The chain is: -`Evidence → Components → SystemImplementation → SSP → Risk` - -This requires user action and only triggers on "not-satisfied" evidence. We want: -1. **Decouple from system components** — resolve SSPs via `Evidence labels → Filters → Controls → SSPs` -2. **Trigger on ALL evidence** — not just failures — to prepare for future risk resolution logic (open → remediated) -3. **Completely replace** the component-based SSP resolution (no fallback) - -## Approach: In-Worker Filter Evaluation - -Modify the existing risk worker to resolve SSPs via Filters instead of Components. No new tables, workers, or River flows. - -**Why not a cache table?** At expected scale (hundreds of filters), in-memory evaluation is negligible. Cache adds invalidation complexity for no measurable gain. Can be added later if needed. - -## Implementation Steps - -### Step 1: Add Go-based label filter matcher - -**New file:** `internal/converters/labelfilter/matcher.go` - -```go -func MatchLabels(scope *Scope, labels map[string][]string) bool -func NormalizeLabels(labels []struct{ Name, Value string }) map[string][]string -``` - -- Evaluates filter `Scope` (nested AND/OR `Condition`s) against a normalized label map -- Case-insensitive matching (consistent with SQL evaluator in `evidence.go`) -- Nil scope → true (empty filter matches everything) - -**New file:** `internal/converters/labelfilter/matcher_test.go` - -### Step 2: Rename job to handle all evidence (not just failures) - -**Modify:** `internal/service/worker/jobs.go` -- Rename `JobTypeRiskProcessEvidenceFailure` → `JobTypeRiskProcessEvidence` (`"risk_process_evidence"`) -- Rename `RiskProcessEvidenceFailureArgs` → `RiskProcessEvidenceArgs` -- Rename `JobInsertOptionsForRiskProcessEvidenceFailure` → `JobInsertOptionsForRiskProcessEvidence` - -**Modify:** `internal/service/worker/service.go` -- Rename `EnqueueRiskProcessEvidenceFailure` → `EnqueueRiskProcessEvidence` - -**Modify:** `internal/service/relational/evidence/service.go` -- Rename `RiskJobEnqueuer` interface method -- **Remove** the `statusData.State == EvidenceStatusNotSatisfied` guard — always enqueue -- Always set `shouldEnqueueRiskJob = true` after evidence creation - -**Modify:** All test files referencing the old names - -### Step 3: Add filter-based SSP resolution to risk worker - -**Modify:** `internal/service/worker/risk_evidence_worker.go` - -Add new method: -```go -func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidenceLabels []relational.Labels) ([]resolvedSSPInfo, error) -``` - -Implementation: -1. `db.Find(&filters)` — load all filters -2. Normalize evidence labels into `map[string][]string` -3. Evaluate each filter via `labelfilter.MatchLabels` -4. Single SQL query for matching filter IDs → SSPs + control info: - -```sql -SELECT DISTINCT ci.system_security_plan_id, fc.control_catalog_id, fc.control_id -FROM filter_controls fc -JOIN implemented_requirements ir ON UPPER(ir.control_id) = UPPER(fc.control_id) -JOIN control_implementations ci ON ci.id = ir.control_implementation_id -WHERE fc.filter_id IN (?) -``` - -Return type groups control links by SSP ID: -```go -type resolvedSSPInfo struct { - SSPID uuid.UUID - ControlLinks []controlLinkInfo // {CatalogID, ControlID} pairs -} -type controlLinkInfo struct { - CatalogID uuid.UUID - ControlID string -} -``` - -### Step 4: Refactor `Work()` and `createOrUpdateRisksForSSPs` - -**Modify:** `internal/service/worker/risk_evidence_worker.go` - -- `Work()` calls `resolveSSPsViaFilters` once, passes results to template loop -- For non-"not-satisfied" evidence: early return after filter matching (future: risk resolution) -- `createOrUpdateRisksForSSPs` accepts `[]resolvedSSPInfo` instead of extracting from components -- **Remove** `extractSSPIDsFromComponents` entirely - -```go -func (w *RiskEvidenceWorker) Work(ctx context.Context, job *river.Job[RiskProcessEvidenceArgs]) error { - evidence := w.loadEvidenceWithRelations(...) - - // Resolve SSPs via filter matching - sspInfos := w.resolveSSPsViaFilters(ctx, evidence.Labels) - if len(sspInfos) == 0 { return nil } - - // For now, only create risks for not-satisfied evidence - // Future: handle risk resolution for satisfied evidence - if evidence.Status.Data().State != "not-satisfied" { return nil } - - riskTemplates := w.loadRiskTemplates(...) - filtered := w.filterRiskTemplatesByViolations(...) - - for _, rt := range filtered { - w.createOrUpdateRisksForSSPs(ctx, rt, evidence, sspInfos) - } -} -``` - -### Step 5: Wire control links into `createRiskLinks` - -**Modify:** `internal/service/worker/risk_evidence_worker.go` - -Update `createRiskLinks` signature to accept `controlLinks []controlLinkInfo`: - -```go -func (w *RiskEvidenceWorker) createRiskLinks(ctx, db, riskID, riskSSPID, evidence, controlLinks) error -``` - -Add control link creation (replaces TODO at current lines 573-574): -```go -for _, cl := range controlLinks { - link := &risks.RiskControlLink{ - RiskID: riskID, CatalogID: cl.CatalogID, ControlID: cl.ControlID, CreatedAt: now, - } - db.Clauses(clause.OnConflict{DoNothing: true}).Create(link) -} -``` - -Remove the component-link section (lines 510-571) since we're removing component-based resolution. Keep the evidence link creation. - -### Step 6: Clean up evidence loading - -**Modify:** `internal/service/worker/risk_evidence_worker.go` - -In `loadEvidenceWithRelations`, remove the `Preload("Components")` since components are no longer needed for SSP resolution. Keep Labels, Subjects, InventoryItems. - -### Step 7: Tests - -**Modify:** `internal/service/worker/risk_evidence_worker_test.go` - -- Update all references to renamed job types/args -- Add tests for `resolveSSPsViaFilters` (matching/non-matching filters) -- Add test: evidence labels → filter match → SSP found → risk created with control links -- Add test: satisfied evidence → no risks created (early return) -- Remove tests that depend on component-based SSP resolution - -**Modify:** `internal/service/relational/evidence/service_test.go` (if exists) -- Update enqueue condition tests (always enqueues now) - -## Files Changed - -| File | Change | -|------|--------| -| `internal/converters/labelfilter/matcher.go` | **NEW** — `MatchLabels`, `NormalizeLabels` | -| `internal/converters/labelfilter/matcher_test.go` | **NEW** — unit tests | -| `internal/service/worker/risk_evidence_worker.go` | **MODIFY** — replace component-based with filter-based SSP resolution, add control links, update arg types | -| `internal/service/worker/jobs.go` | **MODIFY** — rename job type, args, insert opts | -| `internal/service/worker/service.go` | **MODIFY** — rename enqueue method | -| `internal/service/relational/evidence/service.go` | **MODIFY** — rename interface, always enqueue | -| `internal/service/worker/risk_evidence_worker_test.go` | **MODIFY** — update tests | -| Evidence service tests | **MODIFY** — update enqueue expectations | - -## Key References - -- Existing filter→control SQL pattern: `system_component_suggestions.go:76-84` -- Label filter types: `converters/labelfilter/labelfilter.go` -- SQL filter evaluation (reference for Go matcher semantics): `relational/evidence.go` — `getScopeClause`/`getConditionClause` -- Risk control link model: `risks/links.go:23-34` -- Worker registration: `jobs.go:660-663` -- Enqueue trigger: `evidence/service.go:134-138` (the guard to remove) - -## Verification - -1. `go test ./internal/converters/labelfilter/...` — matcher unit tests -2. `go test ./internal/service/worker/...` — worker tests with filter-based resolution -3. `go test ./internal/service/relational/evidence/...` — evidence service enqueue tests -4. Manual test: create Filter linked to Control, create SSP with that Control in an ImplementedRequirement, submit evidence matching filter labels → risk created for SSP with control links, no system components needed diff --git a/sdk/types/types.go b/sdk/types/types.go index 072870bb..17b0b34c 100644 --- a/sdk/types/types.go +++ b/sdk/types/types.go @@ -178,6 +178,11 @@ type Remediation struct { Tasks []RemediationTask `json:"tasks"` } +type RiskTemplateLabelSchema struct { + Key string `json:"key"` + Description *string `json:"description,omitempty"` +} + type RiskTemplate struct { ID string `json:"id"` Name string `json:"name"` @@ -186,6 +191,14 @@ type RiskTemplate struct { LikelihoodHint *string `json:"likelihood-hint,omitempty"` ImpactHint *string `json:"impact-hint,omitempty"` + TitleTemplate *string `json:"title-template,omitempty"` + StatementTemplate *string `json:"statement-template,omitempty"` + LikelihoodHintTemplate *string `json:"likelihood-hint-template,omitempty"` + ImpactHintTemplate *string `json:"impact-hint-template,omitempty"` + + DedupeLabelKeys []string `json:"dedupe-label-keys,omitempty"` + LabelSchema []RiskTemplateLabelSchema `json:"label-schema,omitempty"` + ViolationIds []string `json:"violation-ids"` ThreatRefs []ThreatRef `json:"threat-ids"`