diff --git a/cmd/devguard/main.go b/cmd/devguard/main.go index 2c091ade5..ef1653942 100644 --- a/cmd/devguard/main.go +++ b/cmd/devguard/main.go @@ -155,6 +155,7 @@ func main() { fx.Invoke(func(FalsePositiveRuleRouter router.VEXRuleRouter) {}), fx.Invoke(func(ExternalReferenceRouter router.ExternalReferenceRouter) {}), fx.Invoke(func(CrowdsourcedVexingRouter router.CrowdsourcedVexingRouter) {}), + fx.Invoke(func(AdvisoryRouter router.AdvisoryRouter) {}), fx.Invoke(func(lc fx.Lifecycle, encryptionService shared.DBEncryptionService) { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { diff --git a/controllers/advisory_controller.go b/controllers/advisory_controller.go new file mode 100644 index 000000000..b1f23d958 --- /dev/null +++ b/controllers/advisory_controller.go @@ -0,0 +1,120 @@ +// Copyright (C) 2023 Tim Bastin, l3montree GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controllers + +import ( + "github.com/google/uuid" + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/transformer" + "github.com/labstack/echo/v4" +) + +type AdvisoryController struct { + advisoryService shared.AdvisoryService +} + +func NewAdvisoryController(advisoryService shared.AdvisoryService) *AdvisoryController { + return &AdvisoryController{ + advisoryService: advisoryService, + } +} + +func (controller *AdvisoryController) Create(ctx shared.Context) error { + var req dtos.AdvisoryCreate + if err := ctx.Bind(&req); err != nil { + return echo.NewHTTPError(400, "unable to process request").WithInternal(err) + } + + newAdvisory := transformer.AdvisoryCreateRequestToModel(req) + + err := controller.advisoryService.Create(ctx.Request().Context(), &newAdvisory) + + if err != nil { + return echo.NewHTTPError(409, "could not set advisory").WithInternal(err) + } + + return ctx.NoContent(200) +} + +func (controller *AdvisoryController) ReadAll(ctx shared.Context) error { + asset := shared.GetAsset(ctx) + advisories, err := controller.advisoryService.ReadAll(ctx.Request().Context(), asset.ID) + if err != nil { + return echo.NewHTTPError(500, "could not get any data").WithInternal(err) + } + return ctx.JSON(200, advisories) +} + +func (controller *AdvisoryController) ReadAdvisory(ctx shared.Context) error { + advisoryID := ctx.Param("id") + parsedID, err := uuid.Parse(advisoryID) + if err != nil { + return echo.NewHTTPError(400, "invalid uuid provided") + } + + advisory, err := controller.advisoryService.ReadAdvisory(ctx.Request().Context(), parsedID) + + if err != nil { + return echo.NewHTTPError(409, "could not get any data").WithInternal(err) + } + + return ctx.JSON(200, advisory) +} + +func (controller *AdvisoryController) Update(ctx shared.Context) error { + var req dtos.AdvisoryUpdate + if err := ctx.Bind(&req); err != nil { + return echo.NewHTTPError(400, "unable to process request").WithInternal(err) + } + + advisoryID := ctx.Param("id") + parsedID, err := uuid.Parse(advisoryID) + if err != nil { + return echo.NewHTTPError(400, "invalid uuid provided") + } + + advisory, err := controller.advisoryService.ReadAdvisory(ctx.Request().Context(), parsedID) + if err != nil { + return echo.NewHTTPError(404, "advisory not found").WithInternal(err) + } + + advisory = transformer.AdvisoryUpdateRequestToModel(req, advisory) + + err = controller.advisoryService.Update(ctx.Request().Context(), parsedID, &advisory) + + if err != nil { + return echo.NewHTTPError(409, "could not update advisory").WithInternal(err) + } + + return ctx.NoContent(200) +} + +func (controller *AdvisoryController) Delete(ctx shared.Context) error { + advisoryID := ctx.Param("id") + parsedID, err := uuid.Parse(advisoryID) + if err != nil { + return echo.NewHTTPError(400, "invalid uuid provided") + } + + err = controller.advisoryService.Delete(ctx.Request().Context(), parsedID) + + if err != nil { + return echo.NewHTTPError(409, "could not remove name").WithInternal(err) + } + + return ctx.NoContent(200) +} diff --git a/controllers/providers.go b/controllers/providers.go index b7d5696cf..df0b40cf5 100644 --- a/controllers/providers.go +++ b/controllers/providers.go @@ -122,4 +122,6 @@ var ControllerModule = fx.Options( //Crowdsourced Vexing fx.Provide(NewCrowdsourcedVexingController), + + fx.Provide(NewAdvisoryController), ) diff --git a/database/migrations/20260623101120_add_advisory_table.up.sql b/database/migrations/20260623101120_add_advisory_table.up.sql new file mode 100644 index 000000000..c38a342c5 --- /dev/null +++ b/database/migrations/20260623101120_add_advisory_table.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE public.advisories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + title TEXT NOT NULL, + description TEXT NOT NULL, + severity TEXT, + vector_string TEXT, + asset_id UUID +); + +CREATE TABLE public.affected_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + ecosystem TEXT NOT NULL, + package_name TEXT, + semver_introduced public.semver, + semver_fixed public.semver +); + +CREATE TABLE public.advisories_affected_packages ( + advisory_id UUID NOT NULL REFERENCES public.advisories(id) ON DELETE CASCADE, + affected_package_id UUID NOT NULL REFERENCES public.affected_packages(id) ON DELETE CASCADE, + CONSTRAINT advisories_affected_packages_pkey PRIMARY KEY (advisory_id, affected_package_id) +); + diff --git a/database/models/advisory_model.go b/database/models/advisory_model.go new file mode 100644 index 000000000..9530fa877 --- /dev/null +++ b/database/models/advisory_model.go @@ -0,0 +1,29 @@ +package models + +import "github.com/google/uuid" + +type Advisory struct { + Model + Title string `json:"title" gorm:"type:text;column:title"` + Description string `json:"description" gorm:"type:text;column:description"` + AffectedPackages []AffectedPackage `json:"affectedPackages" gorm:"many2many:advisories_affected_packages;foreignKey:ID;joinForeignKey:advisory_id;References:ID;joinReferences:affected_package_id;constraint:OnDelete:CASCADE"` + Severity string `json:"severity" gorm:"type:text;column:severity"` + VectorString string `json:"vectorstring" gorm:"type:text;column:vector_string"` + AssetID uuid.UUID `json:"assetID" gorm:"type:uuid;column:asset_id"` +} +type AffectedPackage struct { + Model + Ecosystem string `json:"ecosystem" gorm:"type:text;column:ecosystem"` + PackageName string `json:"packagename" gorm:"type:text;column:package_name"` + SemverIntroduced *string `json:"semverStart" gorm:"type:semver;index"` + SemverFixed *string `json:"semverEnd" gorm:"type:semver;index"` + Advisory []Advisory `json:"advisory" gorm:"many2many:advisories_affected_packages;constraint:OnDelete:CASCADE"` +} + +func (m Advisory) TableName() string { + return "advisories" +} + +func (m AffectedPackage) TableName() string { + return "affected_packages" +} diff --git a/database/repositories/advisory_repository.go b/database/repositories/advisory_repository.go new file mode 100644 index 000000000..1e94ce3f4 --- /dev/null +++ b/database/repositories/advisory_repository.go @@ -0,0 +1,69 @@ +package repositories + +import ( + "context" + + "github.com/google/uuid" + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/utils" + "gorm.io/gorm" +) + +type AdvisoryRepository struct { + db *gorm.DB + utils.Repository[uuid.UUID, models.Advisory, *gorm.DB] +} + +func NewAdvisoryRepository(db *gorm.DB) *AdvisoryRepository { + return &AdvisoryRepository{ + db: db, + Repository: newGormRepository[uuid.UUID, models.Advisory](db), + } +} + +var _ shared.AdvisoryRepository = (*AdvisoryRepository)(nil) + +func (advisoryRepository *AdvisoryRepository) Create(ctx context.Context, tx *gorm.DB, advisory *models.Advisory) error { + err := advisoryRepository.GetDB(ctx, tx).Create(advisory).Error + if err != nil { + return err + } + return nil +} + +func (advisoryRepository *AdvisoryRepository) ReadAll(ctx context.Context, tx *gorm.DB, assetID uuid.UUID) ([]models.Advisory, error) { + advisories := []models.Advisory{} + db := advisoryRepository.db.WithContext(ctx) + if tx != nil { + db = tx + } + err := db.Preload("AffectedPackages").Where("asset_id = ?", assetID).Find(&advisories).Error + return advisories, err +} + +func (advisoryRepository *AdvisoryRepository) ReadAdvisory(ctx context.Context, tx *gorm.DB, id uuid.UUID) (models.Advisory, error) { + advisory := models.Advisory{} + db := advisoryRepository.db.WithContext(ctx) + if tx != nil { + db = tx + } + err := db.Preload("AffectedPackages").Where("id = ?", id).Find(&advisory).Error + return advisory, err +} + +func (advisoryRepository *AdvisoryRepository) Update(ctx context.Context, tx *gorm.DB, id uuid.UUID, advisory *models.Advisory) error { + return advisoryRepository.GetDB(ctx, tx).Session(&gorm.Session{FullSaveAssociations: true}).Save(advisory).Error +} + +func (advisoryRepository *AdvisoryRepository) Delete(ctx context.Context, tx *gorm.DB, id uuid.UUID) error { + db := advisoryRepository.db.WithContext(ctx) + if tx != nil { + db = tx + } + err := db.Preload("AffectedPackages").Delete(&models.Advisory{Model: models.Model{ID: id}}).Error + if err != nil { + return err + } + return nil +} diff --git a/database/repositories/providers.go b/database/repositories/providers.go index 84d540a31..fd9cc645c 100644 --- a/database/repositories/providers.go +++ b/database/repositories/providers.go @@ -59,4 +59,5 @@ var Module = fx.Options( fx.Provide(fx.Annotate(NewTrustedEntityRepository, fx.As(new(shared.TrustedEntityRepository)))), fx.Provide(fx.Annotate(NewDependencyProxyRepository, fx.As(new(shared.DependencyProxySecretRepository)))), fx.Provide(fx.Annotate(NewAdminRepository, fx.As(new(shared.AdminRepository)))), + fx.Provide(fx.Annotate(NewAdvisoryRepository, fx.As(new(shared.AdvisoryRepository)))), ) diff --git a/dtos/advisory_dto.go b/dtos/advisory_dto.go new file mode 100644 index 000000000..d8dfdb797 --- /dev/null +++ b/dtos/advisory_dto.go @@ -0,0 +1,53 @@ +// Copyright (C) 2023 Tim Bastin, l3montree GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package dtos + +import "github.com/google/uuid" + +type AdvisoryCreate struct { + Title string `json:"title" validate:"required"` + Description string `json:"description" validate:"required"` + AffectedPackages []AffectedPackage `json:"affectedPackages"` + Severity string `json:"severity"` + VectorString string `json:"vectorString"` + AssetID uuid.UUID `json:"assetID"` +} +type AdvisoryUpdate struct { + Title *string `json:"title"` + Description *string `json:"description"` + AffectedPackages []AffectedPackage `json:"affectedPackages"` + Severity *string `json:"severity"` + VectorString *string `json:"vectorString"` + AssetID *uuid.UUID `json:"assetID"` +} + +type AdvisoryDTO struct { + ID uuid.UUID `json:"id"` + Title string `json:"title" validate:"required"` + Description string `json:"description" validate:"required"` + AffectedPackages []AffectedPackage `json:"affectedPackages"` + Severity string `json:"severity"` + VectorString string `json:"vectorString"` + AssetID uuid.UUID `json:"assetID"` +} + +type AffectedPackage struct { + ID uuid.UUID `json:"id,omitempty"` + Ecosystem string `json:"ecosystem"` + PackageName string `json:"packageName"` + SemverIntroduced *string `json:"semverStart"` + SemverFixed *string `json:"semverEnd"` +} diff --git a/router/advisory_router.go b/router/advisory_router.go new file mode 100644 index 000000000..53bf6cce7 --- /dev/null +++ b/router/advisory_router.go @@ -0,0 +1,41 @@ +// Copyright (C) 2024 Tim Bastin, l3montree GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package router + +import ( + "github.com/l3montree-dev/devguard/controllers" + "github.com/l3montree-dev/devguard/shared" + "github.com/labstack/echo/v4" +) + +type AdvisoryRouter struct { + *echo.Group +} + +func NewAdvisoryRouter( + assetRepository shared.AssetRepository, + assetVersionGroup AssetVersionRouter, + advisoryController *controllers.AdvisoryController, +) AdvisoryRouter { + advisoryRouter := assetVersionGroup.Group.Group("/advisory") + advisoryRouter.POST("/", advisoryController.Create) + advisoryRouter.GET("/", advisoryController.ReadAll) + advisoryRouter.GET("/:id/", advisoryController.ReadAdvisory) + advisoryRouter.PATCH("/:id/", advisoryController.Update) + advisoryRouter.DELETE("/:id/", advisoryController.Delete) + + return AdvisoryRouter{Group: advisoryRouter} +} diff --git a/router/providers.go b/router/providers.go index 7d5056903..d0624d0b7 100644 --- a/router/providers.go +++ b/router/providers.go @@ -22,4 +22,5 @@ var RouterModule = fx.Options( fx.Provide(NewVEXRuleRouter), fx.Provide(NewExternalReferenceRouter), fx.Provide(NewCrowdsourcedVexingRouter), + fx.Provide(NewAdvisoryRouter), ) diff --git a/services/advisory_service.go b/services/advisory_service.go new file mode 100644 index 000000000..0e12b6f5e --- /dev/null +++ b/services/advisory_service.go @@ -0,0 +1,45 @@ +package services + +import ( + "context" + + "github.com/google/uuid" + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/shared" +) + +type AdvisoryService struct { + advisoryRepository shared.AdvisoryRepository +} + +func NewAdvisoryService(advisoryRepository shared.AdvisoryRepository) *AdvisoryService { + return &AdvisoryService{ + advisoryRepository: advisoryRepository, + } +} + +var _ shared.AdvisoryService = (*AdvisoryService)(nil) + +func (s *AdvisoryService) Create(ctx context.Context, advisory *models.Advisory) error { + return s.advisoryRepository.Create(ctx, nil, advisory) +} + +func (s *AdvisoryService) ReadAll(ctx context.Context, assetID uuid.UUID) ([]models.Advisory, error) { + advisories, err := s.advisoryRepository.ReadAll(ctx, nil, assetID) + if err != nil { + return nil, err + } + return advisories, nil +} + +func (s *AdvisoryService) ReadAdvisory(ctx context.Context, id uuid.UUID) (models.Advisory, error) { + return s.advisoryRepository.ReadAdvisory(ctx, nil, id) +} + +func (s *AdvisoryService) Update(ctx context.Context, id uuid.UUID, advisory *models.Advisory) error { + return s.advisoryRepository.Update(ctx, nil, id, advisory) +} + +func (s *AdvisoryService) Delete(ctx context.Context, id uuid.UUID) error { + return s.advisoryRepository.Delete(ctx, nil, id) +} diff --git a/services/providers.go b/services/providers.go index 54683f373..6ea5b6792 100644 --- a/services/providers.go +++ b/services/providers.go @@ -39,4 +39,5 @@ var ServiceModule = fx.Options( fx.Provide(fx.Annotate(NewAdminService, fx.As(new(shared.AdminService)))), fx.Provide(fx.Annotate(NewCrowdsourcedVexingService, fx.As(new(shared.CrowdSourcedVexingService)))), fx.Provide(fx.Annotate(NewDBEncryptionService, fx.As(new(shared.DBEncryptionService)))), + fx.Provide(fx.Annotate(NewAdvisoryService, fx.As(new(shared.AdvisoryService)))), ) diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index c6ff223a3..26625aaae 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -761,6 +761,22 @@ type RBACProvider interface { GetAllUsers() ([]string, error) } +type AdvisoryService interface { + Create(ctx context.Context, advisory *models.Advisory) error + ReadAll(ctx context.Context, assetID uuid.UUID) ([]models.Advisory, error) + ReadAdvisory(ctx context.Context, id uuid.UUID) (models.Advisory, error) + Update(ctx context.Context, id uuid.UUID, advisory *models.Advisory) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type AdvisoryRepository interface { + Create(ctx context.Context, tx DB, advisory *models.Advisory) error + ReadAll(ctx context.Context, tx DB, assetID uuid.UUID) ([]models.Advisory, error) + ReadAdvisory(ctx context.Context, tx DB, id uuid.UUID) (models.Advisory, error) + Update(ctx context.Context, tx DB, id uuid.UUID, advisory *models.Advisory) error + Delete(ctx context.Context, tx DB, id uuid.UUID) error +} + type RBACMiddleware = func(obj Object, act Action) echo.MiddlewareFunc type Role string diff --git a/transformer/advisory_transformer.go b/transformer/advisory_transformer.go new file mode 100644 index 000000000..e2d6cf867 --- /dev/null +++ b/transformer/advisory_transformer.go @@ -0,0 +1,59 @@ +package transformer + +import ( + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/dtos" +) + +func AdvisoryCreateRequestToModel(c dtos.AdvisoryCreate) models.Advisory { + + components := make([]models.AffectedPackage, len(c.AffectedPackages)) + for i, asset := range c.AffectedPackages { + components[i] = AffectedPackageToModel(asset) + } + + return models.Advisory{ + Title: c.Title, + Description: c.Description, + AffectedPackages: components, + Severity: c.Severity, + VectorString: c.VectorString, + AssetID: c.AssetID, + } +} + +func AdvisoryUpdateRequestToModel(c dtos.AdvisoryUpdate, advisory models.Advisory) models.Advisory { + if c.Title != nil { + advisory.Title = *c.Title + } + if c.Description != nil { + advisory.Description = *c.Description + } + if c.Severity != nil { + advisory.Severity = *c.Severity + } + if c.VectorString != nil { + advisory.VectorString = *c.VectorString + } + if c.AffectedPackages != nil { + components := make([]models.AffectedPackage, len(c.AffectedPackages)) + for i, asset := range c.AffectedPackages { + components[i] = AffectedPackageToModel(asset) + } + advisory.AffectedPackages = components + } + if c.AssetID != nil { + advisory.AssetID = *c.AssetID + } + return advisory +} + +func AffectedPackageToModel(c dtos.AffectedPackage) models.AffectedPackage { + return models.AffectedPackage{ + Model: models.Model{ID: c.ID}, + Ecosystem: c.Ecosystem, + PackageName: c.PackageName, + SemverIntroduced: c.SemverIntroduced, + SemverFixed: c.SemverFixed, + } +}