From d0511090eb9758cb48962edfb529ceb7cb7ce41c Mon Sep 17 00:00:00 2001 From: Mireya Andres Date: Fri, 21 Nov 2025 16:41:49 +0800 Subject: [PATCH 1/3] Add attestations list view Uses the `slsa_provenance_statement` feature flag. This uses the `slsa_provenance_statement` feature flag --- .../projects/attestations_controller.rb | 98 ++++++++++- .../projects/attestations/index.html.haml | 3 +- .../projects/attestations/show.html.haml | 158 ++++++++++++++++++ config/routes/project.rb | 2 +- locale/gitlab.pot | 90 ++++++++++ spec/factories/supply_chain/attestations.rb | 6 +- .../supply_chain/parseable_attestation.json | 40 +++++ .../projects/attestations_controller_spec.rb | 92 +++++++++- .../attestations/index.html.haml_spec.rb | 3 +- .../attestations/show.html.haml_spec.rb | 149 +++++++++++++++++ 10 files changed, 632 insertions(+), 9 deletions(-) create mode 100644 app/views/projects/attestations/show.html.haml create mode 100644 spec/fixtures/supply_chain/parseable_attestation.json create mode 100644 spec/views/projects/attestations/show.html.haml_spec.rb diff --git a/app/controllers/projects/attestations_controller.rb b/app/controllers/projects/attestations_controller.rb index 34f91756d8533d..31e703cc8acf1a 100644 --- a/app/controllers/projects/attestations_controller.rb +++ b/app/controllers/projects/attestations_controller.rb @@ -6,8 +6,8 @@ class AttestationsController < Projects::ApplicationController feature_category :artifact_security - before_action :authorize_read_attestation!, only: [:index, :download] - before_action :check_feature_flag!, only: [:index, :download] + before_action :authorize_read_attestation!, only: [:index, :show, :download] + before_action :check_feature_flag!, only: [:index, :show, :download] def index @project = project @@ -16,6 +16,14 @@ def index .keyset_paginate(cursor: pagination_params[:cursor]) end + def show + @project = project + @attestation_iid_param = safe_params[:id] + @attestation = attestation + @subjects = parsed_metadata['subject'] || [] + @certificate = parsed_certificate_extensions + end + def download return render_404 unless attestation_file @@ -40,8 +48,94 @@ def check_feature_flag! render_404 unless Feature.enabled?(:slsa_provenance_statement, project) end + def clean_extension_value(value) + # Remove ASN.1 type prefixes (dot followed by any character and optional whitespace) + value.to_s.sub(/^\.\S?\s*/, '') + end + def pagination_params params.permit(:cursor) end + + def parsed_metadata + return {} unless attestation && attestation_file + + parsed_file = Gitlab::Json.parse(attestation_file.read) + decoded = Base64.decode64(parsed_file["dsseEnvelope"]["payload"]) + Gitlab::Json.parse(decoded) + + rescue JSON::ParserError, KeyError => e + Gitlab::AppJsonLogger.error( + message: 'Failed to parse attestation metadata.', + error_class: e.class.name, + error_message: e.message, + attestation_id: attestation&.id, + project_id: project.id, + feature_category: 'artifact_security' + ) + {} + end + + def parsed_certificate + return {} unless attestation && attestation_file + + parsed_file = Gitlab::Json.parse(attestation_file.read) + cert_data = parsed_file['verificationMaterial']['certificate']['rawBytes'] + decoded_certificate = Base64.decode64(cert_data) + OpenSSL::X509::Certificate.new(decoded_certificate) + + rescue JSON::ParserError, KeyError => e + Gitlab::AppJsonLogger.error( + message: 'Failed to parse attestation certificate.', + error_class: e.class.name, + error_message: e.message, + attestation_id: attestation&.id, + project_id: project.id, + feature_category: 'artifact_security' + ) + {} + end + + def parsed_certificate_extensions + return {} if parsed_certificate.blank? + + extensions = {} + + extension_map = { + '8' => :issuer, + '9' => :build_signer_uri, + '10' => :build_signer_digest, + '11' => :runner_environment, + '12' => :source_repository_uri, + '13' => :source_repository_digest, + '14' => :source_repository_ref, + '15' => :source_repository_identifier, + '16' => :source_repository_owner_uri, + '17' => :source_repository_owner_identifier, + '18' => :build_config_uri, + '19' => :build_config_digest, + '20' => :build_trigger, + '21' => :runner_invocation_uri, + '22' => :source_repository_visibility + } + + parsed_certificate.extensions.each do |ext| + next unless ext.oid.starts_with?('1.3.6.1.4.1.57264.1.') + + cleaned_key = ext.oid.split('.').last.to_i + cleaned_value = clean_extension_value(ext.value) + + extensions[extension_map[cleaned_key.to_s]] = cleaned_value if cleaned_key >= 8 && cleaned_key <= 22 + end + + extensions + rescue OpenSSL::X509::CertificateError => e + Gitlab::JsonLogger.error( + message: 'Failed to parse certificate extensions', + error: e.message, + attestation_id: attestation&.id + ) + {} + end end end diff --git a/app/views/projects/attestations/index.html.haml b/app/views/projects/attestations/index.html.haml index f90b327b0a24bc..1d192962880fc5 100644 --- a/app/views/projects/attestations/index.html.haml +++ b/app/views/projects/attestations/index.html.haml @@ -29,8 +29,7 @@ = render Pajamas::BadgeComponent.new(attestation.status, icon_only: true, icon: 'status-success', variant: 'success') - else = render Pajamas::BadgeComponent.new(attestation.status, icon_only: true, icon: 'error', variant: 'danger') - -# TODO: add link to show view (gitlab.com/gitlab-org/gitlab/-/issues/566595) - = render Pajamas::ButtonComponent.new(href: '/', variant: :link, block: false, button_options: { class: 'gl-mb-3' }) do + = render Pajamas::ButtonComponent.new(href: project_attestation_path(@project, attestation.iid), variant: :link, block: false, button_options: { class: 'gl-mb-3' }) do = attestation.file.filename .gl-text-subtle.gl-mt-2 - build = attestation.build diff --git a/app/views/projects/attestations/show.html.haml b/app/views/projects/attestations/show.html.haml new file mode 100644 index 00000000000000..f0820df87e508b --- /dev/null +++ b/app/views/projects/attestations/show.html.haml @@ -0,0 +1,158 @@ +- add_to_breadcrumbs s_('Attestations|Attestations'), project_attestations_path(@project) +- page_title "Attestation #{@attestation_iid_param}" +- @breadcrumb_title = @attestation_iid_param + +- if @attestation.nil? + - empty_state_button_text = s_('Attestations|View SLSA Component documentation') + - empty_state_button_link = 'https://gitlab.com/components/slsa#slsa-supply-chain-levels-for-software-artifacts' + = render Pajamas::EmptyStateComponent.new(svg_path: 'illustrations/empty-state/empty-search-md.svg', + title: s_('Attestations|This attestation does not exist'), + primary_button_text: empty_state_button_text, + primary_button_link: empty_state_button_link) do |c| + - c.with_description do + = s_('Attestations|Sign and verify SLSA provenance with a CI/CD Component.') + +- elsif @subjects.blank? + - metadata_error_text = s_('Attestations|View job for this attestation') + - metadata_error_path = project_job_path(@project, @attestation.build) + = render Pajamas::EmptyStateComponent.new(svg_path: 'illustrations/status/status-nothing-md.svg', + title: s_('Attestations|Unable to parse attestation metadata'), + primary_button_text: metadata_error_text, + primary_button_link: metadata_error_path) + +- else + - if @certificate.blank? + = render Pajamas::AlertComponent.new(variant: :danger, dismissible: false, alert_options: { class: 'gl-mt-5' }) do |c| + - c.with_body do + = s_('Attestations|Unable to parse attestation certificate.') + + - download_button_options = { rel: 'nofollow', data: { method: :get }, class: 'gl-my-6' } + - download_link = download_namespace_project_attestation_path({ namespace_id: @project.namespace, project_id: @project, id: @attestation.iid}) + - build = @attestation.build + - relative_time = _("%{relative_time} ago") % { relative_time: time_ago_in_words(@attestation.created_at) } + - timestamp = @attestation.created_at.strftime('%a, %b %d %Y %H:%M:%S %Z') + .gl-col-md-12{ 'data-testid': 'attestation-details' } + .gl-mb-5 + .gl-flex.gl-justify-between + %h2.gl-text-size-h2-display.gl-pb-3 + = s_('Attestations|Attestation') + = render Pajamas::ButtonComponent.new(href: download_link, icon: 'download', button_options: download_button_options) + %table.gl-table.gl-w-full.gl-border-separate.gl-border-t{ 'data-testid': 'attestation-table' } + %colgroup + %col{ width: '40%' } + %col{ width: '60%' } + %tbody + %tr + %td + .gl-font-bold= s_('Attestations|Predicate') + %td= @attestation.predicate_type + %tr + %td + .gl-font-bold= s_('Attestations|Created') + %td #{relative_time} (#{timestamp}) + %tr + %td + .gl-font-bold= s_('Attestations|Commit') + %td + = link_to "#{build.commit.id}", project_commit_path(@project, build.commit) + %tr + %td + .gl-font-bold= s_('Attestations|Build') + %td + = link_to "##{build.id}", project_job_path(@project, build) + %tr + %td + .gl-font-bold= s_('Attestations|Workflow') + %td + = link_to 'View CI/CD configuration', project_ci_pipeline_editor_path(@project) + .gl-mb-5 + %h2.gl-text-size-h2-display.gl-mt-8.gl-pb-3 + = s_("Attestations|Subjects") + %table.gl-table.gl-w-full.gl-border-separate.gl-border-t{ 'data-testid': 'subjects-table' } + %colgroup + %col{ width: '40%' } + %col{ width: '60%' } + %thead + %tr + %th + = s_('Attestations|Subject') + %th= s_('Attestations|Digest') + %tbody + - @subjects.each do |subject| + %tr + %td= subject['name'] + %td sha256:#{subject['digest']['sha256']} + - unless @certificate.blank? + .gl-mb-5 + %h2.gl-text-size-h2-display.gl-mt-8.gl-pb-3 + = s_("Attestations|Certificate") + %table.gl-table.gl-w-full.gl-border-separate.gl-border-t{ 'data-testid': 'certificate-table' } + %colgroup + %col{ width: '40%' } + %col{ width: '60%' } + %thead + %tr + %th + = _('Key') + %th= _('Value') + %tbody + %tr{ 'data-testid': 'build-config-digest' } + %td + .gl-font-bold= s_('Attestations|Build Config Digest') + %td= @certificate[:build_config_digest] + %tr{ 'data-testid': 'build-config-uri' } + %td + .gl-font-bold= s_('Attestations|Build Config URI') + %td= link_to @certificate[:build_config_uri], @certificate[:build_config_uri] + %tr{ 'data-testid': 'build-signer-digest' } + %td + .gl-font-bold= s_('Attestations|Build Signer Digest') + %td= @certificate[:build_signer_digest] + %tr{ 'data-testid': 'build-signer-uri' } + %td + .gl-font-bold= s_('Attestations|Build Signer URI') + %td= link_to @certificate[:build_signer_uri], @certificate[:build_signer_uri] + %tr{ 'data-testid': 'build-trigger' } + %td + .gl-font-bold= s_('Attestations|Build Trigger') + %td= @certificate[:build_trigger] + %tr{ 'data-testid': 'issuer' } + %td + .gl-font-bold= s_('Attestations|Issuer') + %td= @certificate[:issuer] + %tr{ 'data-testid': 'runner-environment' } + %td + .gl-font-bold= s_('Attestations|Runner Environment') + %td= @certificate[:runner_environment] + %tr{ 'data-testid': 'runner-invocation-uri' } + %td + .gl-font-bold= s_('Attestations|Runner Invocation URI') + %td= link_to @certificate[:runner_invocation_uri], @certificate[:runner_invocation_uri] + %tr{ 'data-testid': 'source-repository-digest' } + %td + .gl-font-bold= s_('Attestations|Source Repository Digest') + %td= @certificate[:source_repository_digest] + %tr{ 'data-testid': 'source-repository-identifier' } + %td + .gl-font-bold= s_('Attestations|Source Repository Identifier') + %td= @certificate[:source_repository_identifier] + %tr{ 'data-testid': 'source-repository-owner-identifier' } + %td + .gl-font-bold= s_('Attestations|Source Repository Owner Identifier') + %td= @certificate[:source_repository_owner_identifier] + %tr{ 'data-testid': 'source-repository-owner-uri' } + %td + .gl-font-bold= s_('Attestations|Source Repository Owner URI') + %td= link_to @certificate[:source_repository_owner_uri], @certificate[:source_repository_owner_uri] + %tr{ 'data-testid': 'source-repository-ref' } + %td + .gl-font-bold= s_('Attestations|Source Repository Ref') + %td= @certificate[:source_repository_ref] + %tr{ 'data-testid': 'source-repository-uri' } + %td + .gl-font-bold= s_('Attestations|Source Repository URI') + %td= link_to @certificate[:source_repository_uri], @certificate[:source_repository_uri] + %tr{ 'data-testid': 'source-repository-visibility' } + %td + .gl-font-bold= s_('Attestations|Sourcre Repository Visibility') + %td= @certificate[:source_repository_visibility] diff --git a/config/routes/project.rb b/config/routes/project.rb index bf738c26f2b9ab..ca31525846f79b 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -514,7 +514,7 @@ end end - resources :attestations, only: [:index] do + resources :attestations, only: [:index, :show] do member do get :download end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 72d17a5c43a190..ee18d8afa5aee2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1312,6 +1312,9 @@ msgstr "" msgid "%{relation_type} epic is not present." msgstr "" +msgid "%{relative_time} ago" +msgstr "" + msgid "%{releases} release" msgid_plural "%{releases} releases" msgstr[0] "" @@ -9998,18 +10001,105 @@ msgstr "" msgid "Attempted to update the pipeline trigger token but failed" msgstr "" +msgid "Attestations|Attestation" +msgstr "" + msgid "Attestations|Attestations" msgstr "" +msgid "Attestations|Build" +msgstr "" + +msgid "Attestations|Build Config Digest" +msgstr "" + +msgid "Attestations|Build Config URI" +msgstr "" + +msgid "Attestations|Build Signer Digest" +msgstr "" + +msgid "Attestations|Build Signer URI" +msgstr "" + +msgid "Attestations|Build Trigger" +msgstr "" + +msgid "Attestations|Certificate" +msgstr "" + +msgid "Attestations|Commit" +msgstr "" + +msgid "Attestations|Created" +msgstr "" + +msgid "Attestations|Digest" +msgstr "" + +msgid "Attestations|Issuer" +msgstr "" + msgid "Attestations|No attestations found for this project" msgstr "" +msgid "Attestations|Predicate" +msgstr "" + +msgid "Attestations|Runner Environment" +msgstr "" + +msgid "Attestations|Runner Invocation URI" +msgstr "" + msgid "Attestations|Sign and verify SLSA provenance with a CI/CD Component." msgstr "" +msgid "Attestations|Source Repository Digest" +msgstr "" + +msgid "Attestations|Source Repository Identifier" +msgstr "" + +msgid "Attestations|Source Repository Owner Identifier" +msgstr "" + +msgid "Attestations|Source Repository Owner URI" +msgstr "" + +msgid "Attestations|Source Repository Ref" +msgstr "" + +msgid "Attestations|Source Repository URI" +msgstr "" + +msgid "Attestations|Sourcre Repository Visibility" +msgstr "" + +msgid "Attestations|Subject" +msgstr "" + +msgid "Attestations|Subjects" +msgstr "" + +msgid "Attestations|This attestation does not exist" +msgstr "" + +msgid "Attestations|Unable to parse attestation certificate." +msgstr "" + +msgid "Attestations|Unable to parse attestation metadata" +msgstr "" + msgid "Attestations|View SLSA Component documentation" msgstr "" +msgid "Attestations|View job for this attestation" +msgstr "" + +msgid "Attestations|Workflow" +msgstr "" + msgid "Attribute 'container-overrides' is not yet supported" msgstr "" diff --git a/spec/factories/supply_chain/attestations.rb b/spec/factories/supply_chain/attestations.rb index a88efb205d737c..f54d64f75bb45b 100644 --- a/spec/factories/supply_chain/attestations.rb +++ b/spec/factories/supply_chain/attestations.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :supply_chain_attestation, class: 'SupplyChain::Attestation' do project factory: :project - build factory: [:ci_build, :success] + build factory: [:ci_build, :success, :with_commit] predicate_kind { :provenance } predicate_type { "https://slsa.dev/provenance/v1" } sequence(:subject_digest) { |n| Digest::SHA256.hexdigest("attestation-#{n}") } @@ -13,5 +13,9 @@ trait :with_error_status do status { 'error' } end + + trait :with_parseable_metadata do + file { fixture_file_upload('spec/fixtures/supply_chain/parseable_attestation.json') } + end end end diff --git a/spec/fixtures/supply_chain/parseable_attestation.json b/spec/fixtures/supply_chain/parseable_attestation.json new file mode 100644 index 00000000000000..bed1fe6b7cc69f --- /dev/null +++ b/spec/fixtures/supply_chain/parseable_attestation.json @@ -0,0 +1,40 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { + "rawBytes": "MIIF+zCCBYKgAwIBAgIUAKgfEKfWvaCUklZH0c2d+7w2rJgwCgYIKoZIzj0EAwMwaTEMMAoGA1UEBhMDVVNBMREwDwYDVQQIEwhBbnlQbGFjZTEQMA4GA1UEBxMHQW55dG93bjEUMBIGA1UECRMLMTIzIE1haW4gU3QxDzANBgNVBBETBkFCQ0RFRjENMAsGA1UEChMEYWNtZTAeFw0yNTExMTIxNjQ2MDlaFw0yNTExMTIxNjU2MDlaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ7N7fSy7rsuQDAy5/Ci11vSmvGN4XDFuFJ/yTnFD6VHk+YrdRdD/q9kO2ftPdZDl0plCTeQm85srO7Zypxrb6yo4IEbzCCBGswDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSHpbWMp74eTIcDk7IJ8xRrEttUZTAfBgNVHSMEGDAWgBTkoCXkrI1TAK8OKo8/Tollcu4UPjBiBgNVHREBAf8EWDBWhlRodHRwOi8vZ2RrLnRlc3Q6MzAwMC9kZW1vcy9zbHNhLXZlcmlmaWNhdGlvbi1wcm9qZWN0Ly8uZ2l0bGFiLWNpLnltbEByZWZzL2hlYWRzL21haW4wIgYKKwYBBAGDvzABAQQUaHR0cDovL2dkay50ZXN0OjMwMDAwJAYKKwYBBAGDvzABCAQWDBRodHRwOi8vZ2RrLnRlc3Q6MzAwMDBkBgorBgEEAYO/MAEJBFYMVGh0dHA6Ly9nZGsudGVzdDozMDAwL2RlbW9zL3Nsc2EtdmVyaWZpY2F0aW9uLXByb2plY3QvLy5naXRsYWItY2kueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAEKBCoMKGQ3NWE5NmI1YWFjOThmODA5NzBiZWM1ZDU3ODE5ZDY3YThhMWQ3YWMwHQYKKwYBBAGDvzABCwQPDA1naXRsYWItaG9zdGVkMEEGCisGAQQBg78wAQwEMwwxaHR0cDovL2dpdGxhYi5jb20vZGVtb3Mvc2xzYS12ZXJpZmljYXRpb24tcHJvamVjdDA4BgorBgEEAYO/MAENBCoMKGQ3NWE5NmI1YWFjOThmODA5NzBiZWM1ZDU3ODE5ZDY3YThhMWQ3YWMwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wEgYKKwYBBAGDvzABDwQEDAIyMjAnBgorBgEEAYO/MAEQBBkMF2h0dHA6Ly9naXRsYWIuY29tL2RlbW9zMBIGCisGAQQBg78wAREEBAwCOTUwZAYKKwYBBAGDvzABEgRWDFRodHRwOi8vZ2RrLnRlc3Q6MzAwMC9kZW1vcy9zbHNhLXZlcmlmaWNhdGlvbi1wcm9qZWN0Ly8uZ2l0bGFiLWNpLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABEwQqDChkNzVhOTZiNWFhYzk4ZjgwOTcwYmVjNWQ1NzgxOWQ2N2E4YTFkN2FjMBQGCisGAQQBg78wARQEBgwEcHVzaDBMBgorBgEEAYO/MAEVBD4MPGh0dHA6Ly9naXRsYWIuY29tL2RlbW9zL3Nsc2EtdmVyaWZpY2F0aW9uLXByb2plY3QvLS9qb2JzLzM2OTAWBgorBgEEAYO/MAEWBAgMBnB1YmxpYzCBiwYKKwYBBAHWeQIEAgR9BHsAeQB3ALCJDz3iG2I0RXhFpx5lT0NsRh526qyTUN+uCDETtYVqAAABmnj13AcAAAQDAEgwRgIhALyKhxfooffruq9eIiqOySz7puI0qV6+KeAdxaGhIErRAiEA9sVkqNbQYsxWWcqeNCGfB1uEMKvFpBwMih8kLAJByhYwCgYIKoZIzj0EAwMDZwAwZAIwG/AjbykvPK+dMuQ0qQpVmC7pLttB7ojAHZQs3toofT7rvTBJcQTJLvJM0I79KC+BAjAzJOlxgpJlR2KTCrlGIOYGU8D4JcemApiyDLfbAn2BDlQnBWH6z7ACw98l5dt1U18=" + }, + "tlogEntries": [ + { + "logId": { + "keyId": "Z8Hdyq9v+n+6UxlrNr3J5a2CDOVcOl0KK26KfFslpbA=" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1762965975", + "inclusionPromise": { + "signedEntryTimestamp": "MEUCIQDjCRRqUbFbVP55YGiLU2qwCtIHSAXHQy/oOjFMmxxYNQIgTOS4E2qN5yNwAUOhzSQ9SGOPl5EKNnU++KqjkstLbXc=" + }, + "inclusionProof": { + "rootHash": "fRxfuBdcmmpgWTc4zc1eNOXXRTaLWJOBN2ZcS6MbZBs=", + "treeSize": "1", + "checkpoint": { + "envelope": "dfrey--20230612-P74VR - 4353712705270881066\n1\nfRxfuBdcmmpgWTc4zc1eNOXXRTaLWJOBN2ZcS6MbZBs=\n\n— dfrey--20230612-P74VR Z8HdyjBEAiAio+CtGh6YtDuEiZHOMGZOjRGkgQbn/dh3FdyJ3meMZAIgY9pJdXE6pUyGl8gWnmwEstdQ+izwX2sKIqUrT9p3+lU=\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiNzBhYjc3YzAzNWI4MDJhNzc4ZmE0NDU5ZmZjYWMxMDdkNGFhYTgzMGQ4MTM3NjE3YjU0MzM2ZDdmMGQzMjBhNiJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjUwMjdmNzQyMGQ5MzdhOGQ4ZWUyNWU3NTcxOGZhNTRhMWNhYjQ3NjZmNDFhZmUyZTE3M2IwMjUzZTI5NTA5YjkifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRQzc5NEpsZEUvbEh5RDg3S292OFJheUpLZWZER1pDTTJHRzc2TkllaDBhbGdJZ2VrazVJaDhXUG4zYVFybi9jNTFnTW0wOGxZWC9ZQVN6S0ludjVhdUZTMXc9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VZcmVrTkRRbGxMWjBGM1NVSkJaMGxWUVV0blprVkxabGQyWVVOVmEyeGFTREJqTW1Rck4zY3lja3BuZDBObldVbExiMXBKZW1vd1JVRjNUWGNLWVZSRlRVMUJiMGRCTVZWRlFtaE5SRlpXVGtKTlVrVjNSSGRaUkZaUlVVbEZkMmhDWW01c1VXSkhSbXBhVkVWUlRVRTBSMEV4VlVWQ2VFMUlVVmMxTlFwa1J6a3pZbXBGVlUxQ1NVZEJNVlZGUTFKTlRFMVVTWHBKUlRGb1lWYzBaMVV6VVhoRWVrRk9RbWRPVmtKQ1JWUkNhMFpEVVRCU1JsSnFSVTVOUVhOSENrRXhWVVZEYUUxRldWZE9kRnBVUVdWR2R6QjVUbFJGZUUxVVNYaE9hbEV5VFVSc1lVWjNNSGxPVkVWNFRWUkplRTVxVlRKTlJHeGhUVUZCZDFkVVFWUUtRbWRqY1docmFrOVFVVWxDUW1kbmNXaHJhazlRVVUxQ1FuZE9RMEZCVVRkT04yWlRlVGR5YzNWUlJFRjVOUzlEYVRFeGRsTnRka2RPTkZoRVJuVkdTZ292ZVZSdVJrUTJWa2hySzFseVpGSmtSQzl4T1d0UE1tWjBVR1JhUkd3d2NHeERWR1ZSYlRnMWMzSlBOMXA1Y0hoeVlqWjVielJKUldKNlEwTkNSM04zQ2tSbldVUldVakJRUVZGSUwwSkJVVVJCWjJWQlRVSk5SMEV4VldSS1VWRk5UVUZ2UjBORGMwZEJVVlZHUW5kTlJFMUNNRWRCTVZWa1JHZFJWMEpDVTBnS2NHSlhUWEEzTkdWVVNXTkVhemRKU2poNFVuSkZkSFJWV2xSQlprSm5UbFpJVTAxRlIwUkJWMmRDVkd0dlExaHJja2t4VkVGTE9FOUxiemd2Vkc5c2JBcGpkVFJWVUdwQ2FVSm5UbFpJVWtWQ1FXWTRSVmRFUWxkb2JGSnZaRWhTZDA5cE9IWmFNbEp5VEc1U2JHTXpVVFpOZWtGM1RVTTVhMXBYTVhaamVUbDZDbUpJVG1oTVdGcHNZMjFzYldGWFRtaGtSMngyWW1reGQyTnRPWEZhVjA0d1RIazRkVm95YkRCaVIwWnBURmRPY0V4dWJIUmlSVUo1V2xkYWVrd3lhR3dLV1ZkU2Vrd3lNV2hoVnpSM1NXZFpTMHQzV1VKQ1FVZEVkbnBCUWtGUlVWVmhTRkl3WTBSdmRrd3laR3RoZVRVd1dsaE9NRTlxVFhkTlJFRjNTa0ZaU3dwTGQxbENRa0ZIUkhaNlFVSkRRVkZYUkVKU2IyUklVbmRQYVRoMldqSlNja3h1VW14ak0xRTJUWHBCZDAxRVFtdENaMjl5UW1kRlJVRlpUeTlOUVVWS0NrSkdXVTFXUjJnd1pFaEJOa3g1T1c1YVIzTjFaRWRXZW1SRWIzcE5SRUYzVERKU2JHSlhPWHBNTTA1ell6SkZkR1J0Vm5saFYxcHdXVEpHTUdGWE9YVUtURmhDZVdJeWNHeFpNMUYyVEhrMWJtRllVbk5aVjBsMFdUSnJkV1ZYTVhOUlNFcHNXbTVOZG1GSFZtaGFTRTEyWWxkR2NHSnFRVFJDWjI5eVFtZEZSUXBCV1U4dlRVRkZTMEpEYjAxTFIxRXpUbGRGTlU1dFNURlpWMFpxVDFSb2JVOUVRVFZPZWtKcFdsZE5NVnBFVlROUFJFVTFXa1JaTTFsVWFHaE5WMUV6Q2xsWFRYZElVVmxMUzNkWlFrSkJSMFIyZWtGQ1EzZFJVRVJCTVc1aFdGSnpXVmRKZEdGSE9YcGtSMVpyVFVWRlIwTnBjMGRCVVZGQ1p6YzRkMEZSZDBVS1RYZDNlR0ZJVWpCalJHOTJUREprY0dSSGVHaFphVFZxWWpJd2RscEhWblJpTTAxMll6SjRlbGxUTVRKYVdFcHdXbTFzYWxsWVVuQmlNalIwWTBoS2RncGhiVlpxWkVSQk5FSm5iM0pDWjBWRlFWbFBMMDFCUlU1Q1EyOU5TMGRSTTA1WFJUVk9iVWt4V1ZkR2FrOVVhRzFQUkVFMVRucENhVnBYVFRGYVJGVXpDazlFUlRWYVJGa3pXVlJvYUUxWFVUTlpWMDEzU0hkWlMwdDNXVUpDUVVkRWRucEJRa1JuVVZKRVFUbDVXbGRhZWt3eWFHeFpWMUo2VERJeGFHRlhOSGNLUldkWlMwdDNXVUpDUVVkRWRucEJRa1IzVVVWRVFVbDVUV3BCYmtKbmIzSkNaMFZGUVZsUEwwMUJSVkZDUW10TlJqSm9NR1JJUVRaTWVUbHVZVmhTY3dwWlYwbDFXVEk1ZEV3eVVteGlWemw2VFVKSlIwTnBjMGRCVVZGQ1p6YzRkMEZTUlVWQ1FYZERUMVJWZDFwQldVdExkMWxDUWtGSFJIWjZRVUpGWjFKWENrUkdVbTlrU0ZKM1QyazRkbG95VW5KTWJsSnNZek5STmsxNlFYZE5RemxyV2xjeGRtTjVPWHBpU0U1b1RGaGFiR050YkcxaFYwNW9aRWRzZG1KcE1YY0tZMjA1Y1ZwWFRqQk1lVGgxV2pKc01HSkhSbWxNVjA1d1RHNXNkR0pGUW5sYVYxcDZUREpvYkZsWFVucE1NakZvWVZjMGQwOUJXVXRMZDFsQ1FrRkhSQXAyZWtGQ1JYZFJjVVJEYUd0T2VsWm9UMVJhYVU1WFJtaFplbXMwV21wbmQwOVVZM2RaYlZacVRsZFJNVTU2WjNoUFYxRXlUakpGTkZsVVJtdE9Na1pxQ2sxQ1VVZERhWE5IUVZGUlFtYzNPSGRCVWxGRlFtZDNSV05JVm5waFJFSk5RbWR2Y2tKblJVVkJXVTh2VFVGRlZrSkVORTFRUjJnd1pFaEJOa3g1T1c0S1lWaFNjMWxYU1hWWk1qbDBUREpTYkdKWE9YcE1NMDV6WXpKRmRHUnRWbmxoVjFwd1dUSkdNR0ZYT1hWTVdFSjVZakp3YkZrelVYWk1Vemx4WWpKS2VncE1lazB5VDFSQlYwSm5iM0pDWjBWRlFWbFBMMDFCUlZkQ1FXZE5RbTVDTVZsdGVIQlpla05DYVhkWlMwdDNXVUpDUVVoWFpWRkpSVUZuVWpsQ1NITkJDbVZSUWpOQlRFTktSSG96YVVjeVNUQlNXR2hHY0hnMWJGUXdUbk5TYURVeU5uRjVWRlZPSzNWRFJFVlVkRmxXY1VGQlFVSnRibW94TTBGalFVRkJVVVFLUVVWbmQxSm5TV2hCVEhsTGFIaG1iMjltWm5KMWNUbGxTV2x4VDNsVGVqZHdkVWt3Y1ZZMkswdGxRV1I0WVVkb1NVVnlVa0ZwUlVFNWMxWnJjVTVpVVFwWmMzaFhWMk54WlU1RFIyWkNNWFZGVFV0MlJuQkNkMDFwYURoclRFRktRbmxvV1hkRFoxbEpTMjlhU1hwcU1FVkJkMDFFV25kQmQxcEJTWGRITDBGcUNtSjVhM1pRU3l0a1RYVlJNSEZSY0ZadFF6ZHdUSFIwUWpkdmFrRklXbEZ6TTNSdmIyWlVOM0oyVkVKS1kxRlVTa3gyU2swd1NUYzVTME1yUWtGcVFYb0tTazlzZUdkd1NteFNNa3RVUTNKc1IwbFBXVWRWT0VRMFNtTmxiVUZ3YVhsRVRHWmlRVzR5UWtSc1VXNUNWMGcyZWpkQlEzYzVPR3cxWkhReFZURTRQUW90TFMwdExVVk9SQ0JEUlZKVVNVWkpRMEZVUlMwdExTMHRDZz09In1dfX0=" + } + ] + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiZGVtb2RlbW8uZ2VtIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImU2ZTVmNjM0ZWZmYzMxMGEyYTM4YmVhOGY3MWZlYzA4ZWNhMjA0ODk3NjM2MjhhNmU2Njk1MTI2NWRmMjE3N2MifX1dLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vZG9jcy5naXRsYWIuY29tL2NpL3BpcGVsaW5lX3NlY3VyaXR5L3Nsc2EvcHJvdmVuYW5jZV92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJlbnRyeVBvaW50IjoiYnVpbGQiLCJzb3VyY2UiOiJodHRwOi8vZ2RrLnRlc3Q6MzAwMC9kZW1vcy9zbHNhLXZlcmlmaWNhdGlvbi1wcm9qZWN0IiwidmFyaWFibGVzIjp7IkNJIjoidHJ1ZSIsIkNJX0FQSV9HUkFQSFFMX1VSTCI6Imh0dHA6Ly9nZGsudGVzdDozMDAwL2FwaS9ncmFwaHFsIiwiQ0lfQVBJX1Y0X1VSTCI6Imh0dHA6Ly9nZGsudGVzdDozMDAwL2FwaS92NCIsIkNJX0NPTU1JVF9BVVRIT1IiOiJEYXJieSBGcmV5IFx1MDAzY2RmcmV5QGdpdGxhYi5jb21cdTAwM2UiLCJDSV9DT01NSVRfQkVGT1JFX1NIQSI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJDSV9DT01NSVRfQlJBTkNIIjoibWFpbiIsIkNJX0NPTU1JVF9ERVNDUklQVElPTiI6IiIsIkNJX0NPTU1JVF9NRVNTQUdFIjoiVmVyc2lvbiBidW1wXG4iLCJDSV9DT01NSVRfTUVTU0FHRV9JU19UUlVOQ0FURUQiOiJmYWxzZSIsIkNJX0NPTU1JVF9SRUZfTkFNRSI6Im1haW4iLCJDSV9DT01NSVRfUkVGX1BST1RFQ1RFRCI6InRydWUiLCJDSV9DT01NSVRfUkVGX1NMVUciOiJtYWluIiwiQ0lfQ09NTUlUX1NIQSI6ImQ3NWE5NmI1YWFjOThmODA5NzBiZWM1ZDU3ODE5ZDY3YThhMWQ3YWMiLCJDSV9DT01NSVRfU0hPUlRfU0hBIjoiZDc1YTk2YjUiLCJDSV9DT01NSVRfVElNRVNUQU1QIjoiMjAyNS0xMC0zMVQxMDo1MDoxOC0wNTowMCIsIkNJX0NPTU1JVF9USVRMRSI6IlZlcnNpb24gYnVtcCIsIkNJX0NPTkZJR19QQVRIIjoiLmdpdGxhYi1jaS55bWwiLCJDSV9ERUZBVUxUX0JSQU5DSCI6Im1haW4iLCJDSV9ERUZBVUxUX0JSQU5DSF9TTFVHIjoibWFpbiIsIkNJX0pPQl9HUk9VUF9OQU1FIjoiYnVpbGQiLCJDSV9KT0JfSUQiOiIzNjkiLCJDSV9KT0JfTkFNRSI6ImJ1aWxkIiwiQ0lfSk9CX05BTUVfU0xVRyI6ImJ1aWxkIiwiQ0lfSk9CX1NUQUdFIjoidGVzdCIsIkNJX0pPQl9TVEFSVEVEX0FUIjoiMjAyNS0xMS0xMlQxNjozMTo1OFoiLCJDSV9KT0JfVE9LRU4iOiJbTUFTS0VEXSIsIkNJX0pPQl9VUkwiOiJodHRwOi8vZ2RrLnRlc3Q6MzAwMC9kZW1vcy9zbHNhLXZlcmlmaWNhdGlvbi1wcm9qZWN0Ly0vam9icy8zNjkiLCJDSV9OT0RFX1RPVEFMIjoiMSIsIkNJX1BBR0VTX0RPTUFJTiI6IjE3Mi4xNi4xMjMuMS5uaXAuaW8iLCJDSV9QQUdFU19IT1NUTkFNRSI6InNsc2EtdmVyaWZpY2F0aW9uLXByb2plY3QtMGEwNjg4LjE3Mi4xNi4xMjMuMS5uaXAuaW8iLCJDSV9QQUdFU19VUkwiOiJodHRwOi8vc2xzYS12ZXJpZmljYXRpb24tcHJvamVjdC0wYTA2ODguMTcyLjE2LjEyMy4xLm5pcC5pbzozMDEwIiwiQ0lfUElQRUxJTkVfQ1JFQVRFRF9BVCI6IjIwMjUtMTEtMTJUMTY6MzE6NDJaIiwiQ0lfUElQRUxJTkVfSUQiOiI1NzciLCJDSV9QSVBFTElORV9JSUQiOiIxIiwiQ0lfUElQRUxJTkVfTkFNRSI6bnVsbCwiQ0lfUElQRUxJTkVfU09VUkNFIjoicHVzaCIsIkNJX1BJUEVMSU5FX1VSTCI6Imh0dHA6Ly9nZGsudGVzdDozMDAwL2RlbW9zL3Nsc2EtdmVyaWZpY2F0aW9uLXByb2plY3QvLS9waXBlbGluZXMvNTc3IiwiQ0lfUFJPSkVDVF9DTEFTU0lGSUNBVElPTl9MQUJFTCI6bnVsbCwiQ0lfUFJPSkVDVF9ERVNDUklQVElPTiI6bnVsbCwiQ0lfUFJPSkVDVF9JRCI6IjIyIiwiQ0lfUFJPSkVDVF9OQU1FIjoic2xzYS12ZXJpZmljYXRpb24tcHJvamVjdCIsIkNJX1BST0pFQ1RfTkFNRVNQQUNFIjoiZGVtb3MiLCJDSV9QUk9KRUNUX05BTUVTUEFDRV9JRCI6Ijk1IiwiQ0lfUFJPSkVDVF9OQU1FU1BBQ0VfU0xVRyI6ImRlbW9zIiwiQ0lfUFJPSkVDVF9QQVRIIjoiZGVtb3Mvc2xzYS12ZXJpZmljYXRpb24tcHJvamVjdCIsIkNJX1BST0pFQ1RfUEFUSF9TTFVHIjoiZGVtb3Mtc2xzYS12ZXJpZmljYXRpb24tcHJvamVjdCIsIkNJX1BST0pFQ1RfUkVQT1NJVE9SWV9MQU5HVUFHRVMiOiJydWJ5LHNoZWxsIiwiQ0lfUFJPSkVDVF9ST09UX05BTUVTUEFDRSI6ImRlbW9zIiwiQ0lfUFJPSkVDVF9USVRMRSI6IlNMU0EgVmVyaWZpY2F0aW9uIFByb2plY3QiLCJDSV9QUk9KRUNUX1RPUElDUyI6IiIsIkNJX1BST0pFQ1RfVVJMIjoiaHR0cDovL2dkay50ZXN0OjMwMDAvZGVtb3Mvc2xzYS12ZXJpZmljYXRpb24tcHJvamVjdCIsIkNJX1BST0pFQ1RfVklTSUJJTElUWSI6InB1YmxpYyIsIkNJX1JFR0lTVFJZX1BBU1NXT1JEIjoiW01BU0tFRF0iLCJDSV9SRUdJU1RSWV9VU0VSIjoiZ2l0bGFiLWNpLXRva2VuIiwiQ0lfUkVQT1NJVE9SWV9VUkwiOiJodHRwOi8vZ2l0bGFiLWNpLXRva2VuOmdsY2J0LWV5SnJhV1FpT2lJdExVUk5iVlpLUmpjNVFVUXRiamhJYURaWVlVZDVVMHhrYlc5T1RFaEdOVUkwV1hCcFZGZFNkWGhySWl3aWRIbHdJam9pU2xkVUlpd2lZV3huSWpvaVVsTXlOVFlpZlEuZXlKMlpYSnphVzl1SWpvaU1DNHhMakFpTENKaklqb2lNU0lzSW04aU9pSXhJaXdpZFNJNklqRWlMQ0p3SWpvaWJTSXNJbWNpT2lJeWJpSXNJbXAwYVNJNkltRXdNR1ExT1dRd0xUSTRZalV0TkRjMllpMDVNR1poTFdRMFptTTJORFJrTXpJek1pSXNJbUYxWkNJNkltZHBkR3hoWWkxaGRYUm9laTEwYjJ0bGJpSXNJbk4xWWlJNkltZHBaRG92TDJkcGRHeGhZaTlEYVRvNlFuVnBiR1F2TXpZNUlpd2lhWE56SWpvaVoyUnJMblJsYzNRaUxDSnBZWFFpT2pFM05qSTVOalU1TmpNc0ltNWlaaUk2TVRjMk1qazJOVGsxT0N3aVpYaHdJam94TnpZeU9UWTVPRFl6ZlEuaW1BclhxcVh3TWpfZjZVTUhwQWNheDhiQTJ0eWhfcHRZSTRXWjlpWnhZb0duR0IxQU5XelhYdVFKM2RCN281ZklCemJlOXY3QTNwb1JsclkxT2EweGRiejVYMkVUb29mVlRZVVhqTFU5UzZ6UnN0dzhZajVNeTh5Z1dFY3FQb1FUaFAydVRlRU1ManZ4RjQwNHdpS3lxb043V0FyU19NMHRULU9nYk9NNXVQX1BlNy1Eb0V4WVItU1o3Y1ZXTHM1Q2dkd3lKVE10azFoMDFReUYxZ3VnUFpKUWRvWW9JbUp6TWxCWGpaMG1MbWdJa2tWMHZIZG5KNDItd2xiUzhocmNpV212dTZHbkNCLXpjSkFUMy1kZm1zQ3J2aXhTdGNKUE5oTEI1QVgzY1p2bkdmUDI4VFpzVThYN0JkMFV4UDJPc0wwakl1QWhGX3lORlVZT2pHa2d3QGdkay50ZXN0OjMwMDAvZGVtb3Mvc2xzYS12ZXJpZmljYXRpb24tcHJvamVjdC5naXQiLCJDSV9SVU5ORVJfREVTQ1JJUFRJT04iOiIiLCJDSV9SVU5ORVJfSUQiOiIzMSIsIkNJX1JVTk5FUl9UQUdTIjoiW10iLCJDSV9TRVJWRVJfRlFETiI6Imdkay50ZXN0OjMwMDAiLCJDSV9TRVJWRVJfSE9TVCI6Imdkay50ZXN0IiwiQ0lfU0VSVkVSX05BTUUiOiJHaXRMYWIiLCJDSV9TRVJWRVJfUE9SVCI6IjMwMDAiLCJDSV9TRVJWRVJfUFJPVE9DT0wiOiJodHRwIiwiQ0lfU0VSVkVSX1JFVklTSU9OIjoiM2I1YmVkOGUyZTUiLCJDSV9TRVJWRVJfU0hFTExfU1NIX0hPU1QiOiJnZGsudGVzdCIsIkNJX1NFUlZFUl9TSEVMTF9TU0hfUE9SVCI6IjIyMjIiLCJDSV9TRVJWRVJfVVJMIjoiaHR0cDovL2dkay50ZXN0OjMwMDAiLCJDSV9TRVJWRVJfVkVSU0lPTiI6IjE4LjYuMC1wcmUiLCJDSV9TRVJWRVJfVkVSU0lPTl9NQUpPUiI6IjE4IiwiQ0lfU0VSVkVSX1ZFUlNJT05fTUlOT1IiOiI2IiwiQ0lfU0VSVkVSX1ZFUlNJT05fUEFUQ0giOiIwIiwiQ0lfVEVNUExBVEVfUkVHSVNUUllfSE9TVCI6InJlZ2lzdHJ5LmdpdGxhYi5jb20iLCJHRU1fTkFNRSI6ImRlbW9kZW1vLmdlbSIsIkdFTV9TUEVDIjoiZGVtb2RlbW8uZ2Vtc3BlYyIsIkdFTkVSQVRFX1BST1ZFTkFOQ0UiOiJ0cnVlIiwiR0lUTEFCX0NJIjoidHJ1ZSIsIkdJVExBQl9GRUFUVVJFUyI6ImF1ZGl0X2V2ZW50cyxibG9ja2VkX2lzc3VlcyxibG9ja2VkX3dvcmtfaXRlbXMsYm9hcmRfaXRlcmF0aW9uX2xpc3RzLGNvZGVfb3duZXJzLGNvZGVfcmV2aWV3X2FuYWx5dGljcyxkYXRhX21hbmFnZW1lbnQsZnVsbF9jb2RlcXVhbGl0eV9yZXBvcnQsZ3JvdXBfYWN0aXZpdHlfYW5hbHl0aWNzLGdyb3VwX2J1bGtfZWRpdCxpc3N1YWJsZV9kZWZhdWx0X3RlbXBsYXRlcyxpc3N1ZV93ZWlnaHRzLGl0ZXJhdGlvbnMsbGRhcF9ncm91cF9zeW5jLG1lcmdlX3JlcXVlc3RfYXBwcm92ZXJzLG1pbGVzdG9uZV9jaGFydHMsbXVsdGlwbGVfaXNzdWVfYXNzaWduZWVzLG11bHRpcGxlX2xkYXBfc2VydmVycyxtdWx0aXBsZV9tZXJnZV9yZXF1ZXN0X2Fzc2lnbmVlcyxtdWx0aXBsZV9tZXJnZV9yZXF1ZXN0X3Jldmlld2Vycyxwcm9qZWN0X21lcmdlX3JlcXVlc3RfYW5hbHl0aWNzLHByb3RlY3RlZF9yZWZzX2Zvcl91c2VycyxwdXNoX3J1bGVzLHJlc291cmNlX2FjY2Vzc190b2tlbixzZWF0X2xpbmssc2VhdF91c2FnZV9xdW90YXMscGlwZWxpbmVzX3VzYWdlX3F1b3Rhcyx0cmFuc2Zlcl91c2FnZV9xdW90YXMsd2lwX2xpbWl0cyx6b2VrdF9jb2RlX3NlYXJjaCxzZWF0X2NvbnRyb2wsdXNhZ2VfYmlsbGluZyxkZXNjcmlwdGlvbl9kaWZmcyxzZW5kX2VtYWlsc19mcm9tX2FkbWluX2FyZWEscmVwb3NpdG9yeV9zaXplX2xpbWl0LG1haW50ZW5hbmNlX21vZGUsc2NvcGVkX2lzc3VlX2JvYXJkLGNvbnRyaWJ1dGlvbl9hbmFseXRpY3MsZ3JvdXBfd2ViaG9va3MsbWVtYmVyX2xvY2ssZWxhc3RpY19zZWFyY2gscmVwb3NpdG9yeV9taXJyb3JzLGFpX2NoYXQsYWlfY2F0YWxvZyxhaV93b3JrZmxvd3MsYWRtaW5fYXVkaXRfbG9nLGFnZW50X21hbmFnZWRfcmVzb3VyY2VzLGFnZW50aWNfY2hhdCxhbGxvd19wZXJzb25hbF9zbmlwcGV0cyxhdWRpdG9yX3VzZXIsYmxvY2tpbmdfbWVyZ2VfcmVxdWVzdHMsYm9hcmRfYXNzaWduZWVfbGlzdHMsYm9hcmRfbWlsZXN0b25lX2xpc3RzLGNpX3NlY3JldHNfbWFuYWdlbWVudCxjaV9waXBlbGluZV9jYW5jZWxsYXRpb25fcmVzdHJpY3Rpb25zLGNsdXN0ZXJfYWdlbnRzX2NpX2ltcGVyc29uYXRpb24sY2x1c3Rlcl9hZ2VudHNfdXNlcl9pbXBlcnNvbmF0aW9uLGNsdXN0ZXJfZGVwbG95bWVudHMsY29kZV9vd25lcl9hcHByb3ZhbF9yZXF1aXJlZCxjb2RlX3N1Z2dlc3Rpb25zLGNvbW1pdF9jb21taXR0ZXJfY2hlY2ssY29tbWl0X2NvbW1pdHRlcl9uYW1lX2NoZWNrLGNvbXBsaWFuY2VfZnJhbWV3b3JrLGNvbnRhaW5lcl92aXJ0dWFsX3JlZ2lzdHJ5LGN1c3RvbV9jb21wbGlhbmNlX2ZyYW1ld29ya3MsY3VzdG9tX2ZpZWxkcyxjdXN0b21fZmlsZV90ZW1wbGF0ZXMsY3VzdG9tX3Byb2plY3RfdGVtcGxhdGVzLGN5Y2xlX2FuYWx5dGljc19mb3JfZ3JvdXBzLGN5Y2xlX2FuYWx5dGljc19mb3JfcHJvamVjdHMsZGJfbG9hZF9iYWxhbmNpbmcsZGVmYXVsdF9icmFuY2hfcHJvdGVjdGlvbl9yZXN0cmljdGlvbl9pbl9ncm91cHMsZGVmYXVsdF9wcm9qZWN0X2RlbGV0aW9uX3Byb3RlY3Rpb24sZGVsZXRlX3VuY29uZmlybWVkX3VzZXJzLGRlcGVuZGVuY3lfcHJveHlfZm9yX3BhY2thZ2VzLGRpc2FibGVfZXh0ZW5zaW9uc19tYXJrZXRwbGFjZV9mb3JfZW50ZXJwcmlzZV91c2VycyxkaXNhYmxlX25hbWVfdXBkYXRlX2Zvcl91c2VycyxkaXNhYmxlX3BlcnNvbmFsX2FjY2Vzc190b2tlbnMsZGlzYWJsZV9zc2hfa2V5cyxkb21haW5fdmVyaWZpY2F0aW9uLGVwaWNfY29sb3JzLGVwaWNzLGV4dGVuZGVkX2F1ZGl0X2V2ZW50cyxleHRlcm5hbF9hdXRob3JpemF0aW9uX3NlcnZpY2VfYXBpX21hbmFnZW1lbnQsZmVhdHVyZV9mbGFnc19jb2RlX3JlZmVyZW5jZXMsZmlsZV9sb2NrcyxnZW8sZ2VuZXJpY19hbGVydF9maW5nZXJwcmludGluZyxnaXRfdHdvX2ZhY3Rvcl9lbmZvcmNlbWVudCxncm91cF9hbGxvd2VkX2VtYWlsX2RvbWFpbnMsZ3JvdXBfY292ZXJhZ2VfcmVwb3J0cyxncm91cF9mb3JraW5nX3Byb3RlY3Rpb24sZ3JvdXBfbGV2ZWxfYW5hbHl0aWNzX2Rhc2hib2FyZCxncm91cF9sZXZlbF9jb21wbGlhbmNlX2Rhc2hib2FyZCxncm91cF9taWxlc3RvbmVfcHJvamVjdF9yZWxlYXNlcyxncm91cF9wcm9qZWN0X3RlbXBsYXRlcyxncm91cF9yZXBvc2l0b3J5X2FuYWx5dGljcyxncm91cF9zYW1sLGdyb3VwX3Njb3BlZF9jaV92YXJpYWJsZXMsaWRlX3NjaGVtYV9jb25maWcsaW5jaWRlbnRfbWV0cmljX3VwbG9hZCxpbnN0YW5jZV9sZXZlbF9zY2ltLGppcmFfaXNzdWVzX2ludGVncmF0aW9uLGxkYXBfZ3JvdXBfc3luY19maWx0ZXIsbGlua2VkX2l0ZW1zX2VwaWNzLG1lcmdlX3JlcXVlc3RfcGVyZm9ybWFuY2VfbWV0cmljcyxhZG1pbl9tZXJnZV9yZXF1ZXN0X2FwcHJvdmVyc19ydWxlcyxtZXJnZV90cmFpbnMsbWV0cmljc19yZXBvcnRzLG1jcF9zZXJ2ZXIsbXVsdGlwbGVfYWxlcnRfaHR0cF9pbnRlZ3JhdGlvbnMsbXVsdGlwbGVfYXBwcm92YWxfcnVsZXMsbXVsdGlwbGVfZ3JvdXBfaXNzdWVfYm9hcmRzLG9iamVjdF9zdG9yYWdlLG1pY3Jvc29mdF9ncm91cF9zeW5jLG9wZXJhdGlvbnNfZGFzaGJvYXJkLHBhY2thZ2VfZm9yd2FyZGluZyxwYWNrYWdlc192aXJ0dWFsX3JlZ2lzdHJ5LHBhZ2VzX3NpemVfbGltaXQscGFnZXNfbXVsdGlwbGVfdmVyc2lvbnMscHJvZHVjdGl2aXR5X2FuYWx5dGljcyxwcm9qZWN0X2FsaWFzZXMscHJvamVjdF9sZXZlbF9hbmFseXRpY3NfZGFzaGJvYXJkLHByb3RlY3RlZF9lbnZpcm9ubWVudHMscmVqZWN0X25vbl9kY29fY29tbWl0cyxyZWplY3RfdW5zaWduZWRfY29tbWl0cyxyZWxhdGVkX2VwaWNzLHJlbW90ZV9kZXZlbG9wbWVudCxzYW1sX2dyb3VwX3N5bmMsc2VydmljZV9hY2NvdW50cyxzY29wZWRfbGFiZWxzLHNtYXJ0Y2FyZF9hdXRoLHNzaF9jZXJ0aWZpY2F0ZXMsc3dpbWxhbmVzLHRhcmdldF9icmFuY2hfcnVsZXMsdHJvdWJsZXNob290X2pvYix0eXBlX29mX3dvcmtfYW5hbHl0aWNzLG1pbmltYWxfYWNjZXNzX3JvbGUsdW5wcm90ZWN0aW9uX3Jlc3RyaWN0aW9ucyxjaV9wcm9qZWN0X3N1YnNjcmlwdGlvbnMsaW5jaWRlbnRfdGltZWxpbmVfdmlldyxvbmNhbGxfc2NoZWR1bGVzLGVzY2FsYXRpb25fcG9saWNpZXMsemVudGFvX2lzc3Vlc19pbnRlZ3JhdGlvbixjb3ZlcmFnZV9jaGVja19hcHByb3ZhbF9ydWxlLGlzc3VhYmxlX3Jlc291cmNlX2xpbmtzLGdyb3VwX3Byb3RlY3RlZF9icmFuY2hlcyxncm91cF9sZXZlbF9tZXJnZV9jaGVja3Nfc2V0dGluZyxvaWRjX2NsaWVudF9ncm91cHNfY2xhaW0sZGlzYWJsZV9kZWxldGluZ19hY2NvdW50X2Zvcl91c2VycyxkaXNhYmxlX3ByaXZhdGVfcHJvZmlsZXMsZ3JvdXBfc2F2ZWRfcmVwbGllcyxyZXF1ZXN0ZWRfY2hhbmdlc19ibG9ja19tZXJnZV9yZXF1ZXN0LHByb2plY3Rfc2F2ZWRfcmVwbGllcyxkZWZhdWx0X3JvbGVzX2Fzc2lnbmVlcyxjaV9jb21wb25lbnRfdXNhZ2VzX2luX3Byb2plY3RzLGJyYW5jaF9ydWxlX3NxdWFzaF9vcHRpb25zLHdvcmtfaXRlbV9zdGF0dXMsZ2xhYl9hc2tfZ2l0X2NvbW1hbmQsZ2VuZXJhdGVfY29tbWl0X21lc3NhZ2Usc3VtbWFyaXplX25ld19tZXJnZV9yZXF1ZXN0LHN1bW1hcml6ZV9yZXZpZXcsZ2VuZXJhdGVfZGVzY3JpcHRpb24sc3VtbWFyaXplX2NvbW1lbnRzLHJldmlld19tZXJnZV9yZXF1ZXN0LGJvYXJkX3N0YXR1c19saXN0cyxkaXNhYmxlX2ludml0ZV9tZW1iZXJzLHNlbGZfaG9zdGVkX21vZGVscyxuYXRpdmVfc2VjcmV0c19tYW5hZ2VtZW50LGdyb3VwX2lwX3Jlc3RyaWN0aW9uLGlzc3Vlc19hbmFseXRpY3MscGFzc3dvcmRfY29tcGxleGl0eSxncm91cF93aWtpcyxlbWFpbF9hZGRpdGlvbmFsX3RleHQsY3VzdG9tX2ZpbGVfdGVtcGxhdGVzX2Zvcl9uYW1lc3BhY2UsaW5jaWRlbnRfc2xhLGV4cG9ydF91c2VyX3Blcm1pc3Npb25zLGNyb3NzX3Byb2plY3RfcGlwZWxpbmVzLGZlYXR1cmVfZmxhZ3NfcmVsYXRlZF9pc3N1ZXMsbWVyZ2VfcGlwZWxpbmVzLGNpX2NkX3Byb2plY3RzLGdpdGh1Yl9pbnRlZ3JhdGlvbixhaV9hZ2VudHMsYWlfY29uZmlnX2NoYXQsYWlfZmVhdHVyZXMsYW1hem9uX3EsYXBpX2Rpc2NvdmVyeSxhcGlfZnV6emluZyxhdXRvX3JvbGxiYWNrLGNsdXN0ZXJfcmVjZXB0aXZlX2FnZW50cyxjbHVzdGVyX2ltYWdlX3NjYW5uaW5nLGV4dGVybmFsX3N0YXR1c19jaGVja3MsY29tcGxpYW5jZV9waXBlbGluZV9jb25maWd1cmF0aW9uLGNvbnRhaW5lcl9yZWdpc3RyeV9pbW11dGFibGVfdGFnX3J1bGVzLGNvbnRhaW5lcl9zY2FubmluZyxjcmVkZW50aWFsc19pbnZlbnRvcnksY3VzdG9tX3JvbGVzLGRhc3QsZGVwZW5kZW5jeV9zY2FubmluZyxkb3JhNF9hbmFseXRpY3MsZGVzY3JpcHRpb25fY29tcG9zZXIsZW50ZXJwcmlzZV90ZW1wbGF0ZXMsZW52aXJvbm1lbnRfYWxlcnRzLGV2YWx1YXRlX2dyb3VwX2xldmVsX2NvbXBsaWFuY2VfcGlwZWxpbmUsZXhwbGFpbl9jb2RlLGV4dGVybmFsX2F1ZGl0X2V2ZW50cyxleHBlcmltZW50YWxfZmVhdHVyZXMsZ2VuZXJhdGVfdGVzdF9maWxlLGdpdF9hYnVzZV9yYXRlX2xpbWl0LGdyb3VwX2NpX2NkX2FuYWx5dGljcyxncm91cF9sZXZlbF9jb21wbGlhbmNlX2FkaGVyZW5jZV9yZXBvcnQsZ3JvdXBfbGV2ZWxfY29tcGxpYW5jZV92aW9sYXRpb25zX3JlcG9ydCxwcm9qZWN0X2xldmVsX2NvbXBsaWFuY2VfZGFzaGJvYXJkLHByb2plY3RfbGV2ZWxfY29tcGxpYW5jZV9hZGhlcmVuY2VfcmVwb3J0LHByb2plY3RfbGV2ZWxfY29tcGxpYW5jZV92aW9sYXRpb25zX3JlcG9ydCxpbmNpZGVudF9tYW5hZ2VtZW50LGlubGluZV9jb2RlcXVhbGl0eSxpbnNpZ2h0cyxpbnRlZ3JhdGlvbnNfYWxsb3dfbGlzdCxpc3N1YWJsZV9oZWFsdGhfc3RhdHVzLGlzc3Vlc19jb21wbGV0ZWRfYW5hbHl0aWNzLGppcmFfdnVsbmVyYWJpbGl0aWVzX2ludGVncmF0aW9uLGppcmFfaXNzdWVfYXNzb2NpYXRpb25fZW5mb3JjZW1lbnQsa3ViZXJuZXRlc19jbHVzdGVyX3Z1bG5lcmFiaWxpdGllcyxsaWNlbnNlX3NjYW5uaW5nLG9rcnMscGVyc29uYWxfYWNjZXNzX3Rva2VuX2V4cGlyYXRpb25fcG9saWN5LHNlY3JldF9wdXNoX3Byb3RlY3Rpb24scHJvZHVjdF9hbmFseXRpY3MscHJvamVjdF9xdWFsaXR5X3N1bW1hcnkscXVhbGl0eV9tYW5hZ2VtZW50LHJlbGVhc2VfZXZpZGVuY2VfdGVzdF9hcnRpZmFjdHMscmVwb3J0X2FwcHJvdmVyX3J1bGVzLHJlcXVpcmVkX2NpX3RlbXBsYXRlcyxyZXF1aXJlbWVudHMscnVubmVyX21haW50ZW5hbmNlX25vdGUscnVubmVyX21haW50ZW5hbmNlX25vdGVfZm9yX25hbWVzcGFjZSxydW5uZXJfcGVyZm9ybWFuY2VfaW5zaWdodHMscnVubmVyX3BlcmZvcm1hbmNlX2luc2lnaHRzX2Zvcl9uYW1lc3BhY2UscnVubmVyX3VwZ3JhZGVfbWFuYWdlbWVudCxydW5uZXJfdXBncmFkZV9tYW5hZ2VtZW50X2Zvcl9uYW1lc3BhY2Usc2FzdCxzYXN0X2FkdmFuY2VkLHNhc3RfaWFjLHNhc3RfY3VzdG9tX3J1bGVzZXRzLHNhc3RfZnBfcmVkdWN0aW9uLHNlY3JldF9kZXRlY3Rpb24sc2VjdXJpdHlfYXR0cmlidXRlcyxzZWN1cml0eV9jb25maWd1cmF0aW9uX2luX3VpLHNlY3VyaXR5X2Rhc2hib2FyZCxzZWN1cml0eV9pbnZlbnRvcnksc2VjdXJpdHlfb25fZGVtYW5kX3NjYW5zLHNlY3VyaXR5X29yY2hlc3RyYXRpb25fcG9saWNpZXMsc2VjdXJpdHlfdHJhaW5pbmcsc3NoX2tleV9leHBpcmF0aW9uX3BvbGljeSxzdW1tYXJpemVfbXJfY2hhbmdlcyxzdGFsZV9ydW5uZXJfY2xlYW51cF9mb3JfbmFtZXNwYWNlLHN0YXR1c19wYWdlLHN1Z2dlc3RlZF9yZXZpZXdlcnMsc3ViZXBpY3Msb2JzZXJ2YWJpbGl0eSx1bmlxdWVfcHJvamVjdF9kb3dubG9hZF9saW1pdCx2dWxuZXJhYmlsaXR5X2ZpbmRpbmdfc2lnbmF0dXJlcyxjb250YWluZXJfc2Nhbm5pbmdfZm9yX3JlZ2lzdHJ5LHNlY3JldF9kZXRlY3Rpb25fdmFsaWRpdHlfY2hlY2tzLHNlY3VyaXR5X2V4Y2x1c2lvbnMsc2VjdXJpdHlfc2NhbnNfYXBpLG9ic2VydmFiaWxpdHlfYWxlcnRzLG1lYXN1cmVfY29tbWVudF90ZW1wZXJhdHVyZSxsaWNlbnNlX2luZm9ybWF0aW9uX3NvdXJjZSxjb3ZlcmFnZV9mdXp6aW5nLGRldm9wc19hZG9wdGlvbixncm91cF9sZXZlbF9kZXZvcHNfYWRvcHRpb24saW5zdGFuY2VfbGV2ZWxfZGV2b3BzX2Fkb3B0aW9uIiwiR0lUTEFCX1VTRVJfRU1BSUwiOiJnaXRsYWJfYWRtaW5fYTUxNWFmQGV4YW1wbGUuY29tIiwiR0lUTEFCX1VTRVJfSUQiOiIxIiwiR0lUTEFCX1VTRVJfTE9HSU4iOiJyb290IiwiR0lUTEFCX1VTRVJfTkFNRSI6IkFkbWluaXN0cmF0b3IiLCJTSUdTVE9SRV9JRF9UT0tFTiI6IltNQVNLRURdIn19LCJpbnRlcm5hbFBhcmFtZXRlcnMiOnsiYXJjaGl0ZWN0dXJlIjoiYXJtNjQiLCJleGVjdXRvciI6ImRvY2tlciIsImpvYiI6MzY5LCJuYW1lIjoiQXVaWmpfdzYifSwicmVzb2x2ZWREZXBlbmRlbmNpZXMiOlt7InVyaSI6Imh0dHA6Ly9nZGsudGVzdDozMDAwL2RlbW9zL3Nsc2EtdmVyaWZpY2F0aW9uLXByb2plY3QiLCJkaWdlc3QiOnsiZ2l0Q29tbWl0IjoiZDc1YTk2YjVhYWM5OGY4MDk3MGJlYzVkNTc4MTlkNjdhOGExZDdhYyJ9fV19LCJydW5EZXRhaWxzIjp7ImJ1aWxkZXIiOnsiaWQiOiJodHRwOi8vZ2RrLnRlc3Q6MzAwMC9kZW1vcy9zbHNhLXZlcmlmaWNhdGlvbi1wcm9qZWN0Ly0vcnVubmVycy8zMSIsInZlcnNpb24iOnsiZ2l0bGFiLXJ1bm5lciI6ImJkYTg0ODcxIn19LCJtZXRhZGF0YSI6eyJpbnZvY2F0aW9uSUQiOiIzNjkiLCJzdGFydGVkT24iOiIyMDI1LTExLTEyVDE2OjMxOjU4WiIsImZpbmlzaGVkT24iOiIyMDI1LTExLTEyVDE2OjMyOjEzWiJ9fX19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEUCIQC794JldE/lHyD87Kov8RayJKefDGZCM2GG76NIeh0algIgekk5Ih8WPn3aQrn/c51gMm08lYX/YASzKInv5auFS1w=" + } + ] + } +} diff --git a/spec/requests/projects/attestations_controller_spec.rb b/spec/requests/projects/attestations_controller_spec.rb index ffcc9e6852af17..46e039503113a8 100644 --- a/spec/requests/projects/attestations_controller_spec.rb +++ b/spec/requests/projects/attestations_controller_spec.rb @@ -4,8 +4,9 @@ RSpec.describe Projects::AttestationsController, feature_category: :artifact_security do let_it_be(:project) { create(:project, :public) } - let_it_be(:attestation) { create(:supply_chain_attestation, project: project) } + let(:attestation) { create(:supply_chain_attestation, :with_parseable_metadata, project: project) } let_it_be(:user) { project.first_owner } + let_it_be(:non_member) { create(:user) } before do sign_in(user) @@ -22,7 +23,6 @@ def get_index context 'when slsa_provenance_statement is enabled' do context 'when user is not authorized to read attestations' do let_it_be(:project) { create(:project, :private) } - let_it_be(:non_member) { create(:user) } it 'returns 404' do sign_in(non_member) @@ -66,6 +66,94 @@ def get_index end end + describe 'GET show' do + def get_show + get project_attestation_path(project, attestation.iid) + end + + context 'when slsa_provenance_statement is enabled' do + context 'when user is not authorized to read attestations' do + let_it_be(:project) { create(:project, :private) } + + it 'returns 404' do + sign_in(non_member) + get_show + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user can read attestation' do + before do + # attestation.build doesn't persist; stub build with commit so view can render properly without errors + build = create(:ci_build, :with_commit, project: project) + + # rubocop:disable RSpec/AnyInstanceOf -- multiple tests need the same stub; this generally should not affect + # request tests, it's just so view is rendered properly + allow_any_instance_of(SupplyChain::Attestation).to receive(:build).and_return(build) + # rubocop:enable RSpec/AnyInstanceOf + end + + it 'renders show view of attestation' do + get_show + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when attestation has valid metadata' do + it 'shows attestation details' do + get_show + + # table should be rendered + expect(response.body).to include('data-testid="attestation-table"') + expect(response.body).to include('data-testid="subjects-table"') + expect(response.body).to include('data-testid="certificate-table"') + + # assert parsed subjects details (subject and digest) + expect(response.body).to include('demodemo.gem') + expect(response.body).to include('sha256:e6e5f634effc310a2a38bea8f71fec08eca20489763628a6e66951265df2177c') + + # assert parsed certificate details (build config URI and build config digest) + expect(response.body).to include('http://gdk.test:3000/demos/slsa-verification-project//.gitlab-ci.yml@refs/heads/main') + expect(response.body).to include('d75a96b5aac98f80970bec5d57819d67a8a1d7ac') + end + end + + context 'when attestation file cannot be parsed' do + before do + # rubocop:disable RSpec/AnyInstanceOf -- file is called multiple times + allow_any_instance_of(SupplyChain::Attestation).to receive(:file).and_return( + instance_double(SupplyChain::AttestationUploader, read: 'invalid json', object_store: 'local') + ) + # rubocop:enable RSpec/AnyInstanceOf + end + + it 'logs error and does not show attestation details' do + expect(Gitlab::AppJsonLogger).to receive(:error).twice + + get_show + + expect(response.body).not_to include('data-testid="attestation-table"') + expect(response.body).not_to include('data-testid="subjects-table"') + expect(response.body).not_to include('data-testid="certificate-table"') + end + end + end + end + + context 'when slsa_provenance_statement is disabled' do + before do + stub_feature_flags(slsa_provenance_statement: false) + end + + it 'returns 404' do + get_show + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + describe 'GET download' do def download_attestation params = { namespace_id: project.namespace, project_id: project, id: attestation.iid } diff --git a/spec/views/projects/attestations/index.html.haml_spec.rb b/spec/views/projects/attestations/index.html.haml_spec.rb index 4d1b66ac1123f0..27d2d09894c598 100644 --- a/spec/views/projects/attestations/index.html.haml_spec.rb +++ b/spec/views/projects/attestations/index.html.haml_spec.rb @@ -40,7 +40,8 @@ expect(rendered).to have_selector('[data-testid="attestations-table"]') within("[data-testid='attestations-#{attestation.iid}']") do - expect(rendered).to have_content(attestation.file.filename) + expect(rendered).to have_link(attestation.file.filename, + href: project_attestation_path(project, attestation.iid)) expect(rendered).to have_link(attestation.build.iid, href: project_job_path(project, build)) expect(rendered).to have_content(attestation.predicate_type) expect(rendered).to have_content(attestation.predicate_type) diff --git a/spec/views/projects/attestations/show.html.haml_spec.rb b/spec/views/projects/attestations/show.html.haml_spec.rb new file mode 100644 index 00000000000000..e7146d2b1c371b --- /dev/null +++ b/spec/views/projects/attestations/show.html.haml_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'projects/attestations/show.html.haml', feature_category: :artifact_security do + let_it_be(:project) { create(:project, :public) } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- needs persisted associations for attestations + let_it_be(:attestation) { create(:supply_chain_attestation, project: project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- needs persisted associations to get all the details + let_it_be(:build) { attestation.build } + let_it_be(:user) { project.first_owner } + + before do + sign_in(user) + assign(:project, project) + end + + describe 'when attestation does not exist' do + before do + assign(:attestations, nil) + assign(:attestation_iid_param, '123') + end + + it 'renders the empty state' do + render + + expect(rendered).not_to have_selector('[data-testid="attestation-details"]') + expect(rendered).to have_content('This attestation does not exist') + expect(rendered).to have_link('View SLSA Component documentation', href: 'https://gitlab.com/components/slsa#slsa-supply-chain-levels-for-software-artifacts') + end + end + + describe 'when metadata does not exist' do + before do + assign(:attestation, attestation) + assign(:attestation_iid_param, '123') + assign(:subjects, []) + end + + it 'renders error message' do + render + + expect(rendered).not_to have_selector('[data-testid="attestation-details"]') + expect(rendered).to have_content('Unable to parse attestation metadata') + expect(rendered).to have_link('View job for this attestation', href: project_job_path(project, build)) + end + end + + describe 'when certificate does not exist' do + before do + assign(:attestation, attestation) + assign(:attestation_iid_param, '123') + assign(:subjects, [{ + 'name' => 'demodemo.gem', + 'digest' => { 'sha256' => 'e6e5f634effc310a2a38bea8f71fec08eca20489763628a6e66951265df2177c' } + }]) + assign(:certificate, {}) + end + + it 'renders error message' do + render + + expect(rendered).not_to have_selector('[data-testid="certificate-table"]') + expect(rendered).to have_content('Unable to parse attestation certificate') + end + end + + describe 'when all attestation data are available' do + before do + assign(:attestation, attestation) + assign(:attestation_iid_param, attestation.iid) + assign(:subjects, [{ + 'name' => 'demodemo.gem', + 'digest' => { 'sha256' => 'e6e5f634effc310a2a38bea8f71fec08eca20489763628a6e66951265df2177c' } + }]) + assign(:certificate, { + build_config_uri: 'http://gdk.test:3000/demos/slsa-verification-project//.gitlab-ci.yml@refs/heads/main', + build_config_digest: 'd75a96b5aac98f80970bec5d57819d67a8a1d7ac', + build_signer_uri: 'http://gdk.test:3000/demos/slsa-verification-project//.gitlab-ci.yml@refs/heads/main', + build_signer_digest: 'd75a96b5aac98f80970bec5d57819d67a8a1d7ac', + build_trigger: 'push', + issuer: 'http://gdk.test:3000', + runner_environment: 'gitlab-hosted', + runner_invocation_uri: 'http://gitlab.com/demos/slsa-verification-project/-/jobs/369', + source_repository_digest: 'd75a96b5aac98f80970bec5d57819d67a8a1d7ac', + source_repository_identifier: '22', + source_repository_owner_identifier: '95', + source_repository_owner_uri: 'http://gitlab.com/demos', + source_repository_ref: 'refs/heads/main', + source_repository_uri: 'http://gitlab.com/demos/slsa-verification-project', + source_repository_visibility: 'public' + }) + end + + it 'renders download link' do + render + + expect(rendered).to have_selector('[data-testid="attestation-details"]') + expect(rendered).to have_link(href: download_namespace_project_attestation_path({ + namespace_id: project.namespace, project_id: project, id: attestation.iid + })) + end + + it 'renders attestation details' do + render + + within("[data-testid='attestation-table']") do + timestamp = attestation.created_at.strftime('%a, %b %d %Y %H:%M:%S %Z') + + expect(rendered).to have_content(attestation.predicate_type) + expect(rendered).to have_content("#{time_ago_in_words(attestation.created_at)} ago (#{timestamp})") + expect(rendered).to have_link(build.commit.id, href: project_commit_path(project, build.commit)) + expect(rendered).to have_link(build.iid, href: project_job_path(project, build)) + end + end + + it 'renders subjects details' do + render + + within("[data-testid='subjects-table']") do + expect(rendered).to have_content('demodemo.gem') + expect(rendered).to have_content('sha:e6e5f634effc310a2a38bea8f71fec08eca20489763628a6e66951265df2177c') + end + end + + it 'renders certificate details' do + render + + within("[data-testid='certificate-table']") do + expect(rendered).to have_content("[data-testid='build_config_uri']", text: 'http://gdk.test:3000/demos/slsa-verification-project//.gitlab-ci.yml@refs/heads/main') + expect(rendered).to have_content("[data-testid='build_config_digest']", + text: 'd75a96b5aac98f80970bec5d57819d67a8a1d7ac') + expect(rendered).to have_content("[data-testid='build_signer_uri']", text: 'http://gdk.test:3000/demos/slsa-verification-project//.gitlab-ci.yml@refs/heads/main') + expect(rendered).to have_content("[data-testid='build_signer_digest']", + text: 'd75a96b5aac98f80970bec5d57819d67a8a1d7ac') + expect(rendered).to have_content("[data-testid='build_trigger']", text: 'push') + expect(rendered).to have_content("[data-testid='issuer']", text: 'http://gdk.test:3000') + expect(rendered).to have_content("[data-testid='runner_environment']", text: 'gitlab-hosted') + expect(rendered).to have_content("[data-testid='runner_invocation_uri']", text: 'http://gitlab.com/demos/slsa-verification-project/-/jobs/369') + expect(rendered).to have_content("[data-testid='source_repository_digest']", + text: 'd75a96b5aac98f80970bec5d57819d67a8a1d7ac') + expect(rendered).to have_content("[data-testid='source_repository_identifier']", text: '22') + expect(rendered).to have_content("[data-testid='source_repository_owner_identifier']", text: '95') + expect(rendered).to have_content("[data-testid='source_repository_owner_uri']", text: 'http://gitlab.com/demos') + expect(rendered).to have_content("[data-testid='source_repository_ref']", text: 'refs/heads/main') + expect(rendered).to have_content("[data-testid='source_repository_uri']", text: 'http://gitlab.com/demos/slsa-verification-project') + expect(rendered).to have_content("[data-testid='source_repository_visibility']", text: 'public') + end + end + end +end -- GitLab From 9a2e51581b02a94657271a5da49e7a580e080498 Mon Sep 17 00:00:00 2001 From: Mireya Andres Date: Thu, 11 Dec 2025 20:58:01 +0800 Subject: [PATCH 2/3] Cache file reading, update logger, fix typo --- .../projects/attestations_controller.rb | 37 ++++++++++--------- .../projects/attestations/show.html.haml | 2 +- locale/gitlab.pot | 2 +- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/app/controllers/projects/attestations_controller.rb b/app/controllers/projects/attestations_controller.rb index 31e703cc8acf1a..91885f193226d2 100644 --- a/app/controllers/projects/attestations_controller.rb +++ b/app/controllers/projects/attestations_controller.rb @@ -58,22 +58,25 @@ def pagination_params end def parsed_metadata - return {} unless attestation && attestation_file - - parsed_file = Gitlab::Json.parse(attestation_file.read) - decoded = Base64.decode64(parsed_file["dsseEnvelope"]["payload"]) - Gitlab::Json.parse(decoded) - - rescue JSON::ParserError, KeyError => e - Gitlab::AppJsonLogger.error( - message: 'Failed to parse attestation metadata.', - error_class: e.class.name, - error_message: e.message, - attestation_id: attestation&.id, - project_id: project.id, - feature_category: 'artifact_security' - ) - {} + @parsed_metadata ||= begin + if attestation && attestation_file + parsed_file = Gitlab::Json.parse(attestation_file.read) + decoded = Base64.decode64(parsed_file["dsseEnvelope"]["payload"]) + Gitlab::Json.parse(decoded) + else + {} + end + rescue JSON::ParserError, KeyError => e + Gitlab::AppJsonLogger.error( + message: 'Failed to parse attestation metadata.', + error_class: e.class.name, + error_message: e.message, + attestation_id: attestation&.id, + project_id: project.id, + feature_category: 'artifact_security' + ) + {} + end end def parsed_certificate @@ -130,7 +133,7 @@ def parsed_certificate_extensions extensions rescue OpenSSL::X509::CertificateError => e - Gitlab::JsonLogger.error( + Gitlab::AppJsonLogger.error( message: 'Failed to parse certificate extensions', error: e.message, attestation_id: attestation&.id diff --git a/app/views/projects/attestations/show.html.haml b/app/views/projects/attestations/show.html.haml index f0820df87e508b..0cca0071bf5b0e 100644 --- a/app/views/projects/attestations/show.html.haml +++ b/app/views/projects/attestations/show.html.haml @@ -154,5 +154,5 @@ %td= link_to @certificate[:source_repository_uri], @certificate[:source_repository_uri] %tr{ 'data-testid': 'source-repository-visibility' } %td - .gl-font-bold= s_('Attestations|Sourcre Repository Visibility') + .gl-font-bold= s_('Attestations|Source Repository Visibility') %td= @certificate[:source_repository_visibility] diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ee18d8afa5aee2..0dbfed06378ea6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10073,7 +10073,7 @@ msgstr "" msgid "Attestations|Source Repository URI" msgstr "" -msgid "Attestations|Sourcre Repository Visibility" +msgid "Attestations|Source Repository Visibility" msgstr "" msgid "Attestations|Subject" -- GitLab From 26c6fcd8238bdc7e671b9d2bd7e0d20ea602276e Mon Sep 17 00:00:00 2001 From: Mireya Andres Date: Mon, 15 Dec 2025 21:37:56 +0800 Subject: [PATCH 3/3] Move certificate to partial and create `parsed_attestation_file` --- .../projects/attestations_controller.rb | 40 +++++----- .../_attestation_certificate.haml | 73 ++++++++++++++++++ .../projects/attestations/show.html.haml | 74 +------------------ .../projects/attestations_controller_spec.rb | 2 +- 4 files changed, 93 insertions(+), 96 deletions(-) create mode 100644 app/views/projects/attestations/_attestation_certificate.haml diff --git a/app/controllers/projects/attestations_controller.rb b/app/controllers/projects/attestations_controller.rb index 91885f193226d2..aa3a111852c595 100644 --- a/app/controllers/projects/attestations_controller.rb +++ b/app/controllers/projects/attestations_controller.rb @@ -57,12 +57,10 @@ def pagination_params params.permit(:cursor) end - def parsed_metadata - @parsed_metadata ||= begin + def parsed_attestation_file + @parsed_attestation_file ||= begin if attestation && attestation_file - parsed_file = Gitlab::Json.parse(attestation_file.read) - decoded = Base64.decode64(parsed_file["dsseEnvelope"]["payload"]) - Gitlab::Json.parse(decoded) + Gitlab::Json.parse(attestation_file.read) else {} end @@ -79,22 +77,27 @@ def parsed_metadata end end + def parsed_metadata + @parsed_metadata ||= if parsed_attestation_file.blank? + {} + else + decoded = Base64.decode64(parsed_attestation_file["dsseEnvelope"]["payload"]) + Gitlab::Json.parse(decoded) + end + end + def parsed_certificate - return {} unless attestation && attestation_file + return {} if parsed_attestation_file.blank? - parsed_file = Gitlab::Json.parse(attestation_file.read) - cert_data = parsed_file['verificationMaterial']['certificate']['rawBytes'] + cert_data = parsed_attestation_file['verificationMaterial']['certificate']['rawBytes'] decoded_certificate = Base64.decode64(cert_data) OpenSSL::X509::Certificate.new(decoded_certificate) - rescue JSON::ParserError, KeyError => e + rescue OpenSSL::X509::CertificateError => e Gitlab::AppJsonLogger.error( - message: 'Failed to parse attestation certificate.', - error_class: e.class.name, - error_message: e.message, - attestation_id: attestation&.id, - project_id: project.id, - feature_category: 'artifact_security' + message: 'Failed to parse certificate extensions', + error: e.message, + attestation_id: attestation&.id ) {} end @@ -132,13 +135,6 @@ def parsed_certificate_extensions end extensions - rescue OpenSSL::X509::CertificateError => e - Gitlab::AppJsonLogger.error( - message: 'Failed to parse certificate extensions', - error: e.message, - attestation_id: attestation&.id - ) - {} end end end diff --git a/app/views/projects/attestations/_attestation_certificate.haml b/app/views/projects/attestations/_attestation_certificate.haml new file mode 100644 index 00000000000000..b8f7b1077c904b --- /dev/null +++ b/app/views/projects/attestations/_attestation_certificate.haml @@ -0,0 +1,73 @@ +.gl-mb-5 + %h2.gl-text-size-h2-display.gl-mt-8.gl-pb-3 + = s_("Attestations|Certificate") + %table.gl-table.gl-w-full.gl-border-separate.gl-border-t{ 'data-testid': 'certificate-table' } + %colgroup + %col{ width: '40%' } + %col{ width: '60%' } + %thead + %tr + %th + = _('Key') + %th= _('Value') + %tbody + %tr{ 'data-testid': 'build-config-digest' } + %td + .gl-font-bold= s_('Attestations|Build Config Digest') + %td= @certificate[:build_config_digest] + %tr{ 'data-testid': 'build-config-uri' } + %td + .gl-font-bold= s_('Attestations|Build Config URI') + %td= link_to @certificate[:build_config_uri], @certificate[:build_config_uri] + %tr{ 'data-testid': 'build-signer-digest' } + %td + .gl-font-bold= s_('Attestations|Build Signer Digest') + %td= @certificate[:build_signer_digest] + %tr{ 'data-testid': 'build-signer-uri' } + %td + .gl-font-bold= s_('Attestations|Build Signer URI') + %td= link_to @certificate[:build_signer_uri], @certificate[:build_signer_uri] + %tr{ 'data-testid': 'build-trigger' } + %td + .gl-font-bold= s_('Attestations|Build Trigger') + %td= @certificate[:build_trigger] + %tr{ 'data-testid': 'issuer' } + %td + .gl-font-bold= s_('Attestations|Issuer') + %td= @certificate[:issuer] + %tr{ 'data-testid': 'runner-environment' } + %td + .gl-font-bold= s_('Attestations|Runner Environment') + %td= @certificate[:runner_environment] + %tr{ 'data-testid': 'runner-invocation-uri' } + %td + .gl-font-bold= s_('Attestations|Runner Invocation URI') + %td= link_to @certificate[:runner_invocation_uri], @certificate[:runner_invocation_uri] + %tr{ 'data-testid': 'source-repository-digest' } + %td + .gl-font-bold= s_('Attestations|Source Repository Digest') + %td= @certificate[:source_repository_digest] + %tr{ 'data-testid': 'source-repository-identifier' } + %td + .gl-font-bold= s_('Attestations|Source Repository Identifier') + %td= @certificate[:source_repository_identifier] + %tr{ 'data-testid': 'source-repository-owner-identifier' } + %td + .gl-font-bold= s_('Attestations|Source Repository Owner Identifier') + %td= @certificate[:source_repository_owner_identifier] + %tr{ 'data-testid': 'source-repository-owner-uri' } + %td + .gl-font-bold= s_('Attestations|Source Repository Owner URI') + %td= link_to @certificate[:source_repository_owner_uri], @certificate[:source_repository_owner_uri] + %tr{ 'data-testid': 'source-repository-ref' } + %td + .gl-font-bold= s_('Attestations|Source Repository Ref') + %td= @certificate[:source_repository_ref] + %tr{ 'data-testid': 'source-repository-uri' } + %td + .gl-font-bold= s_('Attestations|Source Repository URI') + %td= link_to @certificate[:source_repository_uri], @certificate[:source_repository_uri] + %tr{ 'data-testid': 'source-repository-visibility' } + %td + .gl-font-bold= s_('Attestations|Source Repository Visibility') + %td= @certificate[:source_repository_visibility] diff --git a/app/views/projects/attestations/show.html.haml b/app/views/projects/attestations/show.html.haml index 0cca0071bf5b0e..fed64fded62d56 100644 --- a/app/views/projects/attestations/show.html.haml +++ b/app/views/projects/attestations/show.html.haml @@ -83,76 +83,4 @@ %td= subject['name'] %td sha256:#{subject['digest']['sha256']} - unless @certificate.blank? - .gl-mb-5 - %h2.gl-text-size-h2-display.gl-mt-8.gl-pb-3 - = s_("Attestations|Certificate") - %table.gl-table.gl-w-full.gl-border-separate.gl-border-t{ 'data-testid': 'certificate-table' } - %colgroup - %col{ width: '40%' } - %col{ width: '60%' } - %thead - %tr - %th - = _('Key') - %th= _('Value') - %tbody - %tr{ 'data-testid': 'build-config-digest' } - %td - .gl-font-bold= s_('Attestations|Build Config Digest') - %td= @certificate[:build_config_digest] - %tr{ 'data-testid': 'build-config-uri' } - %td - .gl-font-bold= s_('Attestations|Build Config URI') - %td= link_to @certificate[:build_config_uri], @certificate[:build_config_uri] - %tr{ 'data-testid': 'build-signer-digest' } - %td - .gl-font-bold= s_('Attestations|Build Signer Digest') - %td= @certificate[:build_signer_digest] - %tr{ 'data-testid': 'build-signer-uri' } - %td - .gl-font-bold= s_('Attestations|Build Signer URI') - %td= link_to @certificate[:build_signer_uri], @certificate[:build_signer_uri] - %tr{ 'data-testid': 'build-trigger' } - %td - .gl-font-bold= s_('Attestations|Build Trigger') - %td= @certificate[:build_trigger] - %tr{ 'data-testid': 'issuer' } - %td - .gl-font-bold= s_('Attestations|Issuer') - %td= @certificate[:issuer] - %tr{ 'data-testid': 'runner-environment' } - %td - .gl-font-bold= s_('Attestations|Runner Environment') - %td= @certificate[:runner_environment] - %tr{ 'data-testid': 'runner-invocation-uri' } - %td - .gl-font-bold= s_('Attestations|Runner Invocation URI') - %td= link_to @certificate[:runner_invocation_uri], @certificate[:runner_invocation_uri] - %tr{ 'data-testid': 'source-repository-digest' } - %td - .gl-font-bold= s_('Attestations|Source Repository Digest') - %td= @certificate[:source_repository_digest] - %tr{ 'data-testid': 'source-repository-identifier' } - %td - .gl-font-bold= s_('Attestations|Source Repository Identifier') - %td= @certificate[:source_repository_identifier] - %tr{ 'data-testid': 'source-repository-owner-identifier' } - %td - .gl-font-bold= s_('Attestations|Source Repository Owner Identifier') - %td= @certificate[:source_repository_owner_identifier] - %tr{ 'data-testid': 'source-repository-owner-uri' } - %td - .gl-font-bold= s_('Attestations|Source Repository Owner URI') - %td= link_to @certificate[:source_repository_owner_uri], @certificate[:source_repository_owner_uri] - %tr{ 'data-testid': 'source-repository-ref' } - %td - .gl-font-bold= s_('Attestations|Source Repository Ref') - %td= @certificate[:source_repository_ref] - %tr{ 'data-testid': 'source-repository-uri' } - %td - .gl-font-bold= s_('Attestations|Source Repository URI') - %td= link_to @certificate[:source_repository_uri], @certificate[:source_repository_uri] - %tr{ 'data-testid': 'source-repository-visibility' } - %td - .gl-font-bold= s_('Attestations|Source Repository Visibility') - %td= @certificate[:source_repository_visibility] + = render 'attestation_certificate' diff --git a/spec/requests/projects/attestations_controller_spec.rb b/spec/requests/projects/attestations_controller_spec.rb index 46e039503113a8..04eed0d35cdc51 100644 --- a/spec/requests/projects/attestations_controller_spec.rb +++ b/spec/requests/projects/attestations_controller_spec.rb @@ -129,7 +129,7 @@ def get_show end it 'logs error and does not show attestation details' do - expect(Gitlab::AppJsonLogger).to receive(:error).twice + expect(Gitlab::AppJsonLogger).to receive(:error) get_show -- GitLab