diff --git a/app/graphql/types/security/ref_input_type.rb b/app/graphql/types/security/ref_input_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..df6aaf507c30955eda9e29d98503c9848fedc93b
--- /dev/null
+++ b/app/graphql/types/security/ref_input_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module Security
+ class RefInputType < BaseInputObject
+ graphql_name 'Ref'
+ description 'Input for specifying a Git reference'
+
+ argument :name, GraphQL::Types::String, required: true,
+ description: 'Name of the ref.'
+
+ argument :ref_type, Types::RefTypeEnum, required: true,
+ description: 'Type of the ref (heads or tags).'
+ end
+ end
+end
diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index 26ed284ec1e8a16e75b77f6923e94d81a63ab451..b44aee9ad15a05ff772ad3124026f6872b6b9463 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -23083,6 +23083,29 @@ The edge type for [`SecurityPolicyType`](#securitypolicytype).
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`SecurityPolicyType`](#securitypolicytype) | The item at the end of the edge. |
+#### `SecurityTrackedRefConnection`
+
+The connection type for [`SecurityTrackedRef`](#securitytrackedref).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `edges` | [`[SecurityTrackedRefEdge]`](#securitytrackedrefedge) | A list of edges. |
+| `nodes` | [`[SecurityTrackedRef]`](#securitytrackedref) | A list of nodes. |
+| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `SecurityTrackedRefEdge`
+
+The edge type for [`SecurityTrackedRef`](#securitytrackedref).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| `node` | [`SecurityTrackedRef`](#securitytrackedref) | The item at the end of the edge. |
+
#### `SentryErrorConnection`
The connection type for [`SentryError`](#sentryerror).
@@ -42040,6 +42063,7 @@ Project-level settings for product analytics provider.
| `securityPolicyProjectLinkedProjects` | [`ProjectConnection`](#projectconnection) | Projects linked to the project, when used as Security Policy Project. (see [Connections](#connections)) |
| `securityScanProfiles` {{< icon name="warning-solid" >}} | [`[ScanProfileType!]`](#scanprofiletype) | **Introduced** in GitLab 18.7. **Status**: Experiment. Security scan profiles attached to the project. |
| `securityScanners` | [`SecurityScanners`](#securityscanners) | Information about security analyzers used in the project. |
+| `securityTrackedRefs` {{< icon name="warning-solid" >}} | [`SecurityTrackedRefConnection`](#securitytrackedrefconnection) | **Introduced** in GitLab 18.8. **Status**: Experiment. Refs tracked for security vulnerabilities. |
| `sentryErrors` | [`SentryErrorCollection`](#sentryerrorcollection) | Paginated collection of Sentry errors on the project. |
| `serviceDeskAddress` | [`String`](#string) | E-mail address of the Service Desk. |
| `serviceDeskEnabled` | [`Boolean`](#boolean) | Indicates if the project has Service Desk enabled. |
@@ -46397,6 +46421,23 @@ Represents a list of security scanners.
| `enabled` | [`[SecurityScannerType!]`](#securityscannertype) | List of analyzers which are enabled for the project. |
| `pipelineRun` | [`[SecurityScannerType!]`](#securityscannertype) | List of analyzers which ran successfully in the latest pipeline. |
+### `SecurityTrackedRef`
+
+Represents a ref (branch or tag) tracked for security vulnerabilities.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `commit` | [`Commit`](#commit) | Latest commit on the ref. |
+| `id` | [`ID!`](#id) | Global ID of the tracked ref. |
+| `isDefault` | [`Boolean!`](#boolean) | Whether the ref is the default branch. |
+| `isProtected` | [`Boolean!`](#boolean) | Whether the ref is protected. |
+| `name` | [`String!`](#string) | Name of the ref (branch or tag name). |
+| `refType` | [`RefType!`](#reftype) | Type of the ref (branch or tag). |
+| `trackedAt` | [`Time!`](#time) | When tracking was enabled for the ref. |
+| `vulnerabilitiesCount` | [`Int!`](#int) | Count of open vulnerabilities on the ref. |
+
### `SecurityTrainingUrl`
Represents a URL related to a security training.
diff --git a/ee/app/finders/security/tracked_refs_finder.rb b/ee/app/finders/security/tracked_refs_finder.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ed694e784b7dc06217f6a16f6693ed8ddcee3799
--- /dev/null
+++ b/ee/app/finders/security/tracked_refs_finder.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Security
+ class TrackedRefsFinder
+ def initialize(project, current_user = nil)
+ @project = project
+ @current_user = current_user
+ end
+
+ def execute
+ return Security::ProjectTrackedContext.none unless can_read_security_refs?
+
+ @project.security_project_tracked_contexts.tracked
+ end
+
+ private
+
+ attr_reader :project, :current_user
+
+ def can_read_security_refs?
+ Ability.allowed?(current_user, :read_security_resource, project)
+ end
+ end
+end
diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb
index b120e9c664b2e22d2c5370833cf652177a531d7d..203dfe6dc40a96248dabf8fbf5149a73a5caa6ef 100644
--- a/ee/app/graphql/ee/types/project_type.rb
+++ b/ee/app/graphql/ee/types/project_type.rb
@@ -716,6 +716,13 @@ module ProjectType
description: 'Security scan profiles attached to the project.',
authorize: :read_security_scan_profiles,
experiment: { milestone: '18.7' }
+
+ field :security_tracked_refs,
+ ::Types::Security::TrackedRefType.connection_type,
+ null: true,
+ description: 'Refs tracked for security vulnerabilities.',
+ resolver: ::Resolvers::Security::TrackedRefsResolver,
+ experiment: { milestone: '18.8' }
end
def tracking_key
diff --git a/ee/app/graphql/resolvers/security/tracked_refs_resolver.rb b/ee/app/graphql/resolvers/security/tracked_refs_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..50b973e96c8d03cd5d583a642880c36bb442136c
--- /dev/null
+++ b/ee/app/graphql/resolvers/security/tracked_refs_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Security
+ class TrackedRefsResolver < BaseResolver
+ type Types::Security::TrackedRefType.connection_type, null: true
+
+ authorize :read_security_resource
+
+ description 'Security tracked refs for vulnerability tracking'
+
+ def resolve
+ ::Security::TrackedRefsFinder.new(object, current_user).execute
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/types/security/tracked_ref_type.rb b/ee/app/graphql/types/security/tracked_ref_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c5c6e1048d37e61c511b244b505b0975d1030180
--- /dev/null
+++ b/ee/app/graphql/types/security/tracked_ref_type.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Types
+ module Security
+ class TrackedRefType < BaseObject
+ graphql_name 'SecurityTrackedRef'
+ description 'Represents a ref (branch or tag) tracked for security vulnerabilities'
+
+ authorize :read_security_resource
+
+ field :id, GraphQL::Types::ID, null: false,
+ description: 'Global ID of the tracked ref.'
+
+ field :name, GraphQL::Types::String, null: false,
+ description: 'Name of the ref (branch or tag name).'
+
+ field :ref_type, Types::RefTypeEnum, null: false,
+ description: 'Type of the ref (branch or tag).'
+
+ field :is_default, GraphQL::Types::Boolean, null: false,
+ description: 'Whether the ref is the default branch.'
+
+ field :is_protected, GraphQL::Types::Boolean, null: false,
+ description: 'Whether the ref is protected.'
+
+ field :commit, Types::Repositories::CommitType, null: true,
+ description: 'Latest commit on the ref.'
+
+ field :vulnerabilities_count, GraphQL::Types::Int, null: false,
+ description: 'Count of open vulnerabilities on the ref.'
+
+ field :tracked_at, Types::TimeType, null: false,
+ description: 'When tracking was enabled for the ref.'
+ end
+ end
+end
diff --git a/ee/app/policies/security/project_tracked_context_policy.rb b/ee/app/policies/security/project_tracked_context_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5e2e393065f6f7b8564ab32ca08337e270818ac8
--- /dev/null
+++ b/ee/app/policies/security/project_tracked_context_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Security
+ class ProjectTrackedContextPolicy < BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/ee/spec/finders/security/tracked_refs_finder_spec.rb b/ee/spec/finders/security/tracked_refs_finder_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4fa9aa899b2b51d55d49383d8f3a2e35598ff9d6
--- /dev/null
+++ b/ee/spec/finders/security/tracked_refs_finder_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Security::TrackedRefsFinder, feature_category: :vulnerability_management do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:main_branch) { create(:security_project_tracked_context, :tracked, :default, project: project) }
+ let_it_be(:tag_ref) do
+ create(:security_project_tracked_context, :tracked, :tag, project: project, context_name: 'v1.0')
+ end
+
+ subject(:finder) { described_class.new(project, user) }
+
+ before do
+ stub_licensed_features(security_dashboard: true)
+ end
+
+ describe '#execute' do
+ context 'when user has developer access' do
+ before_all { project.add_developer(user) }
+
+ it 'returns all tracked refs for the project' do
+ expect(finder.execute).to contain_exactly(main_branch, tag_ref)
+ end
+ end
+
+ context 'when user lacks permission' do
+ it 'returns empty relation' do
+ expect(finder.execute).to be_empty
+ end
+ end
+
+ context 'when user is nil' do
+ let(:finder) { described_class.new(project, nil) }
+
+ it 'returns empty relation' do
+ expect(finder.execute).to be_empty
+ end
+ end
+ end
+end
diff --git a/ee/spec/graphql/resolvers/security/tracked_refs_resolver_spec.rb b/ee/spec/graphql/resolvers/security/tracked_refs_resolver_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..94c2096462bbbfd5396a202b512482d6bcf79724
--- /dev/null
+++ b/ee/spec/graphql/resolvers/security/tracked_refs_resolver_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Security::TrackedRefsResolver, feature_category: :vulnerability_management do
+ include GraphqlHelpers
+
+ before_all do
+ Security::ProjectTrackedContext.delete_all
+ end
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ let_it_be(:main_branch) do
+ create(:security_project_tracked_context, :tracked, :default,
+ project: project, context_name: 'main', context_type: :branch)
+ end
+
+ let_it_be(:tag_ref) do
+ create(:security_project_tracked_context, :tracked,
+ project: project, context_name: 'v1.1.0', context_type: :tag)
+ end
+
+ let_it_be(:untracked_ref) do
+ create(:security_project_tracked_context, :untracked,
+ project: project, context_name: 'untracked-branch', context_type: :branch)
+ end
+
+ subject(:resolver) { described_class }
+
+ specify { expect(resolver.type).to eq(Types::Security::TrackedRefType.connection_type) }
+ specify { expect(resolver.null).to be_truthy }
+ specify { expect(resolver.required_permissions).to include(:read_security_resource) }
+
+ describe '#resolve' do
+ before do
+ stub_licensed_features(security_dashboard: true)
+ end
+
+ context 'when user has permission' do
+ before_all do
+ project.add_developer(user)
+ end
+
+ it 'returns tracked refs for the project' do
+ result = resolve(resolver, obj: project, ctx: { current_user: user })
+
+ expect(result).to contain_exactly(main_branch, tag_ref)
+ expect(result).not_to include(untracked_ref)
+ end
+
+ it 'supports pagination' do
+ result = resolve(resolver, obj: project, ctx: { current_user: user }, args: { first: 1 })
+
+ expect(result).to be_a(Gitlab::Graphql::Pagination::Keyset::Connection)
+ expect(result.nodes.size).to be <= 1
+ end
+ end
+
+ context 'when user lacks permission' do
+ it 'returns empty result' do
+ result = resolve(resolver, obj: project, ctx: { current_user: user })
+
+ expect(result).to be_empty
+ end
+ end
+
+ context 'when user is nil' do
+ it 'returns empty result' do
+ result = resolve(resolver, obj: project, ctx: { current_user: nil })
+
+ expect(result).to be_empty
+ end
+ end
+
+ context 'when security dashboard is not licensed' do
+ before_all do
+ project.add_developer(user)
+ end
+
+ before do
+ stub_licensed_features(security_dashboard: false)
+ end
+
+ it 'returns empty result' do
+ result = resolve(resolver, obj: project, ctx: { current_user: user })
+
+ expect(result).to be_empty
+ end
+ end
+ end
+end
diff --git a/ee/spec/graphql/types/project_type_spec.rb b/ee/spec/graphql/types/project_type_spec.rb
index 6b87d331325215dee3ca66141d7bbdef5f00f840..dc4cd21a8048cf078ae3c85212a3b52503be59c4 100644
--- a/ee/spec/graphql/types/project_type_spec.rb
+++ b/ee/spec/graphql/types/project_type_spec.rb
@@ -39,7 +39,7 @@
compliance_standards_adherence target_branch_rules duo_workflow_status_check component_usages
vulnerability_archives component_versions vulnerability_statistic analyzer_statuses
compliance_requirement_statuses duo_agentic_chat_available container_scanning_for_registry_enabled duo_workflow_workflows
- duo_workflow_events security_metrics ai_catalog_item security_scan_profiles
+ duo_workflow_events security_metrics ai_catalog_item security_scan_profiles security_tracked_refs
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/ee/spec/graphql/types/security/ref_input_type_spec.rb b/ee/spec/graphql/types/security/ref_input_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4a9c44066f1acd885bcf22b69a39cc0d7c681d3c
--- /dev/null
+++ b/ee/spec/graphql/types/security/ref_input_type_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::Security::RefInputType, feature_category: :vulnerability_management do
+ specify { expect(described_class.graphql_name).to eq('Ref') }
+ specify { expect(described_class.description).to eq('Input for specifying a Git reference') }
+
+ it 'has the expected arguments' do
+ expect(described_class.arguments.keys).to contain_exactly('name', 'refType')
+ end
+
+ describe 'name argument' do
+ let(:argument) { described_class.arguments['name'] }
+
+ it 'has correct properties' do
+ expect(argument.type).to eq(GraphQL::Types::String.to_non_null_type)
+ expect(argument.description).to eq('Name of the ref.')
+ end
+ end
+
+ describe 'refType argument' do
+ let(:argument) { described_class.arguments['refType'] }
+
+ it 'has correct properties' do
+ expect(argument.type).to eq(Types::RefTypeEnum.to_non_null_type)
+ expect(argument.description).to eq('Type of the ref (heads or tags).')
+ end
+ end
+end
diff --git a/ee/spec/graphql/types/security/tracked_ref_type_spec.rb b/ee/spec/graphql/types/security/tracked_ref_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b4a746339e4c5ebb41311472ac0a32ad4b315f0d
--- /dev/null
+++ b/ee/spec/graphql/types/security/tracked_ref_type_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::Security::TrackedRefType, feature_category: :vulnerability_management do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:tracked_context) do
+ create(:security_project_tracked_context, :tracked, :default,
+ project: project, context_name: 'main', context_type: :branch)
+ end
+
+ before_all do
+ project.add_developer(user)
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_security_resource) }
+
+ describe 'field definitions' do
+ subject { described_class }
+
+ it { is_expected.to have_graphql_field(:id) }
+ it { is_expected.to have_graphql_field(:name) }
+ it { is_expected.to have_graphql_field(:ref_type) }
+ it { is_expected.to have_graphql_field(:is_default) }
+ it { is_expected.to have_graphql_field(:is_protected) }
+ it { is_expected.to have_graphql_field(:commit) }
+ it { is_expected.to have_graphql_field(:vulnerabilities_count) }
+ it { is_expected.to have_graphql_field(:tracked_at) }
+
+ it 'has correct field descriptions' do
+ expect(described_class.fields['id'].description).to include('Global ID')
+ expect(described_class.fields['name'].description).to include('Name of the ref')
+ expect(described_class.fields['refType'].description).to include('Type of the ref')
+ expect(described_class.fields['isDefault'].description).to include('Whether the ref is the default branch')
+ expect(described_class.fields['isProtected'].description).to include('Whether the ref is protected')
+ expect(described_class.fields['commit'].description).to include('Latest commit')
+ expect(described_class.fields['vulnerabilitiesCount'].description).to include('Count of open vulnerabilities')
+ expect(described_class.fields['trackedAt'].description).to include('When tracking was enabled')
+ end
+ end
+end