Skip to content

Commit cc0ffdd

Browse files
authored
Add macos_verify_code_signing action (#757)
2 parents b616fd0 + 70e8a2b commit cc0ffdd

3 files changed

Lines changed: 386 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
- 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]
1414

1515
### Bug Fixes
1616

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# frozen_string_literal: true
2+
3+
require 'fastlane/action'
4+
require 'fastlane_core/ui/ui'
5+
6+
module Fastlane
7+
module Actions
8+
class MacosVerifyCodeSigningAction < Action
9+
def self.run(params)
10+
paths = params[:artifact_path]
11+
UI.user_error!('No artifact to verify: `artifact_path` is empty') if paths.empty?
12+
13+
paths.each do |path|
14+
UI.user_error!("There is no artifact at #{path}") unless File.exist?(path)
15+
16+
UI.message("Verifying #{path}")
17+
18+
case File.extname(path).downcase
19+
when '.app'
20+
verify_app_bundle(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization])
21+
when '.dmg'
22+
verify_disk_image(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization])
23+
else
24+
UI.user_error!("Don't know how to verify #{path}. Supported artifacts are `.app` bundles and `.dmg` disk images")
25+
end
26+
end
27+
28+
UI.success("Verified #{paths.length} artifact(s)")
29+
end
30+
31+
def self.verify_app_bundle(path:, expected_authority:, verify_notarization:)
32+
UI.user_error!("#{path} is not signed at all") unless signed?(path, deep: true)
33+
verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil?
34+
35+
return unless verify_notarization
36+
37+
verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'execute', '--verbose=2', path)
38+
verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path)
39+
end
40+
41+
# Unlike an app bundle, a disk image is often not signed at all — `electron-builder`, for one,
42+
# only signs the app inside it. Gatekeeper then rejects the image itself with `no usable
43+
# signature` even when it carries a notarization ticket, so the stapled ticket is the only
44+
# check that means anything for an unsigned image.
45+
#
46+
def self.verify_disk_image(path:, expected_authority:, verify_notarization:)
47+
# Said up front because `sh` logs a non-zero exit in red regardless of it being handled,
48+
# which reads as a broken build in the CI log of an otherwise passing job.
49+
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).")
50+
51+
if signed?(path)
52+
verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil?
53+
verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path) if verify_notarization
54+
else
55+
UI.important("#{path} is not signed — skipping its signature checks. We expect that the app it contains is the one carrying the signature.")
56+
end
57+
58+
verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path) if verify_notarization
59+
end
60+
61+
# Distinguishes an artifact that carries no signature at all from one whose signature is
62+
# broken: the former is expected for a disk image, the latter always a failure.
63+
#
64+
# @param deep [Boolean] Whether to also verify the nested code an app bundle embeds.
65+
#
66+
def self.signed?(path, deep: false)
67+
command = ['codesign', '--verify']
68+
command << '--deep' if deep
69+
command += ['--strict', '--verbose=2', path]
70+
71+
exitstatus, output = sh(*command) { |status, result, _| [status.exitstatus, result] }
72+
73+
return true if exitstatus.zero?
74+
return false if output.include?('not signed at all')
75+
76+
UI.user_error!("The code signature of #{path} is not valid:\n#{output}")
77+
end
78+
79+
def self.verify!(error_message, *command)
80+
sh(*command, error_callback: ->(_) { UI.user_error!(error_message) })
81+
end
82+
83+
def self.verify_authority!(path:, expected_authority:)
84+
details = sh('codesign', '--display', '--verbose=2', path)
85+
return if details.include?("Authority=#{expected_authority}")
86+
87+
UI.user_error!("#{path} is not signed by '#{expected_authority}':\n#{details}")
88+
end
89+
90+
#####################################################
91+
# @!group Documentation
92+
#####################################################
93+
94+
def self.description
95+
'Verify that macOS artifacts are properly code signed and notarized'
96+
end
97+
98+
def self.details
99+
<<~DETAILS
100+
Verify that the given macOS artifacts are signed, and optionally notarized.
101+
102+
The checks that apply are picked from the artifact's extension:
103+
104+
- `.app` — the signature is valid and satisfies its designated requirement, Gatekeeper accepts
105+
the bundle for execution, and a notarization ticket is stapled to it.
106+
- `.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.
107+
DETAILS
108+
end
109+
110+
def self.available_options
111+
[
112+
FastlaneCore::ConfigItem.new(
113+
key: :artifact_path,
114+
description: 'The path, or list of paths, to the `.app` bundle(s) or `.dmg` disk image(s) to verify',
115+
type: Array,
116+
verify_block: proc do |value|
117+
UI.user_error!('`artifact_path` must be a String or an Array of Strings') unless value.all?(String)
118+
end
119+
),
120+
FastlaneCore::ConfigItem.new(
121+
key: :expected_authority,
122+
description: 'The signing authority the artifact is expected to be signed by, e.g. `Developer ID Application: ACME, Inc. (ABCDE12345)`. ' \
123+
+ 'When omitted, any valid signature is accepted',
124+
type: String,
125+
optional: true,
126+
default_value: nil
127+
),
128+
FastlaneCore::ConfigItem.new(
129+
key: :verify_notarization,
130+
description: 'Whether to also assert that the artifact is accepted by Gatekeeper and has a notarization ticket stapled to it',
131+
type: Boolean,
132+
default_value: true
133+
),
134+
]
135+
end
136+
137+
def self.authors
138+
['Automattic']
139+
end
140+
141+
def self.is_supported?(platform)
142+
platform == :mac
143+
end
144+
end
145+
end
146+
end
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe Fastlane::Actions::MacosVerifyCodeSigningAction do
6+
let(:authority) { 'Developer ID Application: Automattic, Inc. (ABCDE12345)' }
7+
8+
# Runs the action against artifacts that exist on disk, so that the existence
9+
# check doesn't get in the way of asserting on the commands the action runs.
10+
#
11+
def with_artifacts(*names)
12+
in_tmp_dir do |tmp_dir|
13+
paths = names.map do |name|
14+
path = File.join(tmp_dir, name)
15+
# A `.app` is a directory and a `.dmg` a file, but the action only calls
16+
# `File.exist?`, so either satisfies it.
17+
FileUtils.mkdir_p(path)
18+
path
19+
end
20+
yield(paths)
21+
end
22+
end
23+
24+
def expect_codesign_verify_deep(path, exitstatus: 0, output: '')
25+
expect_shell_command('codesign', '--verify', '--deep', '--strict', '--verbose=2', path, exitstatus: exitstatus, output: output)
26+
end
27+
28+
def expect_codesign_verify(path, exitstatus: 0, output: '')
29+
expect_shell_command('codesign', '--verify', '--strict', '--verbose=2', path, exitstatus: exitstatus, output: output)
30+
end
31+
32+
def expect_codesign_display(path, authority:)
33+
expect_shell_command('codesign', '--display', '--verbose=2', path, output: "Executable=#{path}\nAuthority=#{authority}\n")
34+
end
35+
36+
def expect_gatekeeper_assess_execute(path, exitstatus: 0)
37+
expect_shell_command('spctl', '--assess', '--type', 'execute', '--verbose=2', path, exitstatus: exitstatus)
38+
end
39+
40+
def expect_gatekeeper_assess_open(path, exitstatus: 0)
41+
expect_shell_command('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path, exitstatus: exitstatus)
42+
end
43+
44+
def expect_stapler_validate(path, exitstatus: 0)
45+
expect_shell_command('xcrun', 'stapler', 'validate', path, exitstatus: exitstatus)
46+
end
47+
48+
before do
49+
allow_fastlane_action_sh
50+
end
51+
52+
describe 'app bundles' do
53+
it 'verifies the signature, Gatekeeper acceptance and stapled ticket' do
54+
with_artifacts('Test.app') do |(path)|
55+
expect_codesign_verify_deep(path)
56+
expect_gatekeeper_assess_execute(path)
57+
expect_stapler_validate(path)
58+
59+
run_described_fastlane_action(artifact_path: path)
60+
end
61+
end
62+
63+
it 'skips the notarization checks when `verify_notarization` is `false`' do
64+
with_artifacts('Test.app') do |(path)|
65+
expect_codesign_verify_deep(path)
66+
expect(Open3).not_to receive(:popen2e).with('spctl', any_args)
67+
expect(Open3).not_to receive(:popen2e).with('xcrun', any_args)
68+
69+
run_described_fastlane_action(artifact_path: path, verify_notarization: false)
70+
end
71+
end
72+
73+
it 'passes when signed by the expected authority' do
74+
with_artifacts('Test.app') do |(path)|
75+
expect_codesign_verify_deep(path)
76+
expect_codesign_display(path, authority: authority)
77+
expect_gatekeeper_assess_execute(path)
78+
expect_stapler_validate(path)
79+
80+
run_described_fastlane_action(artifact_path: path, expected_authority: authority)
81+
end
82+
end
83+
84+
it 'fails when signed by a different authority' do
85+
with_artifacts('Test.app') do |(path)|
86+
expect_codesign_verify_deep(path)
87+
expect_codesign_display(path, authority: 'Apple Development: Someone Else (ZZZZZ99999)')
88+
89+
expect { run_described_fastlane_action(artifact_path: path, expected_authority: authority) }
90+
.to raise_error(FastlaneCore::Interface::FastlaneError, /is not signed by '#{Regexp.escape(authority)}'/)
91+
end
92+
end
93+
94+
it 'fails when the signature is not valid' do
95+
with_artifacts('Test.app') do |(path)|
96+
expect_codesign_verify_deep(path, exitstatus: 1, output: "#{path}: invalid signature (code or signature have been modified)\n")
97+
98+
expect { run_described_fastlane_action(artifact_path: path) }
99+
.to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.app is not valid/)
100+
end
101+
end
102+
103+
# Unlike a disk image, an app bundle without a signature is always a failure.
104+
it 'fails when it is not signed at all' do
105+
with_artifacts('Test.app') do |(path)|
106+
expect_codesign_verify_deep(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n")
107+
108+
expect { run_described_fastlane_action(artifact_path: path) }
109+
.to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app is not signed at all/)
110+
end
111+
end
112+
113+
it 'fails when Gatekeeper rejects it' do
114+
with_artifacts('Test.app') do |(path)|
115+
expect_codesign_verify_deep(path)
116+
expect_gatekeeper_assess_execute(path, exitstatus: 3)
117+
118+
expect { run_described_fastlane_action(artifact_path: path) }
119+
.to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app was rejected by Gatekeeper/)
120+
end
121+
end
122+
123+
it 'fails when it has no notarization ticket stapled' do
124+
with_artifacts('Test.app') do |(path)|
125+
expect_codesign_verify_deep(path)
126+
expect_gatekeeper_assess_execute(path)
127+
expect_stapler_validate(path, exitstatus: 65)
128+
129+
expect { run_described_fastlane_action(artifact_path: path) }
130+
.to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app has no notarization ticket stapled to it/)
131+
end
132+
end
133+
end
134+
135+
describe 'disk images' do
136+
# `electron-builder` signs the app inside the image but not the image itself,
137+
# which is what our own `.dmg` artifacts look like.
138+
it 'checks only the stapled ticket when the image is not signed' do
139+
with_artifacts('Test.dmg') do |(path)|
140+
expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n")
141+
expect_stapler_validate(path)
142+
expect(Open3).not_to receive(:popen2e).with('spctl', any_args)
143+
expect(FastlaneCore::UI).to receive(:important).with(/is not signed — skipping its signature checks/)
144+
145+
run_described_fastlane_action(artifact_path: path)
146+
end
147+
end
148+
149+
it 'does not assert the authority of an unsigned image' do
150+
with_artifacts('Test.dmg') do |(path)|
151+
expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n")
152+
expect_stapler_validate(path)
153+
expect(Open3).not_to receive(:popen2e).with('codesign', '--display', any_args)
154+
155+
run_described_fastlane_action(artifact_path: path, expected_authority: authority)
156+
end
157+
end
158+
159+
it 'checks Gatekeeper and the authority when the image is signed' do
160+
with_artifacts('Test.dmg') do |(path)|
161+
expect_codesign_verify(path)
162+
expect_codesign_display(path, authority: authority)
163+
expect_gatekeeper_assess_open(path)
164+
expect_stapler_validate(path)
165+
166+
run_described_fastlane_action(artifact_path: path, expected_authority: authority)
167+
end
168+
end
169+
170+
it 'fails when the image carries a broken signature' do
171+
with_artifacts('Test.dmg') do |(path)|
172+
expect_codesign_verify(path, exitstatus: 1, output: "#{path}: invalid signature (code or signature have been modified)\n")
173+
174+
expect { run_described_fastlane_action(artifact_path: path) }
175+
.to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.dmg is not valid/)
176+
end
177+
end
178+
179+
it 'fails when it has no notarization ticket stapled' do
180+
with_artifacts('Test.dmg') do |(path)|
181+
expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n")
182+
expect_stapler_validate(path, exitstatus: 65)
183+
184+
expect { run_described_fastlane_action(artifact_path: path) }
185+
.to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.dmg has no notarization ticket stapled to it/)
186+
end
187+
end
188+
189+
it 'skips every check but the signature when `verify_notarization` is `false`' do
190+
with_artifacts('Test.dmg') do |(path)|
191+
expect_codesign_verify(path)
192+
expect(Open3).not_to receive(:popen2e).with('spctl', any_args)
193+
expect(Open3).not_to receive(:popen2e).with('xcrun', any_args)
194+
195+
run_described_fastlane_action(artifact_path: path, verify_notarization: false)
196+
end
197+
end
198+
end
199+
200+
it 'verifies every artifact when given a list of paths' do
201+
with_artifacts('One.app', 'Two.dmg') do |(app_path, dmg_path)|
202+
expect_codesign_verify_deep(app_path)
203+
expect_gatekeeper_assess_execute(app_path)
204+
expect_stapler_validate(app_path)
205+
206+
expect_codesign_verify(dmg_path, exitstatus: 1, output: "#{dmg_path}: code object is not signed at all\n")
207+
expect_stapler_validate(dmg_path)
208+
209+
run_described_fastlane_action(artifact_path: [app_path, dmg_path])
210+
end
211+
end
212+
213+
it 'fails on an artifact it does not know how to verify' do
214+
with_artifacts('Test.zip') do |(path)|
215+
expect { run_described_fastlane_action(artifact_path: path) }
216+
.to raise_error(FastlaneCore::Interface::FastlaneError, /Don't know how to verify .*Test\.zip/)
217+
end
218+
end
219+
220+
it 'fails when there is no artifact at the given path' do
221+
expect { run_described_fastlane_action(artifact_path: '/path/to/Missing.app') }
222+
.to raise_error(FastlaneCore::Interface::FastlaneError, %r{There is no artifact at /path/to/Missing\.app})
223+
end
224+
225+
it 'fails when given an empty list of paths' do
226+
expect { run_described_fastlane_action(artifact_path: []) }
227+
.to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` is empty/)
228+
end
229+
230+
it 'fails when `artifact_path` is neither a String nor an Array' do
231+
expect { run_described_fastlane_action(artifact_path: 42) }
232+
.to raise_error(FastlaneCore::Interface::FastlaneError, /'artifact_path' value must be either `Array` or `comma-separated String`!/)
233+
end
234+
235+
it 'fails when `artifact_path` is an Array of something other than Strings' do
236+
expect { run_described_fastlane_action(artifact_path: [42]) }
237+
.to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` must be a String or an Array of Strings/)
238+
end
239+
end

0 commit comments

Comments
 (0)