diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a1d1c8d7c4c2d32fc60a34c0fe46265aaf1d51c5..310b9652c956193528e9d531a60971b1c807a749 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -15355,6 +15355,29 @@ The edge type for [`ProjectSavedReply`](#projectsavedreply). | <a id="projectsavedreplyedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="projectsavedreplyedgenode"></a>`node` | [`ProjectSavedReply`](#projectsavedreply) | The item at the end of the edge. | +#### `ProjectSecurityExclusionConnection` + +The connection type for [`ProjectSecurityExclusion`](#projectsecurityexclusion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectsecurityexclusionconnectionedges"></a>`edges` | [`[ProjectSecurityExclusionEdge]`](#projectsecurityexclusionedge) | A list of edges. | +| <a id="projectsecurityexclusionconnectionnodes"></a>`nodes` | [`[ProjectSecurityExclusion]`](#projectsecurityexclusion) | A list of nodes. | +| <a id="projectsecurityexclusionconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `ProjectSecurityExclusionEdge` + +The edge type for [`ProjectSecurityExclusion`](#projectsecurityexclusion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectsecurityexclusionedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="projectsecurityexclusionedgenode"></a>`node` | [`ProjectSecurityExclusion`](#projectsecurityexclusion) | The item at the end of the edge. | + #### `ProjectWikiRepositoryRegistryConnection` The connection type for [`ProjectWikiRepositoryRegistry`](#projectwikirepositoryregistry). @@ -30931,6 +30954,28 @@ four standard [pagination arguments](#pagination-arguments): | <a id="projectscanresultpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. | | <a id="projectscanresultpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. | +##### `Project.securityExclusions` + +Security exclusions of the project. + +DETAILS: +**Introduced** in GitLab 17.4. +**Status**: Experiment. + +Returns [`ProjectSecurityExclusionConnection`](#projectsecurityexclusionconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectsecurityexclusionsactive"></a>`active` | [`Boolean`](#boolean) | Filter entries by active status. | +| <a id="projectsecurityexclusionsscanner"></a>`scanner` | [`ExclusionScannerEnum`](#exclusionscannerenum) | Filter entries by scanner. | +| <a id="projectsecurityexclusionstype"></a>`type` | [`ExclusionTypeEnum`](#exclusiontypeenum) | Filter entries by exclusion type. | + ##### `Project.securityPolicyProjectSuggestions` Security policy project suggestions. @@ -31448,6 +31493,21 @@ Representation of a project secrets manager. | <a id="projectsecretsmanagerproject"></a>`project` | [`Project!`](#project) | Project the secrets manager belong to. | | <a id="projectsecretsmanagerstatus"></a>`status` | [`ProjectSecretsManagerStatus`](#projectsecretsmanagerstatus) | Status of the project secrets manager. | +### `ProjectSecurityExclusion` + +Represents a project-level security scanner exclusion. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectsecurityexclusionactive"></a>`active` | [`Boolean!`](#boolean) | Whether the exclusion is active. | +| <a id="projectsecurityexclusiondescription"></a>`description` | [`String`](#string) | Optional description for the exclusion. | +| <a id="projectsecurityexclusionid"></a>`id` | [`ID!`](#id) | ID of the exclusion. | +| <a id="projectsecurityexclusionscanner"></a>`scanner` | [`ExclusionScannerEnum!`](#exclusionscannerenum) | Security scanner the exclusion will be used for. | +| <a id="projectsecurityexclusiontype"></a>`type` | [`ExclusionTypeEnum!`](#exclusiontypeenum) | Type of the exclusion. | +| <a id="projectsecurityexclusionvalue"></a>`value` | [`String!`](#string) | Value of the exclusion. | + ### `ProjectSecurityPolicySource` Represents the source of a security policy belonging to a project. @@ -36683,6 +36743,25 @@ Event action. | <a id="eventactionreopened"></a>`REOPENED` | Reopened action. | | <a id="eventactionupdated"></a>`UPDATED` | Updated action. | +### `ExclusionScannerEnum` + +Enum for the security scanners used with exclusions. + +| Value | Description | +| ----- | ----------- | +| <a id="exclusionscannerenumsecret_push_protection"></a>`SECRET_PUSH_PROTECTION` | Secret Push Protection. | + +### `ExclusionTypeEnum` + +Enum for types of exclusion for a security scanner. + +| Value | Description | +| ----- | ----------- | +| <a id="exclusiontypeenumpath"></a>`PATH` | File or directory location. | +| <a id="exclusiontypeenumraw_value"></a>`RAW_VALUE` | Raw value to ignore. | +| <a id="exclusiontypeenumregex_pattern"></a>`REGEX_PATTERN` | Regex pattern matching rules. | +| <a id="exclusiontypeenumrule"></a>`RULE` | Scanner rule identifier. | + ### `ExtensionsMarketplaceOptInStatus` Values for status of the Web IDE Extension Marketplace opt-in for the user. diff --git a/ee/app/finders/security/project_security_exclusions_finder.rb b/ee/app/finders/security/project_security_exclusions_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..d0049b98077809eab4f7188f4255127c5775c38e --- /dev/null +++ b/ee/app/finders/security/project_security_exclusions_finder.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Finder used to retrieve security scanners' exclusions for a project. +# +# Basic usage: +# +# Security::ProjectSecurityExclusionsFinder.new(current_user, project: project).execute +# +# Filter by scopes: +# +# Security::ProjectSecurityExclusionsFinder.new(current_user, project: project, params: { active: false }).execute +# +# Arguments: +# current_user - which user is requesting exclusions. +# project -which project to scope to. +# params: +# scanner: string +# type: string +# status: string +module Security + class ProjectSecurityExclusionsFinder + def initialize(current_user, project:, params: {}) + @current_user = current_user + @project = project + @params = params + end + + def execute + return ProjectSecurityExclusion.none unless can_read_project_security_exclusions? + + exclusions = project.security_exclusions + exclusions = by_scanner(exclusions) + exclusions = by_type(exclusions) + + by_status(exclusions) + end + + private + + attr_reader :current_user, :project, :params + + def can_read_project_security_exclusions? + Ability.allowed?(current_user, :read_project_security_exclusions, project) + end + + def by_scanner(exclusions) + return exclusions unless params[:scanner] + + exclusions.by_scanner(params[:scanner]) + end + + def by_type(exclusions) + return exclusions unless params[:type] + + exclusions.by_type(params[:type]) + end + + def by_status(exclusions) + return exclusions if params[:active].nil? + + exclusions.by_status(params[:active]) + end + end +end diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb index 9ae99b38ed113fb46175c7a71507d1bcd0413194..5aaa170de66951015a85439d5fb8be7e25fa21c4 100644 --- a/ee/app/graphql/ee/types/project_type.rb +++ b/ee/app/graphql/ee/types/project_type.rb @@ -465,6 +465,13 @@ module ProjectType alpha: { milestone: '17.4' }, description: 'Traces attached to the project.', resolver: ::Resolvers::Observability::TracesResolver + + field :security_exclusions, + ::Types::Security::ProjectSecurityExclusionType.connection_type, + null: true, + alpha: { milestone: '17.4' }, + description: 'Security exclusions of the project.', + resolver: ::Resolvers::Security::ProjectSecurityExclusionResolver end def tracking_key diff --git a/ee/app/graphql/resolvers/security/project_security_exclusion_resolver.rb b/ee/app/graphql/resolvers/security/project_security_exclusion_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..7a7ac3ed9af0ad2cc9a252ba8830f6f2caeac92d --- /dev/null +++ b/ee/app/graphql/resolvers/security/project_security_exclusion_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + module Security + class ProjectSecurityExclusionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::Security::ProjectSecurityExclusionType.connection_type, null: true + + authorize :read_project_security_exclusions + + description 'Find security scanner exclusions for a project.' + + argument :scanner, Types::Security::ExclusionScannerEnum, required: false, + description: 'Filter entries by scanner.' + + argument :type, Types::Security::ExclusionTypeEnum, required: false, + description: 'Filter entries by exclusion type.' + + argument :active, GraphQL::Types::Boolean, required: false, + description: 'Filter entries by active status.' + + def resolve(**args) + raise_resource_not_available_error! unless object.licensed_feature_available?(:security_exclusions) + + ::Security::ProjectSecurityExclusionsFinder.new(current_user, project: object, params: args).execute + end + end + end +end diff --git a/ee/app/graphql/resolvers/security_orchestration/pipeline_execution_policy_resolver.rb b/ee/app/graphql/resolvers/security_orchestration/pipeline_execution_policy_resolver.rb index 094fd2f99175d389db15ef2c2caa08dc1e030e1d..125757b87f913e5098953679387fb0bd20ec0388 100644 --- a/ee/app/graphql/resolvers/security_orchestration/pipeline_execution_policy_resolver.rb +++ b/ee/app/graphql/resolvers/security_orchestration/pipeline_execution_policy_resolver.rb @@ -19,7 +19,7 @@ class PipelineExecutionPolicyResolver < BaseResolver default_value: true def resolve(**args) - policies = Security::PipelineExecutionPoliciesFinder.new(context[:current_user], project, args).execute + policies = ::Security::PipelineExecutionPoliciesFinder.new(context[:current_user], project, args).execute construct_pipeline_execution_policies(policies) end end diff --git a/ee/app/graphql/resolvers/security_orchestration/policy_violations_resolver.rb b/ee/app/graphql/resolvers/security_orchestration/policy_violations_resolver.rb index 75bd03cbecd57473557264ff66d01098a683ed2f..734538e457fbfaa40d57721a6f18ed5e9343c8db 100644 --- a/ee/app/graphql/resolvers/security_orchestration/policy_violations_resolver.rb +++ b/ee/app/graphql/resolvers/security_orchestration/policy_violations_resolver.rb @@ -16,7 +16,7 @@ def resolve(**_args) raise_resource_not_available_error! '`save_policy_violation_data` feature flag is disabled.' \ if Feature.disabled?(:save_policy_violation_data, object.project) - Security::ScanResultPolicies::PolicyViolationDetails.new(object) + ::Security::ScanResultPolicies::PolicyViolationDetails.new(object) end end end diff --git a/ee/app/graphql/resolvers/security_orchestration/scan_execution_policy_resolver.rb b/ee/app/graphql/resolvers/security_orchestration/scan_execution_policy_resolver.rb index e5cc9eb02a36219b1c8fba53bdd6459bb70bcba7..196b5dcbeff6261871235234f2d3334cffb80263 100644 --- a/ee/app/graphql/resolvers/security_orchestration/scan_execution_policy_resolver.rb +++ b/ee/app/graphql/resolvers/security_orchestration/scan_execution_policy_resolver.rb @@ -24,7 +24,7 @@ class ScanExecutionPolicyResolver < BaseResolver default_value: true def resolve(**args) - policies = Security::ScanExecutionPoliciesFinder.new(context[:current_user], project, args).execute + policies = ::Security::ScanExecutionPoliciesFinder.new(context[:current_user], project, args).execute construct_scan_execution_policies(policies) end end diff --git a/ee/app/graphql/resolvers/security_orchestration/scan_result_policy_resolver.rb b/ee/app/graphql/resolvers/security_orchestration/scan_result_policy_resolver.rb index 248d9a743ec4219ffd1886fee705f9d40d006b9c..3025c9a93a752ad6c86b673bd3d7b5ef1480378f 100644 --- a/ee/app/graphql/resolvers/security_orchestration/scan_result_policy_resolver.rb +++ b/ee/app/graphql/resolvers/security_orchestration/scan_result_policy_resolver.rb @@ -19,7 +19,7 @@ class ScanResultPolicyResolver < BaseResolver default_value: true def resolve(**args) - policies = Security::ScanResultPoliciesFinder.new(context[:current_user], object, + policies = ::Security::ScanResultPoliciesFinder.new(context[:current_user], object, args.merge(include_invalid: true)).execute construct_scan_result_policies(policies) end diff --git a/ee/app/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver.rb b/ee/app/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver.rb index 4594711fefb3506404e25c86afe95a9ad75adf0e..c4ca4aee1d2cbbb841a88927cba2a682eea2fdec 100644 --- a/ee/app/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver.rb +++ b/ee/app/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver.rb @@ -23,7 +23,7 @@ class SecurityPolicyProjectSuggestionsResolver < BaseResolver def resolve(**args) args[:search_globally] = !gitlab_com_subscription? - Security::SecurityPolicyProjectsFinder + ::Security::SecurityPolicyProjectsFinder .new(container: object, current_user: current_user, params: args) .execute end diff --git a/ee/app/graphql/resolvers/security_report/finding_resolver.rb b/ee/app/graphql/resolvers/security_report/finding_resolver.rb index 91708b56de254b27214c736fd3555334b5817ee1..59b803bc5ee582c5ca77abba0d8312b341975557 100644 --- a/ee/app/graphql/resolvers/security_report/finding_resolver.rb +++ b/ee/app/graphql/resolvers/security_report/finding_resolver.rb @@ -13,9 +13,9 @@ class FindingResolver < BaseResolver def resolve(**args) if Feature.enabled?(:finding_resolver_use_pure_finder, pipeline.project) - Security::PureFindingsFinder.new(pipeline, params: { uuid: args[:uuid], scope: 'all' }).execute&.first + ::Security::PureFindingsFinder.new(pipeline, params: { uuid: args[:uuid], scope: 'all' }).execute&.first else - Security::FindingsFinder.new(pipeline, params: { uuid: args[:uuid], scope: 'all' }).execute&.findings&.first + ::Security::FindingsFinder.new(pipeline, params: { uuid: args[:uuid], scope: 'all' }).execute&.findings&.first end end end diff --git a/ee/app/graphql/resolvers/security_report_summary_resolver.rb b/ee/app/graphql/resolvers/security_report_summary_resolver.rb index 48b89dd52c3527f2fd7ed3b6d571a79125d1bf1d..e21fbd82d699e79a2004e25c3c7c1d622b31f2f4 100644 --- a/ee/app/graphql/resolvers/security_report_summary_resolver.rb +++ b/ee/app/graphql/resolvers/security_report_summary_resolver.rb @@ -13,7 +13,7 @@ class SecurityReportSummaryResolver < BaseResolver def resolve(lookahead:) return unless authorized_resource?(pipeline.project) - Security::ReportSummaryService.new( + ::Security::ReportSummaryService.new( pipeline, selection_information(lookahead) ).execute diff --git a/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb b/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb index 7e02c6d53264dcf2ea38440c498aa1c62cbc8573..ec9e60fd7a72a73a3218235c6653a2a6efa26d4d 100644 --- a/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb +++ b/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb @@ -94,7 +94,7 @@ def resolve(**args) private def vulnerabilities(filters) - Security::VulnerabilityReadsFinder.new(vulnerable, filters).execute + ::Security::VulnerabilityReadsFinder.new(vulnerable, filters).execute end end end diff --git a/ee/app/graphql/types/security/exclusion_scanner_enum.rb b/ee/app/graphql/types/security/exclusion_scanner_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..9726d03f5fba11812e90b0fddf86b92795a6906c --- /dev/null +++ b/ee/app/graphql/types/security/exclusion_scanner_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Security + class ExclusionScannerEnum < Types::BaseEnum + graphql_name 'ExclusionScannerEnum' + description 'Enum for the security scanners used with exclusions' + + value 'SECRET_PUSH_PROTECTION', value: 'secret_push_protection', description: 'Secret Push Protection.' + end + end +end diff --git a/ee/app/graphql/types/security/exclusion_type_enum.rb b/ee/app/graphql/types/security/exclusion_type_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..cf05cdfd8bc1451ec99c866f4806ee2320fdd21b --- /dev/null +++ b/ee/app/graphql/types/security/exclusion_type_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Security + class ExclusionTypeEnum < Types::BaseEnum + graphql_name 'ExclusionTypeEnum' + description 'Enum for types of exclusion for a security scanner' + + value 'PATH', value: 'path', description: 'File or directory location.' + value 'REGEX_PATTERN', value: 'regex_pattern', description: 'Regex pattern matching rules.' + value 'RAW_VALUE', value: 'raw_value', description: 'Raw value to ignore.' + value 'RULE', value: 'rule', description: 'Scanner rule identifier.' + end + end +end diff --git a/ee/app/graphql/types/security/project_security_exclusion_type.rb b/ee/app/graphql/types/security/project_security_exclusion_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..9f3eb142b0148192da5acdc39da1a92a16e14a20 --- /dev/null +++ b/ee/app/graphql/types/security/project_security_exclusion_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module Security + # rubocop: disable Graphql/AuthorizeTypes -- The resolver authorizes the request + class ProjectSecurityExclusionType < BaseObject + graphql_name 'ProjectSecurityExclusion' + description 'Represents a project-level security scanner exclusion' + + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the exclusion.' + + field :scanner, Types::Security::ExclusionScannerEnum, + null: false, + description: 'Security scanner the exclusion will be used for.' + + field :type, Types::Security::ExclusionTypeEnum, + null: false, + description: 'Type of the exclusion.' + + field :value, GraphQL::Types::String, + null: false, + description: 'Value of the exclusion.' + + field :description, GraphQL::Types::String, + null: true, + description: 'Optional description for the exclusion.' + + field :active, GraphQL::Types::Boolean, + null: false, + description: 'Whether the exclusion is active.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb index a4da6ffd030557a7a0380125941dfb05e30fde1c..22e454cbc742af4dbaaa583fcd7c37fa4ee4e526 100644 --- a/ee/app/models/gitlab_subscriptions/features.rb +++ b/ee/app/models/gitlab_subscriptions/features.rb @@ -265,7 +265,7 @@ class Features unique_project_download_limit vulnerability_finding_signatures container_scanning_for_registry - project_security_exclusions + security_exclusions ].freeze STARTER_FEATURES_WITH_USAGE_PING = %i[ diff --git a/ee/app/models/security/project_security_exclusion.rb b/ee/app/models/security/project_security_exclusion.rb index f85fb91867243b593e48fc55c5ae86bee649dc56..2d0e15905d17b2c76e5caef969b766016d42af89 100644 --- a/ee/app/models/security/project_security_exclusion.rb +++ b/ee/app/models/security/project_security_exclusion.rb @@ -12,5 +12,9 @@ class ProjectSecurityExclusion < Gitlab::Database::SecApplicationRecord validates :scanner, :type, :value, :project, presence: true validates :active, inclusion: { in: [true, false] } validates :value, :description, length: { maximum: 255 } + + scope :by_scanner, ->(scanner) { where(scanner: scanner) } + scope :by_type, ->(type) { where(type: type) } + scope :by_status, ->(status) { where(active: status) } end end diff --git a/ee/lib/api/vulnerabilities.rb b/ee/lib/api/vulnerabilities.rb index 204e3b8c6a94ef291a9de63c1436a01b7104d616..f1252151add73bea02f2f85f9c35d5febc1cd9b5 100644 --- a/ee/lib/api/vulnerabilities.rb +++ b/ee/lib/api/vulnerabilities.rb @@ -12,7 +12,7 @@ class Vulnerabilities < ::API::Base helpers do def vulnerabilities_by(project) - Security::VulnerabilityReadsFinder.new(project).execute.as_vulnerabilities + ::Security::VulnerabilityReadsFinder.new(project).execute.as_vulnerabilities end def find_vulnerability! diff --git a/ee/lib/api/vulnerability_findings.rb b/ee/lib/api/vulnerability_findings.rb index 5aff0c2b5c9d907df1068511f1f10fde0a8b7ceb..c63de9c73558f9b7f1681376a05fee60ff29d325 100644 --- a/ee/lib/api/vulnerability_findings.rb +++ b/ee/lib/api/vulnerability_findings.rb @@ -28,7 +28,7 @@ def vulnerability_findings end def findings - pure_finder = Security::PureFindingsFinder.new(pipeline, params: finder_params) + pure_finder = ::Security::PureFindingsFinder.new(pipeline, params: finder_params) pure_finder.execute .tap { |findings| findings.each(&:remediations) } # initiates Batchloader diff --git a/ee/spec/factories/security/project_security_exclusions.rb b/ee/spec/factories/security/project_security_exclusions.rb index 428648dda78a1bdce7fc0f63f68cfdac75c7182a..a7d33e7299d0ab7cd1830628ab85c7d985b47e31 100644 --- a/ee/spec/factories/security/project_security_exclusions.rb +++ b/ee/spec/factories/security/project_security_exclusions.rb @@ -4,7 +4,7 @@ factory :project_security_exclusion, class: 'Security::ProjectSecurityExclusion' do scanner { 'secret_push_protection' } description { 'basic exclusion with a specific value to exclude from scanning' } - type { 'raw_value' } + type { :raw_value } value { '01234567890123456789-glpat'.reverse } active { true } end diff --git a/ee/spec/finders/security/project_security_exclusions_finder_spec.rb b/ee/spec/finders/security/project_security_exclusions_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..90fd1a104ff4b4b1c06cc5b1d79ac7ee86aa51f4 --- /dev/null +++ b/ee/spec/finders/security/project_security_exclusions_finder_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ProjectSecurityExclusionsFinder, feature_category: :secret_detection do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + let(:exclusions) do + { + inactive: create(:project_security_exclusion, project: project, active: false), + raw_value: create(:project_security_exclusion, project: project), + path: create(:project_security_exclusion, project: project, type: :path, value: 'spec/models/project_spec.rb'), + regex: create(:project_security_exclusion, project: project, type: :regex_pattern, value: 'SK[0-9a-fA-F]{32}'), + rule: create(:project_security_exclusion, project: project, type: :rule, value: 'gitlab_personal_access_token') + } + end + + let(:params) { {} } + + subject(:finder) { described_class.new(user, project: project, params: params) } + + shared_examples 'returns expected exclusions' do |expected_exclusions| + it 'returns the correct exclusions' do + expect(finder.execute).to contain_exactly(*expected_exclusions.map { |key| exclusions[key] }) + end + end + + describe '#execute' do + context 'with a role that can read security exclusions' do + before_all { project.add_maintainer(user) } + + context 'without filters' do + include_examples 'returns expected exclusions', [:rule, :regex, :raw_value, :path, :inactive] + end + + context 'when filtering by security scanner' do + let(:params) { { scanner: 'secret_push_protection' } } + + include_examples 'returns expected exclusions', [:rule, :regex, :raw_value, :path, :inactive] + end + + context 'when filtering by exclusion type' do + let(:params) { { type: 'rule' } } + + include_examples 'returns expected exclusions', [:rule] + end + + context 'when filtering by exclusion status' do + let(:params) { { active: true } } + + include_examples 'returns expected exclusions', [:rule, :regex, :raw_value, :path] + end + end + + context 'with a role that cannot read security exclusions' do + before_all { project.add_reporter(user) } + + it 'returns no exclusions' do + expect(finder.execute).to be_empty + end + end + end +end diff --git a/ee/spec/graphql/resolvers/pipeline_security_report_findings_resolver_spec.rb b/ee/spec/graphql/resolvers/pipeline_security_report_findings_resolver_spec.rb index a9d874dbfae0b9f6cae7489e06065986c3a59d93..67471b55a2cf019d830895e133159a1f3057406d 100644 --- a/ee/spec/graphql/resolvers/pipeline_security_report_findings_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/pipeline_security_report_findings_resolver_spec.rb @@ -17,10 +17,10 @@ let(:params) { {} } - let(:mock_pure_finder) { instance_double(Security::PureFindingsFinder, execute: returned_findings) } + let(:mock_pure_finder) { instance_double(::Security::PureFindingsFinder, execute: returned_findings) } before do - allow(Security::PureFindingsFinder).to receive(:new).and_return(mock_pure_finder) + allow(::Security::PureFindingsFinder).to receive(:new).and_return(mock_pure_finder) end context 'when given severities' do diff --git a/ee/spec/graphql/resolvers/security/project_security_exclusion_resolver_spec.rb b/ee/spec/graphql/resolvers/security/project_security_exclusion_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..134c915416609c6a4573edbfae3032fcbeb3d7ea --- /dev/null +++ b/ee/spec/graphql/resolvers/security/project_security_exclusion_resolver_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Security::ProjectSecurityExclusionResolver, feature_category: :secret_detection do + include GraphqlHelpers + + describe '#resolve' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:active_exclusion) { create(:project_security_exclusion, project: project) } + let_it_be(:inactive_exclusion) { create(:project_security_exclusion, project: project, active: false) } + + let(:args) { {} } + + subject(:resolver) { resolve(described_class, obj: project, ctx: { current_user: user }, args: args) } + + context 'when the feature is not licensed' do + it 'raises a resource not available error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolver + end + end + end + + context 'when the feature is licensed' do + before do + stub_licensed_features(security_exclusions: true) + end + + context 'for a role that can read security exclusions' do + before_all do + project.add_maintainer(user) + end + + it 'calls ProjectSecurityExclusionsFinder with correct arguments' do + finder = instance_double( + ::Security::ProjectSecurityExclusionsFinder, + execute: [active_exclusion, inactive_exclusion] + ) + + expect(::Security::ProjectSecurityExclusionsFinder).to receive(:new) + .with(user, project: project, params: args) + .and_return(finder) + + resolver + end + + it 'returns all exclusions when no arguments are provided' do + expect(resolver).to contain_exactly(active_exclusion, inactive_exclusion) + end + + context 'when filtering by scanner' do + let(:args) { { scanner: 'secret_push_protection' } } + + it 'passes the scanner argument to the finder' do + expect(::Security::ProjectSecurityExclusionsFinder).to receive(:new) + .with(user, project: project, params: hash_including(scanner: 'secret_push_protection')) + .and_call_original + + resolver + end + end + + context 'when filtering by type' do + let(:args) { { type: 'raw_value' } } + + it 'passes the type argument to the finder' do + expect(::Security::ProjectSecurityExclusionsFinder).to receive(:new) + .with(user, project: project, params: hash_including(type: 'raw_value')) + .and_call_original + + resolver + end + end + + context 'when filtering by active status' do + let(:args) { { active: true } } + + it 'passes the status argument to the finder' do + expect(::Security::ProjectSecurityExclusionsFinder).to receive(:new) + .with(user, project: project, params: hash_including(active: true)) + .and_call_original + + resolver + end + end + end + + context 'for a role that cannot read security exclusions' do + before_all do + project.add_reporter(user) + end + + it 'returns no exclusions' do + expect(resolver).to be_empty + end + end + end + end +end diff --git a/ee/spec/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver_spec.rb b/ee/spec/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver_spec.rb index aa07e547895744b02a3cc85b508b473a5559a1ad..8117abc35976e0e3b37a9edffd8f1b0dbb7cc408 100644 --- a/ee/spec/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/security_orchestration/security_policy_project_suggestions_resolver_spec.rb @@ -108,7 +108,7 @@ let(:args) { { search: project.full_path } } before do - expect_next_instance_of(Security::SecurityPolicyProjectsFinder) do |service| + expect_next_instance_of(::Security::SecurityPolicyProjectsFinder) do |service| allow(service).to receive(:global_matching_projects) \ .and_return(Project.none) .once diff --git a/ee/spec/graphql/resolvers/security_report_summary_resolver_spec.rb b/ee/spec/graphql/resolvers/security_report_summary_resolver_spec.rb index dfd090cb5c4a95513edea84b387c9ac6697dd6a3..ea7f8ec84a29a8477de8e53be353c96b98f4c582 100644 --- a/ee/spec/graphql/resolvers/security_report_summary_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/security_report_summary_resolver_spec.rb @@ -31,7 +31,7 @@ it 'returns calls the ReportSummaryService' do expect_next_instance_of( - Security::ReportSummaryService, + ::Security::ReportSummaryService, pipeline, expected_selection_info ) do |summary_service| @@ -71,7 +71,7 @@ it 'does not search for :__typename' do expect_next_instance_of( - Security::ReportSummaryService, + ::Security::ReportSummaryService, pipeline, expected_selection_info ) do |summary_service| diff --git a/ee/spec/graphql/types/project_type_spec.rb b/ee/spec/graphql/types/project_type_spec.rb index a736ab45a3d18c0aae59c392149eb88c20378bb8..41c3aaf89289cc7e624084ad709003c9a0c32a87 100644 --- a/ee/spec/graphql/types/project_type_spec.rb +++ b/ee/spec/graphql/types/project_type_spec.rb @@ -33,7 +33,7 @@ runner_cloud_provisioning google_cloud_artifact_registry_repository marked_for_deletion_on is_adjourned_deletion_enabled permanent_deletion_date ai_metrics saved_reply merge_trains pending_member_approvals observability_logs_links observability_metrics_links - observability_traces_links dependencies + observability_traces_links dependencies security_exclusions ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/ee/spec/graphql/types/security/exclusion_scanner_enum_spec.rb b/ee/spec/graphql/types/security/exclusion_scanner_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6337dfdb853cbfc04c86553d50fc6dfaade893dc --- /dev/null +++ b/ee/spec/graphql/types/security/exclusion_scanner_enum_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['ExclusionScannerEnum'], feature_category: :secret_detection do + it { expect(described_class.graphql_name).to eq('ExclusionScannerEnum') } + it { expect(described_class.values.keys).to include(*%w[SECRET_PUSH_PROTECTION]) } +end diff --git a/ee/spec/graphql/types/security/exclusion_type_enum_spec.rb b/ee/spec/graphql/types/security/exclusion_type_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0ec05d26c5a8c870d025d7e9226f8134c939d1e3 --- /dev/null +++ b/ee/spec/graphql/types/security/exclusion_type_enum_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['ExclusionTypeEnum'], feature_category: :secret_detection do + it { expect(described_class.graphql_name).to eq('ExclusionTypeEnum') } + it { expect(described_class.values.keys).to include(*%w[PATH REGEX_PATTERN RAW_VALUE RULE]) } +end diff --git a/ee/spec/graphql/types/security/project_security_exclusion_type_spec.rb b/ee/spec/graphql/types/security/project_security_exclusion_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2c4bca04b413a9ec57229be4804f07f1c470ba7b --- /dev/null +++ b/ee/spec/graphql/types/security/project_security_exclusion_type_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['ProjectSecurityExclusion'], feature_category: :secret_detection do + it { expect(described_class.graphql_name).to eq('ProjectSecurityExclusion') } + it { expect(described_class).to have_graphql_fields(:id, :scanner, :type, :value, :description, :active) } +end diff --git a/ee/spec/models/security/project_security_exclusion_spec.rb b/ee/spec/models/security/project_security_exclusion_spec.rb index 31b29c5d2c76529c0ca3e958dbf96cd837dcadf1..01e9ef611f0993df886faa8f9b5819bc4c45f0d6 100644 --- a/ee/spec/models/security/project_security_exclusion_spec.rb +++ b/ee/spec/models/security/project_security_exclusion_spec.rb @@ -21,6 +21,32 @@ it { is_expected.to define_enum_for(:type).with_values([:path, :regex_pattern, :raw_value, :rule]) } end + describe 'scopes' do + let_it_be(:project) { create(:project) } + let_it_be(:exclusion_1) { create(:project_security_exclusion, project: project) } + let_it_be(:exclusion_2) { create(:project_security_exclusion, project: project, active: false) } + let_it_be(:exclusion_3) { create(:project_security_exclusion, project: project, type: :path) } + + describe '.by_scanner' do + it 'returns the correct records' do + expect(described_class.by_scanner(:secret_push_protection)).to match_array([exclusion_1, exclusion_2, + exclusion_3]) + end + end + + describe '.by_type' do + it 'returns the correct records' do + expect(described_class.by_type(:raw_value)).to match_array([exclusion_1, exclusion_2]) + end + end + + describe '.by_status' do + it 'returns the correct records' do + expect(described_class.by_status(true)).to match_array([exclusion_1, exclusion_3]) + end + end + end + context 'with loose foreign key on project_security_exclusions.project_id' do it_behaves_like 'cleanup by a loose foreign key' do let_it_be(:parent) { create(:project) }