diff --git a/controllers/integration_controller.go b/controllers/integration_controller.go index 1105916e9..4a14b568f 100644 --- a/controllers/integration_controller.go +++ b/controllers/integration_controller.go @@ -21,9 +21,11 @@ import ( "github.com/l3montree-dev/devguard/integrations/githubint" "github.com/l3montree-dev/devguard/integrations/gitlabint" "github.com/l3montree-dev/devguard/integrations/jiraint" + "github.com/l3montree-dev/devguard/integrations/trivyoperatorint" "github.com/l3montree-dev/devguard/shared" ) + type IntegrationController struct { gitlabOauth2Integration map[string]*gitlabint.GitlabOauth2Config } @@ -192,3 +194,34 @@ func (c *IntegrationController) DeleteJiraAccessToken(ctx shared.Context) error return nil } + +func (c *IntegrationController) HandleTrivyOperatorWebhook(ctx shared.Context) error { + thirdPartyIntegration := shared.GetThirdPartyIntegration(ctx) + t := thirdPartyIntegration.GetIntegration(shared.TrivyOperatorIntegrationID) + if t == nil { + return ctx.JSON(404, "Trivy Operator integration not enabled") + } + return t.(*trivyoperatorint.TrivyOperatorIntegration).HandleWebhook(ctx) +} + +func (c *IntegrationController) CreateTrivyOperatorIntegration(ctx shared.Context) error { + thirdPartyIntegration := shared.GetThirdPartyIntegration(ctx) + t := thirdPartyIntegration.GetIntegration(shared.TrivyOperatorIntegrationID) + if t == nil { + return ctx.JSON(404, "Trivy Operator integration not enabled") + } + return t.(*trivyoperatorint.TrivyOperatorIntegration).Create(ctx) +} + +func (c *IntegrationController) DeleteTrivyOperatorIntegration(ctx shared.Context) error { + thirdPartyIntegration := shared.GetThirdPartyIntegration(ctx) + t := thirdPartyIntegration.GetIntegration(shared.TrivyOperatorIntegrationID) + if t == nil { + return ctx.JSON(404, "Trivy Operator integration not enabled") + } + if err := t.(*trivyoperatorint.TrivyOperatorIntegration).Delete(ctx); err != nil { + slog.Error("could not delete trivy operator integration", "err", err) + return err + } + return nil +} diff --git a/database/migrations/20260515000000_add_trivy_operator_integrations.up.sql b/database/migrations/20260515000000_add_trivy_operator_integrations.up.sql new file mode 100644 index 000000000..badff8072 --- /dev/null +++ b/database/migrations/20260515000000_add_trivy_operator_integrations.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE trivy_operator_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + name VARCHAR(255) NOT NULL, + cluster_id VARCHAR(255) NOT NULL, + secret TEXT NOT NULL, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + CONSTRAINT uq_trivy_operator_cluster_org UNIQUE (cluster_id, org_id) +); diff --git a/database/models/org_model.go b/database/models/org_model.go index 8dfbf617c..ca9c24654 100644 --- a/database/models/org_model.go +++ b/database/models/org_model.go @@ -23,6 +23,8 @@ type Org struct { JiraIntegrations []JiraIntegration `json:"jiraIntegrations" gorm:"foreignKey:OrgID;"` + TrivyOperatorIntegrations []TrivyOperatorIntegration `json:"trivyOperatorIntegrations" gorm:"foreignKey:OrgID;"` + SharesVulnInformation bool `json:"sharesVulnInformation" gorm:"default:false"` Webhooks []WebhookIntegration `json:"webhooks" gorm:"foreignKey:OrgID;"` diff --git a/database/models/trivy_operator_model.go b/database/models/trivy_operator_model.go new file mode 100644 index 000000000..4af8ffd5d --- /dev/null +++ b/database/models/trivy_operator_model.go @@ -0,0 +1,20 @@ +// Copyright (C) 2026 l3montree GmbH +// SPDX-License-Identifier: AGPL-3.0-or-later + +package models + +import "github.com/google/uuid" + +type TrivyOperatorIntegration struct { + Model + + Name string `json:"name" gorm:"type:varchar(255);not null"` + ClusterID string `json:"clusterId" gorm:"column:cluster_id;type:varchar(255);not null;uniqueIndex:uq_trivy_operator_cluster_org"` + Secret string `json:"secret" gorm:"type:text;not null"` + Org Org `json:"-" gorm:"foreignKey:OrgID;constraint:OnDelete:CASCADE;"` + OrgID uuid.UUID `json:"orgId" gorm:"column:org_id;uniqueIndex:uq_trivy_operator_cluster_org"` +} + +func (TrivyOperatorIntegration) TableName() string { + return "trivy_operator_integrations" +} diff --git a/database/repositories/org_repository.go b/database/repositories/org_repository.go index 2089893db..08798436f 100644 --- a/database/repositories/org_repository.go +++ b/database/repositories/org_repository.go @@ -63,7 +63,7 @@ func (g *orgRepository) Save(ctx context.Context, tx *gorm.DB, org *models.Org) func (g *orgRepository) ReadBySlug(ctx context.Context, tx *gorm.DB, slug string) (models.Org, error) { var t models.Org - err := g.GetDB(ctx, tx).Model(models.Org{}).Preload("GithubAppInstallations").Preload("JiraIntegrations").Preload("GitLabIntegrations").Preload("Webhooks", "project_id IS NULL").Where("slug = ?", slug).First(&t).Error + err := g.GetDB(ctx, tx).Model(models.Org{}).Preload("GithubAppInstallations").Preload("JiraIntegrations").Preload("GitLabIntegrations").Preload("TrivyOperatorIntegrations").Preload("Webhooks", "project_id IS NULL").Where("slug = ?", slug).First(&t).Error return t, err } diff --git a/database/repositories/providers.go b/database/repositories/providers.go index 886db5816..92d53fb0c 100644 --- a/database/repositories/providers.go +++ b/database/repositories/providers.go @@ -58,4 +58,5 @@ var Module = fx.Options( fx.Provide(fx.Annotate(NewExternalReferenceRepository, fx.As(new(shared.ExternalReferenceRepository)))), fx.Provide(fx.Annotate(NewTrustedEntityRepository, fx.As(new(shared.TrustedEntityRepository)))), fx.Provide(fx.Annotate(NewDependencyProxyRepository, fx.As(new(shared.DependencyProxySecretRepository)))), + fx.Provide(fx.Annotate(NewTrivyOperatorIntegrationRepository, fx.As(new(shared.TrivyOperatorIntegrationRepository)))), ) diff --git a/database/repositories/trivy_operator_integration_repository.go b/database/repositories/trivy_operator_integration_repository.go new file mode 100644 index 000000000..77b08bf05 --- /dev/null +++ b/database/repositories/trivy_operator_integration_repository.go @@ -0,0 +1,41 @@ +// Copyright (C) 2026 l3montree GmbH +// SPDX-License-Identifier: AGPL-3.0-or-later + +package repositories + +import ( + "context" + + "github.com/google/uuid" + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/utils" + "gorm.io/gorm" +) + +type trivyOperatorIntegrationRepository struct { + db *gorm.DB + utils.Repository[uuid.UUID, models.TrivyOperatorIntegration, *gorm.DB] +} + +func NewTrivyOperatorIntegrationRepository(db *gorm.DB) *trivyOperatorIntegrationRepository { + return &trivyOperatorIntegrationRepository{ + db: db, + Repository: newGormRepository[uuid.UUID, models.TrivyOperatorIntegration](db), + } +} + +func (r *trivyOperatorIntegrationRepository) FindByOrganizationID(ctx context.Context, tx *gorm.DB, orgID uuid.UUID) ([]models.TrivyOperatorIntegration, error) { + var integrations []models.TrivyOperatorIntegration + if err := r.GetDB(ctx, tx).Find(&integrations, "org_id = ?", orgID).Error; err != nil { + return nil, err + } + return integrations, nil +} + +func (r *trivyOperatorIntegrationRepository) FindBySecret(ctx context.Context, tx *gorm.DB, secret string) (models.TrivyOperatorIntegration, error) { + var integration models.TrivyOperatorIntegration + if err := r.GetDB(ctx, tx).First(&integration, "secret = ?", secret).Error; err != nil { + return models.TrivyOperatorIntegration{}, err + } + return integration, nil +} diff --git a/dtos/integrations_obj.go b/dtos/integrations_obj.go index 0382f171b..4ce0b93b1 100644 --- a/dtos/integrations_obj.go +++ b/dtos/integrations_obj.go @@ -15,6 +15,13 @@ type JiraIntegrationDTO struct { UserEmail string `json:"userEmail"` } +type TrivyOperatorIntegrationDTO struct { + ID string `json:"id"` + Name string `json:"name"` + ClusterID string `json:"clusterId"` + Secret string `json:"secret"` +} + type WebhookIntegrationDTO struct { ID string `json:"id"` Name string `json:"name"` diff --git a/dtos/org_dto.go b/dtos/org_dto.go index fb2dc8769..589bc2da4 100644 --- a/dtos/org_dto.go +++ b/dtos/org_dto.go @@ -98,6 +98,8 @@ type OrgDTO struct { JiraIntegrations []JiraIntegrationDTO `json:"jiraIntegrations" gorm:"foreignKey:OrgID;"` + TrivyOperatorIntegrations []TrivyOperatorIntegrationDTO `json:"trivyOperatorIntegrations"` + SharesVulnInformation bool `json:"sharesVulnInformation"` IsPublic bool `json:"isPublic" gorm:"default:false;"` ConfigFiles map[string]any `json:"configFiles"` diff --git a/integrations/providers.go b/integrations/providers.go index 430401e50..21b62b8b2 100644 --- a/integrations/providers.go +++ b/integrations/providers.go @@ -20,6 +20,7 @@ import ( "github.com/l3montree-dev/devguard/integrations/githubint" "github.com/l3montree-dev/devguard/integrations/gitlabint" "github.com/l3montree-dev/devguard/integrations/jiraint" + "github.com/l3montree-dev/devguard/integrations/trivyoperatorint" "github.com/l3montree-dev/devguard/shared" "go.uber.org/fx" ) @@ -37,10 +38,22 @@ var Module = fx.Options( // Jira Integration fx.Provide(jiraint.NewJiraIntegration), - // Aggregated Third Party Integration + // Trivy Operator Integration (ScanService and AssetService injected via Invoke to break DI cycle) + fx.Provide(trivyoperatorint.NewTrivyOperatorIntegration), + fx.Invoke(func(t *trivyoperatorint.TrivyOperatorIntegration, s shared.ScanService, a shared.AssetService) { + t.SetScanService(s) + t.SetAssetService(a) + }), fx.Provide(fx.Annotate( - func(externalUserRepository shared.ExternalUserRepository, gitlabIntegration *gitlabint.GitlabIntegration, githubIntegration *githubint.GithubIntegration, jiraIntegration *jiraint.JiraIntegration, webhookIntegration *controllers.WebhookController) shared.IntegrationAggregate { - return NewThirdPartyIntegrations(externalUserRepository, githubIntegration, jiraIntegration, gitlabIntegration, webhookIntegration) + func( + externalUserRepository shared.ExternalUserRepository, + gitlabIntegration *gitlabint.GitlabIntegration, + githubIntegration *githubint.GithubIntegration, + jiraIntegration *jiraint.JiraIntegration, + webhookIntegration *controllers.WebhookController, + trivyOperatorIntegration *trivyoperatorint.TrivyOperatorIntegration, + ) shared.IntegrationAggregate { + return NewThirdPartyIntegrations(externalUserRepository, githubIntegration, jiraIntegration, gitlabIntegration, webhookIntegration, trivyOperatorIntegration) }, fx.As(new(shared.IntegrationAggregate)), )), diff --git a/integrations/trivyoperatorint/trivy_operator_integration.go b/integrations/trivyoperatorint/trivy_operator_integration.go new file mode 100644 index 000000000..5d98d1034 --- /dev/null +++ b/integrations/trivyoperatorint/trivy_operator_integration.go @@ -0,0 +1,468 @@ +// Copyright (C) 2026 l3montree GmbH +// SPDX-License-Identifier: AGPL-3.0-or-later + +package trivyoperatorint + +import ( + "bytes" + "context" + cryptoRand "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/google/uuid" + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/normalize" + "github.com/l3montree-dev/devguard/shared" + "gorm.io/gorm" +) + +var trivyOperatorKinds = map[string]bool{ + "VulnerabilityReport": false, + "SbomReport": true, + "ConfigAuditReport": false, + "ClusterRbacAssessmentReport": false, + "ExposedSecretReport": false, + "InfraAssessmentReport": false, + "ClusterComplianceReport": false, + "RbacAssessmentReport": false, +} + +// operatorObject is the inner object of a Trivy Operator webhook payload. +type operatorObject struct { + Kind string `json:"kind"` + Metadata struct { + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + Report struct { + Artifact struct { + Tag string `json:"tag"` + } `json:"artifact"` + Components json.RawMessage `json:"components"` // CycloneDX BOM + } `json:"report"` +} + +// webhookPayload is the shape of Trivy Operator webhook requests. +type webhookPayload struct { + Verb string `json:"verb"` + OperatorObject operatorObject `json:"operatorObject"` +} + +type TrivyOperatorIntegration struct { + repository shared.TrivyOperatorIntegrationRepository + orgRepository shared.OrganizationRepository + projectRepository shared.ProjectRepository + assetRepository shared.AssetRepository + assetVersionRepo shared.AssetVersionRepository + artifactRepository shared.ArtifactRepository + assetVersionSvc shared.AssetVersionService + assetService shared.AssetService + projectService shared.ProjectService + rbacProvider shared.RBACProvider + // set via SetScanService after construction to break the DI cycle: + // TrivyOperatorIntegration → ScanService → DependencyVulnService → IntegrationAggregate → TrivyOperatorIntegration + scanService shared.ScanService +} + +var _ shared.ThirdPartyIntegration = &TrivyOperatorIntegration{} + +func NewTrivyOperatorIntegration( + repository shared.TrivyOperatorIntegrationRepository, + orgRepository shared.OrganizationRepository, + projectRepository shared.ProjectRepository, + assetRepository shared.AssetRepository, + assetVersionRepo shared.AssetVersionRepository, + artifactRepository shared.ArtifactRepository, + assetVersionSvc shared.AssetVersionService, + projectService shared.ProjectService, + rbacProvider shared.RBACProvider, +) *TrivyOperatorIntegration { + return &TrivyOperatorIntegration{ + repository: repository, + orgRepository: orgRepository, + projectRepository: projectRepository, + assetRepository: assetRepository, + assetVersionRepo: assetVersionRepo, + artifactRepository: artifactRepository, + assetVersionSvc: assetVersionSvc, + projectService: projectService, + rbacProvider: rbacProvider, + } +} + +func (t *TrivyOperatorIntegration) SetAssetService(s shared.AssetService) { + t.assetService = s +} + +func (t *TrivyOperatorIntegration) SetScanService(s shared.ScanService) { + t.scanService = s +} + +func (t *TrivyOperatorIntegration) GetID() shared.IntegrationID { + return shared.TrivyOperatorIntegrationID +} + +// WantsToHandleWebhook detects Trivy Operator requests by the "kind" field in the JSON body. +func (t *TrivyOperatorIntegration) WantsToHandleWebhook(ctx shared.Context) bool { + body, err := io.ReadAll(ctx.Request().Body) + if err != nil { + return false + } + ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body)) + + var probe struct { + Verb string `json:"verb"` + OperatorObject struct { + Kind string `json:"kind"` + } `json:"operatorObject"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return false + } + fmt.Println("Trivy Operator probe:", "kind", probe.OperatorObject.Kind, "verb", probe.Verb) + if probe.Verb == "delete" { + + //return false + } + if trivyOperatorKinds[probe.OperatorObject.Kind] || probe.Verb == "delete" { + fmt.Printf("Trivy Operator report received: kind=%s, remote_addr=%s, user_agent=%s, content_length=%d\n", probe.OperatorObject.Kind, ctx.RealIP(), ctx.Request().Header.Get("User-Agent"), ctx.Request().ContentLength) + } + return trivyOperatorKinds[probe.OperatorObject.Kind] || probe.Verb == "delete" +} + +func (t *TrivyOperatorIntegration) HandleWebhook(ctx shared.Context) error { + //reqCtx := ctx.Request().Context() + reqCtx := context.WithoutCancel(ctx.Request().Context()) + // --- Auth --- + secret := strings.TrimPrefix(ctx.Request().Header.Get("Authorization"), "Bearer ") + if secret == "" { + return ctx.JSON(401, map[string]string{"error": "missing Authorization header"}) + } + integration, err := t.repository.FindBySecret(reqCtx, nil, secret) + if err != nil { + slog.Warn("trivy operator: unknown secret", "remote_addr", ctx.RealIP()) + return ctx.JSON(401, map[string]string{"error": "unauthorized"}) + } + + // --- Parse body --- + body, err := io.ReadAll(ctx.Request().Body) + if err != nil { + return err + } + + //save the file for debugging purposes + + if err := os.WriteFile(fmt.Sprintf("trivy-operator-report-%s.json", time.Now().Format("20060102-150405")), body, 0644); err != nil { + slog.Error("trivy operator: could not save report body", "err", err) + } + + var payload webhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return ctx.JSON(400, map[string]string{"error": "invalid JSON"}) + } + + report := payload.OperatorObject + containerName := report.Metadata.Labels["trivy-operator.container.name"] + namespace := report.Metadata.Namespace + tag := report.Report.Artifact.Tag + if containerName == "" { + return ctx.JSON(400, map[string]string{"error": "missing trivy-operator.container.name label"}) + } + + // --- Load org --- + org, err := t.orgRepository.GetOrgByID(reqCtx, nil, integration.OrgID) + if err != nil { + slog.Error("trivy operator: org not found", "orgID", integration.OrgID, "err", err) + return ctx.JSON(500, map[string]string{"error": "org not found"}) + } + + // --- Handle delete verb --- + if payload.Verb == "delete" { + return t.handleDelete(reqCtx, ctx, org.ID, integration.ClusterID, namespace, containerName, tag) + } + + // --- Find or create cluster project (top-level) --- + clusterProject, err := t.findOrCreateProject(reqCtx, org.ID, integration.ClusterID, integration.Name, nil, models.ProjectTypeDefault) + if err != nil { + return ctx.JSON(500, map[string]string{"error": "could not find or create cluster project"}) + } + + // --- Find or create namespace sub-project --- + namespaceProject, err := t.findOrCreateProject(reqCtx, org.ID, namespace, namespace, &clusterProject.ID, models.ProjectTypeDefault) + if err != nil { + return ctx.JSON(500, map[string]string{"error": "could not find or create namespace project"}) + } + + // --- Find or create asset --- + asset, err := t.findOrCreateAsset(reqCtx, org.ID, namespaceProject.ID, containerName) + if err != nil { + return ctx.JSON(500, map[string]string{"error": "could not find or create asset"}) + } + + // --- Parse CycloneDX BOM from report.components --- + bom := new(cdx.BOM) + if err := json.Unmarshal(report.Report.Components, bom); err != nil { + slog.Error("trivy operator: failed to parse CycloneDX BOM", "err", err) + return ctx.JSON(400, map[string]string{"error": "invalid CycloneDX BOM in report.components"}) + } + + // --- Scan --- + assetVersionName := tag + if assetVersionName == "" { + assetVersionName = "latest" + } + artifactName := normalize.ArtifactPurl("trivy-operator", fmt.Sprintf("%s/%s/%s", org.Slug, clusterProject.Slug, asset.Slug)) + + normalized, err := normalize.SBOMGraphFromCycloneDX(bom, artifactName, "trivy-operator", asset.KeepOriginalSbomRootComponent) + if err != nil { + slog.Error("trivy operator: failed to normalize BOM", "err", err) + return ctx.JSON(400, map[string]string{"error": "could not normalize SBOM"}) + } + + assetVersion, err := t.assetVersionRepo.FindOrCreate(reqCtx, nil, assetVersionName, asset.ID, tag != "", nil) + if err != nil { + slog.Error("trivy operator: could not find or create asset version", "err", err) + return ctx.JSON(500, map[string]string{"error": "could not find or create asset version"}) + } + + artifact := models.Artifact{ + ArtifactName: artifactName, + AssetVersionName: assetVersion.Name, + AssetID: asset.ID, + } + if err := t.artifactRepository.Save(reqCtx, nil, &artifact); err != nil { + slog.Error("trivy operator: could not save artifact", "err", err) + return ctx.JSON(500, map[string]string{"error": "could not save artifact"}) + } + + tx := t.assetVersionRepo.GetDB(reqCtx, nil).Begin() + defer tx.Rollback() //nolint:errcheck + + wholeSBOM, err := t.assetVersionSvc.UpdateSBOM(reqCtx, tx, org, *clusterProject, *asset, assetVersion, artifactName, normalized) + if err != nil { + slog.Error("trivy operator: could not update SBOM", "err", err) + return ctx.JSON(500, map[string]string{"error": "could not update SBOM"}) + } + + userAgent := "trivy-operator" + _, _, _, err = t.scanService.ScanNormalizedSBOM(reqCtx, tx, org, *clusterProject, *asset, assetVersion, artifact, wholeSBOM, "trivy-operator", &userAgent) + if err != nil { + slog.Error("trivy operator: scan failed", "err", err) + return ctx.JSON(500, map[string]string{"error": "scan failed"}) + } + + if err := tx.Commit().Error; err != nil { + slog.Error("trivy operator: could not commit transaction", "err", err) + return ctx.JSON(500, map[string]string{"error": "could not commit"}) + } + + slog.Info("trivy operator: SBOM scanned and saved", + "org", org.Slug, + "cluster", clusterProject.Slug, + "namespace", namespaceProject.Slug, + "asset", asset.Slug, + "assetVersion", assetVersion.Name, + ) + + return ctx.JSON(202, map[string]string{"status": "accepted"}) +} + +func (t *TrivyOperatorIntegration) handleDelete(ctx context.Context, httpCtx shared.Context, orgID uuid.UUID, clusterID, namespace, containerName, tag string) error { + clusterProject, err := t.projectRepository.ReadBySlug(ctx, nil, orgID, clusterID) + if err != nil { + slog.Warn("trivy operator: delete - cluster project not found", "clusterID", clusterID) + return httpCtx.JSON(404, map[string]string{"error": "cluster project not found"}) + } + + namespaceProject, err := t.projectRepository.ReadBySlug(ctx, nil, orgID, namespace) + if err != nil { + slog.Warn("trivy operator: delete - namespace project not found", "namespace", namespace) + return httpCtx.JSON(404, map[string]string{"error": "namespace project not found"}) + } + _ = clusterProject + + slug := strings.ToLower(strings.ReplaceAll(containerName, "/", "-")) + asset, err := t.assetRepository.ReadBySlug(ctx, nil, namespaceProject.ID, slug) + if err != nil { + slog.Warn("trivy operator: delete - asset not found", "containerName", containerName) + return httpCtx.JSON(404, map[string]string{"error": "asset not found"}) + } + + assetVersionName := tag + if assetVersionName == "" { + assetVersionName = "latest" + } + + assetVersion, err := t.assetVersionRepo.Read(ctx, nil, assetVersionName, asset.ID) + if err != nil { + slog.Warn("trivy operator: delete - asset version not found", "assetVersionName", assetVersionName, "assetID", asset.ID) + return httpCtx.JSON(404, map[string]string{"error": "asset version not found"}) + } + + if err := t.assetVersionRepo.Delete(ctx, nil, &assetVersion); err != nil { + slog.Error("trivy operator: delete - could not delete asset version", "err", err) + return httpCtx.JSON(500, map[string]string{"error": "could not delete asset version"}) + } + + slog.Info("trivy operator: asset version deleted", + "asset", asset.Slug, + "assetVersion", assetVersion.Name, + ) + return httpCtx.JSON(200, map[string]string{"status": "deleted"}) +} + +func (t *TrivyOperatorIntegration) findOrCreateProject(ctx context.Context, orgID uuid.UUID, slug, name string, parentID *uuid.UUID, projectType models.ProjectType) (*models.Project, error) { + project, err := t.projectRepository.ReadBySlug(ctx, nil, orgID, slug) + if err == nil { + return &project, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + newProject := &models.Project{ + Name: name, + Slug: slug, + OrganizationID: orgID, + ParentID: parentID, + Type: projectType, + } + if err := t.projectRepository.Create(ctx, nil, newProject); err != nil { + return nil, err + } + domainRBAC := t.rbacProvider.GetDomainRBAC(orgID.String()) + if err := t.projectService.BootstrapProject(ctx, domainRBAC, newProject); err != nil { + slog.Error("trivy operator: could not bootstrap project RBAC", "err", err) + } + return newProject, nil +} + +func (t *TrivyOperatorIntegration) findOrCreateAsset(ctx context.Context, orgID uuid.UUID, projectID uuid.UUID, name string) (*models.Asset, error) { + slug := strings.ToLower(strings.ReplaceAll(name, "/", "-")) + + asset, err := t.assetRepository.ReadBySlug(ctx, nil, projectID, slug) + if err == nil { + return &asset, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + newAsset := &models.Asset{ + Name: name, + Slug: slug, + ProjectID: projectID, + } + if err := t.assetRepository.Create(ctx, nil, newAsset); err != nil { + return nil, err + } + + domainRBAC := t.rbacProvider.GetDomainRBAC(orgID.String()) + if err := t.assetService.BootstrapAsset(ctx, domainRBAC, newAsset); err != nil { + slog.Error("trivy operator: could not bootstrap asset RBAC", "err", err) + } + + return newAsset, nil +} + +func (t *TrivyOperatorIntegration) Create(ctx shared.Context) error { + var data struct { + Name string `json:"name"` + ClusterID string `json:"clusterId"` + } + if err := ctx.Bind(&data); err != nil { + return ctx.JSON(400, "invalid request data") + } + if data.Name == "" || data.ClusterID == "" { + return ctx.JSON(400, "name and clusterId are required") + } + + secretBytes := make([]byte, 32) + if _, err := cryptoRand.Read(secretBytes); err != nil { + return ctx.JSON(500, "could not generate secret") + } + secret := hex.EncodeToString(secretBytes) + + integration := &models.TrivyOperatorIntegration{ + Name: data.Name, + ClusterID: data.ClusterID, + Secret: secret, + OrgID: shared.GetOrg(ctx).GetID(), + } + if err := t.repository.Save(ctx.Request().Context(), nil, integration); err != nil { + slog.Error("trivy operator: could not save integration", "err", err) + return ctx.JSON(500, "could not save integration") + } + + return ctx.JSON(200, dtos.TrivyOperatorIntegrationDTO{ + ID: integration.ID.String(), + Name: integration.Name, + ClusterID: integration.ClusterID, + Secret: integration.Secret, + }) +} + +func (t *TrivyOperatorIntegration) Delete(ctx shared.Context) error { + id := ctx.Param("trivy_operator_integration_id") + parsedID, err := uuid.Parse(id) + if err != nil { + return ctx.JSON(400, "invalid id") + } + if err := t.repository.Delete(ctx.Request().Context(), nil, parsedID); err != nil { + slog.Error("trivy operator: could not delete integration", "err", err) + return ctx.JSON(500, "could not delete integration") + } + return ctx.JSON(200, map[string]string{"message": "deleted"}) +} + +// --- Stubs for interface compliance --- + +func (t *TrivyOperatorIntegration) ListOrgs(_ shared.Context) ([]models.Org, error) { + return nil, nil +} + +func (t *TrivyOperatorIntegration) ListGroups(_ context.Context, _ string, _ string) ([]models.Project, []shared.Role, error) { + return nil, nil, nil +} + +func (t *TrivyOperatorIntegration) ListProjects(_ context.Context, _ string, _ string, _ string) ([]models.Asset, []shared.Role, error) { + return nil, nil, nil +} + +func (t *TrivyOperatorIntegration) ListRepositories(_ shared.Context) ([]dtos.GitRepository, error) { + return nil, nil +} + +func (t *TrivyOperatorIntegration) HasAccessToExternalEntityProvider(_ shared.Context, _ string) (bool, error) { + return false, nil +} + +func (t *TrivyOperatorIntegration) HandleEvent(_ context.Context, _ any, _ *string) error { + return nil +} + +func (t *TrivyOperatorIntegration) CreateIssue(_ context.Context, _ models.Asset, _ string, _ models.Vuln, _ string, _ string, _ string, _ string, _ *string) error { + return nil +} + +func (t *TrivyOperatorIntegration) UpdateIssue(_ context.Context, _ models.Asset, _ string, _ models.Vuln, _ *string) error { + return nil +} + +func (t *TrivyOperatorIntegration) CreateLabels(_ context.Context, _ models.Asset) error { + return nil +} + +func (t *TrivyOperatorIntegration) CompareIssueStatesAndResolveDifferences(_ context.Context, _ models.Asset, _ []models.DependencyVuln) error { + return nil +} diff --git a/router/org_router.go b/router/org_router.go index eccc375e0..ba9fedd6b 100644 --- a/router/org_router.go +++ b/router/org_router.go @@ -97,6 +97,8 @@ func NewOrgRouter( organizationUpdateAccessControlRequired.DELETE("/integrations/gitlab/:gitlab_integration_id/", integrationController.DeleteGitLabAccessToken) organizationUpdateAccessControlRequired.DELETE("/members/:userID/", orgController.RemoveMember) organizationUpdateAccessControlRequired.DELETE("/integrations/jira/:jira_integration_id/", integrationController.DeleteJiraAccessToken) + organizationUpdateAccessControlRequired.POST("/integrations/trivy-operator/", integrationController.CreateTrivyOperatorIntegration) + organizationUpdateAccessControlRequired.DELETE("/integrations/trivy-operator/:trivy_operator_integration_id/", integrationController.DeleteTrivyOperatorIntegration) organizationUpdateAccessControlRequired.DELETE("/integrations/webhook/:id/", webhookIntegration.Delete) organizationUpdateAccessControlRequired.PATCH("/", orgController.Update) diff --git a/services/asset_version_service.go b/services/asset_version_service.go index 5737861c6..feb6abbbf 100644 --- a/services/asset_version_service.go +++ b/services/asset_version_service.go @@ -35,7 +35,6 @@ type assetVersionService struct { componentRepository shared.ComponentRepository assetVersionRepository shared.AssetVersionRepository componentService shared.ComponentService - thirdPartyIntegration shared.IntegrationAggregate licenseRiskRepository shared.LicenseRiskRepository vexRuleService shared.VEXRuleService utils.FireAndForgetSynchronizer @@ -43,12 +42,11 @@ type assetVersionService struct { var _ shared.AssetVersionService = &assetVersionService{} -func NewAssetVersionService(assetVersionRepository shared.AssetVersionRepository, componentRepository shared.ComponentRepository, componentService shared.ComponentService, thirdPartyIntegration shared.IntegrationAggregate, licenseRiskRepository shared.LicenseRiskRepository, synchronizer utils.FireAndForgetSynchronizer, vexRuleService shared.VEXRuleService) *assetVersionService { +func NewAssetVersionService(assetVersionRepository shared.AssetVersionRepository, componentRepository shared.ComponentRepository, componentService shared.ComponentService, licenseRiskRepository shared.LicenseRiskRepository, synchronizer utils.FireAndForgetSynchronizer, vexRuleService shared.VEXRuleService) *assetVersionService { return &assetVersionService{ assetVersionRepository: assetVersionRepository, componentRepository: componentRepository, componentService: componentService, - thirdPartyIntegration: thirdPartyIntegration, licenseRiskRepository: licenseRiskRepository, FireAndForgetSynchronizer: synchronizer, vexRuleService: vexRuleService, diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index c5f3f5312..82efa3926 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -541,6 +541,14 @@ type GitlabIntegrationRepository interface { Delete(ctx context.Context, tx DB, id uuid.UUID) error } +type TrivyOperatorIntegrationRepository interface { + Save(ctx context.Context, tx DB, model *models.TrivyOperatorIntegration) error + Read(ctx context.Context, tx DB, id uuid.UUID) (models.TrivyOperatorIntegration, error) + FindByOrganizationID(ctx context.Context, tx DB, orgID uuid.UUID) ([]models.TrivyOperatorIntegration, error) + Delete(ctx context.Context, tx DB, id uuid.UUID) error + FindBySecret(ctx context.Context, tx DB, secret string) (models.TrivyOperatorIntegration, error) +} + type GitLabOauth2TokenRepository interface { Save(ctx context.Context, tx DB, model ...*models.GitLabOauth2Token) error FindByUserIDAndProviderID(ctx context.Context, tx DB, userID string, providerID string) (*models.GitLabOauth2Token, error) diff --git a/shared/thirdparty_integration.go b/shared/thirdparty_integration.go index 8a60433ad..d2ee5579f 100644 --- a/shared/thirdparty_integration.go +++ b/shared/thirdparty_integration.go @@ -11,11 +11,12 @@ import ( type IntegrationID string const ( - GitLabIntegrationID IntegrationID = "gitlab" - GitHubIntegrationID IntegrationID = "github" - AggregateID IntegrationID = "aggregate" - JiraIntegrationID IntegrationID = "jira" - WebhookIntegrationID IntegrationID = "webhook" + GitLabIntegrationID IntegrationID = "gitlab" + GitHubIntegrationID IntegrationID = "github" + AggregateID IntegrationID = "aggregate" + JiraIntegrationID IntegrationID = "jira" + WebhookIntegrationID IntegrationID = "webhook" + TrivyOperatorIntegrationID IntegrationID = "trivy-operator" ) type ThirdPartyIntegration interface { diff --git a/transformer/org_transformer.go b/transformer/org_transformer.go index a8742f995..253b05e5f 100644 --- a/transformer/org_transformer.go +++ b/transformer/org_transformer.go @@ -135,6 +135,15 @@ func obfuscateJiraIntegrations(integration models.JiraIntegration) dtos.JiraInte } } +func TrivyOperatorIntegrationToDTO(integration models.TrivyOperatorIntegration) dtos.TrivyOperatorIntegrationDTO { + return dtos.TrivyOperatorIntegrationDTO{ + ID: integration.ID.String(), + Name: integration.Name, + ClusterID: integration.ClusterID, + Secret: integration.Secret, + } +} + func obfuscateWebhookIntegrations(integration models.WebhookIntegration) dtos.WebhookIntegrationDTO { return dtos.WebhookIntegrationDTO{ ID: integration.ID.String(), @@ -179,7 +188,8 @@ func OrgDTOFromModel(org models.Org) dtos.OrgDTO { Projects: utils.Map(org.Projects, ProjectModelToDTO), GithubAppInstallations: utils.Map(org.GithubAppInstallations, GithubAppInstallationToDTO), GitLabIntegrations: utils.Map(org.GitLabIntegrations, obfuscateGitLabIntegrations), - JiraIntegrations: utils.Map(org.JiraIntegrations, obfuscateJiraIntegrations), + JiraIntegrations: utils.Map(org.JiraIntegrations, obfuscateJiraIntegrations), + TrivyOperatorIntegrations: utils.Map(org.TrivyOperatorIntegrations, TrivyOperatorIntegrationToDTO), ConfigFiles: org.ConfigFiles, Language: org.Language, ExternalEntityProviderID: org.ExternalEntityProviderID,