diff --git a/api/docs.go b/api/docs.go index 3f4ab2a18..9aa4cfed7 100644 --- a/api/docs.go +++ b/api/docs.go @@ -5471,6 +5471,9 @@ const docTemplate = `{ "api.LightwellPackageVersionResponse": { "type": "object", "properties": { + "coordinates": { + "type": "string" + }, "created_at": { "type": "string" }, @@ -5483,6 +5486,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "purl": { + "type": "string" + }, "release": { "type": "string" }, diff --git a/api/openapi.json b/api/openapi.json index b2594c632..d8492e0a7 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -470,6 +470,9 @@ }, "api.LightwellPackageVersionResponse": { "properties": { + "coordinates": { + "type": "string" + }, "created_at": { "type": "string" }, @@ -482,6 +485,9 @@ "name": { "type": "string" }, + "purl": { + "type": "string" + }, "release": { "type": "string" }, diff --git a/pkg/api/lightwell_packages.go b/pkg/api/lightwell_packages.go index 4bf1e6fbd..9f951ad80 100644 --- a/pkg/api/lightwell_packages.go +++ b/pkg/api/lightwell_packages.go @@ -33,6 +33,8 @@ type LightwellPackageVersionResponse struct { RepositoryUUID string `json:"repository_uuid"` Release string `json:"release,omitempty"` CreatedAt string `json:"created_at,omitempty"` + Purl string `json:"purl"` + Coordinates string `json:"coordinates"` } // LightwellPackageVersionCollectionResponse is a paginated collection of cross-repo package versions. diff --git a/pkg/handler/lightwell_advisories.go b/pkg/handler/lightwell_advisories.go index cafcee349..a12e41809 100644 --- a/pkg/handler/lightwell_advisories.go +++ b/pkg/handler/lightwell_advisories.go @@ -24,6 +24,7 @@ func RegisterLightwellAdvisoryRoutes(engine *echo.Group, daoReg *dao.DaoRegistry FeatureServiceClient: *fsClient, } addRepoRoute(engine, http.MethodGet, "/lightwell/advisories", h.list, rbac.RbacVerbRead) + addRepoRoute(engine, http.MethodGet, "/lightwell/repositories/:repository_name/advisories", h.listRepoAdvisories, rbac.RbacVerbRead) } // listLightwellAdvisories godoc @@ -105,3 +106,9 @@ func parseLightwellAdvisoryFilters(c echo.Context) api.LightwellAdvisoryFilterDa BindError() return filters } + +func (h *LightwellAdvisoryHandler) listRepoAdvisories(c echo.Context) error { + repoName := c.Param("repository_name") + c.QueryParams().Set("repository", repoName) + return h.list(c) +} diff --git a/pkg/handler/lightwell_advisories_test.go b/pkg/handler/lightwell_advisories_test.go index faf4fcb11..ac25355bb 100644 --- a/pkg/handler/lightwell_advisories_test.go +++ b/pkg/handler/lightwell_advisories_test.go @@ -178,6 +178,45 @@ func (s *LightwellAdvisorySuite) TestListAdvisoriesFilterByRepoName() { assert.Equal(t, http.StatusOK, code) } +func (s *LightwellAdvisorySuite) TestNestedRepoAdvisoriesAlias() { + t := s.T() + s.stubLightwellAccess() + + data := []api.LightwellAdvisoryResponse{ + { + AdvisoryID: "CVE-2024-5678", + Severity: "important", + Details: "Test advisory via nested route", + ReferenceURLs: []string{}, + PackageName: "spring-core", + FixedVersions: []string{"5.3.18.rhlw-00003"}, + Repository: "java-remediated", + }, + } + + s.reg.LightwellAdvisory.On("ListAdvisories", test.MockCtx(), mock.MatchedBy(func(opts dao.ListLightwellAdvisoriesOptions) bool { + return opts.RepoName != nil && *opts.RepoName == "java-remediated" && + len(opts.EntitledFeatures) == 1 && opts.EntitledFeatures[0] == "lightwell-network" + })).Return(data, int64(1), nil) + + path := fmt.Sprintf("%s/lightwell/repositories/java-remediated/advisories", api.FullRootPath()) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) + + code, body, err := s.serveRouter(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + var resp api.LightwellAdvisoryCollectionResponse + err = json.Unmarshal(body, &resp) + require.NoError(t, err) + + assert.Equal(t, int64(1), resp.Meta.Count) + assert.Len(t, resp.Data, 1) + assert.Equal(t, "CVE-2024-5678", resp.Data[0].AdvisoryID) + assert.Equal(t, "java-remediated", resp.Data[0].Repository) +} + func (s *LightwellAdvisorySuite) TestListAdvisoriesNoFeatureAccess() { t := s.T() diff --git a/pkg/handler/lightwell_packages.go b/pkg/handler/lightwell_packages.go index 5e125ba79..5bfd9b49d 100644 --- a/pkg/handler/lightwell_packages.go +++ b/pkg/handler/lightwell_packages.go @@ -34,6 +34,8 @@ func RegisterLightwellPackageRoutes(engine *echo.Group, daoReg *dao.DaoRegistry, } addRepoRoute(engine, http.MethodGet, "/lightwell/packages", h.listPackages, rbac.RbacVerbRead) addRepoRoute(engine, http.MethodGet, "/lightwell/package_versions", h.listPackageVersions, rbac.RbacVerbRead) + addRepoRoute(engine, http.MethodGet, "/lightwell/repositories/:repository_name/packages", h.listRepoPackages, rbac.RbacVerbRead) + addRepoRoute(engine, http.MethodGet, "/lightwell/repositories/:repository_name/package_versions", h.listRepoPackageVersions, rbac.RbacVerbRead) } // listLightwellPackages godoc @@ -221,7 +223,6 @@ func (h *LightwellPackagesHandler) fetchPackagesFromRepo(ctx context.Context, re return nil, err } - // Fetch all packages from this repo (no server-side pagination — small datasets) pageOpts := tangy.PageOptions{Offset: 0, Limit: MaxLimit} switch repo.ContentType { @@ -355,19 +356,22 @@ func (h *LightwellPackagesHandler) filterVersionsByResolvingCve(ctx context.Cont return nil, err } - fixedSet := make(map[string]map[string]bool) + type repoPackage struct{ repo, name string } + fixedSet := make(map[repoPackage]map[string]bool) for _, m := range matches { - if fixedSet[m.PackageName] == nil { - fixedSet[m.PackageName] = make(map[string]bool) + key := repoPackage{repo: m.RepoName, name: m.PackageName} + if fixedSet[key] == nil { + fixedSet[key] = make(map[string]bool) } for _, v := range m.FixedVersions { - fixedSet[m.PackageName][v] = true + fixedSet[key][v] = true } } var result []api.LightwellPackageVersionResponse for _, item := range items { - if versions, ok := fixedSet[item.Name]; ok && versions[item.Version] { + key := repoPackage{repo: item.Repository, name: item.Name} + if versions, ok := fixedSet[key]; ok && versions[item.Version] { result = append(result, item) } } @@ -382,27 +386,65 @@ func (h *LightwellPackagesHandler) filterVersionsByVulnerableCve(ctx context.Con return nil, err } - affectedPackages := make(map[string]bool) - fixedSet := make(map[string]map[string]bool) + type repoPackage struct{ repo, name string } + affectedPackages := make(map[repoPackage]bool) + fixedSet := make(map[repoPackage]map[string]bool) for _, m := range matches { - affectedPackages[m.PackageName] = true - if fixedSet[m.PackageName] == nil { - fixedSet[m.PackageName] = make(map[string]bool) + key := repoPackage{repo: m.RepoName, name: m.PackageName} + affectedPackages[key] = true + if fixedSet[key] == nil { + fixedSet[key] = make(map[string]bool) } for _, v := range m.FixedVersions { - fixedSet[m.PackageName][v] = true + fixedSet[key][v] = true } } var result []api.LightwellPackageVersionResponse for _, item := range items { - if affectedPackages[item.Name] && !fixedSet[item.Name][item.Version] { + key := repoPackage{repo: item.Repository, name: item.Name} + if affectedPackages[key] && !fixedSet[key][item.Version] { result = append(result, item) } } return result, nil } +// --- PURL / coordinate builders --- + +func buildPURL(contentType, group, name, version string) string { + switch contentType { + case config.ContentTypeMaven: + return fmt.Sprintf("pkg:maven/%s/%s@%s", group, name, version) + case config.ContentTypePython: + return fmt.Sprintf("pkg:pypi/%s@%s", name, version) + case config.ContentTypeNpm: + if group == "-" || group == "" { + return fmt.Sprintf("pkg:npm/%s@%s", name, version) + } + scope := strings.TrimPrefix(group, "@") + return fmt.Sprintf("pkg:npm/%%40%s/%s@%s", scope, name, version) + default: + return "" + } +} + +func buildCoordinates(contentType, group, name string) string { + switch contentType { + case config.ContentTypeMaven: + return fmt.Sprintf("%s:%s", group, name) + case config.ContentTypePython: + return name + case config.ContentTypeNpm: + if group == "-" || group == "" { + return name + } + return fmt.Sprintf("%s/%s", group, name) + default: + return "" + } +} + // --- mapping helpers --- func mapMavenToLightwellPackages(resp tangy.MavenPackageListResponse, repo api.RepositoryResponse) []api.LightwellPackageResponse { @@ -477,6 +519,8 @@ func expandMavenVersions(resp tangy.MavenPackageListResponse, repo api.Repositor Ecosystem: config.ContentTypeMaven, Repository: repo.Name, RepositoryUUID: repo.UUID, + Purl: buildPURL(config.ContentTypeMaven, item.GroupID, item.ArtifactID, v), + Coordinates: buildCoordinates(config.ContentTypeMaven, item.GroupID, item.ArtifactID), } if rel, ok := relMap[v]; ok { ver.Release = rel.Release @@ -499,6 +543,8 @@ func expandPythonVersions(resp tangy.PythonPackageListResponse, repo api.Reposit Ecosystem: config.ContentTypePython, Repository: repo.Name, RepositoryUUID: repo.UUID, + Purl: buildPURL(config.ContentTypePython, "", item.NameNormalized, v), + Coordinates: buildCoordinates(config.ContentTypePython, "", item.NameNormalized), } if info, ok := verMap[v]; ok { ver.CreatedAt = info.CreatedAt @@ -522,6 +568,8 @@ func expandNpmVersions(resp tangy.NpmPackageListResponse, repo api.RepositoryRes Ecosystem: config.ContentTypeNpm, Repository: repo.Name, RepositoryUUID: repo.UUID, + Purl: buildPURL(config.ContentTypeNpm, scope, name, v), + Coordinates: buildCoordinates(config.ContentTypeNpm, scope, name), } if info, ok := verMap[v]; ok { ver.CreatedAt = info.CreatedAt @@ -641,6 +689,20 @@ func npmVersionMap(versions []tangy.NpmVersionInfo) map[string]versionCreatedAt return m } +// --- nested repo-scoped alias handlers --- + +func (h *LightwellPackagesHandler) listRepoPackages(c echo.Context) error { + repoName := c.Param("repository_name") + c.QueryParams().Set("repository", repoName) + return h.listPackages(c) +} + +func (h *LightwellPackagesHandler) listRepoPackageVersions(c echo.Context) error { + repoName := c.Param("repository_name") + c.QueryParams().Set("repository", repoName) + return h.listPackageVersions(c) +} + // --- sort helpers --- func sortLightwellPackages(items []api.LightwellPackageResponse, sortBy string) { diff --git a/pkg/handler/lightwell_packages_test.go b/pkg/handler/lightwell_packages_test.go index e90e97fe0..22cd22ac6 100644 --- a/pkg/handler/lightwell_packages_test.go +++ b/pkg/handler/lightwell_packages_test.go @@ -117,6 +117,48 @@ func mavenTangResponse() tangy.MavenPackageListResponse { } } +func newNpmRepo() api.RepositoryResponse { + return api.RepositoryResponse{ + UUID: "ggg-hhh-iii", + Name: "lightwell/npm/remediated", + ContentType: config.ContentTypeNpm, + Origin: config.OriginLightwell, + SecurityLevel: "remediated", + PublishedDistBasePath: "npm/remediated", + OrgID: test_handler.MockOrgId, + } +} + +func npmScopedTangResponse() tangy.NpmPackageListResponse { + return tangy.NpmPackageListResponse{ + Results: []tangy.NpmPackageListItem{ + { + Name: "@types/is-odd", + Versions: []string{"3.0.0.rhlw-00001"}, + LatestVersions: []tangy.NpmVersionInfo{ + {Version: "3.0.0.rhlw-00001", CreatedAt: "2024-07-01T10:00:00Z"}, + }, + }, + }, + Total: 1, Limit: 200, Offset: 0, + } +} + +func npmUnscopedTangResponse() tangy.NpmPackageListResponse { + return tangy.NpmPackageListResponse{ + Results: []tangy.NpmPackageListItem{ + { + Name: "lodash", + Versions: []string{"4.17.21.rhlw-00001"}, + LatestVersions: []tangy.NpmVersionInfo{ + {Version: "4.17.21.rhlw-00001", CreatedAt: "2024-07-02T10:00:00Z"}, + }, + }, + }, + Total: 1, Limit: 200, Offset: 0, + } +} + func pythonTangResponse() tangy.PythonPackageListResponse { return tangy.PythonPackageListResponse{ Results: []tangy.PythonPackageListItem{ @@ -211,7 +253,6 @@ func (s *LightwellPackagesSuite) TestListPackagesTypeFilter() { t := s.T() mavenRepo := newMavenRepo() - // Only maven repo should be returned when filtering by ecosystem=maven s.reg.RepositoryConfig.On( "List", test.MockCtx(), test_handler.MockOrgId, mock.MatchedBy(func(p api.PaginationData) bool { return p.Limit == MaxLimit }), @@ -302,6 +343,8 @@ func (s *LightwellPackagesSuite) TestListPackageVersionsSingleRepo() { assert.Len(t, resp.Data, 2) assert.Equal(t, "jackson-databind", resp.Data[0].Name) assert.Equal(t, config.ContentTypeMaven, resp.Data[0].Ecosystem) + assert.Equal(t, "pkg:maven/com.fasterxml.jackson.core/jackson-databind@"+resp.Data[0].Version, resp.Data[0].Purl) + assert.Equal(t, "com.fasterxml.jackson.core:jackson-databind", resp.Data[0].Coordinates) } func (s *LightwellPackagesSuite) TestListPackageVersionsWithNameFilter() { @@ -340,7 +383,6 @@ func (s *LightwellPackagesSuite) TestListPackageVersionsPagination() { tangy.MavenPackageListFilters{}, tangy.PageOptions{Offset: 0, Limit: MaxLimit}, ).Return(mavenTangResponse(), nil) - // Request with limit=1&offset=0 — should get 1 of 2 versions path := fmt.Sprintf("%s/lightwell/package_versions?limit=1&offset=0", api.FullRootPath()) req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) @@ -426,6 +468,8 @@ func (s *LightwellPackagesSuite) TestListPackageVersionsResolvesCveFilter() { assert.Len(t, resp.Data, 1) assert.Equal(t, "jackson-databind", resp.Data[0].Name) assert.Equal(t, "2.15.3.rhlw-00001", resp.Data[0].Version) + assert.Equal(t, "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.15.3.rhlw-00001", resp.Data[0].Purl) + assert.Equal(t, "com.fasterxml.jackson.core:jackson-databind", resp.Data[0].Coordinates) } func (s *LightwellPackagesSuite) TestListPackageVersionsVulnerableToCveFilter() { @@ -439,8 +483,6 @@ func (s *LightwellPackagesSuite) TestListPackageVersionsVulnerableToCveFilter() tangy.MavenPackageListFilters{}, tangy.PageOptions{Offset: 0, Limit: MaxLimit}, ).Return(mavenTangResponse(), nil) - // Advisory says jackson-databind is fixed at 2.15.3.rhlw-00001, so - // the older version 2.14.2.rhlw-00001 should be returned as vulnerable. s.reg.LightwellAdvisory.On("ListAdvisoriesByCveID", test.MockCtx(), "CVE-2024-8888").Return([]dao.LightwellAdvisoryCveMatch{ { PackageName: "jackson-databind", @@ -466,3 +508,158 @@ func (s *LightwellPackagesSuite) TestListPackageVersionsVulnerableToCveFilter() assert.Equal(t, "jackson-databind", resp.Data[0].Name) assert.Equal(t, "2.14.2.rhlw-00001", resp.Data[0].Version) } + +// --- nested repo-scoped alias tests --- + +func (s *LightwellPackagesSuite) TestNestedRepoPackagesAlias() { + t := s.T() + + mavenRepo := newMavenRepo() + mavenRepo.Name = "java-remediated" + s.reg.RepositoryConfig.On( + "List", test.MockCtx(), test_handler.MockOrgId, + mock.MatchedBy(func(p api.PaginationData) bool { return p.Limit == MaxLimit }), + mock.MatchedBy(func(f api.FilterData) bool { return f.Origin == config.OriginLightwell }), + ).Return(api.RepositoryCollectionResponse{Data: []api.RepositoryResponse{mavenRepo}}, int64(1), nil) + + href := "/api/pulp/repos/maven/1/" + s.stubRepoHref(mavenRepo, href) + s.tangClient.On("MavenPackageList", test.MockCtx(), href, + tangy.MavenPackageListFilters{}, tangy.PageOptions{Offset: 0, Limit: MaxLimit}, + ).Return(mavenTangResponse(), nil) + + path := fmt.Sprintf("%s/lightwell/repositories/java-remediated/packages", api.FullRootPath()) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) + + code, body, err := s.serveRouter(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + var resp api.LightwellPackageCollectionResponse + require.NoError(t, json.Unmarshal(body, &resp)) + + assert.Equal(t, int64(1), resp.Meta.Count) + assert.Len(t, resp.Data, 1) + assert.Equal(t, "jackson-databind", resp.Data[0].Name) +} + +func (s *LightwellPackagesSuite) TestNestedRepoPackageVersionsAlias() { + t := s.T() + + mavenRepo := newMavenRepo() + mavenRepo.Name = "java-remediated" + s.reg.RepositoryConfig.On( + "List", test.MockCtx(), test_handler.MockOrgId, + mock.MatchedBy(func(p api.PaginationData) bool { return p.Limit == MaxLimit }), + mock.MatchedBy(func(f api.FilterData) bool { return f.Origin == config.OriginLightwell }), + ).Return(api.RepositoryCollectionResponse{Data: []api.RepositoryResponse{mavenRepo}}, int64(1), nil) + + href := "/api/pulp/repos/maven/1/" + s.stubRepoHref(mavenRepo, href) + s.tangClient.On("MavenPackageList", test.MockCtx(), href, + tangy.MavenPackageListFilters{}, tangy.PageOptions{Offset: 0, Limit: MaxLimit}, + ).Return(mavenTangResponse(), nil) + + path := fmt.Sprintf("%s/lightwell/repositories/java-remediated/package_versions", api.FullRootPath()) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) + + code, body, err := s.serveRouter(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + var resp api.LightwellPackageVersionCollectionResponse + require.NoError(t, json.Unmarshal(body, &resp)) + + assert.Equal(t, int64(2), resp.Meta.Count) + assert.Len(t, resp.Data, 2) +} + +// --- npm PURL / coordinates tests --- + +func (s *LightwellPackagesSuite) TestListPackageVersionsNpmScoped() { + t := s.T() + + npmRepo := newNpmRepo() + s.stubLightwellRepos([]api.RepositoryResponse{npmRepo}) + href := "/api/pulp/repos/npm/1/" + s.stubRepoHref(npmRepo, href) + s.tangClient.On("NpmPackageList", test.MockCtx(), href, + tangy.NpmPackageListFilters{}, tangy.PageOptions{Offset: 0, Limit: MaxLimit}, + ).Return(npmScopedTangResponse(), nil) + + path := fmt.Sprintf("%s/lightwell/package_versions", api.FullRootPath()) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) + + code, body, err := s.serveRouter(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + var resp api.LightwellPackageVersionCollectionResponse + require.NoError(t, json.Unmarshal(body, &resp)) + + assert.Len(t, resp.Data, 1) + assert.Equal(t, "is-odd", resp.Data[0].Name) + assert.Equal(t, "@types", resp.Data[0].Group) + assert.Equal(t, "pkg:npm/%40types/is-odd@3.0.0.rhlw-00001", resp.Data[0].Purl) + assert.Equal(t, "@types/is-odd", resp.Data[0].Coordinates) +} + +func (s *LightwellPackagesSuite) TestListPackageVersionsNpmUnscoped() { + t := s.T() + + npmRepo := newNpmRepo() + s.stubLightwellRepos([]api.RepositoryResponse{npmRepo}) + href := "/api/pulp/repos/npm/1/" + s.stubRepoHref(npmRepo, href) + s.tangClient.On("NpmPackageList", test.MockCtx(), href, + tangy.NpmPackageListFilters{}, tangy.PageOptions{Offset: 0, Limit: MaxLimit}, + ).Return(npmUnscopedTangResponse(), nil) + + path := fmt.Sprintf("%s/lightwell/package_versions", api.FullRootPath()) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) + + code, body, err := s.serveRouter(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + var resp api.LightwellPackageVersionCollectionResponse + require.NoError(t, json.Unmarshal(body, &resp)) + + assert.Len(t, resp.Data, 1) + assert.Equal(t, "lodash", resp.Data[0].Name) + assert.Equal(t, "-", resp.Data[0].Group) + assert.Equal(t, "pkg:npm/lodash@4.17.21.rhlw-00001", resp.Data[0].Purl) + assert.Equal(t, "lodash", resp.Data[0].Coordinates) +} + +func (s *LightwellPackagesSuite) TestListPackageVersionsPythonPurl() { + t := s.T() + + pythonRepo := newPythonRepo() + s.stubLightwellRepos([]api.RepositoryResponse{pythonRepo}) + href := "/api/pulp/repos/python/1/" + s.stubRepoHref(pythonRepo, href) + s.tangClient.On("PythonPackageList", test.MockCtx(), href, + tangy.PythonPackageListFilters{}, tangy.PageOptions{Offset: 0, Limit: MaxLimit}, + ).Return(pythonTangResponse(), nil) + + path := fmt.Sprintf("%s/lightwell/package_versions", api.FullRootPath()) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) + + code, body, err := s.serveRouter(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + var resp api.LightwellPackageVersionCollectionResponse + require.NoError(t, json.Unmarshal(body, &resp)) + + assert.Len(t, resp.Data, 1) + assert.Equal(t, "requests", resp.Data[0].Name) + assert.Equal(t, "pkg:pypi/requests@2.31.0.rhlw-00001", resp.Data[0].Purl) + assert.Equal(t, "requests", resp.Data[0].Coordinates) +}