diff --git a/ee/app/models/package_metadata/package.rb b/ee/app/models/package_metadata/package.rb index 5538c17de5244b90493e47f49eb690485406b942..59b685600d9bb5b07ca8a9c85df2af0bfe50c91f 100644 --- a/ee/app/models/package_metadata/package.rb +++ b/ee/app/models/package_metadata/package.rb @@ -3,7 +3,18 @@ module PackageMetadata class Package < ApplicationRecord DEFAULT_LICENSES_IDX = 0 - OTHER_LICENSES_IDX = 3 + LOWEST_VERSION_IDX = 1 + HIGHEST_VERSION_IDX = 2 + OTHER_LICENSES_IDX = 3 + + # Our license-db exporter exports license and advisory data from the + # external license-db to a public GCP bucket for ingestion by the GitLab + # Rails application. Using the same regular expression in both projects + # ensures consistency across system boundaries. + # https://pkg.go.dev/github.com/hashicorp/go-version#pkg-constants + # rubocop: disable Layout/LineLength + VERSION_REGEXP_RAW = /v?([0-9]+(\.[0-9]+)*?)(-([0-9]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)|(-?([A-Za-z\-~]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)))?(\+([0-9A-Za-z\-~]+(\.[0-9A-Za-z\-~]+)*))??/ + # rubocop: enable Layout/LineLength has_many :package_versions, inverse_of: :package, foreign_key: :pm_package_id @@ -20,7 +31,7 @@ def license_ids_for(version:) # might be nil, in which case we return an empty array. return [] if licenses.blank? - matching_other_license_ids(version: version) || default_license_ids + matching_other_license_ids(version: version) || default_license_ids(version: version) end # Takes an array of components and uses the purl_type and name fields to search for matching @@ -50,12 +61,39 @@ def matching_other_license_ids(version:) nil end - def default_license_ids - licenses[DEFAULT_LICENSES_IDX] + def lowest_version + licenses[LOWEST_VERSION_IDX] + end + + def highest_version + licenses[HIGHEST_VERSION_IDX] end def other_licenses licenses[OTHER_LICENSES_IDX] end + + def default_license_ids(version:) + # if the version does not match the exporter format, return an empty array. + # https://gitlab.com/gitlab-org/gitlab/-/issues/410434 + return [] unless VERSION_REGEXP_RAW.match(version) + + # if the given version is greater than the highest known version or lower + # than the lowest known version, then the version is not supported, in + # which case we return an empty array. + return [] unless version_in_default_licenses_range?(version) + + licenses[DEFAULT_LICENSES_IDX] + end + + def version_in_default_licenses_range?(input_version) + interval = VersionParser.parse("=#{input_version}") + + range = VersionRange.new + range.add(VersionParser.parse("<#{lowest_version}")) if lowest_version + range.add(VersionParser.parse(">#{highest_version}")) if highest_version + + !range.overlaps_with?(interval) + end end end diff --git a/ee/lib/gitlab/license_scanning/package_licenses.rb b/ee/lib/gitlab/license_scanning/package_licenses.rb index 9ebfbb95096fcbbe1ed5049be87a53e5388efb4f..fd20ac03881f7267813bba8718bc4b356f8c2856 100644 --- a/ee/lib/gitlab/license_scanning/package_licenses.rb +++ b/ee/lib/gitlab/license_scanning/package_licenses.rb @@ -131,16 +131,20 @@ def requested_data_for_package(package) # uncompressed and compressed queries. def add_record_with_known_licenses(purl_type:, name:, version:, license_ids:, path:) all_records[component_key(name: name, version: version, purl_type: purl_type)] = - Hashie::Mash.new(purl_type: purl_type, name: name, version: version, - licenses: licenses_with_names_for(license_ids: license_ids), path: path || '') + Hashie::Mash.new( + purl_type: purl_type, name: name, version: version, + licenses: licenses_with_names_for(license_ids: license_ids), path: path || '' + ) end def add_record_with_unknown_license(component) key = component_key(name: component.name, version: component.version, purl_type: component.purl_type) - all_records[key] = Hashie::Mash.new( - purl_type: component.purl_type, name: component.name, version: component.version, - licenses: [UNKNOWN_LICENSE]) + all_records[key] = + Hashie::Mash.new( + purl_type: component.purl_type, name: component.name, version: component.version, + licenses: [UNKNOWN_LICENSE], path: component.path || '' + ) end def use_replica_if_available(&block) diff --git a/ee/spec/lib/gitlab/license_scanning/package_licenses_spec.rb b/ee/spec/lib/gitlab/license_scanning/package_licenses_spec.rb index 2fd9113d3435bfb9e7cc53ec1b0d5c59de148f95..0c9c0ab890b5961b9e357d16284a8e9c90a07c79 100644 --- a/ee/spec/lib/gitlab/license_scanning/package_licenses_spec.rb +++ b/ee/spec/lib/gitlab/license_scanning/package_licenses_spec.rb @@ -393,9 +393,9 @@ where(:case_name, :name, :purl_type, :version) do "name does not match" | "does-not-match" | "golang" | "v1.10.0" "purl_type does not match" | "beego" | "npm" | "v1.10.0" - # TODO: re-enable the following when https://gitlab.com/gitlab-org/vulnerability-research/foss/semver_dialects/-/issues/3 - # has been completed. - # "version is invalid" | "beego" | "golang" | "invalid-version" + "version is too low" | "beego" | "golang" | "v00000000" + "version is too high" | "beego" | "golang" | "v999999999" + "version is invalid" | "beego" | "golang" | "invalid-version" end with_them do @@ -403,53 +403,27 @@ it "returns 'unknown' as the license" do expect(fetch).to eq([ - "name" => name, "purl_type" => purl_type, "version" => version, + "name" => name, "path" => "", "purl_type" => purl_type, "version" => version, "licenses" => [{ "name" => "unknown", "spdx_identifier" => "unknown", "url" => nil }] ]) end end + end - context 'and the version is invalid' do - let(:components_to_fetch) do - [Hashie::Mash.new({ name: "beego", purl_type: "golang", version: "invalid-version" })] - end - - # this test shows that the current matching behaviour is incorrect, because the default - # license is returned, when 'unknown' should actually be returned. - # We need to add a new `valid?` method to the semver_dialects gem to handle invalid versions. - # - # See https://gitlab.com/gitlab-org/vulnerability-research/foss/semver_dialects/-/issues/3 - # for more details. - # - # TODO: once we have a `valid?` method in the semver_dialects gem, remove this test - # and add a test to the table in the `returns 'unknown' as the license` example above. - it "returns the default licenses" do - expect(fetch).to eq([ - "name" => "beego", "purl_type" => "golang", "version" => "invalid-version", "path" => "", - "licenses" => [{ - "name" => "Default License 2.1", - "spdx_identifier" => "DEFAULT-2.1", - "url" => "https://spdx.org/licenses/DEFAULT-2.1.html" - }] - ]) - end + context 'when the version is between the highest and lowest versions' do + let(:components_to_fetch) do + [Hashie::Mash.new({ name: "beego", purl_type: "golang", version: "v00000005" })] end - context 'and the version does not match' do - let(:components_to_fetch) do - [Hashie::Mash.new({ name: "beego", purl_type: "golang", version: "123.456.789" })] - end - - it "returns the default licenses" do - expect(fetch).to eq([ - "name" => "beego", "purl_type" => "golang", "version" => "123.456.789", "path" => "", - "licenses" => [{ - "name" => "Default License 2.1", - "spdx_identifier" => "DEFAULT-2.1", - "url" => "https://spdx.org/licenses/DEFAULT-2.1.html" - }] - ]) - end + it "returns the default licenses" do + expect(fetch).to eq([ + "name" => "beego", "purl_type" => "golang", "version" => "v00000005", "path" => "", + "licenses" => [{ + "name" => "Default License 2.1", + "spdx_identifier" => "DEFAULT-2.1", + "url" => "https://spdx.org/licenses/DEFAULT-2.1.html" + }] + ]) end end diff --git a/ee/spec/models/package_metadata/package_spec.rb b/ee/spec/models/package_metadata/package_spec.rb index 073debf3852a14862b602e8f767c595ab931a11c..379b41189e51b1406b1a70670b21a4dc0ffe76d8 100644 --- a/ee/spec/models/package_metadata/package_spec.rb +++ b/ee/spec/models/package_metadata/package_spec.rb @@ -29,7 +29,7 @@ describe '#license_ids_for' do context 'when licenses are present' do let(:default) { [5, 7] } - let(:highest) { '0.0.2' } + let(:highest) { '0.0.3' } let(:lowest) { '0.0.1' } let(:other) { [[[2, 4], ['v0.0.3', 'v0.0.4']], [[3], ['v0.0.5']]] } @@ -43,9 +43,114 @@ end end + context 'and the given version does not match any of the versions in other licenses' do + context 'and the given version exactly matches the highest version' do + it 'returns the default licenses' do + expect(package.license_ids_for(version: highest)).to eq(default) + end + end + + context 'and the given version exactly matches the lowest version' do + it 'returns the default licenses' do + expect(package.license_ids_for(version: lowest)).to eq(default) + end + end + + context 'and the given version is between the highest and lowest versions' do + it 'returns the default licenses' do + expect(package.license_ids_for(version: "0.0.2")).to eq(default) + end + end + + context 'and the given version is higher than the highest version' do + it 'returns an empty array' do + expect(package.license_ids_for(version: "9.9.9")).to be_empty + end + end + + context 'and the given version is lower than the lowest version' do + it 'returns an empty array' do + expect(package.license_ids_for(version: "0.0.0")).to be_empty + end + end + end + end + + context 'when licenses are present but highest and lowest are missing' do + let(:default) { [5, 7] } + let(:highest) { nil } + let(:lowest) { nil } + let(:other) { [[[2, 4], ['v0.0.3', 'v0.0.4']], [[3], ['v0.0.5']]] } + + subject(:package) do + build_stubbed(:pm_package, name: "cliui", purl_type: "npm", licenses: [default, lowest, highest, other]) + end + context 'and the given version does not match any of the versions in other licenses' do it 'returns the default licenses' do - expect(package.license_ids_for(version: "9.9.9")).to eq(default) + expect(package.license_ids_for(version: "0.0.2")).to eq(default) + end + end + end + + context 'when licenses are present but highest is missing' do + let(:default) { [5, 7] } + let(:highest) { nil } + let(:lowest) { '0.0.1' } + let(:other) { [[[2, 4], ['v0.0.3', 'v0.0.4']], [[3], ['v0.0.5']]] } + + subject(:package) do + build_stubbed(:pm_package, name: "cliui", purl_type: "npm", licenses: [default, lowest, highest, other]) + end + + context 'and the given version does not match any of the versions in other licenses' do + context 'and the given version exactly matches the lowest version' do + it 'returns the default licenses' do + expect(package.license_ids_for(version: lowest)).to eq(default) + end + end + + context 'and the given version is higher than the lowest version' do + it 'returns the default licenses' do + expect(package.license_ids_for(version: "9.9.9")).to eq(default) + end + end + + context 'and the given version is lower than the lowest version' do + it 'returns an empty array' do + expect(package.license_ids_for(version: "0.0.0")).to be_empty + end + end + end + end + + context 'when licenses are present but lowest is missing' do + let(:default) { [5, 7] } + let(:highest) { '0.0.3' } + let(:lowest) { nil } + let(:other) { [[[2, 4], ['v0.0.3', 'v0.0.4']], [[3], ['v0.0.5']]] } + + subject(:package) do + build_stubbed(:pm_package, name: "cliui", purl_type: "npm", licenses: [default, lowest, highest, other]) + end + + context 'and the given version does not match any of the versions in other licenses' do + context 'and the given version exactly matches the highest version' do + it 'returns the default licenses' do + expect(package.license_ids_for(version: highest)).to eq(default) + end + end + + context 'and the given version is lower than the highest version' do + it 'returns the default licenses' do + expect(package.license_ids_for(version: "0.0.2")).to eq(default) + end + end + + context 'and the given version is higher than the highest version' do + it 'returns an empty array' do + expect(package.license_ids_for(version: "9.9.9")).to be_empty + end end end end diff --git a/ee/spec/services/sbom/ingestion/tasks/ingest_occurrences_spec.rb b/ee/spec/services/sbom/ingestion/tasks/ingest_occurrences_spec.rb index 0a8397255cc342b7a3525fe2a0435f9a8228c05d..59965ef484f989b673297a6a9cf3899428e1ede4 100644 --- a/ee/spec/services/sbom/ingestion/tasks/ingest_occurrences_spec.rb +++ b/ee/spec/services/sbom/ingestion/tasks/ingest_occurrences_spec.rb @@ -23,9 +23,13 @@ let(:ingested_occurrence) { Sbom::Occurrence.last } before do - licenses = ["MIT", "Apache-2.0"] + default_licenses = ["MIT", "Apache-2.0"] + occurrence_maps.map(&:report_component).each do |component| - create(:pm_package, name: component.name, purl_type: component.purl&.type, default_license_names: licenses) + create(:pm_package, name: component.name, purl_type: component.purl&.type, + lowest_version: component.version, highest_version: component.version, + default_license_names: default_licenses + ) end end