Skip to content
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# frozen_string_literal: true

require 'fastlane/action'
require 'fastlane_core/ui/ui'

module Fastlane
module Actions
class MacosVerifyCodeSigningAction < Action
Comment thread
Copilot marked this conversation as resolved.
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
229 changes: 229 additions & 0 deletions spec/macos_verify_code_signing_spec.rb
Original file line number Diff line number Diff line change
@@ -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