diff --git a/pkg/api/osvdev.go b/pkg/api/osvdev.go new file mode 100644 index 000000000..ca0b40af8 --- /dev/null +++ b/pkg/api/osvdev.go @@ -0,0 +1,100 @@ +package api + +// OSV (Open Source Vulnerabilities) API/schema types. +// +// These mirror the osv.dev REST API (https://google.github.io/osv.dev/api/) and the +// OSV schema (https://ossf.github.io/osv-schema/) so that OSV tooling such as +// osv-scanner can consume our Lightwell advisories by pointing at /demo/osvdev +// instead of api.osv.dev. Field names use snake_case to match the real service. + +// OsvPackage identifies an affected package. +type OsvPackage struct { + Ecosystem string `json:"ecosystem,omitempty"` + Name string `json:"name,omitempty"` + Purl string `json:"purl,omitempty"` +} + +// OsvEvent is a single point in an affected range. Exactly one field is set. +type OsvEvent struct { + Introduced string `json:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty"` + LastAffected string `json:"last_affected,omitempty"` + Limit string `json:"limit,omitempty"` +} + +// OsvRange is an ordered set of version events for an ecosystem. +type OsvRange struct { + Type string `json:"type"` + Repo string `json:"repo,omitempty"` + Events []OsvEvent `json:"events"` +} + +// OsvAffected describes a package and the versions it is affected in. +type OsvAffected struct { + Package OsvPackage `json:"package"` + Ranges []OsvRange `json:"ranges,omitempty"` + Versions []string `json:"versions,omitempty"` +} + +// OsvReference is a URL reference for a vulnerability. +type OsvReference struct { + Type string `json:"type"` + URL string `json:"url"` +} + +// OsvSeverity is a severity score for a vulnerability. +type OsvSeverity struct { + Type string `json:"type"` + Score string `json:"score"` +} + +// OsvVulnerability is a full OSV record. +type OsvVulnerability struct { + SchemaVersion string `json:"schema_version,omitempty"` + ID string `json:"id"` + Modified string `json:"modified"` + Published string `json:"published,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Summary string `json:"summary,omitempty"` + Details string `json:"details,omitempty"` + Severity []OsvSeverity `json:"severity,omitempty"` + Affected []OsvAffected `json:"affected,omitempty"` + References []OsvReference `json:"references,omitempty"` +} + +// OsvQuery is the body of POST /v1/query and each entry of a batch query. +type OsvQuery struct { + Commit string `json:"commit,omitempty"` + Version string `json:"version,omitempty"` + Package OsvPackage `json:"package,omitempty"` + PageToken string `json:"page_token,omitempty"` +} + +// OsvBatchQuery is the body of POST /v1/querybatch. +type OsvBatchQuery struct { + Queries []OsvQuery `json:"queries"` +} + +// OsvVulnerabilityList is the response of POST /v1/query. +type OsvVulnerabilityList struct { + Vulns []OsvVulnerability `json:"vulns,omitempty"` + NextPageToken string `json:"next_page_token,omitempty"` +} + +// OsvVulnStub is the lightweight vulnerability reference returned by querybatch. +type OsvVulnStub struct { + ID string `json:"id"` + Modified string `json:"modified"` +} + +// OsvBatchResult is a single query's result within a batch response. +type OsvBatchResult struct { + Vulns []OsvVulnStub `json:"vulns,omitempty"` + NextPageToken string `json:"next_page_token,omitempty"` +} + +// OsvBatchVulnerabilityList is the response of POST /v1/querybatch, index-aligned +// with the request queries. +type OsvBatchVulnerabilityList struct { + Results []OsvBatchResult `json:"results"` +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 6c0472615..05544be51 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -110,6 +110,7 @@ type FeatureSet struct { LightwellBeacon Feature `mapstructure:"lightwell_beacon"` LightwellLens Feature `mapstructure:"lightwell_lens"` LightwellStoreUploads Feature `mapstructure:"lightwell_store_uploads"` + LightwellOsvDemo Feature `mapstructure:"lightwell_osv_demo"` AdminJfrogUpload Feature `mapstructure:"admin_jfrog_upload"` } diff --git a/pkg/dao/dao_mock.go b/pkg/dao/dao_mock.go index 7bb8b2816..13363e2a6 100644 --- a/pkg/dao/dao_mock.go +++ b/pkg/dao/dao_mock.go @@ -9597,6 +9597,68 @@ func (_c *MockLightwellAdvisoryDao_List_Call) RunAndReturn(run func(ctx context. return _c } +// ListForOsv provides a mock function for the type MockLightwellAdvisoryDao +func (_mock *MockLightwellAdvisoryDao) ListForOsv(ctx context.Context) ([]models.LightwellAdvisory, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListForOsv") + } + + var r0 []models.LightwellAdvisory + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]models.LightwellAdvisory, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []models.LightwellAdvisory); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.LightwellAdvisory) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockLightwellAdvisoryDao_ListForOsv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListForOsv' +type MockLightwellAdvisoryDao_ListForOsv_Call struct { + *mock.Call +} + +// ListForOsv is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockLightwellAdvisoryDao_Expecter) ListForOsv(ctx interface{}) *MockLightwellAdvisoryDao_ListForOsv_Call { + return &MockLightwellAdvisoryDao_ListForOsv_Call{Call: _e.mock.On("ListForOsv", ctx)} +} + +func (_c *MockLightwellAdvisoryDao_ListForOsv_Call) Run(run func(ctx context.Context)) *MockLightwellAdvisoryDao_ListForOsv_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockLightwellAdvisoryDao_ListForOsv_Call) Return(lightwellAdvisories []models.LightwellAdvisory, err error) *MockLightwellAdvisoryDao_ListForOsv_Call { + _c.Call.Return(lightwellAdvisories, err) + return _c +} + +func (_c *MockLightwellAdvisoryDao_ListForOsv_Call) RunAndReturn(run func(ctx context.Context) ([]models.LightwellAdvisory, error)) *MockLightwellAdvisoryDao_ListForOsv_Call { + _c.Call.Return(run) + return _c +} + // ListByRepository provides a mock function for the type MockLightwellAdvisoryDao func (_mock *MockLightwellAdvisoryDao) ListByRepository(ctx context.Context, repoConfigUUID string) ([]LightwellAdvisoryInput, error) { ret := _mock.Called(ctx, repoConfigUUID) diff --git a/pkg/dao/interfaces.go b/pkg/dao/interfaces.go index 83293d13b..832191338 100644 --- a/pkg/dao/interfaces.go +++ b/pkg/dao/interfaces.go @@ -276,6 +276,7 @@ type LightwellAdvisoryDao interface { SyncForRepository(ctx context.Context, repoConfigUUID string, repoName string, advisories []LightwellAdvisoryInput) error ListByRepository(ctx context.Context, repoConfigUUID string) ([]LightwellAdvisoryInput, error) List(ctx context.Context, offset int, limit int) ([]LightwellAdvisoryInput, int64, error) + ListForOsv(ctx context.Context) ([]models.LightwellAdvisory, error) ListUnnotifiedAdvisories(ctx context.Context, repoConfigUUID string, orgID string) ([]LightwellNotificationData, error) MarkAsNotified(ctx context.Context, repoConfigUUID string, orgID string, data []LightwellNotificationData) error } diff --git a/pkg/dao/lightwell_advisory.go b/pkg/dao/lightwell_advisory.go index 5f41b40a1..181324ca6 100644 --- a/pkg/dao/lightwell_advisory.go +++ b/pkg/dao/lightwell_advisory.go @@ -60,6 +60,18 @@ func (d lightwellAdvisoryDaoImpl) List(ctx context.Context, offset int, limit in return advisoryInputs(advisories), total, nil } +// ListForOsv returns all advisories (with timestamps) for serving the osv.dev-compatible +// demo feed. Unlike List, it returns full models so callers can build OSV records that +// require modified/published timestamps. +func (d lightwellAdvisoryDaoImpl) ListForOsv(ctx context.Context) ([]models.LightwellAdvisory, error) { + var advisories []models.LightwellAdvisory + result := d.db.WithContext(ctx).Order("advisory_id ASC").Find(&advisories) + if result.Error != nil { + return nil, fmt.Errorf("failed to list advisories for osv feed: %w", result.Error) + } + return advisories, nil +} + func advisoryInputs(advisories []models.LightwellAdvisory) []LightwellAdvisoryInput { inputs := make([]LightwellAdvisoryInput, len(advisories)) for i, a := range advisories { diff --git a/pkg/handler/api.go b/pkg/handler/api.go index 6af44ba15..384597822 100644 --- a/pkg/handler/api.go +++ b/pkg/handler/api.go @@ -124,6 +124,10 @@ func RegisterRoutes(ctx context.Context, engine *echo.Echo) { } } + // osv.dev-compatible demo feed is public (no identity/RBAC) and rooted at + // /demo/osvdev, so register it on the raw engine rather than the API group. + RegisterOsvDevRoutes(engine, dao.GetDaoRegistry(db.DB)) + data, err := json.MarshalIndent(engine.Routes(), "", " ") if err == nil { log.Debug().Msg(string(data)) diff --git a/pkg/handler/osvdev.go b/pkg/handler/osvdev.go new file mode 100644 index 000000000..b431b3bb2 --- /dev/null +++ b/pkg/handler/osvdev.go @@ -0,0 +1,205 @@ +package handler + +import ( + "archive/zip" + "bytes" + "encoding/json" + "net/http" + "strings" + + "github.com/content-services/content-sources-backend/pkg/api" + "github.com/content-services/content-sources-backend/pkg/config" + "github.com/content-services/content-sources-backend/pkg/dao" + ce "github.com/content-services/content-sources-backend/pkg/errors" + "github.com/content-services/content-sources-backend/pkg/lightwell/osv" + "github.com/labstack/echo/v4" +) + +// OsvDevHandler serves an osv.dev-compatible mock of our Lightwell advisories under +// /demo/osvdev. It is public (no identity required) and gated by the +// LightwellOsvDemo feature flag. See pkg/lightwell/osv for the data mapping. +type OsvDevHandler struct { + DaoRegistry dao.DaoRegistry +} + +// RegisterOsvDevRoutes registers the osv.dev mock on the raw Echo engine (not the +// versioned API group), so paths are literally rooted at /demo/osvdev with no +// identity/RBAC. SkipMiddleware skips auth for the /demo/osvdev prefix. +func RegisterOsvDevRoutes(engine *echo.Echo, daoReg *dao.DaoRegistry) { + if engine == nil { + panic("engine is nil") + } + if daoReg == nil { + panic("daoReg is nil") + } + + h := OsvDevHandler{DaoRegistry: *daoReg} + engine.POST("/demo/osvdev/v1/query", h.query, requireOsvDemo) + engine.POST("/demo/osvdev/v1/querybatch", h.queryBatch, requireOsvDemo) + engine.GET("/demo/osvdev/v1/vulns/:id", h.getVuln, requireOsvDemo) + engine.GET("/demo/osvdev/ecosystems.txt", h.ecosystems, requireOsvDemo) + engine.GET("/demo/osvdev/all.zip", h.exportAll, requireOsvDemo) + engine.GET("/demo/osvdev/:ecosystem/all.zip", h.exportEcosystem, requireOsvDemo) + engine.GET("/demo/osvdev/:ecosystem/:id", h.getRecordFile, requireOsvDemo) +} + +// requireOsvDemo returns 404 when the demo feature is disabled, so the routes are +// invisible in production. The plain Enabled flag is used (not FeatureAccessible), +// since these routes are public and carry no identity/org. +func requireOsvDemo(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if !config.Get().Features.LightwellOsvDemo.Enabled { + return ce.NewErrorResponse(http.StatusNotFound, "Not found", "osv.dev demo is not enabled") + } + return next(c) + } +} + +func (h *OsvDevHandler) records(c echo.Context) ([]api.OsvVulnerability, error) { + advisories, err := h.DaoRegistry.LightwellAdvisory.ListForOsv(c.Request().Context()) + if err != nil { + return nil, ce.NewErrorResponse(ce.HttpCodeForDaoError(err), "Error loading advisories", err.Error()) + } + recs := osv.BuildRecords(advisories) + osv.SortRecords(recs) + return recs, nil +} + +// query implements POST /demo/osvdev/v1/query. +func (h *OsvDevHandler) query(c echo.Context) error { + var q api.OsvQuery + if err := c.Bind(&q); err != nil { + return ce.NewErrorResponse(http.StatusBadRequest, "Invalid query", err.Error()) + } + + recs, err := h.records(c) + if err != nil { + return err + } + + var vulns []api.OsvVulnerability + for _, rec := range recs { + if osv.Matches(rec, q) { + vulns = append(vulns, rec) + } + } + return c.JSON(http.StatusOK, api.OsvVulnerabilityList{Vulns: vulns}) +} + +// queryBatch implements POST /demo/osvdev/v1/querybatch. Results are index-aligned +// with the request queries and contain only {id, modified} stubs. +func (h *OsvDevHandler) queryBatch(c echo.Context) error { + var batch api.OsvBatchQuery + if err := c.Bind(&batch); err != nil { + return ce.NewErrorResponse(http.StatusBadRequest, "Invalid query", err.Error()) + } + + recs, err := h.records(c) + if err != nil { + return err + } + + results := make([]api.OsvBatchResult, len(batch.Queries)) + for i, q := range batch.Queries { + var stubs []api.OsvVulnStub + for _, rec := range recs { + if osv.Matches(rec, q) { + stubs = append(stubs, api.OsvVulnStub{ID: rec.ID, Modified: rec.Modified}) + } + } + results[i] = api.OsvBatchResult{Vulns: stubs} + } + return c.JSON(http.StatusOK, api.OsvBatchVulnerabilityList{Results: results}) +} + +// getVuln implements GET /demo/osvdev/v1/vulns/:id. +func (h *OsvDevHandler) getVuln(c echo.Context) error { + id := c.Param("id") + recs, err := h.records(c) + if err != nil { + return err + } + for _, rec := range recs { + if rec.ID == id { + return c.JSON(http.StatusOK, rec) + } + } + return ce.NewErrorResponse(http.StatusNotFound, "Not found", "no vulnerability with id "+id) +} + +// getRecordFile implements GET /demo/osvdev/:ecosystem/:id, serving .json to +// emulate the GCS export layout. +func (h *OsvDevHandler) getRecordFile(c echo.Context) error { + id := strings.TrimSuffix(c.Param("id"), ".json") + recs, err := h.records(c) + if err != nil { + return err + } + for _, rec := range recs { + if rec.ID == id { + return c.JSON(http.StatusOK, rec) + } + } + return ce.NewErrorResponse(http.StatusNotFound, "Not found", "no vulnerability with id "+id) +} + +// ecosystems implements GET /demo/osvdev/ecosystems.txt. +func (h *OsvDevHandler) ecosystems(c echo.Context) error { + return c.String(http.StatusOK, osv.DefaultEcosystem+"\n") +} + +// exportAll implements GET /demo/osvdev/all.zip. +func (h *OsvDevHandler) exportAll(c echo.Context) error { + recs, err := h.records(c) + if err != nil { + return err + } + return h.writeZip(c, recs) +} + +// exportEcosystem implements GET /demo/osvdev/:ecosystem/all.zip. +func (h *OsvDevHandler) exportEcosystem(c echo.Context) error { + ecosystem := c.Param("ecosystem") + recs, err := h.records(c) + if err != nil { + return err + } + filtered := make([]api.OsvVulnerability, 0, len(recs)) + for _, rec := range recs { + if recordInEcosystem(rec, ecosystem) { + filtered = append(filtered, rec) + } + } + return h.writeZip(c, filtered) +} + +func recordInEcosystem(rec api.OsvVulnerability, ecosystem string) bool { + for _, aff := range rec.Affected { + if strings.EqualFold(aff.Package.Ecosystem, ecosystem) { + return true + } + } + return false +} + +func (h *OsvDevHandler) writeZip(c echo.Context, recs []api.OsvVulnerability) error { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, rec := range recs { + w, err := zw.Create(rec.ID + ".json") + if err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error building zip", err.Error()) + } + body, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error building zip", err.Error()) + } + if _, err := w.Write(body); err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error building zip", err.Error()) + } + } + if err := zw.Close(); err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error building zip", err.Error()) + } + return c.Blob(http.StatusOK, "application/zip", buf.Bytes()) +} diff --git a/pkg/handler/osvdev_test.go b/pkg/handler/osvdev_test.go new file mode 100644 index 000000000..32853414b --- /dev/null +++ b/pkg/handler/osvdev_test.go @@ -0,0 +1,185 @@ +package handler + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/content-services/content-sources-backend/pkg/api" + "github.com/content-services/content-sources-backend/pkg/config" + "github.com/content-services/content-sources-backend/pkg/dao" + ce "github.com/content-services/content-sources-backend/pkg/errors" + "github.com/content-services/content-sources-backend/pkg/lightwell/osv" + "github.com/content-services/content-sources-backend/pkg/middleware" + "github.com/content-services/content-sources-backend/pkg/models" + "github.com/labstack/echo/v4" + "github.com/redhatinsights/platform-go-middlewares/v2/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type OsvDevSuite struct { + suite.Suite + reg *dao.MockDaoRegistry +} + +func TestOsvDevSuite(t *testing.T) { + suite.Run(t, new(OsvDevSuite)) +} + +func (suite *OsvDevSuite) SetupTest() { + suite.reg = dao.GetMockDaoRegistry(suite.T()) +} + +// serve mounts the identity middleware (with the standard skipper) and registers +// the osv routes on the raw engine, exactly like the real server. No identity +// header is ever set, verifying the routes are public. +func (suite *OsvDevSuite) serve(req *http.Request, enabled bool) (int, []byte) { + config.Get().Features.LightwellOsvDemo.Enabled = enabled + + router := echo.New() + router.Use(middleware.WrapMiddlewareWithSkipper(identity.EnforceIdentity, middleware.SkipMiddleware)) + router.HTTPErrorHandler = config.CustomHTTPErrorHandler + RegisterOsvDevRoutes(router, suite.reg.ToDaoRegistry()) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + resp := rr.Result() + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, body +} + +func (suite *OsvDevSuite) advisories() []models.LightwellAdvisory { + now := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + a := models.LightwellAdvisory{ + AdvisoryID: "LW-2026-1", + PackageName: "left-pad", + FixedVersions: []string{"1.3.0"}, + Details: "left-pad flaw", + } + a.CreatedAt = now + a.UpdatedAt = now + b := models.LightwellAdvisory{ + AdvisoryID: "LW-2026-2", + PackageName: "log4j", + FixedVersions: []string{"2.17.0"}, + } + b.CreatedAt = now + b.UpdatedAt = now + return []models.LightwellAdvisory{a, b} +} + +func postJSON(path, body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func (suite *OsvDevSuite) TestQueryHit() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(suite.advisories(), nil) + + req := postJSON("/demo/osvdev/v1/query", `{"version":"1.2.0","package":{"name":"left-pad"}}`) + code, body := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusOK, code) + + var resp api.OsvVulnerabilityList + assert.NoError(suite.T(), json.Unmarshal(body, &resp)) + assert.Len(suite.T(), resp.Vulns, 1) + assert.Equal(suite.T(), "LW-2026-1", resp.Vulns[0].ID) +} + +func (suite *OsvDevSuite) TestQueryMissReturnsEmptyObject() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(suite.advisories(), nil) + + req := postJSON("/demo/osvdev/v1/query", `{"version":"9.9.9","package":{"name":"left-pad"}}`) + code, body := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusOK, code) + assert.JSONEq(suite.T(), `{}`, string(body)) +} + +func (suite *OsvDevSuite) TestQueryBatchIndexAlignedStubs() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(suite.advisories(), nil) + + body := `{"queries":[{"version":"1.0.0","package":{"name":"left-pad"}},{"commit":"deadbeef"}]}` + req := postJSON("/demo/osvdev/v1/querybatch", body) + code, respBody := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusOK, code) + + var resp api.OsvBatchVulnerabilityList + assert.NoError(suite.T(), json.Unmarshal(respBody, &resp)) + assert.Len(suite.T(), resp.Results, 2) + assert.Len(suite.T(), resp.Results[0].Vulns, 1) + assert.Equal(suite.T(), "LW-2026-1", resp.Results[0].Vulns[0].ID) + assert.NotEmpty(suite.T(), resp.Results[0].Vulns[0].Modified) + assert.Empty(suite.T(), resp.Results[1].Vulns) +} + +func (suite *OsvDevSuite) TestGetVuln() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(suite.advisories(), nil) + + req := httptest.NewRequest(http.MethodGet, "/demo/osvdev/v1/vulns/LW-2026-2", nil) + code, body := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusOK, code) + + var rec api.OsvVulnerability + assert.NoError(suite.T(), json.Unmarshal(body, &rec)) + assert.Equal(suite.T(), "LW-2026-2", rec.ID) +} + +func (suite *OsvDevSuite) TestGetVulnNotFound() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(suite.advisories(), nil) + + req := httptest.NewRequest(http.MethodGet, "/demo/osvdev/v1/vulns/NOPE", nil) + code, _ := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusNotFound, code) +} + +func (suite *OsvDevSuite) TestExportAllZip() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(suite.advisories(), nil) + + req := httptest.NewRequest(http.MethodGet, "/demo/osvdev/all.zip", nil) + code, body := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusOK, code) + + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + assert.NoError(suite.T(), err) + names := make([]string, 0, len(zr.File)) + for _, f := range zr.File { + names = append(names, f.Name) + } + assert.ElementsMatch(suite.T(), []string{"LW-2026-1.json", "LW-2026-2.json"}, names) +} + +func (suite *OsvDevSuite) TestExportEcosystemZip() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(suite.advisories(), nil) + + req := httptest.NewRequest(http.MethodGet, "/demo/osvdev/"+strings.ReplaceAll(osv.DefaultEcosystem, " ", "%20")+"/all.zip", nil) + code, body := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusOK, code) + + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + assert.NoError(suite.T(), err) + assert.Len(suite.T(), zr.File, 2) +} + +func (suite *OsvDevSuite) TestDisabledReturns404() { + req := postJSON("/demo/osvdev/v1/query", `{"package":{"name":"left-pad"}}`) + code, _ := suite.serve(req, false) + assert.Equal(suite.T(), http.StatusNotFound, code) +} + +func (suite *OsvDevSuite) TestDaoError() { + suite.reg.LightwellAdvisory.On("ListForOsv", mock.Anything).Return(nil, &ce.DaoError{Message: "db down"}) + + req := postJSON("/demo/osvdev/v1/query", `{"package":{"name":"left-pad"}}`) + code, _ := suite.serve(req, true) + assert.Equal(suite.T(), http.StatusInternalServerError, code) +} diff --git a/pkg/lightwell/osv/converter.go b/pkg/lightwell/osv/converter.go new file mode 100644 index 000000000..1643bc2b4 --- /dev/null +++ b/pkg/lightwell/osv/converter.go @@ -0,0 +1,237 @@ +// Package osv converts Lightwell advisories into osv.dev-compatible records and +// answers OSV-style version queries against them. It backs the /demo/osvdev mock +// that serves our Lightwell content until it is accepted upstream by osv.dev. +package osv + +import ( + "sort" + "strconv" + "strings" + "time" + + "github.com/content-services/content-sources-backend/pkg/api" + "github.com/content-services/content-sources-backend/pkg/models" +) + +// DefaultEcosystem is used for advisories because LightwellAdvisory has no +// ecosystem column. This is a documented simplification of the mock: all records +// are served under a single ecosystem directory in the static export. +const DefaultEcosystem = "Red Hat" + +// schemaVersion is the OSV schema version we advertise. +const schemaVersion = "1.6.0" + +// Ecosystem returns the OSV ecosystem name for an advisory. +func Ecosystem(_ models.LightwellAdvisory) string { + return DefaultEcosystem +} + +// BuildRecords groups advisories by AdvisoryID and maps each group to a single OSV +// record (one affected[] entry per advisory row, so one OSV id can cover several +// packages). Input order is preserved by advisory id. +func BuildRecords(advisories []models.LightwellAdvisory) []api.OsvVulnerability { + order := make([]string, 0, len(advisories)) + groups := make(map[string][]models.LightwellAdvisory) + for _, a := range advisories { + if _, ok := groups[a.AdvisoryID]; !ok { + order = append(order, a.AdvisoryID) + } + groups[a.AdvisoryID] = append(groups[a.AdvisoryID], a) + } + + records := make([]api.OsvVulnerability, 0, len(order)) + for _, id := range order { + records = append(records, buildRecord(id, groups[id])) + } + return records +} + +func buildRecord(id string, rows []models.LightwellAdvisory) api.OsvVulnerability { + rec := api.OsvVulnerability{ + SchemaVersion: schemaVersion, + ID: id, + } + + var modified, published time.Time + seenRefs := map[string]bool{} + for _, r := range rows { + if modified.IsZero() || r.UpdatedAt.After(modified) { + modified = r.UpdatedAt + } + if published.IsZero() || r.CreatedAt.Before(published) { + published = r.CreatedAt + } + if rec.Details == "" && r.Details != "" { + rec.Details = r.Details + } + if len(rec.Severity) == 0 { + if sev := severity(r.Severity); sev != nil { + rec.Severity = []api.OsvSeverity{*sev} + } + } + rec.Affected = append(rec.Affected, affected(r)) + for _, u := range r.ReferenceURLs { + if u == "" || seenRefs[u] { + continue + } + seenRefs[u] = true + rec.References = append(rec.References, api.OsvReference{Type: "ADVISORY", URL: u}) + } + } + + if !modified.IsZero() { + rec.Modified = modified.UTC().Format(time.RFC3339) + } + if !published.IsZero() { + rec.Published = published.UTC().Format(time.RFC3339) + } + return rec +} + +// severity maps a Lightwell severity to an OSV severity entry. Lightwell stores a +// qualitative label (Critical/Important/...) or a CVSS vector; OSV's score field +// expects a CVSS vector, so we only emit an entry for vectors. +func severity(s string) *api.OsvSeverity { + if strings.HasPrefix(s, "CVSS:") { + return &api.OsvSeverity{Type: "CVSS_V3", Score: s} + } + return nil +} + +func affected(a models.LightwellAdvisory) api.OsvAffected { + events := []api.OsvEvent{{Introduced: "0"}} + for _, v := range a.FixedVersions { + if v == "" { + continue + } + events = append(events, api.OsvEvent{Fixed: v}) + } + return api.OsvAffected{ + Package: api.OsvPackage{Ecosystem: Ecosystem(a), Name: a.PackageName}, + Ranges: []api.OsvRange{{Type: "ECOSYSTEM", Events: events}}, + } +} + +// Matches reports whether a record satisfies an OSV query. commit queries never +// match (we have no GIT ranges). A version query matches when the package name +// (and ecosystem, if supplied) matches an affected entry and the version is not +// yet fixed. +func Matches(rec api.OsvVulnerability, q api.OsvQuery) bool { + if q.Commit != "" { + return false + } + name, ecosystem := queryPackage(q) + if name == "" { + return false + } + for _, aff := range rec.Affected { + if !strings.EqualFold(aff.Package.Name, name) { + continue + } + if ecosystem != "" && !strings.EqualFold(aff.Package.Ecosystem, ecosystem) { + continue + } + if q.Version == "" || versionAffected(q.Version, aff) { + return true + } + } + return false +} + +// queryPackage resolves the package name and ecosystem from a query, preferring +// explicit fields and falling back to a purl. +func queryPackage(q api.OsvQuery) (name, ecosystem string) { + name = strings.TrimSpace(q.Package.Name) + ecosystem = strings.TrimSpace(q.Package.Ecosystem) + if name == "" && q.Package.Purl != "" { + name, _ = parsePurl(q.Package.Purl) + } + return name, ecosystem +} + +// parsePurl extracts the package name and version from a Package URL such as +// "pkg:npm/left-pad@1.3.0". It is intentionally lenient (best-effort for the mock). +func parsePurl(purl string) (name, version string) { + p := strings.TrimPrefix(purl, "pkg:") + if at := strings.LastIndex(p, "@"); at != -1 { + version = p[at+1:] + p = p[:at] + } + if slash := strings.LastIndex(p, "/"); slash != -1 { + p = p[slash+1:] + } + return p, version +} + +// versionAffected reports whether version is affected given an affected entry: +// affected when it is strictly below the lowest fixed version, or when no fixed +// version is known (the whole package is affected). +func versionAffected(version string, aff api.OsvAffected) bool { + var fixed []string + for _, r := range aff.Ranges { + for _, e := range r.Events { + if e.Fixed != "" { + fixed = append(fixed, e.Fixed) + } + } + } + if len(fixed) == 0 { + return true + } + for _, f := range fixed { + if compareVersions(version, f) < 0 { + return true + } + } + return false +} + +// compareVersions compares two dotted version strings segment by segment. Numeric +// segments compare numerically; otherwise they compare lexically. Returns -1, 0, +// or 1. This is a best-effort comparator sufficient for the demo mock. +func compareVersions(a, b string) int { + as := splitVersion(a) + bs := splitVersion(b) + n := len(as) + if len(bs) > n { + n = len(bs) + } + for i := 0; i < n; i++ { + var av, bv string + if i < len(as) { + av = as[i] + } + if i < len(bs) { + bv = bs[i] + } + ai, aErr := strconv.Atoi(av) + bi, bErr := strconv.Atoi(bv) + if aErr == nil && bErr == nil { + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + continue + } + if av != bv { + if av < bv { + return -1 + } + return 1 + } + } + return 0 +} + +func splitVersion(v string) []string { + return strings.FieldsFunc(v, func(r rune) bool { + return r == '.' || r == '-' || r == '+' || r == '~' || r == '_' + }) +} + +// SortRecords orders records by id for stable output (e.g. the static export). +func SortRecords(recs []api.OsvVulnerability) { + sort.Slice(recs, func(i, j int) bool { return recs[i].ID < recs[j].ID }) +} diff --git a/pkg/lightwell/osv/converter_test.go b/pkg/lightwell/osv/converter_test.go new file mode 100644 index 000000000..8fecda031 --- /dev/null +++ b/pkg/lightwell/osv/converter_test.go @@ -0,0 +1,155 @@ +package osv + +import ( + "testing" + "time" + + "github.com/content-services/content-sources-backend/pkg/api" + "github.com/content-services/content-sources-backend/pkg/models" + "github.com/stretchr/testify/assert" +) + +func advisory(id, pkg string, fixed []string, updated time.Time) models.LightwellAdvisory { + a := models.LightwellAdvisory{ + AdvisoryID: id, + PackageName: pkg, + FixedVersions: fixed, + } + a.CreatedAt = updated + a.UpdatedAt = updated + return a +} + +func TestBuildRecordsGroupsByAdvisoryID(t *testing.T) { + t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + advisories := []models.LightwellAdvisory{ + advisory("LW-1", "left-pad", []string{"1.3.0"}, t1), + advisory("LW-1", "right-pad", []string{"2.0.0"}, t2), + advisory("LW-2", "log4j", []string{"2.17.0"}, t1), + } + + recs := BuildRecords(advisories) + assert.Len(t, recs, 2) + + assert.Equal(t, "LW-1", recs[0].ID) + assert.Len(t, recs[0].Affected, 2) + // modified is the max UpdatedAt across the group. + assert.Equal(t, t2.Format(time.RFC3339), recs[0].Modified) + assert.Equal(t, t1.Format(time.RFC3339), recs[0].Published) + + assert.Equal(t, "LW-2", recs[1].ID) + assert.Len(t, recs[1].Affected, 1) +} + +func TestBuildRecordsMapsAffectedAndReferences(t *testing.T) { + a := advisory("LW-1", "left-pad", []string{"1.3.0", "1.2.5"}, time.Now()) + a.ReferenceURLs = []string{"https://example.com/a", "https://example.com/a", "https://example.com/b"} + a.Details = "A bad bug" + + recs := BuildRecords([]models.LightwellAdvisory{a}) + assert.Len(t, recs, 1) + rec := recs[0] + + assert.Equal(t, "A bad bug", rec.Details) + assert.Equal(t, DefaultEcosystem, rec.Affected[0].Package.Ecosystem) + assert.Equal(t, "left-pad", rec.Affected[0].Package.Name) + + events := rec.Affected[0].Ranges[0].Events + assert.Equal(t, "0", events[0].Introduced) + assert.Equal(t, "1.3.0", events[1].Fixed) + assert.Equal(t, "1.2.5", events[2].Fixed) + + // References are de-duplicated. + assert.Len(t, rec.References, 2) + assert.Equal(t, "ADVISORY", rec.References[0].Type) +} + +func TestBuildRecordsSeverityOnlyForVectors(t *testing.T) { + qualitative := advisory("LW-1", "p", nil, time.Now()) + qualitative.Severity = "Critical" + vector := advisory("LW-2", "p", nil, time.Now()) + vector.Severity = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + + recs := BuildRecords([]models.LightwellAdvisory{qualitative, vector}) + assert.Empty(t, recs[0].Severity) + assert.Len(t, recs[1].Severity, 1) + assert.Equal(t, "CVSS_V3", recs[1].Severity[0].Type) +} + +func makeRecord(pkg string, fixed []string) api.OsvVulnerability { + return BuildRecords([]models.LightwellAdvisory{advisory("LW-1", pkg, fixed, time.Now())})[0] +} + +func TestMatches(t *testing.T) { + rec := makeRecord("left-pad", []string{"1.3.0"}) + + tests := []struct { + name string + query api.OsvQuery + want bool + }{ + { + name: "no version matches package", + query: api.OsvQuery{Package: api.OsvPackage{Name: "left-pad"}}, + want: true, + }, + { + name: "version below fix is affected", + query: api.OsvQuery{Version: "1.2.0", Package: api.OsvPackage{Name: "left-pad"}}, + want: true, + }, + { + name: "version at fix is not affected", + query: api.OsvQuery{Version: "1.3.0", Package: api.OsvPackage{Name: "left-pad"}}, + want: false, + }, + { + name: "version above fix is not affected", + query: api.OsvQuery{Version: "1.4.0", Package: api.OsvPackage{Name: "left-pad"}}, + want: false, + }, + { + name: "ecosystem mismatch", + query: api.OsvQuery{Version: "1.2.0", Package: api.OsvPackage{Name: "left-pad", Ecosystem: "npm"}}, + want: false, + }, + { + name: "matching ecosystem", + query: api.OsvQuery{Version: "1.2.0", Package: api.OsvPackage{Name: "left-pad", Ecosystem: DefaultEcosystem}}, + want: true, + }, + { + name: "different package", + query: api.OsvQuery{Version: "1.2.0", Package: api.OsvPackage{Name: "right-pad"}}, + want: false, + }, + { + name: "commit queries never match", + query: api.OsvQuery{Commit: "deadbeef"}, + want: false, + }, + { + name: "purl fallback for name", + query: api.OsvQuery{Version: "1.2.0", Package: api.OsvPackage{Purl: "pkg:npm/left-pad@1.2.0"}}, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, Matches(rec, tc.query)) + }) + } +} + +func TestMatchesNoFixedVersionAlwaysAffected(t *testing.T) { + rec := makeRecord("left-pad", nil) + assert.True(t, Matches(rec, api.OsvQuery{Version: "9.9.9", Package: api.OsvPackage{Name: "left-pad"}})) +} + +func TestCompareVersions(t *testing.T) { + assert.Equal(t, -1, compareVersions("1.2.0", "1.3.0")) + assert.Equal(t, 1, compareVersions("1.10.0", "1.9.0")) + assert.Equal(t, 0, compareVersions("1.2.0", "1.2.0")) + assert.Equal(t, -1, compareVersions("1.2", "1.2.1")) +} diff --git a/pkg/middleware/enforce_identity.go b/pkg/middleware/enforce_identity.go index 5d952766c..89c4a1bed 100644 --- a/pkg/middleware/enforce_identity.go +++ b/pkg/middleware/enforce_identity.go @@ -88,6 +88,12 @@ func getPath(c echo.Context) (path string) { } func SkipMiddleware(c echo.Context) bool { + // The osv.dev demo feed is public. Its routes are rooted at /demo/osvdev + // (outside the API group), so match the raw route before getPath, which + // strips the API prefix and would mangle these paths. + if strings.HasPrefix(MatchedRoute(c), "/demo/osvdev") { + return true + } p := getPath(c) if SkipRbac(c, p) || SkipAuth(p) { return true