diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e8d8666..12bed920d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- New `macos_verify_code_signing` action, asserting that macOS artifacts are signed, signed by the expected authority, accepted by Gatekeeper, and have a notarization ticket stapled to them. Handles `.app` bundles and `.dmg` disk images, picking the checks that apply to each. [#757] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb new file mode 100644 index 000000000..1824b0bcc --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'fastlane_core/ui/ui' + +module Fastlane + module Actions + class MacosVerifyCodeSigningAction < Action + def self.run(params) + paths = Array(params[:artifact_path]) + UI.user_error!('No artifact to verify: `artifact_path` is empty') if paths.empty? + + paths.each do |path| + UI.user_error!("There is no artifact at #{path}") unless File.exist?(path) + + UI.message("Verifying #{path}") + + case File.extname(path).downcase + when '.app' + verify_app_bundle(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization]) + when '.dmg' + verify_disk_image(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization]) + else + UI.user_error!("Don't know how to verify #{path}. Supported artifacts are `.app` bundles and `.dmg` disk images") + end + end + + UI.success("Verified #{paths.length} artifact(s)") + end + + def self.verify_app_bundle(path:, expected_authority:, verify_notarization:) + verify!("The code signature of #{path} is not valid", 'codesign', '--verify', '--deep', '--strict', '--verbose=2', path) + verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil? + + return unless verify_notarization + + verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'execute', '--verbose=2', path) + verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path) + end + + # Unlike an app bundle, a disk image is often not signed at all — `electron-builder`, for one, + # only signs the app inside it. Gatekeeper then rejects the image itself with `no usable + # signature` even when it carries a notarization ticket, so the stapled ticket is the only + # check that means anything for an unsigned image. + # + def self.verify_disk_image(path:, expected_authority:, verify_notarization:) + # Said up front because `sh` logs a non-zero exit in red regardless of it being handled, + # which reads as a broken build in the CI log of an otherwise passing job. + UI.message("Checking whether #{path} is signed. Disk images usually aren't, so a `codesign` failure due to the image being unsigned is expected and tolerated (other signature failures will still fail).") + + if signed?(path) + verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil? + verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path) if verify_notarization + else + UI.important("#{path} is not signed — skipping its signature checks. Only the app it contains carries a signature.") + end + + verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path) if verify_notarization + end + + # Distinguishes an artifact that carries no signature at all from one whose signature is + # broken: the former is expected for a disk image, the latter always a failure. + # + def self.signed?(path) + exitstatus, output = sh('codesign', '--verify', '--strict', '--verbose=2', path) { |status, result, _| [status.exitstatus, result] } + + return true if exitstatus.zero? + return false if output.include?('not signed at all') + + UI.user_error!("The code signature of #{path} is not valid:\n#{output}") + end + + def self.verify!(error_message, *command) + sh(*command, error_callback: ->(_) { UI.user_error!(error_message) }) + end + + def self.verify_authority!(path:, expected_authority:) + details = sh('codesign', '--display', '--verbose=2', path) + return if details.include?("Authority=#{expected_authority}") + + UI.user_error!("#{path} is not signed by '#{expected_authority}':\n#{details}") + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Verify that macOS artifacts are properly code signed and notarized' + end + + def self.details + <<~DETAILS + Verify that the given macOS artifacts are signed, and optionally notarized. + + The checks that apply are picked from the artifact's extension: + + - `.app` — the signature is valid and satisfies its designated requirement, Gatekeeper accepts + the bundle for execution, and a notarization ticket is stapled to it. + - `.dmg` — if the image is signed, the signature is valid (and can be checked against the expected authority) and Gatekeeper accepts opening it; when `verify_notarization` is true, a notarization ticket is stapled to the image. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :artifact_path, + description: 'The path, or list of paths, to the `.app` bundle(s) or `.dmg` disk image(s) to verify', + is_string: false, + verify_block: proc do |value| + next if value.is_a?(String) + next if value.is_a?(Array) && value.all?(String) + + UI.user_error!('`artifact_path` must be a String or an Array of Strings') + end + ), + FastlaneCore::ConfigItem.new( + key: :expected_authority, + description: 'The signing authority the artifact is expected to be signed by, e.g. `Developer ID Application: ACME, Inc. (ABCDE12345)`. ' \ + + 'When omitted, any valid signature is accepted', + type: String, + optional: true, + default_value: nil + ), + FastlaneCore::ConfigItem.new( + key: :verify_notarization, + description: 'Whether to also assert that the artifact is accepted by Gatekeeper and has a notarization ticket stapled to it', + type: Boolean, + default_value: true + ), + ] + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + platform == :mac + end + end + end +end diff --git a/spec/macos_verify_code_signing_spec.rb b/spec/macos_verify_code_signing_spec.rb new file mode 100644 index 000000000..dca333e4f --- /dev/null +++ b/spec/macos_verify_code_signing_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Actions::MacosVerifyCodeSigningAction do + let(:authority) { 'Developer ID Application: Automattic, Inc. (ABCDE12345)' } + + # Runs the action against artifacts that exist on disk, so that the existence + # check doesn't get in the way of asserting on the commands the action runs. + # + def with_artifacts(*names) + in_tmp_dir do |tmp_dir| + paths = names.map do |name| + path = File.join(tmp_dir, name) + # A `.app` is a directory and a `.dmg` a file, but the action only calls + # `File.exist?`, so either satisfies it. + FileUtils.mkdir_p(path) + path + end + yield(paths) + end + end + + def expect_codesign_verify_deep(path, exitstatus: 0) + expect_shell_command('codesign', '--verify', '--deep', '--strict', '--verbose=2', path, exitstatus: exitstatus) + end + + def expect_codesign_verify(path, exitstatus: 0, output: '') + expect_shell_command('codesign', '--verify', '--strict', '--verbose=2', path, exitstatus: exitstatus, output: output) + end + + def expect_codesign_display(path, authority:) + expect_shell_command('codesign', '--display', '--verbose=2', path, output: "Executable=#{path}\nAuthority=#{authority}\n") + end + + def expect_gatekeeper_assess_execute(path, exitstatus: 0) + expect_shell_command('spctl', '--assess', '--type', 'execute', '--verbose=2', path, exitstatus: exitstatus) + end + + def expect_gatekeeper_assess_open(path, exitstatus: 0) + expect_shell_command('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path, exitstatus: exitstatus) + end + + def expect_stapler_validate(path, exitstatus: 0) + expect_shell_command('xcrun', 'stapler', 'validate', path, exitstatus: exitstatus) + end + + before do + allow_fastlane_action_sh + end + + describe 'app bundles' do + it 'verifies the signature, Gatekeeper acceptance and stapled ticket' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_gatekeeper_assess_execute(path) + expect_stapler_validate(path) + + run_described_fastlane_action(artifact_path: path) + end + end + + it 'skips the notarization checks when `verify_notarization` is `false`' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect(Open3).not_to receive(:popen2e).with('spctl', any_args) + expect(Open3).not_to receive(:popen2e).with('xcrun', any_args) + + run_described_fastlane_action(artifact_path: path, verify_notarization: false) + end + end + + it 'passes when signed by the expected authority' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_codesign_display(path, authority: authority) + expect_gatekeeper_assess_execute(path) + expect_stapler_validate(path) + + run_described_fastlane_action(artifact_path: path, expected_authority: authority) + end + end + + it 'fails when signed by a different authority' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_codesign_display(path, authority: 'Apple Development: Someone Else (ZZZZZ99999)') + + expect { run_described_fastlane_action(artifact_path: path, expected_authority: authority) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /is not signed by '#{Regexp.escape(authority)}'/) + end + end + + it 'fails when the signature is not valid' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path, exitstatus: 1) + + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.app is not valid/) + end + end + + it 'fails when Gatekeeper rejects it' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_gatekeeper_assess_execute(path, exitstatus: 3) + + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app was rejected by Gatekeeper/) + end + end + + it 'fails when it has no notarization ticket stapled' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_gatekeeper_assess_execute(path) + expect_stapler_validate(path, exitstatus: 65) + + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app has no notarization ticket stapled to it/) + end + end + end + + describe 'disk images' do + # `electron-builder` signs the app inside the image but not the image itself, + # which is what our own `.dmg` artifacts look like. + it 'checks only the stapled ticket when the image is not signed' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n") + expect_stapler_validate(path) + expect(Open3).not_to receive(:popen2e).with('spctl', any_args) + expect(FastlaneCore::UI).to receive(:important).with(/is not signed — skipping its signature checks/) + + run_described_fastlane_action(artifact_path: path) + end + end + + it 'does not assert the authority of an unsigned image' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n") + expect_stapler_validate(path) + expect(Open3).not_to receive(:popen2e).with('codesign', '--display', any_args) + + run_described_fastlane_action(artifact_path: path, expected_authority: authority) + end + end + + it 'checks Gatekeeper and the authority when the image is signed' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path) + expect_codesign_display(path, authority: authority) + expect_gatekeeper_assess_open(path) + expect_stapler_validate(path) + + run_described_fastlane_action(artifact_path: path, expected_authority: authority) + end + end + + it 'fails when the image carries a broken signature' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: invalid signature (code or signature have been modified)\n") + + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.dmg is not valid/) + end + end + + it 'fails when it has no notarization ticket stapled' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n") + expect_stapler_validate(path, exitstatus: 65) + + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.dmg has no notarization ticket stapled to it/) + end + end + + it 'skips every check but the signature when `verify_notarization` is `false`' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path) + expect(Open3).not_to receive(:popen2e).with('spctl', any_args) + expect(Open3).not_to receive(:popen2e).with('xcrun', any_args) + + run_described_fastlane_action(artifact_path: path, verify_notarization: false) + end + end + end + + it 'verifies every artifact when given a list of paths' do + with_artifacts('One.app', 'Two.dmg') do |(app_path, dmg_path)| + expect_codesign_verify_deep(app_path) + expect_gatekeeper_assess_execute(app_path) + expect_stapler_validate(app_path) + + expect_codesign_verify(dmg_path, exitstatus: 1, output: "#{dmg_path}: code object is not signed at all\n") + expect_stapler_validate(dmg_path) + + run_described_fastlane_action(artifact_path: [app_path, dmg_path]) + end + end + + it 'fails on an artifact it does not know how to verify' do + with_artifacts('Test.zip') do |(path)| + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Don't know how to verify .*Test\.zip/) + end + end + + it 'fails when there is no artifact at the given path' do + expect { run_described_fastlane_action(artifact_path: '/path/to/Missing.app') } + .to raise_error(FastlaneCore::Interface::FastlaneError, %r{There is no artifact at /path/to/Missing\.app}) + end + + it 'fails when given an empty list of paths' do + expect { run_described_fastlane_action(artifact_path: []) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` is empty/) + end + + it 'fails when `artifact_path` is neither a String nor an Array' do + expect { run_described_fastlane_action(artifact_path: 42) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` must be a String or an Array of Strings/) + end + + it 'fails when `artifact_path` is an Array of something other than Strings' do + expect { run_described_fastlane_action(artifact_path: [42]) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` must be a String or an Array of Strings/) + end +end