From 0ce1879b8271e01a157ee4b3362cbf454d10a68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zaj=C4=85c?= <mzajac@gitlab.com> Date: Thu, 29 Aug 2024 11:54:13 +0200 Subject: [PATCH] Support finding Sbom::Occurrences via dependency names Changelog: added EE: true --- doc/api/graphql/reference/index.md | 34 +++++++++++++++ ee/app/finders/sbom/aggregations_finder.rb | 8 ++++ ee/app/finders/sbom/components_finder.rb | 25 +++++++++++ ee/app/finders/sbom/dependencies_finder.rb | 7 +++ ee/app/graphql/ee/types/query_type.rb | 7 +++ .../resolvers/sbom/component_resolver.rb | 18 ++++++++ .../resolvers/sbom/dependencies_resolver.rb | 6 +++ ee/app/graphql/types/sbom/component_type.rb | 18 ++++++++ ee/app/models/sbom/component.rb | 4 ++ ee/app/models/sbom/occurrence.rb | 8 ++++ ee/app/policies/sbom/component_policy.rb | 7 +++ ...el_dependencies_filtering_by_component.yml | 6 +-- .../finders/sbom/aggregations_finder_spec.rb | 28 ++++++++++++ .../finders/sbom/components_finder_spec.rb | 43 +++++++++++++++++++ .../finders/sbom/dependencies_finder_spec.rb | 14 ++++++ .../resolvers/sbom/component_resolver_spec.rb | 36 ++++++++++++++++ ee/spec/graphql/types/query_type_spec.rb | 3 +- ee/spec/models/sbom/component_spec.rb | 20 +++++++++ ee/spec/models/sbom/occurrence_spec.rb | 9 ++++ .../policies/sbom/component_policy_spec.rb | 14 ++++++ 20 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 ee/app/finders/sbom/components_finder.rb create mode 100644 ee/app/graphql/resolvers/sbom/component_resolver.rb create mode 100644 ee/app/graphql/types/sbom/component_type.rb create mode 100644 ee/app/policies/sbom/component_policy.rb create mode 100644 ee/spec/finders/sbom/components_finder_spec.rb create mode 100644 ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb create mode 100644 ee/spec/policies/sbom/component_policy_spec.rb diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 9b77b41e0b3dc..3dd8f5f926bdd 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -342,6 +342,22 @@ DETAILS: Returns [`CloudConnectorStatus`](#cloudconnectorstatus). +### `Query.components` + +Find software dependencies by name. + +DETAILS: +**Introduced** in GitLab 17.4. +**Status**: Experiment. + +Returns [`[Component!]`](#component). + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="querycomponentsname"></a>`name` | [`String`](#string) | Entire name or part of the name. | + ### `Query.containerRepository` Find a container repository. @@ -19729,6 +19745,17 @@ Compliance violation associated with a merged merge request. | <a id="complianceviolationseveritylevel"></a>`severityLevel` | [`ComplianceViolationSeverity!`](#complianceviolationseverity) | Severity of the compliance violation. | | <a id="complianceviolationviolatinguser"></a>`violatingUser` | [`UserCore!`](#usercore) | User suspected of causing the compliance violation. | +### `Component` + +A software dependency used by a project. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="componentid"></a>`id` | [`SbomComponentID!`](#sbomcomponentid) | ID of the dependency. | +| <a id="componentname"></a>`name` | [`String!`](#string) | Name of the dependency. | + ### `ComposerMetadata` Composer metadata. @@ -29643,6 +29670,7 @@ four standard [pagination arguments](#pagination-arguments): | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="projectdependenciescomponentids"></a>`componentIds` | [`[SbomComponentID!]`](#sbomcomponentid) | Filter dependencies by component IDs. | | <a id="projectdependenciescomponentnames"></a>`componentNames` | [`[String!]`](#string) | Filter dependencies by component names. | | <a id="projectdependenciespackagemanagers"></a>`packageManagers` | [`[PackageManager!]`](#packagemanager) | Filter dependencies by package managers. | | <a id="projectdependenciessort"></a>`sort` | [`DependencySort`](#dependencysort) | Sort dependencies by given criteria. | @@ -39356,6 +39384,12 @@ A `RemoteDevelopmentWorkspacesAgentConfigID` is a global ID. It is encoded as a An example `RemoteDevelopmentWorkspacesAgentConfigID` is: `"gid://gitlab/RemoteDevelopment::WorkspacesAgentConfig/1"`. +### `SbomComponentID` + +A `SbomComponentID` is a global ID. It is encoded as a string. + +An example `SbomComponentID` is: `"gid://gitlab/Sbom::Component/1"`. + ### `SecurityTrainingProviderID` A `SecurityTrainingProviderID` is a global ID. It is encoded as a string. diff --git a/ee/app/finders/sbom/aggregations_finder.rb b/ee/app/finders/sbom/aggregations_finder.rb index 2f37fbf5db3bf..e1fc603129ed3 100644 --- a/ee/app/finders/sbom/aggregations_finder.rb +++ b/ee/app/finders/sbom/aggregations_finder.rb @@ -87,6 +87,7 @@ def inner_occurrences .unarchived relation = filter_by_licences(relation) + relation = filter_by_component_ids(relation) relation .order(inner_order) @@ -100,6 +101,13 @@ def filter_by_licences(relation) relation.by_primary_license(params[:licenses]) end + def filter_by_component_ids(relation) + return relation if Feature.disabled?(:group_level_dependencies_filtering_by_component, namespace) + return relation unless params[:component_ids].present? + + relation.filter_by_component_ids(params[:component_ids]) + end + def inner_order evaluator = ->(column) { column_expression(column) } diff --git a/ee/app/finders/sbom/components_finder.rb b/ee/app/finders/sbom/components_finder.rb new file mode 100644 index 0000000000000..1329d1971065a --- /dev/null +++ b/ee/app/finders/sbom/components_finder.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Sbom + class ComponentsFinder + DEFAULT_MAX_RESULTS = 30 + + def initialize(name) + @name = name + end + + def execute + components + end + + private + + attr_reader :name + + def components + return Sbom::Component.limit(DEFAULT_MAX_RESULTS) unless name + + Sbom::Component.by_name(name).limit(DEFAULT_MAX_RESULTS) + end + end +end diff --git a/ee/app/finders/sbom/dependencies_finder.rb b/ee/app/finders/sbom/dependencies_finder.rb index ffaf46a32cc46..3897f8516e52f 100644 --- a/ee/app/finders/sbom/dependencies_finder.rb +++ b/ee/app/finders/sbom/dependencies_finder.rb @@ -35,6 +35,7 @@ def execute filter_by_source_types filter_by_package_managers filter_by_component_names + filter_by_component_ids filter_by_licences filter_by_visibility sort @@ -64,6 +65,12 @@ def filter_by_component_names @collection = @collection.filter_by_component_names(params[:component_names]) end + def filter_by_component_ids + return if params[:component_ids].blank? + + @collection = @collection.filter_by_component_ids(params[:component_ids]) + end + def filter_by_licences return if params[:licenses].blank? diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb index bcb237290ab93..b14d55133b89b 100644 --- a/ee/app/graphql/ee/types/query_type.rb +++ b/ee/app/graphql/ee/types/query_type.rb @@ -219,6 +219,13 @@ module QueryType alpha: { milestone: '17.4' }, description: 'Find a project secrets manager.', resolver: ::Resolvers::SecretsManagement::ProjectSecretsManagerResolver + + field :components, + [::Types::Sbom::ComponentType], + null: true, + description: 'Find software dependencies by name.', + resolver: ::Resolvers::Sbom::ComponentResolver, + alpha: { milestone: '17.4' } end def vulnerability(id:) diff --git a/ee/app/graphql/resolvers/sbom/component_resolver.rb b/ee/app/graphql/resolvers/sbom/component_resolver.rb new file mode 100644 index 0000000000000..fef4af4f8bdc1 --- /dev/null +++ b/ee/app/graphql/resolvers/sbom/component_resolver.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Resolvers + module Sbom + class ComponentResolver < BaseResolver + type [::Types::Sbom::ComponentType], null: true + + description 'Software dependencies, optionally filtered by name' + + argument :name, ::GraphQL::Types::String, required: false, + description: 'Entire name or part of the name.' + + def resolve(name: nil) + ::Sbom::ComponentsFinder.new(name).execute + end + end + end +end diff --git a/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb b/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb index cf5cc1f5f88b6..aefe4e2ed5413 100644 --- a/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb +++ b/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb @@ -32,11 +32,17 @@ class DependenciesResolver < BaseResolver required: false, description: 'Filter dependencies by component names.' + argument :component_ids, [Types::GlobalIDType[::Sbom::Component]], + required: false, + description: 'Filter dependencies by component IDs.' + argument :source_types, [Types::Sbom::SourceTypeEnum], required: false, default_value: ::Sbom::Source::DEFAULT_SOURCES.keys.map(&:to_s) + ['nil_source'], description: 'Filter dependencies by source type.' + validates mutually_exclusive: [:component_names, :component_ids] + alias_method :project, :object def resolve_with_lookahead(**args) diff --git a/ee/app/graphql/types/sbom/component_type.rb b/ee/app/graphql/types/sbom/component_type.rb new file mode 100644 index 0000000000000..33c13a9630889 --- /dev/null +++ b/ee/app/graphql/types/sbom/component_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Sbom + class ComponentType < BaseObject + graphql_name 'Component' + description 'A software dependency used by a project' + + authorize :read_component + + field :id, ::Types::GlobalIDType[::Sbom::Component], + null: false, description: 'ID of the dependency.' + + field :name, GraphQL::Types::String, + null: false, description: 'Name of the dependency.' + end + end +end diff --git a/ee/app/models/sbom/component.rb b/ee/app/models/sbom/component.rb index fc74ef4b4fd6d..3667f9e68e525 100644 --- a/ee/app/models/sbom/component.rb +++ b/ee/app/models/sbom/component.rb @@ -20,5 +20,9 @@ class Component < ::Gitlab::Database::SecApplicationRecord scope :by_unique_attributes, ->(name, purl_type, component_type, organization_id) do where(name: name, purl_type: purl_type, component_type: component_type, organization_id: organization_id) end + + scope :by_name, ->(name) do + where(Sbom::Component.arel_table[:name].matches("%#{name}%")) + end end end diff --git a/ee/app/models/sbom/occurrence.rb b/ee/app/models/sbom/occurrence.rb index 827c78f6861ab..352cec6828261 100644 --- a/ee/app/models/sbom/occurrence.rb +++ b/ee/app/models/sbom/occurrence.rb @@ -116,6 +116,14 @@ class Occurrence < Gitlab::Database::SecApplicationRecord left_outer_joins(:source).where(sbom_sources: { source_type: source_types }) end + scope :filter_by_component_names, ->(component_names) do + where(component_name: component_names) + end + + scope :filter_by_component_ids, ->(component_ids) do + where(component_id: component_ids) + end + scope :filter_by_search_with_component_and_group, ->(search, component_id, group) do relation = includes(project: :namespace) .where(component_version_id: component_id, project: group.all_projects) diff --git a/ee/app/policies/sbom/component_policy.rb b/ee/app/policies/sbom/component_policy.rb new file mode 100644 index 0000000000000..ff2ebb4258cc1 --- /dev/null +++ b/ee/app/policies/sbom/component_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Sbom + class ComponentPolicy < BasePolicy + rule { default }.enable :read_component + end +end diff --git a/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml b/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml index c23416e76f440..37a3a037cde28 100644 --- a/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml +++ b/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml @@ -1,9 +1,9 @@ --- name: group_level_dependencies_filtering_by_component -feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442406 -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148257 +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454305 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161932 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454589 -milestone: '16.11' +milestone: '16.4' group: group::threat insights type: wip default_enabled: false diff --git a/ee/spec/finders/sbom/aggregations_finder_spec.rb b/ee/spec/finders/sbom/aggregations_finder_spec.rb index c16a996ab81ae..b3ae5ccedaf0f 100644 --- a/ee/spec/finders/sbom/aggregations_finder_spec.rb +++ b/ee/spec/finders/sbom/aggregations_finder_spec.rb @@ -191,6 +191,34 @@ def occurrence(name:, severity:, traits: []) end end + describe 'filtering by component IDs' do + let_it_be(:component_1) { create(:sbom_component) } + let_it_be(:component_2) { create(:sbom_component) } + let_it_be(:occurrence_1) do + create(:sbom_occurrence, component_id: component_1.id, project: target_projects.first) + end + + let_it_be(:occurrence_2) { create(:sbom_occurrence, component_id: component_2.id, project: target_projects.last) } + + let(:params) { { component_ids: [component_1.id, component_2.id] } } + + context 'when feature flag is enabled' do + it 'returns only matching Sbom::Occurrences' do + expect(execute.to_a).to match_array([occurrence_1, occurrence_2]) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(group_level_dependencies_filtering_by_component: false) + end + + it 'returns the original relation' do + expect(execute.to_a).to match_array(target_occurrences + [occurrence_1, occurrence_2]) + end + end + end + describe 'filtering by license' do using RSpec::Parameterized::TableSyntax diff --git a/ee/spec/finders/sbom/components_finder_spec.rb b/ee/spec/finders/sbom/components_finder_spec.rb new file mode 100644 index 0000000000000..5617362a35644 --- /dev/null +++ b/ee/spec/finders/sbom/components_finder_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sbom::ComponentsFinder, feature_category: :vulnerability_management do + let(:finder) { described_class.new(query) } + let_it_be(:component_1) { create(:sbom_component, name: "activerecord") } + let_it_be(:component_2) { create(:sbom_component, name: "activejob") } + let_it_be(:component_3) { create(:sbom_component, name: "activestorage") } + let_it_be(:component_4) { create(:sbom_component, name: "activesupport") } + + describe '#execute' do + subject(:find) { finder.execute } + + context 'when given no query string' do + let(:query) { nil } + + it "returns all Sbom::Components" do + expect(find).to match_array([component_1, component_2, component_3, component_4]) + end + + context 'when there is more than maximum limit Sbom::Components' do + before do + stub_const("#{described_class}::DEFAULT_MAX_RESULTS", 3) + create_list(:sbom_component, described_class::DEFAULT_MAX_RESULTS) + end + + it 'does not return more than Sbom::Component::DEFAULT_MAX_RESULTS results' do + expect(Sbom::Component.count).to be > described_class::DEFAULT_MAX_RESULTS + expect(find.length).to be <= described_class::DEFAULT_MAX_RESULTS + end + end + end + + context 'when given a query string' do + let(:query) { "actives" } + + it "returns all matching Sbom::Components" do + expect(find).to match_array([component_3, component_4]) + end + end + end +end diff --git a/ee/spec/finders/sbom/dependencies_finder_spec.rb b/ee/spec/finders/sbom/dependencies_finder_spec.rb index 8778ede2a045f..eb2e6f1a454d7 100644 --- a/ee/spec/finders/sbom/dependencies_finder_spec.rb +++ b/ee/spec/finders/sbom/dependencies_finder_spec.rb @@ -170,6 +170,20 @@ end end + context 'when filtered by component IDs' do + let_it_be(:params) do + { + component_ids: [occurrence_1.component_id] + } + end + + it 'returns only records corresponding to the filter' do + component_ids = dependencies.map(&:component_id) + + expect(component_ids).to eq([occurrence_1.component_id]) + end + end + context 'when filtered by license' do let_it_be(:params) do { diff --git a/ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb b/ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb new file mode 100644 index 0000000000000..747f014ccd3ee --- /dev/null +++ b/ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Sbom::ComponentResolver, feature_category: :vulnerability_management do + include GraphqlHelpers + + let_it_be(:component_1) { create(:sbom_component, name: "activestorage") } + let_it_be(:component_2) { create(:sbom_component, name: "activesupport") } + let_it_be(:component_3) { create(:sbom_component, name: "log4j") } + + describe '#resolve' do + subject { resolve_components(args: { name: name }) } + + context 'when not given a query string' do + let(:name) { nil } + + it { is_expected.to match_array([component_1, component_2, component_3]) } + end + + context 'when given a query string' do + let(:name) { "actives" } + + it { is_expected.to match_array([component_1, component_2]) } + end + end + + def resolve_components(args: {}) + resolve( + described_class, + obj: nil, + args: args, + ctx: {} + ) + end +end diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb index 0d55a3d622fd0..3c02309176004 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -48,7 +48,8 @@ :self_managed_users_queued_for_role_promotion, :ai_self_hosted_models, :cloud_connector_status, - :project_secrets_manager + :project_secrets_manager, + :components ] all_expected_fields = expected_foss_fields + expected_ee_fields diff --git a/ee/spec/models/sbom/component_spec.rb b/ee/spec/models/sbom/component_spec.rb index 9402dcb04aab3..78536693fe330 100644 --- a/ee/spec/models/sbom/component_spec.rb +++ b/ee/spec/models/sbom/component_spec.rb @@ -57,6 +57,26 @@ end end + describe '.by_name' do + let_it_be(:component_1) do + create(:sbom_component, name: 'activesupport') + end + + let_it_be(:component_2) do + create(:sbom_component, name: 'activestorage') + end + + let_it_be(:non_matching_component) do + create(:sbom_component, name: 'log4j') + end + + subject(:results) { described_class.by_name('actives') } + + it 'returns only the matching components' do + expect(results).to match_array([component_1, component_2]) + end + end + context 'with loose foreign key on sbom_components.organization_id' do it_behaves_like 'cleanup by a loose foreign key' do let_it_be(:parent) { create(:organization) } diff --git a/ee/spec/models/sbom/occurrence_spec.rb b/ee/spec/models/sbom/occurrence_spec.rb index f64b97d6b3f15..02a4bd9f6bb67 100644 --- a/ee/spec/models/sbom/occurrence_spec.rb +++ b/ee/spec/models/sbom/occurrence_spec.rb @@ -298,6 +298,15 @@ end end + describe '.filter_by_component_ids' do + let_it_be(:occurrence_1) { create(:sbom_occurrence) } + let_it_be(:occurrence_2) { create(:sbom_occurrence) } + + it 'returns records filtered by component IDs' do + expect(described_class.filter_by_component_ids([occurrence_1.component_id])).to eq([occurrence_1]) + end + end + describe '.filter_by_source_types' do let_it_be(:container_scanning_occurrence) { create(:sbom_occurrence, :os_occurrence) } let_it_be(:dependency_scanning_occurrence) { create(:sbom_occurrence) } diff --git a/ee/spec/policies/sbom/component_policy_spec.rb b/ee/spec/policies/sbom/component_policy_spec.rb new file mode 100644 index 0000000000000..9d7fce571a724 --- /dev/null +++ b/ee/spec/policies/sbom/component_policy_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sbom::ComponentPolicy, feature_category: :vulnerability_management do + let_it_be(:user) { create(:user) } + let_it_be(:sbom_component) { create(:sbom_component) } + + subject { described_class.new(user, sbom_component) } + + describe "reading Sbom::Components present in GitLab" do + it { is_expected.to be_allowed(:read_component) } + end +end -- GitLab