diff --git a/db/docs/sbom_occurrences.yml b/db/docs/sbom_occurrences.yml index 554c4856eeb5971b0b6772b7d3da817fb5c0c4bf..63461f440f4bef5d119ffee5536d66f28ab960a1 100644 --- a/db/docs/sbom_occurrences.yml +++ b/db/docs/sbom_occurrences.yml @@ -1,6 +1,7 @@ --- table_name: sbom_occurrences classes: +- Sbom::DependencyPath - Sbom::Occurrence feature_categories: - dependency_management diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index c41e121ded096e767fba511469cddc566e2f5aa5..09eb99c8683eb26293f7019f426f4e0dfc52aed5 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -205,6 +205,8 @@ The following metrics are available: | `gitlab_rack_attack_throttle_period_seconds` | Gauge | 17.6 | Reports the duration over which requests for a client are counted before Rack Attack throttles them. | `event_name` | | `gitlab_application_rate_limiter_throttle_utilization_ratio` | Histogram | 17.6 | Utilization ratio of a throttle in GitLab Application Rate Limiter. | `throttle_key`, `peek`, `feature_category` | | `search_zoekt_task_processing_queue_size` | Gauge | 17.9 | Number of tasks waiting to be processed by Zoekt. | `node_name` | +| `gitlab_dependency_path_cte_real_duration_seconds` | Histogram | 17.10 | Duration in seconds spent resolving the ancestor dependency paths for a given component. | | +| `dependency_path_cte_paths_found` | Counter | 17.10 | Counts the number of ancestor dependency paths found for a given dependency. | `max_depth_reached`, `cyclic` | ## Metrics controlled by a feature flag diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 796c878d352d28c567952fc68aeeaf71d4ab8931..8543875fca40e4a13a2df6b7b110f661dead1fae 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -24220,6 +24220,29 @@ A software dependency used by a project. | <a id="dependencyversion"></a>`version` | [`String`](#string) | Version of the dependency. | | <a id="dependencyvulnerabilitycount"></a>`vulnerabilityCount` | [`Int!`](#int) | Number of vulnerabilities within the dependency. | +### `DependencyPath` + +Ancestor path of a given dependency. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="dependencypathiscyclic"></a>`isCyclic` | [`Boolean!`](#boolean) | Indicates if the path is cyclic. | +| <a id="dependencypathmaxdepthreached"></a>`maxDepthReached` | [`Boolean!`](#boolean) | Indicates if the path reached the maximum depth (20). | +| <a id="dependencypathpath"></a>`path` | [`[DependencyPathPartial!]!`](#dependencypathpartial) | Name of the dependency. | + +### `DependencyPathPartial` + +Ancestor path partial of a given dependency. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="dependencypathpartialname"></a>`name` | [`String!`](#string) | Name of the dependency. | +| <a id="dependencypathpartialversion"></a>`version` | [`String!`](#string) | Version of the dependency. | + ### `DependencyProxyBlob` Dependency proxy blob. @@ -34090,6 +34113,24 @@ four standard [pagination arguments](#pagination-arguments): | <a id="projectdependenciessort"></a>`sort` | [`DependencySort`](#dependencysort) | Sort dependencies by given criteria. | | <a id="projectdependenciessourcetypes"></a>`sourceTypes` | [`[SbomSourceType!]`](#sbomsourcetype) | Filter dependencies by source type. | +##### `Project.dependencyPaths` + +Ancestor dependency paths for a dependency used by the project. \ + Returns `null` if `dependency_graph_graphql` feature flag is disabled. + +{{< details >}} +**Introduced** in GitLab 17.10. +**Status**: Experiment. +{{< /details >}} + +Returns [`[DependencyPath!]`](#dependencypath). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectdependencypathscomponent"></a>`component` | [`SbomComponentID!`](#sbomcomponentid) | Dependency path for component. | + ##### `Project.deployment` Details of the deployment of the project. diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb index f5086baa7eaddc6706d751967d0089694f4356c0..fbc26dab8d8a48d310a45517d4912c73fc95eece 100644 --- a/ee/app/graphql/ee/types/project_type.rb +++ b/ee/app/graphql/ee/types/project_type.rb @@ -372,6 +372,15 @@ module ProjectType description: 'Software dependencies used by the project.', resolver: ::Resolvers::Sbom::DependenciesResolver + field :dependency_paths, + [::Types::Sbom::DependencyPathType], + null: true, + authorize: :read_dependency, + description: 'Ancestor dependency paths for a dependency used by the project. \ + Returns `null` if `dependency_graph_graphql` feature flag is disabled.', + resolver: ::Resolvers::Sbom::DependencyPathsResolver, + experiment: { milestone: '17.10' } + field :components, [::Types::Sbom::ComponentType], null: true, diff --git a/ee/app/graphql/resolvers/sbom/dependency_paths_resolver.rb b/ee/app/graphql/resolvers/sbom/dependency_paths_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..349ab62b057bdc974460df24405cd12e4ee5dc4c --- /dev/null +++ b/ee/app/graphql/resolvers/sbom/dependency_paths_resolver.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Resolvers + module Sbom + class DependencyPathsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type [Types::Sbom::DependencyPathType], null: true + + authorize :read_dependency + authorizes_object! + + argument :component, Types::GlobalIDType[::Sbom::Component], + required: true, + description: 'Dependency path for component.' + + alias_method :project, :object + + def resolve(component:) + return if Feature.disabled?(:dependency_graph_graphql, project) + + component_id = resolve_gid(component, ::Sbom::Component) + result = Gitlab::Metrics.measure(:dependency_path_cte) do + ::Sbom::DependencyPath.find(id: component_id, project_id: project.id) + end + record_metrics(result) + result + end + + private + + def resolve_gid(gid, gid_class) + Types::GlobalIDType[gid_class].coerce_isolated_input(gid).model_id + end + + def record_metrics(result) + counter = Gitlab::Metrics.counter( + :dependency_path_cte_paths_found, + 'Count of Dependency Paths found using the recursive CTE' + ) + + counter.increment( + { cyclic: false, max_depth_reached: false }, + result.count { |r| !r.is_cyclic && !r.max_depth_reached } + ) + counter.increment( + { cyclic: false, max_depth_reached: true }, + result.count { |r| !r.is_cyclic && r.max_depth_reached } + ) + counter.increment( + { cyclic: true, max_depth_reached: false }, + result.count { |r| r.is_cyclic && !r.max_depth_reached } + ) + counter.increment( + { cyclic: true, max_depth_reached: true }, + result.count { |r| r.is_cyclic && r.max_depth_reached } + ) + end + end + end +end diff --git a/ee/app/graphql/types/sbom/dependency_path_partial_type.rb b/ee/app/graphql/types/sbom/dependency_path_partial_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..2aac8d2dddfc1b29095c1cb37f40975de9d7960e --- /dev/null +++ b/ee/app/graphql/types/sbom/dependency_path_partial_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Sbom + class DependencyPathPartialType < BaseObject # rubocop:disable Graphql/AuthorizeTypes -- Authorization checks are implemented on the parent object. + graphql_name 'DependencyPathPartial' + description 'Ancestor path partial of a given dependency.' + + field :name, GraphQL::Types::String, + null: false, description: 'Name of the dependency.' + + field :version, GraphQL::Types::String, + null: false, description: 'Version of the dependency.' + end + end +end diff --git a/ee/app/graphql/types/sbom/dependency_path_type.rb b/ee/app/graphql/types/sbom/dependency_path_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..75b0b74b393c75be3cb7a83812523da363afea7d --- /dev/null +++ b/ee/app/graphql/types/sbom/dependency_path_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Sbom + class DependencyPathType < BaseObject # rubocop:disable Graphql/AuthorizeTypes -- Authorization checks are implemented on the parent object. + graphql_name 'DependencyPath' + description 'Ancestor path of a given dependency.' + + field :path, [DependencyPathPartialType], + null: false, description: 'Name of the dependency.' + + field :is_cyclic, GraphQL::Types::Boolean, + null: false, description: 'Indicates if the path is cyclic.' + + field :max_depth_reached, GraphQL::Types::Boolean, + null: false, + description: "Indicates if the path reached the maximum depth (#{::Sbom::DependencyPath::MAX_DEPTH})." + end + end +end diff --git a/ee/app/models/sbom/dependency_path.rb b/ee/app/models/sbom/dependency_path.rb new file mode 100644 index 0000000000000000000000000000000000000000..f40b3ee4eb913038bc3f28b8e9011acc54d6ccf5 --- /dev/null +++ b/ee/app/models/sbom/dependency_path.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Sbom + class DependencyPath < ::Gitlab::Database::SecApplicationRecord + include IgnorableColumns + + self.table_name = 'sbom_occurrences' + ignore_columns %w[created_at updated_at component_version_id pipeline_id source_id commit_sha + component_id uuid package_manager component_name input_file_path licenses highest_severity vulnerability_count + source_package_id archived traversal_ids ancestors reachability], remove_never: true + + MAX_DEPTH = 20 + + attribute :id, :integer + attribute :dependency_name, :string + attribute :project_id, :integer + attribute :full_path, :string, array: true + attribute :version, :string, array: true + attribute :is_cyclic, :boolean + attribute :max_depth_reached, :boolean + + def self.find(id:, project_id:) + query = <<-SQL + WITH RECURSIVE dependency_tree AS ( + SELECT + so.component_id as id, + so.component_name as dependency_name, + so.project_id, + ARRAY [a->>'name', so.component_name] as full_path, + ARRAY [a->>'version', versions.version] as version, + ARRAY [concat_ws('@', a->>'name', a->>'version'), concat_ws('@', so.component_name, versions.version)] as combined_path, + false as is_cyclic, + false as max_depth_reached + FROM + sbom_occurrences so + inner join sbom_component_versions versions on versions.id = so.component_version_id + CROSS JOIN LATERAL jsonb_array_elements(so.ancestors) as a + where + so.component_id = :id + and so.project_id = :project_id + UNION + ALL + SELECT + dt.id, + dt.dependency_name, + dt.project_id, + ARRAY [a->>'name'] || dt.full_path, + ARRAY [a->>'version'] || dt.version, + ARRAY [concat_ws('@', a->>'name', a->>'version')] || dt.combined_path, + dt.combined_path && ARRAY[concat_ws('@', a->>'name', a->>'version')], + array_length(dt.full_path, 1) = :max_depth + FROM + dependency_tree dt + JOIN sbom_occurrences so ON so.component_name = dt.full_path [1] + join sbom_component_versions versions on versions.id = so.component_version_id and versions.version = dt.version [1] + CROSS JOIN LATERAL jsonb_array_elements(so.ancestors) as a + WHERE + array_length(dt.full_path, 1) <= :max_depth + and so.project_id = :project_id + and not dt.is_cyclic + ) + SELECT + id, + dependency_name, + project_id, + full_path, + combined_path, + version, + is_cyclic, + max_depth_reached + FROM + dependency_tree + WHERE NOT EXISTS ( -- Remove partial paths + SELECT 1 + FROM dependency_tree dt2 + WHERE dependency_tree.combined_path <@ dt2.combined_path -- Current path is a sub-path of another path + AND dependency_tree.combined_path <> dt2.combined_path -- Don't remove yourself! + AND NOT dependency_tree.is_cyclic -- Keep cyclic paths + ); + SQL + + query_params = { + project_id: project_id, + id: id, + max_depth: MAX_DEPTH + } + + sql = sanitize_sql_array([query, query_params]) + + DependencyPath.find_by_sql(sql) + end + + def path + full_path.each_with_index.map do |path, index| + { + name: path, + version: version[index] + } + end + end + end +end diff --git a/ee/config/feature_flags/beta/dependency_graph_graphql.yml b/ee/config/feature_flags/beta/dependency_graph_graphql.yml new file mode 100644 index 0000000000000000000000000000000000000000..d33b5689a8a2963a13d94bcbc266cd0397aa529a --- /dev/null +++ b/ee/config/feature_flags/beta/dependency_graph_graphql.yml @@ -0,0 +1,9 @@ +--- +name: dependency_graph_graphql +feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/16815 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182289 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/521318 +milestone: '17.10' +group: group::security infrastructure +type: beta +default_enabled: false diff --git a/ee/spec/graphql/resolvers/sbom/dependency_paths_resolver_spec.rb b/ee/spec/graphql/resolvers/sbom/dependency_paths_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..afc642a1872f758f443823ef7f69031966e7fdaa --- /dev/null +++ b/ee/spec/graphql/resolvers/sbom/dependency_paths_resolver_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Sbom::DependencyPathsResolver, feature_category: :vulnerability_management do + include GraphqlHelpers + + before do + stub_licensed_features(security_dashboard: true, dependency_scanning: true) + end + + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:group, developers: user) } + let_it_be(:project) { create(:project, namespace: namespace) } + + let_it_be(:component) { create(:sbom_component, name: "activestorage") } + + subject(:get_paths) { sync(resolve_dependency_paths(args: args)) } + + context 'when given a project' do + let(:project_or_namespace) { project } + + context 'when feature flag is OFF' do + before do + stub_feature_flags(dependency_graph_graphql: false) + end + + let(:args) do + { + component: component.to_gid + } + end + + it { is_expected.to be_nil } + + it 'does not record metrics' do + expect(Gitlab::Metrics).not_to receive(:measure) + end + end + + context 'when feature flag is ON' do + before do + stub_feature_flags(dependency_graph_graphql: true) + end + + let(:args) do + { + component: component.to_gid + } + end + + let(:result) do + [Sbom::DependencyPath.new( + id: component.id, + project_id: project.id, + dependency_name: component.name, + full_path: %w[ancestor_1 ancestor_2 dependency], + version: ['0.0.1', '0.0.2', '0.0.3'], + is_cyclic: false, + max_depth_reached: false + )] + end + + it 'returns data from DependencyPath.find' do + expect(::Sbom::DependencyPath).to receive(:find) + .with(id: component.id.to_s, project_id: project.id) + .and_return(result) + is_expected.to eq(result) + end + + it 'records execution time' do + expect(::Sbom::DependencyPath).to receive(:find) + .with(id: component.id.to_s, project_id: project.id) + .and_return(result) + expect(Gitlab::Metrics).to receive(:measure) + .with(:dependency_path_cte) + .and_call_original + + get_paths + end + + it 'records metrics' do + expect(::Sbom::DependencyPath).to receive(:find) + .with(id: component.id.to_s, project_id: project.id) + .and_return(result) + counter_double = instance_double(Prometheus::Client::Counter) + expect(Gitlab::Metrics).to receive(:counter) + .with(:dependency_path_cte_paths_found, 'Count of Dependency Paths found using the recursive CTE') + .and_return(counter_double) + + expect(counter_double).to receive(:increment) + .with({ cyclic: false, max_depth_reached: false }, 1) + expect(counter_double).to receive(:increment) + .with({ cyclic: false, max_depth_reached: true }, 0) + expect(counter_double).to receive(:increment) + .with({ cyclic: true, max_depth_reached: false }, 0) + expect(counter_double).to receive(:increment) + .with({ cyclic: true, max_depth_reached: true }, 0) + + get_paths + end + end + end + + private + + def resolve_dependency_paths(args: {}) + resolve( + described_class, + obj: project_or_namespace, + args: args, + ctx: { current_user: user } + ) + end +end diff --git a/ee/spec/graphql/types/sbom/dependency_path_partial_type_spec.rb b/ee/spec/graphql/types/sbom/dependency_path_partial_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..830e8aed02e190d7c9dc7eabed3198fad45fc408 --- /dev/null +++ b/ee/spec/graphql/types/sbom/dependency_path_partial_type_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Sbom::DependencyPathPartialType, feature_category: :dependency_management do + let(:fields) { %i[name version] } + + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/ee/spec/graphql/types/sbom/dependency_path_type_spec.rb b/ee/spec/graphql/types/sbom/dependency_path_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fe539dc4a3d02663ffe4f1b36db77b0a3415f0f2 --- /dev/null +++ b/ee/spec/graphql/types/sbom/dependency_path_type_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Sbom::DependencyPathType, feature_category: :dependency_management do + let(:fields) { %i[path isCyclic maxDepthReached] } + + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/ee/spec/models/sbom/dependency_path_spec.rb b/ee/spec/models/sbom/dependency_path_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a7f708692a129afb738b10278182758794a7fb4d --- /dev/null +++ b/ee/spec/models/sbom/dependency_path_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sbom::DependencyPath, feature_category: :vulnerability_management do + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:group, developers: user) } + let_it_be(:project) { create(:project, namespace: namespace) } + + let_it_be(:component_1) { create(:sbom_component, name: "activestorage") } + let_it_be(:component_version_1) { create(:sbom_component_version, component: component_1, version: '1.2.3') } + + let_it_be(:component_2) { create(:sbom_component, name: "activesupport") } + let_it_be(:component_version_2) { create(:sbom_component_version, component: component_2, version: '2.3.4') } + + let_it_be(:component_3) { create(:sbom_component, name: "activejob") } + let_it_be(:component_version_3) { create(:sbom_component_version, component: component_3, version: '3.4.5') } + + subject(:find_dependencies) { described_class.find(id: id, project_id: project.id) } + + context 'when given a project' do + context 'without cycles or exceeding the max depth' do + let_it_be(:occurrence_1) do + create(:sbom_occurrence, component: component_1, project: project, component_version: component_version_1) + end + + let_it_be(:occurrence_2) do + create(:sbom_occurrence, + component: component_2, + project: project, + component_version: component_version_2, + ancestors: [{ name: component_1.name, version: component_version_1.version }] + ) + end + + let_it_be(:occurrence_3) do + create(:sbom_occurrence, + component: component_3, + project: project, + component_version: component_version_3, + ancestors: [{ name: component_2.name, version: component_version_2.version }] + ) + end + + context 'when ancestors can be found' do + let(:id) do + component_3.id + end + + context 'for a dependency with children' do + let(:id) do + component_2.id + end + + it 'traverses until it finds no more ancestors, and skips children' do + is_expected.to eq([described_class.new( + id: component_2.id, + project_id: project.id, + dependency_name: component_2.name, + full_path: [component_1.name, component_2.name], + version: [component_version_1.version, component_version_2.version], + is_cyclic: false, + max_depth_reached: false + )]) + end + + it 'returns expected ancestors' do + expect(find_dependencies[0].path).to eq([ + { name: component_1.name, version: component_version_1.version }, + { name: component_2.name, version: component_version_2.version } + ]) + end + end + + context 'for a dependency with no children' do + let(:id) do + component_3.id + end + + it 'traverses until it finds no more ancestors' do + is_expected.to eq([described_class.new( + id: component_3.id, + project_id: project.id, + dependency_name: component_3.name, + full_path: [component_1.name, component_2.name, component_3.name], + version: [component_version_1.version, component_version_2.version, component_version_3.version], + is_cyclic: false, + max_depth_reached: false + )]) + end + + it 'returns expected ancestors' do + expect(find_dependencies[0].path).to eq([ + { name: component_1.name, version: component_version_1.version }, + { name: component_2.name, version: component_version_2.version }, + { name: component_3.name, version: component_version_3.version } + ]) + end + end + end + + context 'when ancestors cannot be found' do + let(:id) do + component_1.id + end + + it 'returns an empty array' do + is_expected.to eq([]) + end + end + end + + context 'if there is a cycle' do + let_it_be(:occurrence_1) do + create(:sbom_occurrence, + component: component_1, + project: project, + component_version: component_version_1, + ancestors: [{ name: component_3.name, version: component_version_3.version }] + ) + end + + let_it_be(:occurrence_2) do + create(:sbom_occurrence, + component: component_2, + project: project, + component_version: component_version_2, + ancestors: [{ name: component_1.name, version: component_version_1.version }] + ) + end + + let_it_be(:occurrence_3) do + create(:sbom_occurrence, + component: component_3, + project: project, + component_version: component_version_3, + ancestors: [{ name: component_2.name, version: component_version_2.version }] + ) + end + + let(:id) do + component_3.id + end + + it 'traverses until it finds the cycle and stops' do + is_expected.to eq([described_class.new( + id: component_3.id, + project_id: project.id, + dependency_name: component_3.name, + full_path: [component_3.name, component_1.name, component_2.name, component_3.name], + version: [component_version_3.version, + component_version_1.version, component_version_2.version, component_version_3.version], + is_cyclic: true, + max_depth_reached: false + )]) + end + + it 'returns expected ancestors' do + expect(find_dependencies[0].path).to eq([ + { name: component_3.name, version: component_version_3.version }, + { name: component_1.name, version: component_version_1.version }, + { name: component_2.name, version: component_version_2.version }, + { name: component_3.name, version: component_version_3.version } + ]) + end + end + + context 'if it exceeds the max depth' do + before do + stub_const("#{described_class}::MAX_DEPTH", 1) + end + + let_it_be(:occurrence_1) do + create(:sbom_occurrence, + component: component_1, + project: project, + component_version: component_version_1, + ancestors: [{ name: component_3.name, version: component_version_3.version }] + ) + end + + let_it_be(:occurrence_2) do + create(:sbom_occurrence, + component: component_2, + project: project, + component_version: component_version_2, + ancestors: [{ name: component_1.name, version: component_version_1.version }] + ) + end + + let_it_be(:occurrence_3) do + create(:sbom_occurrence, + component: component_3, + project: project, + component_version: component_version_3, + ancestors: [{ name: component_2.name, version: component_version_2.version }] + ) + end + + let(:id) do + component_3.id + end + + it 'traverses until it reaches max depth and stops' do + is_expected.to eq([described_class.new( + id: component_3.id, + project_id: project.id, + dependency_name: component_3.name, + full_path: [component_2.name, component_3.name], + version: [component_version_2.version, component_version_3.version], + is_cyclic: false, + max_depth_reached: true + )]) + end + + it 'returns expected ancestors' do + expect(find_dependencies[0].path).to eq([ + { name: component_2.name, version: component_version_2.version }, + { name: component_3.name, version: component_version_3.version } + ]) + end + end + end +end