diff --git a/internal/clients/clientimpl/osvmatcher/osvmatcher.go b/internal/clients/clientimpl/osvmatcher/osvmatcher.go index 6f3b50a4f79..3ee135c9b5b 100644 --- a/internal/clients/clientimpl/osvmatcher/osvmatcher.go +++ b/internal/clients/clientimpl/osvmatcher/osvmatcher.go @@ -12,6 +12,7 @@ import ( "github.com/google/osv-scanner/v2/internal/cachedregexp" "github.com/google/osv-scanner/v2/internal/cmdlogger" "github.com/google/osv-scanner/v2/internal/imodels" + "github.com/google/osv-scanner/v2/internal/tuxcare" "github.com/ossf/osv-schema/bindings/go/osvconstants" "github.com/ossf/osv-schema/bindings/go/osvschema" "golang.org/x/sync/errgroup" @@ -139,6 +140,19 @@ func (matcher *OSVMatcher) MatchVulnerabilities(ctx context.Context, pkgs []*ext } func pkgToQuery(pkg *extractor.Package) *api.Query { + // Route vendor-rebuilt packages (e.g. TuxCare) before the base-ecosystem gate + // below. This must run even when the package has no base ecosystem — scalibr + // leaves CentOS/Oracle RPMs without one — so it cannot be gated on a non-empty + // base ecosystem. routedQueryPackage already requires a name and version. + if routed := routedQueryPackage(pkg); routed != nil { + return &api.Query{ + Package: routed, + Param: &api.Query_Version{ + Version: tuxcare.QueryVersion(pkg), + }, + } + } + if imodels.Name(pkg) != "" && !imodels.Ecosystem(pkg).IsEmpty() && imodels.Version(pkg) != "" { name := imodels.Name(pkg) diff --git a/internal/clients/clientimpl/osvmatcher/osvmatcher_test.go b/internal/clients/clientimpl/osvmatcher/osvmatcher_test.go index 1e4e7b410c1..80a0ed963b9 100644 --- a/internal/clients/clientimpl/osvmatcher/osvmatcher_test.go +++ b/internal/clients/clientimpl/osvmatcher/osvmatcher_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "testing" "time" @@ -17,6 +18,8 @@ import ( "google.golang.org/protobuf/proto" "osv.dev/bindings/go/api" "osv.dev/bindings/go/osvdev" + + "github.com/google/osv-scanner/v2/internal/imodels" ) func TestOSVMatcher_MatchVulnerabilities(t *testing.T) { @@ -125,6 +128,63 @@ func writeProtoJSON(t *testing.T, w http.ResponseWriter, msg proto.Message) { } } +func TestOSVMatcher_RoutesTuxCarePackageToTuxCareEcosystem(t *testing.T) { + t.Parallel() + + var gotEcosystem, gotName, gotVersion string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case osvdev.QueryBatchEndpoint: + var req api.BatchQuery + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(mustReadAll(t, r), &req); err != nil { + t.Fatalf("failed to decode query batch: %v", err) + } + q := req.GetQueries()[0] + gotEcosystem = q.GetPackage().GetEcosystem() + gotName = q.GetPackage().GetName() + gotVersion = q.GetVersion() + writeProtoJSON(t, w, &api.BatchVulnerabilityList{ + Results: []*api.VulnerabilityList{ + {Vulns: []*osvschema.Vulnerability{{Id: "CLSA-2023-1703184336"}}}, + }, + }) + case osvdev.GetEndpoint + "/CLSA-2023-1703184336": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "CLSA-2023-1703184336"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + matcher := &OSVMatcher{ + Client: osvdev.OSVClient{ + HTTPClient: ts.Client(), + Config: osvdev.DefaultConfig(), + BaseHostURL: ts.URL, + }, + } + + got, err := matcher.MatchVulnerabilities(t.Context(), []*extractor.Package{ + dpkgPkg("squid-cgi", "squid", "3.5.27-1ubuntu1.14+tuxcare.els3", "ubuntu", "18.04"), + }) + if err != nil { + t.Fatalf("MatchVulnerabilities() error = %v", err) + } + + if gotEcosystem != "TuxCare:Ubuntu:18.04" { + t.Errorf("query ecosystem = %q, want %q", gotEcosystem, "TuxCare:Ubuntu:18.04") + } + if gotName != "squid-cgi" { + t.Errorf("query name = %q, want %q", gotName, "squid-cgi") + } + if gotVersion != "3.5.27-1ubuntu1.14+tuxcare.els3" { + t.Errorf("query version = %q, want %q", gotVersion, "3.5.27-1ubuntu1.14+tuxcare.els3") + } + if len(got) != 1 || len(got[0]) != 1 || got[0][0].GetId() != "CLSA-2023-1703184336" { + t.Fatalf("unexpected vulnerabilities: got %#v", got) + } +} + func TestOSVMatcher_MatchVulnerabilitiesDeduplicatesBulkQueries(t *testing.T) { t.Parallel() @@ -180,3 +240,323 @@ func TestOSVMatcher_MatchVulnerabilitiesDeduplicatesBulkQueries(t *testing.T) { t.Fatalf("unexpected vulnerabilities: got %#v", got) } } + +func TestOSVMatcher_UnmarkedDpkgPackageUsesBaseEcosystem(t *testing.T) { + t.Parallel() + + pkg := dpkgPkg("squid", "squid", "3.5.27-1ubuntu1.14", "ubuntu", "16.04") + + var gotEcosystem, gotName string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case osvdev.QueryBatchEndpoint: + var req api.BatchQuery + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(mustReadAll(t, r), &req); err != nil { + t.Fatalf("failed to decode query batch: %v", err) + } + q := req.GetQueries()[0] + gotEcosystem = q.GetPackage().GetEcosystem() + gotName = q.GetPackage().GetName() + writeProtoJSON(t, w, &api.BatchVulnerabilityList{ + Results: []*api.VulnerabilityList{ + {Vulns: []*osvschema.Vulnerability{{Id: "USN-TEST"}}}, + }, + }) + case osvdev.GetEndpoint + "/USN-TEST": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "USN-TEST"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + matcher := &OSVMatcher{ + Client: osvdev.OSVClient{ + HTTPClient: ts.Client(), + Config: osvdev.DefaultConfig(), + BaseHostURL: ts.URL, + }, + } + + _, err := matcher.MatchVulnerabilities(t.Context(), []*extractor.Package{pkg}) + if err != nil { + t.Fatalf("MatchVulnerabilities() error = %v", err) + } + + if strings.HasPrefix(gotEcosystem, "TuxCare") { + t.Errorf("query ecosystem = %q, must not be TuxCare-routed for unmarked package", gotEcosystem) + } + wantEcosystem := imodels.Ecosystem(pkg).String() + if gotEcosystem != wantEcosystem { + t.Errorf("query ecosystem = %q, want base ecosystem %q", gotEcosystem, wantEcosystem) + } + wantName := imodels.Name(pkg) + if gotName != wantName { + t.Errorf("query name = %q, want source name %q", gotName, wantName) + } +} + +func TestOSVMatcher_RoutesTuxCareDebianPackageToTuxCareEcosystem(t *testing.T) { + t.Parallel() + + var gotEcosystem, gotName, gotVersion string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case osvdev.QueryBatchEndpoint: + var req api.BatchQuery + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(mustReadAll(t, r), &req); err != nil { + t.Fatalf("failed to decode query batch: %v", err) + } + q := req.GetQueries()[0] + gotEcosystem = q.GetPackage().GetEcosystem() + gotName = q.GetPackage().GetName() + gotVersion = q.GetVersion() + writeProtoJSON(t, w, &api.BatchVulnerabilityList{ + Results: []*api.VulnerabilityList{ + {Vulns: []*osvschema.Vulnerability{{Id: "CLSA-DEBIAN-TEST"}}}, + }, + }) + case osvdev.GetEndpoint + "/CLSA-DEBIAN-TEST": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "CLSA-DEBIAN-TEST"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + matcher := &OSVMatcher{ + Client: osvdev.OSVClient{ + HTTPClient: ts.Client(), + Config: osvdev.DefaultConfig(), + BaseHostURL: ts.URL, + }, + } + + _, err := matcher.MatchVulnerabilities(t.Context(), []*extractor.Package{ + dpkgPkg("binutils", "binutils", "2.31.1-16+tuxcare.els11", "debian", "10"), + }) + if err != nil { + t.Fatalf("MatchVulnerabilities() error = %v", err) + } + + if gotEcosystem != "TuxCare:Debian:10" { + t.Errorf("query ecosystem = %q, want %q", gotEcosystem, "TuxCare:Debian:10") + } + if gotName != "binutils" { + t.Errorf("query name = %q, want %q", gotName, "binutils") + } + if gotVersion != "2.31.1-16+tuxcare.els11" { + t.Errorf("query version = %q, want %q", gotVersion, "2.31.1-16+tuxcare.els11") + } +} + +func TestOSVMatcher_RoutedQueryPreservesEpoch(t *testing.T) { + t.Parallel() + + var gotVersion, gotEcosystem string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case osvdev.QueryBatchEndpoint: + var req api.BatchQuery + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(mustReadAll(t, r), &req); err != nil { + t.Fatalf("failed to decode query batch: %v", err) + } + q := req.GetQueries()[0] + gotVersion = q.GetVersion() + gotEcosystem = q.GetPackage().GetEcosystem() + writeProtoJSON(t, w, &api.BatchVulnerabilityList{ + Results: []*api.VulnerabilityList{ + {Vulns: []*osvschema.Vulnerability{{Id: "CLSA-EPOCH-TEST"}}}, + }, + }) + case osvdev.GetEndpoint + "/CLSA-EPOCH-TEST": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "CLSA-EPOCH-TEST"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + matcher := &OSVMatcher{ + Client: osvdev.OSVClient{ + HTTPClient: ts.Client(), + Config: osvdev.DefaultConfig(), + BaseHostURL: ts.URL, + }, + } + + _, err := matcher.MatchVulnerabilities(t.Context(), []*extractor.Package{ + dpkgPkg("dbus", "dbus", "2:1.10.6-1ubuntu3.6+tuxcare.els2", "ubuntu", "16.04"), + }) + if err != nil { + t.Fatalf("MatchVulnerabilities() error = %v", err) + } + + if gotVersion != "2:1.10.6-1ubuntu3.6+tuxcare.els2" { + t.Errorf("query version = %q, want epoch-intact version %q", gotVersion, "2:1.10.6-1ubuntu3.6+tuxcare.els2") + } + if gotEcosystem != "TuxCare:Ubuntu:16.04" { + t.Errorf("query ecosystem = %q, want %q", gotEcosystem, "TuxCare:Ubuntu:16.04") + } +} + +func TestOSVMatcher_RoutesCentOS7RpmWithEmptyBaseEcosystem(t *testing.T) { + t.Parallel() + + // Precondition: scalibr gives CentOS RPMs no base ecosystem, so the pkgToQuery + // base-ecosystem gate would drop them without the routing-first fix. + pkg := rpmPkg("glibc", "2.17-326.el7.tuxcare.els2", "centos", "7", "CentOS Linux", "cpe:/o:centos:centos:7") + if !imodels.Ecosystem(pkg).IsEmpty() { + t.Fatalf("precondition: expected empty base ecosystem for CentOS rpm, got %q", imodels.Ecosystem(pkg).String()) + } + + var gotEcosystem, gotName, gotVersion string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case osvdev.QueryBatchEndpoint: + var req api.BatchQuery + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(mustReadAll(t, r), &req); err != nil { + t.Fatalf("failed to decode query batch: %v", err) + } + q := req.GetQueries()[0] + gotEcosystem = q.GetPackage().GetEcosystem() + gotName = q.GetPackage().GetName() + gotVersion = q.GetVersion() + writeProtoJSON(t, w, &api.BatchVulnerabilityList{ + Results: []*api.VulnerabilityList{ + {Vulns: []*osvschema.Vulnerability{{Id: "CLSA-2024-1700000000"}}}, + }, + }) + case osvdev.GetEndpoint + "/CLSA-2024-1700000000": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "CLSA-2024-1700000000"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + matcher := &OSVMatcher{ + Client: osvdev.OSVClient{ + HTTPClient: ts.Client(), + Config: osvdev.DefaultConfig(), + BaseHostURL: ts.URL, + }, + } + + got, err := matcher.MatchVulnerabilities(t.Context(), []*extractor.Package{pkg}) + if err != nil { + t.Fatalf("MatchVulnerabilities() error = %v", err) + } + + if gotEcosystem != "TuxCare:CentOS:7" { + t.Errorf("query ecosystem = %q, want %q", gotEcosystem, "TuxCare:CentOS:7") + } + if gotName != "glibc" { + t.Errorf("query name = %q, want %q", gotName, "glibc") + } + if gotVersion != "2.17-326.el7.tuxcare.els2" { + t.Errorf("query version = %q, want %q", gotVersion, "2.17-326.el7.tuxcare.els2") + } + if len(got) != 1 || len(got[0]) != 1 || got[0][0].GetId() != "CLSA-2024-1700000000" { + t.Fatalf("unexpected vulnerabilities: got %#v", got) + } +} + +func TestOSVMatcher_RoutesStampedCentOS8Rpm(t *testing.T) { + t.Parallel() + pkg := rpmPkg("openssl", "1.1.1g-15.el8.tuxcare.els8", "centos", "8.5", "CentOS Linux", "cpe:/o:centos:centos:8") + if !imodels.Ecosystem(pkg).IsEmpty() { + t.Fatalf("precondition: expected empty base ecosystem for CentOS rpm") + } + var gotEcosystem, gotName string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case osvdev.QueryBatchEndpoint: + var req api.BatchQuery + _ = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(mustReadAll(t, r), &req) + gotEcosystem = req.GetQueries()[0].GetPackage().GetEcosystem() + gotName = req.GetQueries()[0].GetPackage().GetName() + writeProtoJSON(t, w, &api.BatchVulnerabilityList{Results: []*api.VulnerabilityList{{Vulns: []*osvschema.Vulnerability{{Id: "CLSA-2022-1643747494"}}}}}) + case osvdev.GetEndpoint + "/CLSA-2022-1643747494": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "CLSA-2022-1643747494"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + matcher := &OSVMatcher{Client: osvdev.OSVClient{HTTPClient: ts.Client(), Config: osvdev.DefaultConfig(), BaseHostURL: ts.URL}} + got, err := matcher.MatchVulnerabilities(t.Context(), []*extractor.Package{pkg}) + if err != nil { + t.Fatalf("MatchVulnerabilities() error = %v", err) + } + if gotEcosystem != "TuxCare:CentOS:8.5" || gotName != "openssl" { + t.Errorf("query = {%q,%q}, want {TuxCare:CentOS:8.5, openssl}", gotEcosystem, gotName) + } + if len(got) != 1 || len(got[0]) != 1 { + t.Fatalf("unexpected vulns: %#v", got) + } +} + +func TestOSVMatcher_MixedRoutingMapsResultsToCorrectPackage(t *testing.T) { + t.Parallel() + + // [0] marked → routes to TuxCare:Ubuntu:16.04 (CLSA-TEST) + // [1] unmarked → stays at base ecosystem (USN-TEST) + pkgMarked := dpkgPkg("linux-modules-4.4.0-283-generic", "linux-modules-4.4.0-283-generic", + "4.4.0-283.317+tuxcare.els1", "ubuntu", "16.04") + pkgUnmarked := dpkgPkg("squid", "squid", "3.5.27-1ubuntu1.14", "ubuntu", "16.04") + + var queryCount int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case osvdev.QueryBatchEndpoint: + var req api.BatchQuery + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(mustReadAll(t, r), &req); err != nil { + t.Fatalf("failed to decode query batch: %v", err) + } + queryCount = len(req.GetQueries()) + results := make([]*api.VulnerabilityList, len(req.GetQueries())) + for i, q := range req.GetQueries() { + if strings.HasPrefix(q.GetPackage().GetEcosystem(), "TuxCare") { + results[i] = &api.VulnerabilityList{Vulns: []*osvschema.Vulnerability{{Id: "CLSA-TEST"}}} + } else { + results[i] = &api.VulnerabilityList{Vulns: []*osvschema.Vulnerability{{Id: "USN-TEST"}}} + } + } + writeProtoJSON(t, w, &api.BatchVulnerabilityList{Results: results}) + case osvdev.GetEndpoint + "/CLSA-TEST": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "CLSA-TEST"}) + case osvdev.GetEndpoint + "/USN-TEST": + writeProtoJSON(t, w, &osvschema.Vulnerability{Id: "USN-TEST"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + matcher := &OSVMatcher{ + Client: osvdev.OSVClient{ + HTTPClient: ts.Client(), + Config: osvdev.DefaultConfig(), + BaseHostURL: ts.URL, + }, + } + + got, err := matcher.MatchVulnerabilities(t.Context(), []*extractor.Package{pkgMarked, pkgUnmarked}) + if err != nil { + t.Fatalf("MatchVulnerabilities() error = %v", err) + } + + if queryCount != 2 { + t.Fatalf("query count = %d, want 2", queryCount) + } + if len(got) != 2 { + t.Fatalf("result count = %d, want 2", len(got)) + } + if len(got[0]) == 0 || got[0][0].GetId() != "CLSA-TEST" { + t.Errorf("got[0] (marked package) vulns = %v, want [CLSA-TEST]", got[0]) + } + if len(got[1]) == 0 || got[1][0].GetId() != "USN-TEST" { + t.Errorf("got[1] (unmarked package) vulns = %v, want [USN-TEST]", got[1]) + } +} diff --git a/internal/clients/clientimpl/osvmatcher/supplementaryecosystem.go b/internal/clients/clientimpl/osvmatcher/supplementaryecosystem.go new file mode 100644 index 00000000000..f52ac88bfa6 --- /dev/null +++ b/internal/clients/clientimpl/osvmatcher/supplementaryecosystem.go @@ -0,0 +1,43 @@ +package osvmatcher + +import ( + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scanner/v2/internal/imodels" + "github.com/google/osv-scanner/v2/internal/tuxcare" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// supplementaryEcosystemRule routes the osv.dev query for a vendor-rebuilt package +// (detected by a version marker) to the vendor's advisory ecosystem instead of the +// package's base ecosystem. +type supplementaryEcosystemRule struct { + matches func(version string) bool + overlayPackage func(pkg *extractor.Package) *osvschema.Package +} + +// supplementaryEcosystemRules is the built-in registry. TuxCare is the first entry; +// all its logic lives in internal/tuxcare. +var supplementaryEcosystemRules = []supplementaryEcosystemRule{ + {matches: tuxcare.Marker.MatchString, overlayPackage: tuxcare.OverlayPackage}, +} + +// routedQueryPackage returns the vendor package coordinates to query for pkg, or +// nil if no rule applies. Only OSVMatcher.pkgToQuery consults this; CachedOSVMatcher +// deliberately does not route (local matching has no TuxCare version ordering). +func routedQueryPackage(pkg *extractor.Package) *osvschema.Package { + version := imodels.Version(pkg) + if version == "" { + return nil + } + for _, rule := range supplementaryEcosystemRules { + if !rule.matches(version) { + continue + } + if overlay := rule.overlayPackage(pkg); overlay != nil && + overlay.GetName() != "" && overlay.GetEcosystem() != "" { + return overlay + } + } + + return nil +} diff --git a/internal/clients/clientimpl/osvmatcher/supplementaryecosystem_test.go b/internal/clients/clientimpl/osvmatcher/supplementaryecosystem_test.go new file mode 100644 index 00000000000..6906def475d --- /dev/null +++ b/internal/clients/clientimpl/osvmatcher/supplementaryecosystem_test.go @@ -0,0 +1,239 @@ +package osvmatcher + +import ( + "testing" + + "github.com/google/osv-scalibr/extractor" + dpkgmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/dpkg/metadata" + rpmmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/rpm/metadata" + "github.com/google/osv-scalibr/purl" +) + +func dpkgPkg(name, source, version, osID, osVersionID string) *extractor.Package { + return &extractor.Package{ + Name: name, + Version: version, + PURLType: purl.TypeDebian, + Metadata: &dpkgmetadata.Metadata{ + PackageName: name, + SourceName: source, + OSID: osID, + OSVersionID: osVersionID, + }, + } +} + +func TestRoutedQueryPackage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pkg *extractor.Package + wantNil bool + wantName string + wantEcosystem string + }{ + { + name: "marked_ubuntu_routes_to_tuxcare_with_binary_name", + pkg: dpkgPkg("squid-cgi", "squid", "3.5.27-1ubuntu1.14+tuxcare.els3", "ubuntu", "18.04"), + wantName: "squid-cgi", + wantEcosystem: "TuxCare:Ubuntu:18.04", + }, + { + name: "marked_debian_routes_to_tuxcare", + pkg: dpkgPkg("binutils", "binutils", "2.31.1-16+tuxcare.els11", "debian", "10"), + wantName: "binutils", + wantEcosystem: "TuxCare:Debian:10", + }, + { + name: "unmarked_package_returns_nil", + pkg: dpkgPkg("squid", "squid", "3.5.27-1ubuntu1.14", "ubuntu", "18.04"), + wantNil: true, + }, + { + name: "marked_but_unknown_distro_returns_nil", + pkg: dpkgPkg("foo", "foo", "1.0+tuxcare.els1", "fedora", "39"), + wantNil: true, + }, + { + name: "marked_but_missing_version_id_returns_nil", + pkg: dpkgPkg("foo", "foo", "1.0+tuxcare.els1", "ubuntu", ""), + wantNil: true, + }, + { + name: "marked_tilde_separator_routes", + pkg: dpkgPkg("ca-certificates", "ca-certificates", "20221215~16.04.1ubuntu0.1~tuxcare.els1", "ubuntu", "16.04"), + wantName: "ca-certificates", + wantEcosystem: "TuxCare:Ubuntu:16.04", + }, + { + name: "marked_dot_separator_routes", + pkg: dpkgPkg("foo", "foo", "1.0.tuxcare.els1", "ubuntu", "16.04"), + wantName: "foo", + wantEcosystem: "TuxCare:Ubuntu:16.04", + }, + { + name: "non_dpkg_marker_in_version_not_routed", + pkg: &extractor.Package{Name: "leftpad", Version: "1.0.0+tuxcare.1", PURLType: purl.TypeNPM}, + wantNil: true, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := routedQueryPackage(tt.pkg) + if tt.wantNil { + if got != nil { + t.Fatalf("routedQueryPackage() = %#v, want nil", got) + } + + return + } + if got == nil { + t.Fatalf("routedQueryPackage() = nil, want {%q, %q}", tt.wantName, tt.wantEcosystem) + } + if got.GetName() != tt.wantName || got.GetEcosystem() != tt.wantEcosystem { + t.Fatalf("routedQueryPackage() = {%q, %q}, want {%q, %q}", + got.GetName(), got.GetEcosystem(), tt.wantName, tt.wantEcosystem) + } + }) + } +} + +func rpmPkg(name, version, osID, osVersionID, osName, cpe string) *extractor.Package { + return &extractor.Package{ + Name: name, + Version: version, + PURLType: purl.TypeRPM, + Metadata: &rpmmetadata.Metadata{ + PackageName: name, + OSID: osID, + OSVersionID: osVersionID, + OSName: osName, + OSPrettyName: osName, + OSCPEName: cpe, + }, + } +} + +func TestRoutedQueryPackage_RPM(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pkg *extractor.Package + wantNil bool + wantName string + wantEcosystem string + }{ + { + name: "almalinux_uses_full_version_id", + pkg: rpmPkg("gnutls", "3.7.6-21.el9_2.tuxcare.els2", "almalinux", "9.2", "AlmaLinux", "cpe:/o:almalinux:almalinux:9::baseos"), + wantName: "gnutls", + wantEcosystem: "TuxCare:AlmaLinux:9.2", + }, + { + name: "rhel_uses_major_version", + pkg: rpmPkg("bash", "4.4.20-4.el8_4.tuxcare.els1", "rhel", "8.4", "Red Hat Enterprise Linux", "cpe:/o:redhat:enterprise_linux:8::baseos"), + wantName: "bash", + wantEcosystem: "TuxCare:RHEL:8", + }, + { + name: "oracle_uses_major_version", + pkg: rpmPkg("openssl", "1.0.2k-25.el7.tuxcare.els3", "ol", "7.9", "Oracle Linux Server", "cpe:/o:oracle:linux:7:9:server"), + wantName: "openssl", + wantEcosystem: "TuxCare:OracleLinux:7", + }, + { + name: "centos7_uses_major_version", + pkg: rpmPkg("glibc", "2.17-326.el7.tuxcare.els2", "centos", "7", "CentOS Linux", "cpe:/o:centos:centos:7"), + wantName: "glibc", + wantEcosystem: "TuxCare:CentOS:7", + }, + { + name: "centos_stream_detected_via_os_name", + pkg: rpmPkg("curl", "7.61.1-22.el8.tuxcare.els14", "centos", "8", "CentOS Stream", "cpe:/o:centos:centos:8"), + wantName: "curl", + wantEcosystem: "TuxCare:CentOS-Stream:8", + }, + { + name: "unmarked_rpm_returns_nil", + pkg: rpmPkg("gnutls", "3.7.6-21.el9_2", "almalinux", "9.2", "AlmaLinux", ""), + wantNil: true, + }, + { + // CentOS-8 non-Stream is deferred (Phase 2b): channel minor not recoverable. + name: "centos8_marked_but_no_el_token_returns_nil", + pkg: rpmPkg("weird", "1.0-1.tuxcare.els1", "centos", "8", "CentOS Linux", ""), + wantNil: true, + }, + { + // CentOS-8 non-Stream is deferred even when an el token is present. + name: "centos8_non_stream_deferred_even_with_el_token", + pkg: rpmPkg("openssl", "1.1.1g-15.el8_4.tuxcare.els8", "centos", "8", "CentOS Linux", "cpe:/o:centos:centos:8"), + wantNil: true, + }, + { + name: "unknown_rpm_distro_returns_nil", + pkg: rpmPkg("foo", "1.0-1.fc39.tuxcare.els1", "fedora", "39", "Fedora", ""), + wantNil: true, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := routedQueryPackage(tt.pkg) + if tt.wantNil { + if got != nil { + t.Fatalf("routedQueryPackage() = %#v, want nil", got) + } + + return + } + if got == nil { + t.Fatalf("routedQueryPackage() = nil, want {%q, %q}", tt.wantName, tt.wantEcosystem) + } + if got.GetName() != tt.wantName || got.GetEcosystem() != tt.wantEcosystem { + t.Fatalf("routedQueryPackage() = {%q, %q}, want {%q, %q}", + got.GetName(), got.GetEcosystem(), tt.wantName, tt.wantEcosystem) + } + }) + } +} + +// A routed TuxCare RPM query composes routing with the general epoch handling: +// the query targets the TuxCare ecosystem and carries the package epoch, so +// osv.dev orders versions correctly. This is why TuxCare recognition depends on +// the RPM epoch fix. +func TestPkgToQuery_RoutedRPMCarriesEpoch(t *testing.T) { + t.Parallel() + + pkg := &extractor.Package{ + Name: "openssl-libs", + Version: "3.2.2-7.el9_6.tuxcare.1.els7", + PURLType: purl.TypeRPM, + Metadata: &rpmmetadata.Metadata{ + PackageName: "openssl-libs", + Epoch: 1, + OSID: "almalinux", + OSVersionID: "9.6", + OSName: "AlmaLinux", + }, + } + + query := pkgToQuery(pkg) + if query == nil { + t.Fatal("pkgToQuery() = nil, want a routed query") + } + if got := query.GetPackage().GetEcosystem(); got != "TuxCare:AlmaLinux:9.6" { + t.Errorf("query ecosystem = %q, want %q", got, "TuxCare:AlmaLinux:9.6") + } + if got, want := query.GetVersion(), "1:3.2.2-7.el9_6.tuxcare.1.els7"; got != want { + t.Errorf("query version = %q, want %q (epoch-qualified)", got, want) + } +} diff --git a/internal/output/output_result.go b/internal/output/output_result.go index f205ab8a250..a9d9cfef0d0 100644 --- a/internal/output/output_result.go +++ b/internal/output/output_result.go @@ -15,6 +15,7 @@ import ( "github.com/google/osv-scanner/v2/internal/identifiers" "github.com/google/osv-scanner/v2/internal/utility/results" "github.com/google/osv-scanner/v2/internal/utility/severity" + vulnutil "github.com/google/osv-scanner/v2/internal/utility/vulns" "github.com/google/osv-scanner/v2/pkg/models" "github.com/jedib0t/go-pretty/v6/text" @@ -878,12 +879,7 @@ func ecosystemHasRegVuln(ecosystem EcosystemResult) bool { } func removeVariants(ecosystem string) string { - if strings.Contains(ecosystem, "Ubuntu") { - ecosystem := strings.ReplaceAll(strings.ReplaceAll(ecosystem, ":Pro", ""), ":LTS", "") - return ecosystem - } - - return ecosystem + return vulnutil.RemoveVariants(ecosystem) } func formatHiddenVulnsPrompt(hiddenVulns int) string { diff --git a/internal/output/output_result_internal_test.go b/internal/output/output_result_internal_test.go new file mode 100644 index 00000000000..00d56801a89 --- /dev/null +++ b/internal/output/output_result_internal_test.go @@ -0,0 +1,47 @@ +package output + +import ( + "testing" + + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// TuxCare advisories carry a "TuxCare::" ecosystem while the +// scanned package keeps its base ecosystem ("AlmaLinux:9.6"). The fix-version +// reconciliation must normalize the advisory's vendor prefix down to the base, +// analogous to how Ubuntu Pro/LTS variant suffixes are stripped. +func TestGetNextFixVersion_TuxCareOverlayReconcilesToBase(t *testing.T) { + t.Parallel() + + affected := []*osvschema.Affected{ + { + Package: &osvschema.Package{ + Name: "binutils", + Ecosystem: "TuxCare:AlmaLinux:9.6", + }, + Ranges: []*osvschema.Range{ + { + Type: osvschema.Range_ECOSYSTEM, + Events: []*osvschema.Event{ + {Introduced: "0"}, + {Fixed: "2.35.2-63.el9.tuxcare.els10"}, + }, + }, + }, + }, + } + + fixable, fixedVersion := getNextFixVersion( + affected, + "2.35.2-63.el9.tuxcare.els2", // installed TuxCare-rebuilt version + "binutils", + "AlmaLinux:9.6", // the package's base ecosystem + ) + + if !fixable { + t.Fatalf("expected a fix to be available for a TuxCare advisory against an AlmaLinux package, got fixable=false (%q)", fixedVersion) + } + if want := "2.35.2-63.el9.tuxcare.els10"; fixedVersion != want { + t.Errorf("fixedVersion = %q, want %q", fixedVersion, want) + } +} diff --git a/internal/scalibrextract/os/tuxcareelsrepo/extractor.go b/internal/scalibrextract/os/tuxcareelsrepo/extractor.go new file mode 100644 index 00000000000..b6c4650f4e6 --- /dev/null +++ b/internal/scalibrextract/os/tuxcareelsrepo/extractor.go @@ -0,0 +1,61 @@ +// Package tuxcareelsrepo detects a TuxCare CentOS-8 ELS repo file and emits a marker +// carrying the channel, consumed by the host-context enricher in internal/tuxcare. +package tuxcareelsrepo + +import ( + "context" + "path/filepath" + + cpb "github.com/google/osv-scalibr/binary/proto/config_go_proto" + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/filesystem" + "github.com/google/osv-scalibr/inventory" + "github.com/google/osv-scalibr/plugin" + "github.com/google/osv-scalibr/purl" + "github.com/google/osv-scanner/v2/internal/tuxcare" +) + +// Name is the unique name of this extractor. +const Name = "os/tuxcare-els-repo" + +// Extractor emits a marker package for a recognized TuxCare CentOS-8 ELS repo file. +type Extractor struct{} + +// New returns a new instance of the extractor. +func New(_ *cpb.PluginConfig) (filesystem.Extractor, error) { return &Extractor{}, nil } + +// Name of the extractor. +func (e *Extractor) Name() string { return Name } + +// Version of the extractor. +func (e *Extractor) Version() int { return 0 } + +// Requirements of the extractor. +func (e *Extractor) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} } + +func (e *Extractor) fileRequiredPath(base string) bool { + _, ok := tuxcare.RepoFileNames[base] + return ok +} + +// FileRequired matches the TuxCare CentOS-8 ELS repo files. +func (e *Extractor) FileRequired(fapi filesystem.FileAPI) bool { + return e.fileRequiredPath(filepath.Base(fapi.Path())) +} + +// Extract emits a single marker package carrying the detected channel. +func (e *Extractor) Extract(_ context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) { + channel := tuxcare.RepoFileNames[filepath.Base(input.Path)] + if channel == "" { + return inventory.Inventory{}, nil + } + + return inventory.Inventory{Packages: []*extractor.Package{{ + Name: "tuxcare-els-channel-marker", + Metadata: &tuxcare.ChannelMarkerMetadata{Channel: channel}, + Location: extractor.LocationFromPath(input.Path), + }}}, nil +} + +// ToPURL converts a package created by this extractor into a PURL. +func (e *Extractor) ToPURL(_ *extractor.Package) *purl.PackageURL { return nil } diff --git a/internal/scalibrextract/os/tuxcareelsrepo/extractor_test.go b/internal/scalibrextract/os/tuxcareelsrepo/extractor_test.go new file mode 100644 index 00000000000..0fe3f9e5d71 --- /dev/null +++ b/internal/scalibrextract/os/tuxcareelsrepo/extractor_test.go @@ -0,0 +1,32 @@ +package tuxcareelsrepo + +import ( + "path/filepath" + "testing" + + "github.com/google/osv-scanner/v2/internal/tuxcare" +) + +func TestFileRequired(t *testing.T) { + t.Parallel() + e := &Extractor{} + yes := []string{"etc/yum.repos.d/centos8.4-els.repo", "etc/yum.repos.d/centos8.5-els.repo"} + no := []string{"etc/yum.repos.d/centos.repo", "etc/os-release", "centos8.4-els.repo.bak"} + for _, p := range yes { + if !e.fileRequiredPath(filepath.Base(p)) { + t.Errorf("FileRequired(%q) = false, want true", p) + } + } + for _, p := range no { + if e.fileRequiredPath(filepath.Base(p)) { + t.Errorf("FileRequired(%q) = true, want false", p) + } + } +} + +func TestChannelFromBase(t *testing.T) { + t.Parallel() + if got := tuxcare.RepoFileNames["centos8.4-els.repo"]; got != "8.4" { + t.Errorf("channel = %q, want 8.4", got) + } +} diff --git a/internal/scalibrplugin/__snapshots__/resolve_test.snap b/internal/scalibrplugin/__snapshots__/resolve_test.snap index fe2f748353b..fd4c8543a0e 100755 --- a/internal/scalibrplugin/__snapshots__/resolve_test.snap +++ b/internal/scalibrplugin/__snapshots__/resolve_test.snap @@ -37,6 +37,7 @@ os/apk os/chisel os/dpkg os/homebrew +os/tuxcare-els-repo osv/osvscannerjson php/composerlock python/pdmlock @@ -76,6 +77,7 @@ os/apk os/chisel os/dpkg os/homebrew +os/tuxcare-els-repo python/wheelegg rust/cargoauditable vex/os-duplicate/apk @@ -116,6 +118,7 @@ os/apk os/chisel os/dpkg os/homebrew +os/tuxcare-els-repo python/wheelegg rust/cargoauditable vex/os-duplicate/apk @@ -145,6 +148,7 @@ os/apk os/chisel os/dpkg os/homebrew +os/tuxcare-els-repo python/wheelegg rust/cargoauditable vex/os-duplicate/apk diff --git a/internal/scalibrplugin/presets.go b/internal/scalibrplugin/presets.go index 67c2b611603..f7ab5972f04 100644 --- a/internal/scalibrplugin/presets.go +++ b/internal/scalibrplugin/presets.go @@ -57,6 +57,7 @@ import ( "github.com/google/osv-scanner/v2/internal/scalibrextract/filesystem/vendored" "github.com/google/osv-scanner/v2/internal/scalibrextract/language/javascript/nodemodules" "github.com/google/osv-scanner/v2/internal/scalibrextract/language/osv/osvscannerjson" + tuxcareelsrepo "github.com/google/osv-scanner/v2/internal/scalibrextract/os/tuxcareelsrepo" "github.com/google/osv-scanner/v2/internal/scalibrextract/vcs/gitrepo" "github.com/google/osv-scanner/v2/internal/version" ) @@ -166,6 +167,8 @@ var ExtractorPresets = map[string]extractors.InitMap{ chisel.Name: {chisel.New}, // Homebrew homebrew.Name: {homebrew.New}, + // TuxCare CentOS-8 ELS channel marker + tuxcareelsrepo.Name: {tuxcareelsrepo.New}, }, } diff --git a/internal/scalibrplugin/resolve.go b/internal/scalibrplugin/resolve.go index 13ac8a2dea5..8ec64ae4ce3 100644 --- a/internal/scalibrplugin/resolve.go +++ b/internal/scalibrplugin/resolve.go @@ -13,6 +13,7 @@ import ( "github.com/google/osv-scanner/v2/internal/scalibrextract/filesystem/vendored" "github.com/google/osv-scanner/v2/internal/scalibrextract/language/javascript/nodemodules" "github.com/google/osv-scanner/v2/internal/scalibrextract/language/osv/osvscannerjson" + tuxcareelsrepo "github.com/google/osv-scanner/v2/internal/scalibrextract/os/tuxcareelsrepo" "github.com/google/osv-scanner/v2/internal/scalibrextract/vcs/gitrepo" ) @@ -34,6 +35,8 @@ func resolveFromName(name string, cfg *cpb.PluginConfig) (plugin.Plugin, error) return gitrepo.New(cfg) case osvscannerjson.Name: return osvscannerjson.New(cfg) + case tuxcareelsrepo.Name: + return tuxcareelsrepo.New(cfg) default: return nil, fmt.Errorf("not an exact name for a plugin: %q", name) } diff --git a/internal/scalibrplugin/resolve_test.go b/internal/scalibrplugin/resolve_test.go index 78e276e80b6..26876d621ee 100644 --- a/internal/scalibrplugin/resolve_test.go +++ b/internal/scalibrplugin/resolve_test.go @@ -36,6 +36,7 @@ import ( "github.com/google/osv-scalibr/extractor/filesystem/sbom/spdx" "github.com/google/osv-scanner/v2/internal/scalibrextract/filesystem/vendored" "github.com/google/osv-scanner/v2/internal/scalibrextract/language/javascript/nodemodules" + tuxcareelsrepo "github.com/google/osv-scanner/v2/internal/scalibrextract/os/tuxcareelsrepo" "github.com/google/osv-scanner/v2/internal/scalibrextract/vcs/gitrepo" "github.com/google/osv-scanner/v2/internal/scalibrplugin" "github.com/google/osv-scanner/v2/internal/testutility" @@ -527,6 +528,7 @@ func TestResolve_Extractors(t *testing.T) { apkanno.Name, dpkganno.Name, brewsource.Name, + tuxcareelsrepo.Name, }, }, { @@ -549,6 +551,7 @@ func TestResolve_Extractors(t *testing.T) { apkanno.Name, dpkganno.Name, brewsource.Name, + tuxcareelsrepo.Name, }, }, { @@ -576,6 +579,7 @@ func TestResolve_Extractors(t *testing.T) { apkanno.Name, dpkganno.Name, brewsource.Name, + tuxcareelsrepo.Name, }, }, // @@ -601,6 +605,7 @@ func TestResolve_Extractors(t *testing.T) { apkanno.Name, dpkganno.Name, brewsource.Name, + tuxcareelsrepo.Name, }, }, // diff --git a/internal/tuxcare/tuxcare.go b/internal/tuxcare/tuxcare.go new file mode 100644 index 00000000000..4865f3288ab --- /dev/null +++ b/internal/tuxcare/tuxcare.go @@ -0,0 +1,216 @@ +// Package tuxcare holds the vendor-specific rules for recognizing TuxCare ELS +// advisories (marker detection, distro/ecosystem mapping, and CentOS-8 channel +// detection). Shared osv-scanner code references only the exported entrypoints. +package tuxcare + +import ( + "strconv" + "strings" + + "github.com/google/osv-scalibr/binary/proto/metadata" + "github.com/google/osv-scalibr/extractor" + apkmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/apk/metadata" + dpkgmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/dpkg/metadata" + rpmmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/rpm/metadata" + "github.com/google/osv-scanner/v2/internal/cachedregexp" + "github.com/google/osv-scanner/v2/internal/cmdlogger" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +//nolint:gochecknoinits // Using init to register metadata is by design (mirrors osvscannerjson/metadata.go) +func init() { + // ChannelMarkerMetadata is a transient type stripped before matching/output. + // RegisterNil ensures proto conversion silently produces nil instead of + // ErrStructNotRegistered when scalibrSR still contains the marker package. + metadata.RegisterNil[*ChannelMarkerMetadata]() +} + +// Marker matches the installed version of a TuxCare-rebuilt package (separator-tolerant). +var Marker = cachedregexp.MustCompile(`[-.+~]tuxcare`) + +// EcosystemPrefix is prepended to a base ecosystem to form a TuxCare overlay +// ecosystem (e.g. "AlmaLinux:9.6" -> "TuxCare:AlmaLinux:9.6"). +const EcosystemPrefix = "TuxCare:" + +// BaseEcosystem strips the TuxCare overlay prefix, returning the base ecosystem +// a TuxCare advisory reconciles against ("TuxCare:AlmaLinux:9.6" -> "AlmaLinux:9.6"). +// Ecosystems without the prefix are returned unchanged. This mirrors how Ubuntu +// Pro/LTS variant suffixes are normalized away when matching advisories to packages. +func BaseEcosystem(ecosystem string) string { + return strings.TrimPrefix(ecosystem, EcosystemPrefix) +} + +// distroNames maps os-release IDs to the TuxCare deb distro name. +var distroNames = map[string]string{ + "ubuntu": "Ubuntu", + "debian": "Debian", +} + +// OverlayPackage returns the TuxCare ecosystem coordinates (binary name + ecosystem) +// for an OS package, or nil if the package's OS is not a supported TuxCare distro. +func OverlayPackage(pkg *extractor.Package) *osvschema.Package { + distro, version := distroAndVersion(pkg) + if distro == "" || version == "" { + return nil + } + name := osPackageName(pkg) + if name == "" { + return nil + } + + return &osvschema.Package{Name: name, Ecosystem: EcosystemPrefix + distro + ":" + version} +} + +// QueryVersion returns the version to send to osv.dev for a TuxCare-routed +// package. TuxCare ELS advisory records encode the RPM epoch, but scalibr keeps +// it in rpmmetadata.Metadata.Epoch, separate from the version string; prepend it +// when non-zero (e.g. "3.2.2-7.el9_6" -> "1:3.2.2-7.el9_6") so osv.dev does not +// read the missing epoch as 0 and report already-fixed advisories as unfixed. +// deb packages carry any epoch inline in the version (non-RPM metadata), so they +// pass through unchanged. Only routing (OverlayPackage) reaches this, so no +// ecosystem allowlist is needed: every TuxCare ecosystem encodes the epoch. +func QueryVersion(pkg *extractor.Package) string { + if m, ok := pkg.Metadata.(*rpmmetadata.Metadata); ok && m.Epoch > 0 { + return strconv.Itoa(m.Epoch) + ":" + pkg.Version + } + + return pkg.Version +} + +func distroAndVersion(pkg *extractor.Package) (distro, version string) { + switch m := pkg.Metadata.(type) { + case *dpkgmetadata.Metadata: + return distroNames[m.OSID], m.OSVersionID + case *rpmmetadata.Metadata: + return rpmDistroAndVersion(m) + } + + return "", "" +} + +func rpmDistroAndVersion(m *rpmmetadata.Metadata) (distro, version string) { + switch m.OSID { + case "almalinux": + return "AlmaLinux", m.OSVersionID + case "rhel": + return "RHEL", majorVersion(m.OSVersionID) + case "ol": + return "OracleLinux", majorVersion(m.OSVersionID) + case "centos": + if isCentOSStream(m) { + return "CentOS-Stream", majorVersion(m.OSVersionID) + } + switch majorVersion(m.OSVersionID) { + case "6", "7": + return "CentOS", majorVersion(m.OSVersionID) + default: + // CentOS 8.x: the channel minor (8.4/8.5) is supplied by the ELS-repo + // channel enricher, which stamps a "8.4"/"8.5" OSVersionID. A bare "8" + // (no repo file found) does not route. See EnrichHostContext. + if strings.Contains(m.OSVersionID, ".") { + return "CentOS", m.OSVersionID + } + + return "", "" + } + } + + return "", "" +} + +func majorVersion(v string) string { + major, _, _ := strings.Cut(v, ".") + return major +} + +func isCentOSStream(m *rpmmetadata.Metadata) bool { + for _, s := range []string{m.OSName, m.OSPrettyName, m.OSCPEName} { + if strings.Contains(strings.ToLower(s), "stream") { + return true + } + } + + return false +} + +// osPackageName returns the binary package name from OS-package metadata. It +// mirrors imodels.OSPackageName without creating an import cycle. +func osPackageName(pkg *extractor.Package) string { + if m, ok := pkg.Metadata.(*apkmetadata.Metadata); ok { + return m.PackageName + } + if m, ok := pkg.Metadata.(*dpkgmetadata.Metadata); ok { + return m.PackageName + } + if m, ok := pkg.Metadata.(*rpmmetadata.Metadata); ok { + return m.PackageName + } + + return "" +} + +// RepoFileNames maps a TuxCare CentOS-8 ELS repo filename to its channel minor. +// This is the only reliable signal for the CentOS-8 channel (os-release is "8"). +var RepoFileNames = map[string]string{ + "centos8.4-els.repo": "8.4", + "centos8.5-els.repo": "8.5", +} + +// ChannelMarkerMetadata is attached by the repo-file extractor to a synthetic marker +// package to carry the detected host channel to the enricher. It never reaches the +// matcher or output (EnrichHostContext strips it). +type ChannelMarkerMetadata struct { + Channel string // e.g. "8.4" or "8.5" +} + +// IsProtoable satisfies the metadata.Protoable interface required by extractor.Package.Metadata. +func (*ChannelMarkerMetadata) IsProtoable() {} + +// EnrichHostContext derives host-level facts from marker packages in the inventory +// and applies them, returning the inventory with marker packages removed. Currently: +// stamps the CentOS-8 ELS channel minor onto CentOS-8 RPM packages so routing can +// resolve TuxCare:CentOS:8.4 / :8.5. +// +// If multiple markers report conflicting channels (should not happen on a real host), +// a warning is logged and no stamping is performed (safe no-route rather than wrong-route). +func EnrichHostContext(pkgs []*extractor.Package) []*extractor.Package { + channel := "" + conflict := false + for _, p := range pkgs { + if m, ok := p.Metadata.(*ChannelMarkerMetadata); ok && m.Channel != "" { + if channel == "" { + channel = m.Channel + } else if channel != m.Channel { + conflict = true + } + } + } + + if conflict { + cmdlogger.Warnf("tuxcare: conflicting CentOS-8 ELS channel markers detected; skipping channel stamping to avoid wrong-route") + channel = "" + } + + out := make([]*extractor.Package, 0, len(pkgs)) + for _, p := range pkgs { + if _, ok := p.Metadata.(*ChannelMarkerMetadata); ok { + continue // strip the marker + } + if channel != "" { + stampCentOS8Channel(p, channel) + } + out = append(out, p) + } + + return out +} + +// stampCentOS8Channel sets the channel minor as OSVersionID on a CentOS-8 RPM package +// (which otherwise only knows "8"), so rpmDistroAndVersion can route it. +func stampCentOS8Channel(pkg *extractor.Package, channel string) { + m, ok := pkg.Metadata.(*rpmmetadata.Metadata) + if !ok || m.OSID != "centos" || isCentOSStream(m) || majorVersion(m.OSVersionID) != "8" { + return + } + m.OSVersionID = channel +} diff --git a/internal/tuxcare/tuxcare_test.go b/internal/tuxcare/tuxcare_test.go new file mode 100644 index 00000000000..a77ae0682ae --- /dev/null +++ b/internal/tuxcare/tuxcare_test.go @@ -0,0 +1,152 @@ +package tuxcare + +import ( + "testing" + + "github.com/google/osv-scalibr/binary/proto/metadata" + "github.com/google/osv-scalibr/extractor" + dpkgmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/dpkg/metadata" + rpmmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/rpm/metadata" +) + +func rpmPkg(name, version, osID, osVersionID string) *extractor.Package { + return &extractor.Package{ + Name: name, + Version: version, + Metadata: &rpmmetadata.Metadata{PackageName: name, OSID: osID, OSVersionID: osVersionID}, + } +} + +// TuxCare ELS feeds encode the RPM epoch, so a routed query must carry it. deb +// packages keep any epoch inline in the version and must pass through unchanged. +func TestQueryVersion(t *testing.T) { + t.Parallel() + + rpm := func(version string, epoch int) *extractor.Package { + return &extractor.Package{Version: version, Metadata: &rpmmetadata.Metadata{Epoch: epoch}} + } + + tests := []struct { + name string + pkg *extractor.Package + want string + }{ + { + name: "rpm_epoch_prepended", + pkg: rpm("3.2.2-7.el9_6.tuxcare.els1", 1), + want: "1:3.2.2-7.el9_6.tuxcare.els1", + }, + { + name: "rpm_epoch_zero_unchanged", + pkg: rpm("2.17-326.el7.tuxcare.els2", 0), + want: "2.17-326.el7.tuxcare.els2", + }, + { + // deb versions carry the epoch inline; non-RPM metadata must pass through. + name: "dpkg_inline_epoch_unchanged", + pkg: &extractor.Package{Version: "2:1.10.6-1ubuntu3.6+tuxcare.els2", Metadata: &dpkgmetadata.Metadata{}}, + want: "2:1.10.6-1ubuntu3.6+tuxcare.els2", + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := QueryVersion(tt.pkg); got != tt.want { + t.Errorf("QueryVersion() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRepoFileChannel(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "centos8.4-els.repo": "8.4", + "centos8.5-els.repo": "8.5", + "other.repo": "", + } + for fname, want := range cases { + if got := RepoFileNames[fname]; got != want { + t.Errorf("RepoFileNames[%q] = %q, want %q", fname, got, want) + } + } +} + +func TestEnrichHostContext_StampsCentOS8AndStripsMarker(t *testing.T) { + t.Parallel() + marker := &extractor.Package{Metadata: &ChannelMarkerMetadata{Channel: "8.5"}} + kernel := rpmPkg("openssl", "1.1.1g-15.el8.tuxcare.els7", "centos", "8") + pkgs := []*extractor.Package{marker, kernel} + + out := EnrichHostContext(pkgs) + + // marker stripped + if len(out) != 1 || out[0] != kernel { + t.Fatalf("expected only the kernel package to remain, got %d packages", len(out)) + } + // CentOS-8 package stamped with the channel minor + m := kernel.Metadata.(*rpmmetadata.Metadata) + if m.OSVersionID != "8.5" { + t.Errorf("OSVersionID = %q, want %q (stamped)", m.OSVersionID, "8.5") + } + // and now routes + overlay := OverlayPackage(kernel) + if overlay == nil || overlay.GetEcosystem() != "TuxCare:CentOS:8.5" { + t.Errorf("OverlayPackage ecosystem = %v, want TuxCare:CentOS:8.5", overlay) + } +} + +func TestEnrichHostContext_NoMarkerLeavesCentOS8Unrouted(t *testing.T) { + t.Parallel() + kernel := rpmPkg("openssl", "1.1.1g-15.el8.tuxcare.els7", "centos", "8") + out := EnrichHostContext([]*extractor.Package{kernel}) + if len(out) != 1 { + t.Fatalf("expected package retained, got %d", len(out)) + } + if OverlayPackage(kernel) != nil { + t.Errorf("CentOS-8 with no channel marker must not route, got a route") + } +} + +// TestChannelMarkerMetadata_ProtoRegistration is a regression test for C1(b). +// ChannelMarkerMetadata must be registered with metadata.RegisterNil so that +// metadata.StructToProto returns (nil, nil) rather than ErrStructNotRegistered. +// Without the init() registration this test fails. +func TestChannelMarkerMetadata_ProtoRegistration(t *testing.T) { + t.Parallel() + marker := &ChannelMarkerMetadata{Channel: "8.4"} + anyMsg, err := metadata.StructToProto(marker) + if err != nil { + t.Fatalf("metadata.StructToProto(*ChannelMarkerMetadata) returned error: %v; want nil (RegisterNil registration missing?)", err) + } + if anyMsg != nil { + t.Errorf("metadata.StructToProto(*ChannelMarkerMetadata) = %v, want nil (RegisterNil should produce nil)", anyMsg) + } +} + +// TestEnrichHostContext_ConflictingChannels verifies that when multiple marker packages +// report different channels, no stamping is done (safe no-route). +func TestEnrichHostContext_ConflictingChannels(t *testing.T) { + t.Parallel() + marker84 := &extractor.Package{Metadata: &ChannelMarkerMetadata{Channel: "8.4"}} + marker85 := &extractor.Package{Metadata: &ChannelMarkerMetadata{Channel: "8.5"}} + kernel := rpmPkg("openssl", "1.1.1g-15.el8.tuxcare.els7", "centos", "8") + pkgs := []*extractor.Package{marker84, marker85, kernel} + + out := EnrichHostContext(pkgs) + + // Both markers stripped + if len(out) != 1 || out[0] != kernel { + t.Fatalf("expected only kernel package to remain, got %d packages", len(out)) + } + // No stamping — OSVersionID stays "8" (major only), so does not route + m := kernel.Metadata.(*rpmmetadata.Metadata) + if m.OSVersionID != "8" { + t.Errorf("OSVersionID = %q, want %q (must not be stamped on conflict)", m.OSVersionID, "8") + } + if OverlayPackage(kernel) != nil { + t.Errorf("CentOS-8 with conflicting markers must not route, got a route") + } +} diff --git a/internal/utility/vulns/vulnerability.go b/internal/utility/vulns/vulnerability.go index 27222eb41a7..775f570e3a3 100644 --- a/internal/utility/vulns/vulnerability.go +++ b/internal/utility/vulns/vulnerability.go @@ -10,6 +10,7 @@ import ( "github.com/google/osv-scalibr/semantic" "github.com/google/osv-scanner/v2/internal/cmdlogger" "github.com/google/osv-scanner/v2/internal/imodels" + "github.com/google/osv-scanner/v2/internal/tuxcare" "github.com/ossf/osv-schema/bindings/go/osvschema" ) @@ -200,6 +201,21 @@ func NewPackageKey(pkg *osvschema.Package) PackageKey { } } +// RemoveVariants normalizes an advisory's ecosystem variant down to the base +// ecosystem a scanned package reconciles against. It strips the TuxCare vendor +// prefix ("TuxCare:AlmaLinux:9.6" -> "AlmaLinux:9.6") and the Ubuntu Pro/LTS +// variant suffixes ("Ubuntu:Pro:18.04:LTS" -> "Ubuntu:18.04"). Ecosystems +// without a variant are returned unchanged. +func RemoveVariants(ecosystem string) string { + ecosystem = tuxcare.BaseEcosystem(ecosystem) + + if strings.Contains(ecosystem, "Ubuntu") { + ecosystem = strings.ReplaceAll(strings.ReplaceAll(ecosystem, ":Pro", ""), ":LTS", "") + } + + return ecosystem +} + // GetFixedVersions returns a map of fixed versions for each package, or a map of empty slices if no fixed versions are available func GetFixedVersions(v *osvschema.Vulnerability) map[PackageKey][]string { output := map[PackageKey][]string{} @@ -213,6 +229,14 @@ func GetFixedVersions(v *osvschema.Vulnerability) map[PackageKey][]string { for _, e := range r.GetEvents() { if e.GetFixed() != "" { output[packageKey] = append(output[packageKey], e.GetFixed()) + // Advisories for variant ecosystems (e.g. TuxCare or Ubuntu Pro) + // are keyed to the variant, but consumers look fixes up by the + // package's base ecosystem. Expose the fix under the base key too. + if base := RemoveVariants(packageKey.Ecosystem); base != packageKey.Ecosystem { + baseKey := packageKey + baseKey.Ecosystem = base + output[baseKey] = append(output[baseKey], e.GetFixed()) + } if strings.Contains(packageKey.Ecosystem, ":") { unversionedKey := packageKey unversionedKey.Ecosystem = strings.Split(packageKey.Ecosystem, ":")[0] diff --git a/internal/utility/vulns/vulnerability_test.go b/internal/utility/vulns/vulnerability_test.go index 9efeb489049..ca38b5b77cc 100644 --- a/internal/utility/vulns/vulnerability_test.go +++ b/internal/utility/vulns/vulnerability_test.go @@ -776,3 +776,63 @@ func TestOSV_IsAffected_UnsupportedEcosystemDoesNotPanic(t *testing.T) { t.Errorf("Expected IsAffected to return false for unsupported ecosystem (no version comparison possible)") } } + +// TuxCare advisories are keyed to a "TuxCare::" ecosystem, but +// consumers look fixed versions up by the scanned package's base ecosystem +// ("AlmaLinux:9.6"). GetFixedVersions must expose a base-ecosystem key so the +// lookup resolves, mirroring how Ubuntu Pro/LTS variants are normalized. +func TestGetFixedVersions_TuxCareOverlayKeyedToBase(t *testing.T) { + t.Parallel() + + v := &osvschema.Vulnerability{ + Affected: []*osvschema.Affected{ + { + Package: &osvschema.Package{Name: "binutils", Ecosystem: "TuxCare:AlmaLinux:9.6"}, + Ranges: []*osvschema.Range{ + { + Type: osvschema.Range_ECOSYSTEM, + Events: []*osvschema.Event{ + {Introduced: "0"}, + {Fixed: "2.35.2-63.el9.tuxcare.els10"}, + }, + }, + }, + }, + }, + } + + got := vulns.GetFixedVersions(v) + baseKey := vulns.PackageKey{Name: "binutils", Ecosystem: "AlmaLinux:9.6"} + if fixes := got[baseKey]; len(fixes) != 1 || fixes[0] != "2.35.2-63.el9.tuxcare.els10" { + t.Errorf("GetFixedVersions()[%v] = %v, want [2.35.2-63.el9.tuxcare.els10]", baseKey, got[baseKey]) + } +} + +// Ubuntu Pro advisories are keyed "Ubuntu:Pro:18.04:LTS" while the package is +// "Ubuntu:18.04"; the fixed version must resolve under the base key too. +func TestGetFixedVersions_UbuntuProKeyedToBase(t *testing.T) { + t.Parallel() + + v := &osvschema.Vulnerability{ + Affected: []*osvschema.Affected{ + { + Package: &osvschema.Package{Name: "curl", Ecosystem: "Ubuntu:Pro:18.04:LTS"}, + Ranges: []*osvschema.Range{ + { + Type: osvschema.Range_ECOSYSTEM, + Events: []*osvschema.Event{ + {Introduced: "0"}, + {Fixed: "7.58.0-2ubuntu3.24+esm5"}, + }, + }, + }, + }, + }, + } + + got := vulns.GetFixedVersions(v) + baseKey := vulns.PackageKey{Name: "curl", Ecosystem: "Ubuntu:18.04"} + if fixes := got[baseKey]; len(fixes) != 1 || fixes[0] != "7.58.0-2ubuntu3.24+esm5" { + t.Errorf("GetFixedVersions()[%v] = %v, want [7.58.0-2ubuntu3.24+esm5]", baseKey, got[baseKey]) + } +} diff --git a/pkg/osvscanner/hostcontext_test.go b/pkg/osvscanner/hostcontext_test.go new file mode 100644 index 00000000000..194b34d3abf --- /dev/null +++ b/pkg/osvscanner/hostcontext_test.go @@ -0,0 +1,20 @@ +package osvscanner_test + +import ( + "testing" + + "github.com/google/osv-scalibr/extractor" + rpmmetadata "github.com/google/osv-scalibr/extractor/filesystem/os/rpm/metadata" + "github.com/google/osv-scanner/v2/internal/tuxcare" +) + +func TestEnrichHostContextWiredBeforeMatch(t *testing.T) { + t.Parallel() + marker := &extractor.Package{Metadata: &tuxcare.ChannelMarkerMetadata{Channel: "8.4"}} + kernel := &extractor.Package{Name: "openssl", Version: "1.1.1g-15.el8.tuxcare.els7", + Metadata: &rpmmetadata.Metadata{PackageName: "openssl", OSID: "centos", OSVersionID: "8"}} + out := tuxcare.EnrichHostContext([]*extractor.Package{marker, kernel}) + if len(out) != 1 { + t.Fatalf("marker not stripped: %d packages", len(out)) + } +} diff --git a/pkg/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go index e3e86a81efc..eeb92658b80 100644 --- a/pkg/osvscanner/osvscanner.go +++ b/pkg/osvscanner/osvscanner.go @@ -32,6 +32,7 @@ import ( "github.com/google/osv-scanner/v2/internal/imodels" "github.com/google/osv-scanner/v2/internal/imodels/results" "github.com/google/osv-scanner/v2/internal/output" + "github.com/google/osv-scanner/v2/internal/tuxcare" "github.com/google/osv-scanner/v2/pkg/models" "github.com/google/osv-scanner/v2/pkg/osvscanner/internal/imagehelpers" "github.com/ossf/osv-schema/bindings/go/osvconstants" @@ -208,6 +209,7 @@ func DoScan(actions ScannerActions) (models.VulnerabilityResults, error) { } scanResults.Inventory = *packagesAndFindings + scanResults.Inventory.Packages = tuxcare.EnrichHostContext(scanResults.Inventory.Packages) // ----- Filtering ----- unscannablePackages := filterUnscannablePackages(&scanResults, actions) @@ -333,6 +335,10 @@ func DoContainerScan(actions ScannerActions) (models.VulnerabilityResults, error } // --- Save Scalibr Scan Results --- + // EnrichHostContext must run on scalibrSR BEFORE ScanResultToProto: the enricher + // strips the ChannelMarkerMetadata marker package, whose custom metadata type would + // cause ScanResultToProto to return ErrStructNotRegistered otherwise. + scalibrSR.Inventory.Packages = tuxcare.EnrichHostContext(scalibrSR.Inventory.Packages) scanResults.Inventory = scalibrSR.Inventory // --- Fill Image Metadata ---