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,
+ }
+}