diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 4d7dc7f26a0e1eca08ed78b8c5ef6726b43e6351..481f5221287e55e7e7aa318486e9444d50f51a58 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -521,6 +521,11 @@ "format": "uri-reference", "pattern": "^https?://.+\\.ya?ml$" }, + "integrity": { + "description": "SHA256 integrity hash of the remote file content.", + "type": "string", + "pattern": "^sha256-[A-Za-z0-9+/]{43}=$" + }, "rules": { "$ref": "#/definitions/includeRules" }, diff --git a/doc/ci/yaml/_index.md b/doc/ci/yaml/_index.md index 5386cc61296aabb474fd720b81197db66dfc6218..95b5587922909bc80c28a53968bf9723569fe78e 100644 --- a/doc/ci/yaml/_index.md +++ b/doc/ci/yaml/_index.md @@ -177,6 +177,7 @@ And optionally: - [`include:inputs`](#includeinputs) - [`include:rules`](#includerules) +- [`include:integrity`](#includeintegrity) **Additional details**: @@ -349,7 +350,8 @@ include: so you can only include public projects or templates. No variables are available in the `include` section of nested includes. - Be careful when including another project's CI/CD configuration file. No pipelines or notifications trigger when the other project's files change. From a security perspective, this is similar to - pulling a third-party dependency. If you link to another GitLab project you own, consider the use of both + pulling a third-party dependency. To verify the integrity of the included file, consider using the [`integrity`](#includeintegrity) keyword. + If you link to another GitLab project you own, consider the use of both [protected branches](../../user/project/repository/branches/protected.md) and [protected tags](../../user/project/protected_tags.md#prevent-tag-creation-with-the-same-name-as-branches) to enforce change management rules. @@ -465,6 +467,26 @@ In this example, if the `INCLUDE_BUILDS` variable is: - [`rules:changes`](includes.md#include-with-ruleschanges). - [`rules:exists`](includes.md#include-with-rulesexists). +#### `include:integrity` + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178593) in GitLab 17.9. + +Use `integrity` with `include:remote` to specifiy a SHA256 hash of the included remote file. +If `integrity` does not match the actual content, the remote file is not processed +and the pipeline fails. + +**Keyword type**: Global keyword. + +**Supported values**: Base64-encoded SHA256 hash of the included content. + +**Example of `include:integrity`**: + +```yaml +include: + - remote: 'https://gitlab.com/example-project/-/raw/main/.gitlab-ci.yml' + integrity: 'sha256-L3/GAoKaw0Arw6hDCKeKQlV1QPEgHYxGBHsH4zG1IY8=' +``` + ### `stages` > - Support for nested array of strings [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/439451) in GitLab 16.9. diff --git a/lib/gitlab/ci/config/entry/include.rb b/lib/gitlab/ci/config/entry/include.rb index 1896aad4817fdf1d88858fa0bbadb1cbd18c0c17..13aa8fef92927707b0f4d18543dd7d2503242873 100644 --- a/lib/gitlab/ci/config/entry/include.rb +++ b/lib/gitlab/ci/config/entry/include.rb @@ -12,7 +12,7 @@ class Include < ::Gitlab::Config::Entry::Node include ::Gitlab::Config::Entry::Configurable include ::Gitlab::Config::Entry::Attributable - ALLOWED_KEYS = %i[local file remote template component artifact inputs job project ref rules].freeze + ALLOWED_KEYS = %i[local file remote template component artifact inputs job project ref rules integrity].freeze validations do validates :config, hash_or_string: true @@ -28,6 +28,22 @@ class Include < ::Gitlab::Config::Entry::Node if config[:project] && config[:file].blank? errors.add(:config, "must specify the file where to fetch the config from") end + + if config[:integrity] + errors.add(:config, "integrity can only be specified for remote includes") if config[:remote].blank? + + unless config[:integrity].is_a?(String) && config[:integrity].start_with?('sha256-') + errors.add(:config, "integrity hash must start with 'sha256-'") + next + end + + hash = config[:integrity].delete_prefix('sha256-') + begin + Base64.strict_decode64(hash) + rescue ArgumentError + errors.add(:config, "integrity hash must be base64 encoded") + end + end end with_options allow_nil: true do diff --git a/lib/gitlab/ci/config/external/file/remote.rb b/lib/gitlab/ci/config/external/file/remote.rb index a7c7c0c2d05c89942ee9bd2e9cb03d8415120ab7..3afba9022060049907aaa5efc0804f86eb420b5e 100644 --- a/lib/gitlab/ci/config/external/file/remote.rb +++ b/lib/gitlab/ci/config/external/file/remote.rb @@ -20,7 +20,9 @@ def preload_content def content fetch_with_error_handling do - fetch_async_content.value + fetch_async_content.value.tap do |content| + verify_integrity(content) if params[:integrity] + end end end strong_memoize_attr :content @@ -76,6 +78,17 @@ def fetch_with_error_handling response.body if errors.none? end + + def verify_integrity(content) + expected_hash = params[:integrity].delete_prefix('sha256-') + actual_hash = Base64.strict_encode64( + Digest::SHA256.digest(content) + ) + + unless Rack::Utils.secure_compare(actual_hash, expected_hash) + errors.push("Remote file `#{masked_location}` failed integrity check!") + end + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/include_spec.rb b/spec/lib/gitlab/ci/config/entry/include_spec.rb index c3b826954874f5312d5d5c9d55db32c54fab143e..cf0c94fab6c5e512713bda039522c41cb5fb84d8 100644 --- a/spec/lib/gitlab/ci/config/entry/include_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/include_spec.rb @@ -114,6 +114,69 @@ end end end + + context 'when using "remote" with integrity' do + let(:config) do + { + remote: 'https://example.com/file.yml', + integrity: 'sha256-abc123def456' + } + end + + it { is_expected.to be_valid } + + context 'when integrity has invalid format' do + ['invalid-hash', 123].each do |invalid_integrity| + context "when integrity is #{invalid_integrity}" do + let(:config) do + { + remote: 'https://example.com/file.yml', + integrity: invalid_integrity + } + end + + it { is_expected.not_to be_valid } + + it 'has specific error' do + expect(include_entry.errors) + .to include('include config integrity hash must start with \'sha256-\'') + end + end + end + end + + context 'when integrity is not base64 encoded' do + let(:config) do + { + remote: 'https://example.com/file.yml', + integrity: 'sha256-not!valid@base64' + } + end + + it { is_expected.not_to be_valid } + + it 'has specific error' do + expect(include_entry.errors) + .to include('include config integrity hash must be base64 encoded') + end + end + + context 'when integrity is used without remote' do + let(:config) do + { + local: 'test.yml', + integrity: 'sha256-abc123def456' + } + end + + it { is_expected.not_to be_valid } + + it 'has specific error' do + expect(include_entry.errors) + .to include('include config integrity can only be specified for remote includes') + end + end + end end context 'when value is something else' do diff --git a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb index 60cd338b49fc675c9bf104eb81624190d2873173..b0173de0f8a16f749accec4ae14ec0870221e147 100644 --- a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb @@ -94,6 +94,26 @@ it { is_expected.to be_falsy } end + + context 'when integrity is specified' do + let(:params) { { remote: location, integrity: integrity_hash } } + + before do + stub_full_request(location).to_return(body: remote_file_content) + end + + context 'with matching integrity hash' do + let(:integrity_hash) { "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(remote_file_content))}" } + + it { is_expected.to be_truthy } + end + + context 'with non-matching integrity hash' do + let(:integrity_hash) { "sha256-#{Base64.strict_encode64(Digest::SHA256.digest('different content'))}" } + + it { is_expected.to be_falsy } + end + end end describe "#content" do @@ -244,6 +264,20 @@ expect(subject).to eq "Remote file could not be fetched because Connection refused!" end end + + context 'when integrity check fails' do + let(:params) { { remote: location, integrity: "sha256-#{Base64.strict_encode64(Digest::SHA256.digest('different content'))}" } } + + before do + stub_full_request(location).to_return(body: remote_file_content) + end + + it 'returns error message about integrity check failure' do + expect(error_message).to eq( + 'Remote file `https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.[MASKED]xxx.yml` failed integrity check!' + ) + end + end end describe '#expand_context' do