howsigned 0.0.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/LICENSE +21 -0
- data/README.md +59 -0
- data/Rakefile +9 -0
- data/bin/howsigned +109 -8
- data/lib/howsigned/version.rb +5 -0
- data/lib/howsigned.rb +224 -0
- data/test/howsigned_test.rb +85 -0
- metadata +37 -39
- data/lib/compare.rb +0 -49
- data/lib/contained_binaries_definition.rb +0 -4
- data/lib/entitlements.rb +0 -51
- data/lib/extract_zip.rb +0 -12
- data/lib/profiles.rb +0 -46
- data/lib/validate_ipa.rb +0 -4
- data/lib/verify.rb +0 -34
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6b35357e3a7d5fede023bc0b0010c8cda63ba024fdb3de8526a4b703385b2728
|
|
4
|
+
data.tar.gz: 2a207720c330ad476eff671a8e9f6d3c480bede84e4cd334ca0c31edf7e3badb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d6592ef12478f2bb4ed6e233a4f9cad65eede3fbe8a6e7a1a1e38aaa85da9bd4a2d0094869d87c1ca2d2457eeb52e997dfc6ed86260e54343ea87deed47d29d
|
|
7
|
+
data.tar.gz: c6761223afc9ecb8a043cb388d13424074996b9e920593d0ff8b57da471bba413d039ee9736a68058abb5e71f87dedf84bda0e88e6d2de6879b077948e46ac0f
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Michael MacDougall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# howsigned
|
|
2
|
+
|
|
3
|
+
`howsigned` is a macOS CLI for auditing the signing state of Apple IPA archives. It is useful when a TestFlight or App Store submission fails because a nested app, extension, framework, or provisioning profile does not match expectations.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
gem install howsigned
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The tool requires macOS developer tools (`codesign` and `security`) and Ruby 3.2 or newer.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
Generate a complete, CI-friendly signing manifest:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
howsigned manifest MyApp.ipa > signing-manifest.json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Verify every discovered nested binary. Exit status `1` means a signature finding:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
howsigned verify MyApp.ipa
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Inspect entitlements or embedded provisioning profiles:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
howsigned entitlements MyApp.ipa
|
|
31
|
+
howsigned profiles MyApp.ipa
|
|
32
|
+
howsigned profiles --expiration MyApp.ipa
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Compare two builds. Exit status `1` means their manifests differ:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
howsigned compare Old.ipa New.ipa
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
All structured output is JSON. Exit status `2` indicates invalid input or usage; `3` indicates an unavailable tool or runtime failure.
|
|
42
|
+
|
|
43
|
+
## GitHub releases
|
|
44
|
+
|
|
45
|
+
CI runs on macOS for Ruby 3.2–3.4, executes the test suite, and builds the gem. A tag such as `v2.0.0` invokes the release workflow and publishes to RubyGems using the repository’s `RUBYGEMS_API_KEY` secret. GitHub is the release source of truth; do not upload gems manually.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
bundle install
|
|
51
|
+
bundle exec rake
|
|
52
|
+
gem build howsigned.gemspec
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The tests use synthetic IPA archives and fake command execution, so they are safe to run without a signed application. The production audit remains macOS-specific because Apple’s signing tools are macOS tools.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT. See [LICENSE](LICENSE).
|
data/Rakefile
ADDED
data/bin/howsigned
CHANGED
|
@@ -1,13 +1,114 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "json"
|
|
6
|
+
require "howsigned"
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
module Howsigned
|
|
9
|
+
class CLI
|
|
10
|
+
EXIT_OK = 0
|
|
11
|
+
EXIT_FINDINGS = 1
|
|
12
|
+
EXIT_USAGE = 2
|
|
13
|
+
EXIT_RUNTIME = 3
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
def self.run(argv)
|
|
16
|
+
new(argv).run
|
|
17
|
+
rescue OptionParser::ParseError, InputError => e
|
|
18
|
+
warn "howsigned: #{e.message}"
|
|
19
|
+
EXIT_USAGE
|
|
20
|
+
rescue Error => e
|
|
21
|
+
warn "howsigned: #{e.message}"
|
|
22
|
+
EXIT_RUNTIME
|
|
23
|
+
end
|
|
13
24
|
|
|
25
|
+
def initialize(argv)
|
|
26
|
+
@argv = argv.dup
|
|
27
|
+
@expiration = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
command = @argv.shift
|
|
32
|
+
if command.nil? || %w[-h --help help].include?(command)
|
|
33
|
+
puts global_help
|
|
34
|
+
return EXIT_OK
|
|
35
|
+
end
|
|
36
|
+
if %w[--version -v].include?(command)
|
|
37
|
+
puts VERSION
|
|
38
|
+
return EXIT_OK
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
command_parser(command).parse!(@argv)
|
|
42
|
+
case command
|
|
43
|
+
when "verify" then run_verify
|
|
44
|
+
when "entitlements" then run_single { |auditor| auditor.entitlements }
|
|
45
|
+
when "profiles" then run_single { |auditor| auditor.profiles(expiration_only: @expiration) }
|
|
46
|
+
when "manifest" then run_single { |auditor| auditor.manifest }
|
|
47
|
+
when "compare" then run_compare
|
|
48
|
+
else raise OptionParser::InvalidArgument, "unknown command #{command.inspect}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def global_help
|
|
55
|
+
<<~USAGE
|
|
56
|
+
Usage: howsigned COMMAND [options]
|
|
57
|
+
|
|
58
|
+
Commands:
|
|
59
|
+
manifest Emit a complete signing manifest as JSON
|
|
60
|
+
verify Verify every discovered nested binary
|
|
61
|
+
entitlements Print entitlements as JSON
|
|
62
|
+
profiles Print embedded provisioning profiles as JSON
|
|
63
|
+
compare Compare signing manifests from two IPAs
|
|
64
|
+
|
|
65
|
+
Run `howsigned COMMAND --help` for command-specific options.
|
|
66
|
+
USAGE
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def command_parser(command)
|
|
70
|
+
OptionParser.new do |opts|
|
|
71
|
+
opts.banner = "Usage: howsigned #{command} [options] IPA#{command == 'compare' ? ' IPA' : ''}"
|
|
72
|
+
opts.on("--expiration", "Print only profile expiration dates") do
|
|
73
|
+
raise OptionParser::InvalidOption, "--expiration is only valid for profiles" unless command == "profiles"
|
|
74
|
+
@expiration = true
|
|
75
|
+
end
|
|
76
|
+
opts.on("-h", "--help", "Show this help") { puts opts; exit EXIT_OK }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_single
|
|
81
|
+
require_args(1)
|
|
82
|
+
value = nil
|
|
83
|
+
Auditor.new(@argv.fetch(0)).with_extracted_ipa { |auditor| value = yield auditor }
|
|
84
|
+
puts JSON.pretty_generate(value)
|
|
85
|
+
EXIT_OK
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def run_verify
|
|
89
|
+
require_args(1)
|
|
90
|
+
result = nil
|
|
91
|
+
Auditor.new(@argv.fetch(0)).with_extracted_ipa { |auditor| result = auditor.verification }
|
|
92
|
+
puts JSON.pretty_generate(result)
|
|
93
|
+
result.all? { |entry| entry["valid"] } ? EXIT_OK : EXIT_FINDINGS
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_compare
|
|
97
|
+
require_args(2)
|
|
98
|
+
manifests = @argv.first(2).map do |path|
|
|
99
|
+
value = nil
|
|
100
|
+
Auditor.new(path).with_extracted_ipa { |auditor| value = auditor.manifest }
|
|
101
|
+
value
|
|
102
|
+
end
|
|
103
|
+
puts JSON.pretty_generate("first" => manifests[0], "second" => manifests[1]) unless manifests[0] == manifests[1]
|
|
104
|
+
puts JSON.pretty_generate("equal" => true) if manifests[0] == manifests[1]
|
|
105
|
+
manifests[0] == manifests[1] ? EXIT_OK : EXIT_FINDINGS
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def require_args(count)
|
|
109
|
+
raise InputError, "expected #{count} IPA path#{count == 1 ? '' : 's'}" if @argv.length < count
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
exit Howsigned::CLI.run(ARGV)
|
data/lib/howsigned.rb
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "find"
|
|
9
|
+
require "zip"
|
|
10
|
+
require "plist"
|
|
11
|
+
require "howsigned/version"
|
|
12
|
+
|
|
13
|
+
module Howsigned
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
class InputError < Error; end
|
|
16
|
+
class ToolError < Error; end
|
|
17
|
+
class UnsafeArchiveError < InputError; end
|
|
18
|
+
|
|
19
|
+
Result = Struct.new(:stdout, :stderr, :status, keyword_init: true) do
|
|
20
|
+
def success?
|
|
21
|
+
status.success?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class CommandRunner
|
|
26
|
+
def initialize(env: {}, executable_finder: nil)
|
|
27
|
+
@env = env
|
|
28
|
+
@executable_finder = executable_finder || ->(name) { name }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(*args, allow_failure: false)
|
|
32
|
+
command = [@executable_finder.call(args.first), *args.drop(1)]
|
|
33
|
+
stdout, stderr, status = Open3.capture3(@env, *command)
|
|
34
|
+
result = Result.new(stdout: stdout, stderr: stderr, status: status)
|
|
35
|
+
return result if result.success? || allow_failure
|
|
36
|
+
|
|
37
|
+
raise ToolError, "#{command.first} failed (#{status.exitstatus}): #{stderr.strip}"
|
|
38
|
+
rescue Errno::ENOENT
|
|
39
|
+
raise ToolError, "Required macOS tool not found: #{args.first}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class IPAArchive
|
|
44
|
+
def initialize(path)
|
|
45
|
+
@path = File.expand_path(path.to_s)
|
|
46
|
+
raise InputError, "IPA not found: #{@path}" unless File.file?(@path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extract
|
|
50
|
+
directory = Dir.mktmpdir("howsigned-")
|
|
51
|
+
Zip::File.open(@path) do |archive|
|
|
52
|
+
archive.each do |entry|
|
|
53
|
+
next if entry.directory?
|
|
54
|
+
|
|
55
|
+
relative = safe_relative_path(entry.name)
|
|
56
|
+
destination = File.expand_path(relative, directory)
|
|
57
|
+
root = File.expand_path(directory) + File::SEPARATOR
|
|
58
|
+
raise UnsafeArchiveError, "Archive entry escapes extraction directory: #{entry.name}" unless destination.start_with?(root)
|
|
59
|
+
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
61
|
+
raise UnsafeArchiveError, "Archive entry traverses a symlink: #{entry.name}" if symlink_in_path?(directory, relative)
|
|
62
|
+
|
|
63
|
+
File.open(destination, "wb") { |file| file.write(entry.get_input_stream.read) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
directory
|
|
67
|
+
rescue StandardError
|
|
68
|
+
FileUtils.remove_entry(directory) if directory && File.exist?(directory)
|
|
69
|
+
raise
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def safe_relative_path(name)
|
|
75
|
+
path = Pathname.new(name.to_s)
|
|
76
|
+
raise UnsafeArchiveError, "Absolute archive entry is not allowed: #{name}" if path.absolute?
|
|
77
|
+
|
|
78
|
+
clean = path.cleanpath.to_s
|
|
79
|
+
raise UnsafeArchiveError, "Parent archive entry is not allowed: #{name}" if clean == ".." || clean.start_with?("../")
|
|
80
|
+
|
|
81
|
+
clean
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def symlink_in_path?(root, relative)
|
|
85
|
+
current = root
|
|
86
|
+
relative.split(File::SEPARATOR)[0...-1].each do |component|
|
|
87
|
+
current = File.join(current, component)
|
|
88
|
+
return true if File.symlink?(current)
|
|
89
|
+
end
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Bundle = Struct.new(:kind, :path, :binary, keyword_init: true) do
|
|
95
|
+
def relative_path(root)
|
|
96
|
+
path.delete_prefix("#{root}/")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class BundleScanner
|
|
101
|
+
EXTENSIONS = {
|
|
102
|
+
".app" => :app,
|
|
103
|
+
".appex" => :appex,
|
|
104
|
+
".framework" => :framework,
|
|
105
|
+
".xpc" => :xpc,
|
|
106
|
+
".watchkitapp" => :watchkitapp,
|
|
107
|
+
".plugin" => :plugin,
|
|
108
|
+
".bundle" => :bundle,
|
|
109
|
+
".dylib" => :dylib
|
|
110
|
+
}.freeze
|
|
111
|
+
|
|
112
|
+
def initialize(root)
|
|
113
|
+
@root = File.expand_path(root)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def scan
|
|
117
|
+
candidates = []
|
|
118
|
+
Find.find(@root) do |path|
|
|
119
|
+
next unless File.file?(path) || File.directory?(path)
|
|
120
|
+
|
|
121
|
+
extension = EXTENSIONS.keys.find { |suffix| path.end_with?(suffix) }
|
|
122
|
+
next unless extension
|
|
123
|
+
|
|
124
|
+
kind = EXTENSIONS.fetch(extension)
|
|
125
|
+
binary = kind == :dylib ? path : binary_for_bundle(path)
|
|
126
|
+
candidates << Bundle.new(kind: kind, path: path, binary: binary) if binary && File.exist?(binary)
|
|
127
|
+
end
|
|
128
|
+
candidates.uniq { |bundle| bundle.binary }.sort_by(&:path)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def binary_for_bundle(path)
|
|
134
|
+
name = File.basename(path, File.extname(path))
|
|
135
|
+
candidates = [
|
|
136
|
+
File.join(path, name),
|
|
137
|
+
File.join(path, "Contents", "MacOS", name),
|
|
138
|
+
File.join(path, "Versions", "Current", name)
|
|
139
|
+
]
|
|
140
|
+
candidates.find { |candidate| File.file?(candidate) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class Auditor
|
|
145
|
+
attr_reader :ipa, :root, :runner, :bundles
|
|
146
|
+
|
|
147
|
+
def initialize(ipa, runner: CommandRunner.new)
|
|
148
|
+
@ipa = File.expand_path(ipa.to_s)
|
|
149
|
+
@runner = runner
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def with_extracted_ipa
|
|
153
|
+
directory, = IPAArchive.new(@ipa).extract
|
|
154
|
+
@root = directory
|
|
155
|
+
@bundles = BundleScanner.new(File.join(directory, "Payload")).scan
|
|
156
|
+
raise InputError, "IPA contains no recognized signed bundles" if @bundles.empty?
|
|
157
|
+
|
|
158
|
+
yield self
|
|
159
|
+
ensure
|
|
160
|
+
FileUtils.remove_entry(directory) if directory && File.exist?(directory)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def verification
|
|
164
|
+
bundles.map do |bundle|
|
|
165
|
+
result = runner.run("codesign", "--verify", "--strict", "--verbose=2", bundle.binary, allow_failure: true)
|
|
166
|
+
{
|
|
167
|
+
"path" => bundle.relative_path(root),
|
|
168
|
+
"binary" => bundle.binary.delete_prefix("#{root}/"),
|
|
169
|
+
"valid" => result.success?,
|
|
170
|
+
"message" => result.success? ? nil : [result.stdout, result.stderr].join.strip
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def entitlements
|
|
176
|
+
collect_plists do |bundle|
|
|
177
|
+
result = runner.run("codesign", "-d", "--entitlements", ":-", bundle.binary, allow_failure: true)
|
|
178
|
+
next if result.stdout.strip.empty? && result.stderr.strip.empty?
|
|
179
|
+
|
|
180
|
+
xml = [result.stdout, result.stderr].join
|
|
181
|
+
plist = parse_plist(xml)
|
|
182
|
+
plist ? { "path" => bundle.relative_path(root), "entitlements" => plist } : nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def profiles(expiration_only: false)
|
|
187
|
+
files = Dir.glob(File.join(root, "**", "*.mobileprovision"))
|
|
188
|
+
files.filter_map do |file|
|
|
189
|
+
result = runner.run("security", "cms", "-D", "-i", file, allow_failure: true)
|
|
190
|
+
plist = parse_plist(result.stdout)
|
|
191
|
+
next unless plist
|
|
192
|
+
|
|
193
|
+
value = expiration_only ? plist["ExpirationDate"] : plist
|
|
194
|
+
{ "path" => file.delete_prefix("#{root}/"), "profile" => value }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def manifest
|
|
199
|
+
{
|
|
200
|
+
"schema" => "howsigned/v2",
|
|
201
|
+
"ipa" => File.basename(ipa),
|
|
202
|
+
"bundles" => bundles.map { |bundle| { "kind" => bundle.kind.to_s, "path" => bundle.relative_path(root), "binary" => bundle.binary.delete_prefix("#{root}/") } },
|
|
203
|
+
"verification" => verification,
|
|
204
|
+
"entitlements" => entitlements,
|
|
205
|
+
"profiles" => profiles
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def collect_plists
|
|
212
|
+
bundles.filter_map { |bundle| yield bundle }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_plist(output)
|
|
216
|
+
text = output.to_s
|
|
217
|
+
start = text.index("<?xml") || text.index("<plist")
|
|
218
|
+
finish = text.index("</plist>")
|
|
219
|
+
return nil unless start && finish
|
|
220
|
+
|
|
221
|
+
Plist.parse_xml(text[start..(finish + "</plist>".length - 1)])
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "zip"
|
|
7
|
+
require "howsigned"
|
|
8
|
+
|
|
9
|
+
class HowsignedTest < Minitest::Test
|
|
10
|
+
FakeResult = Struct.new(:stdout, :stderr) do
|
|
11
|
+
def success?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class PassingRunner
|
|
17
|
+
def run(*_args, allow_failure: false)
|
|
18
|
+
FakeResult.new("", "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ipa_with(entries)
|
|
23
|
+
path = File.join(Dir.tmpdir, "howsigned-test-#{Process.pid}-#{rand(1_000_000)}.ipa")
|
|
24
|
+
Zip::File.open(path, create: true) do |zip|
|
|
25
|
+
entries.each { |name, content| zip.get_output_stream(name) { |stream| stream.write(content) } }
|
|
26
|
+
end
|
|
27
|
+
path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_scanner_finds_app_extensions_frameworks_and_dylibs
|
|
31
|
+
Dir.mktmpdir do |root|
|
|
32
|
+
payload = File.join(root, "Payload")
|
|
33
|
+
paths = [
|
|
34
|
+
"Demo.app/Demo",
|
|
35
|
+
"Demo.app/PlugIns/Share.appex/Share",
|
|
36
|
+
"Demo.app/Frameworks/Kit.framework/Kit",
|
|
37
|
+
"Demo.app/Frameworks/libKit.dylib"
|
|
38
|
+
]
|
|
39
|
+
paths.each do |path|
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(File.join(payload, path)))
|
|
41
|
+
File.write(File.join(payload, path), "binary")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
bundles = Howsigned::BundleScanner.new(payload).scan
|
|
45
|
+
assert_equal %i[app appex dylib framework], bundles.map(&:kind).sort
|
|
46
|
+
assert_includes bundles.map(&:binary), File.join(payload, "Demo.app/Frameworks/libKit.dylib")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_archive_rejects_parent_traversal
|
|
51
|
+
path = ipa_with("../outside" => "nope")
|
|
52
|
+
assert_raises(Howsigned::UnsafeArchiveError) { Howsigned::IPAArchive.new(path).extract }
|
|
53
|
+
ensure
|
|
54
|
+
FileUtils.rm_f(path) if path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_archive_rejects_absolute_paths
|
|
58
|
+
archive = Howsigned::IPAArchive.allocate
|
|
59
|
+
assert_raises(Howsigned::UnsafeArchiveError) { archive.send(:safe_relative_path, "/tmp/outside") }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_runner_preserves_arguments_without_shell_interpolation
|
|
63
|
+
runner = Howsigned::CommandRunner.new(executable_finder: ->(_name) { RbConfig.ruby })
|
|
64
|
+
result = runner.run("ignored", "-e", "puts ARGV.first", "hello world")
|
|
65
|
+
assert result.success?
|
|
66
|
+
assert_equal "hello world", result.stdout.strip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_auditor_generates_manifest_from_synthetic_ipa
|
|
70
|
+
path = ipa_with(
|
|
71
|
+
"Payload/Demo.app/Demo" => "binary",
|
|
72
|
+
"Payload/Demo.app/Info.plist" => "plist"
|
|
73
|
+
)
|
|
74
|
+
manifest = nil
|
|
75
|
+
Howsigned::Auditor.new(path, runner: PassingRunner.new).with_extracted_ipa do |auditor|
|
|
76
|
+
manifest = auditor.manifest
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
assert_equal "howsigned/v2", manifest["schema"]
|
|
80
|
+
assert_equal "app", manifest["bundles"].first["kind"]
|
|
81
|
+
assert_equal true, manifest["verification"].first["valid"]
|
|
82
|
+
ensure
|
|
83
|
+
FileUtils.rm_f(path) if path
|
|
84
|
+
end
|
|
85
|
+
end
|
metadata
CHANGED
|
@@ -1,78 +1,77 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: howsigned
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael MacDougall
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-09-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: plist
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- - "
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '3.
|
|
19
|
+
version: '3.7'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '4'
|
|
20
23
|
type: :runtime
|
|
21
24
|
prerelease: false
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
26
|
requirements:
|
|
24
|
-
- - "
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '3.7'
|
|
30
|
+
- - "<"
|
|
25
31
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
32
|
+
version: '4'
|
|
27
33
|
- !ruby/object:Gem::Dependency
|
|
28
34
|
name: rubyzip
|
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
|
30
36
|
requirements:
|
|
31
|
-
- - "
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '1.1'
|
|
34
|
-
type: :runtime
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - "~>"
|
|
37
|
+
- - ">="
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
41
|
-
-
|
|
42
|
-
name: commander
|
|
43
|
-
requirement: !ruby/object:Gem::Requirement
|
|
44
|
-
requirements:
|
|
45
|
-
- - "~>"
|
|
39
|
+
version: '3.0'
|
|
40
|
+
- - "<"
|
|
46
41
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '4
|
|
42
|
+
version: '4'
|
|
48
43
|
type: :runtime
|
|
49
44
|
prerelease: false
|
|
50
45
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
46
|
requirements:
|
|
52
|
-
- - "
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.0'
|
|
50
|
+
- - "<"
|
|
53
51
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '4
|
|
55
|
-
description:
|
|
56
|
-
|
|
52
|
+
version: '4'
|
|
53
|
+
description: Audit code signing, entitlements, and provisioning profiles inside Apple
|
|
54
|
+
IPA archives
|
|
55
|
+
email: opensource@macdougall.dev
|
|
57
56
|
executables:
|
|
58
57
|
- howsigned
|
|
59
58
|
extensions: []
|
|
60
59
|
extra_rdoc_files: []
|
|
61
60
|
files:
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- Rakefile
|
|
62
64
|
- bin/howsigned
|
|
63
|
-
- lib/
|
|
64
|
-
- lib/
|
|
65
|
-
-
|
|
66
|
-
- lib/extract_zip.rb
|
|
67
|
-
- lib/profiles.rb
|
|
68
|
-
- lib/validate_ipa.rb
|
|
69
|
-
- lib/verify.rb
|
|
65
|
+
- lib/howsigned.rb
|
|
66
|
+
- lib/howsigned/version.rb
|
|
67
|
+
- test/howsigned_test.rb
|
|
70
68
|
homepage: https://github.com/macdoum1/howsigned
|
|
71
69
|
licenses:
|
|
72
70
|
- MIT
|
|
73
71
|
metadata:
|
|
74
72
|
source_code_uri: https://github.com/macdoum1/howsigned
|
|
75
|
-
|
|
73
|
+
rubygems_mfa_required: 'true'
|
|
74
|
+
post_install_message:
|
|
76
75
|
rdoc_options: []
|
|
77
76
|
require_paths:
|
|
78
77
|
- lib
|
|
@@ -80,16 +79,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
80
79
|
requirements:
|
|
81
80
|
- - ">="
|
|
82
81
|
- !ruby/object:Gem::Version
|
|
83
|
-
version: '
|
|
82
|
+
version: '3.2'
|
|
84
83
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
84
|
requirements:
|
|
86
85
|
- - ">="
|
|
87
86
|
- !ruby/object:Gem::Version
|
|
88
87
|
version: '0'
|
|
89
88
|
requirements: []
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
signing_key:
|
|
89
|
+
rubygems_version: 3.5.22
|
|
90
|
+
signing_key:
|
|
93
91
|
specification_version: 4
|
|
94
|
-
summary:
|
|
92
|
+
summary: Inspect and compare the signing state of IPA bundles
|
|
95
93
|
test_files: []
|
data/lib/compare.rb
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
require 'plist'
|
|
2
|
-
require 'zip'
|
|
3
|
-
require 'zip/filesystem'
|
|
4
|
-
require 'validate_ipa'
|
|
5
|
-
require 'extract_zip'
|
|
6
|
-
require 'entitlements'
|
|
7
|
-
require 'profiles'
|
|
8
|
-
|
|
9
|
-
def write_plist_to_path(plist, path)
|
|
10
|
-
File.write(path, plist)
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
command :compare do |c|
|
|
14
|
-
c.syntax = 'howsigned compare [.ipa file] [.ipa file]'
|
|
15
|
-
c.description = 'Prints diff of entitlements of specified .ipa files in plist format, nothing if entitlements are identical'
|
|
16
|
-
c.action do |args, options|
|
|
17
|
-
first_file = validate_ipa(args.pop)
|
|
18
|
-
second_file = validate_ipa(args.pop)
|
|
19
|
-
|
|
20
|
-
first_tempdir = ::File.new(Dir.mktmpdir)
|
|
21
|
-
extract_zip(first_file, first_tempdir)
|
|
22
|
-
|
|
23
|
-
second_tempdir = ::File.new(Dir.mktmpdir)
|
|
24
|
-
extract_zip(second_file, second_tempdir)
|
|
25
|
-
|
|
26
|
-
first_entitlements = get_entitlements(first_tempdir.path)
|
|
27
|
-
second_entitlements = get_entitlements(second_tempdir.path)
|
|
28
|
-
|
|
29
|
-
first_profiles = get_profiles(first_tempdir.path, false)
|
|
30
|
-
second_profiles = get_profiles(second_tempdir.path, false)
|
|
31
|
-
|
|
32
|
-
temp_plist_dir = ::File.new(Dir.mktmpdir).path
|
|
33
|
-
write_plist_to_path(first_entitlements, "#{temp_plist_dir}/entitlements1.plist")
|
|
34
|
-
write_plist_to_path(second_entitlements, "#{temp_plist_dir}/entitlements2.plist")
|
|
35
|
-
write_plist_to_path(first_profiles, "#{temp_plist_dir}/profiles1.plist")
|
|
36
|
-
write_plist_to_path(second_profiles, "#{temp_plist_dir}/profiles2.plist")
|
|
37
|
-
|
|
38
|
-
entitlements_diff = `diff "#{temp_plist_dir}/entitlements1.plist" "#{temp_plist_dir}/entitlements2.plist"`
|
|
39
|
-
profiles_diff = `diff "#{temp_plist_dir}/profiles1.plist" "#{temp_plist_dir}/profiles2.plist"`
|
|
40
|
-
|
|
41
|
-
if (entitlements_diff.length > 0)
|
|
42
|
-
puts entitlements_diff
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
if (profiles_diff.length > 0)
|
|
46
|
-
puts profiles_diff
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
data/lib/entitlements.rb
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# Heavily inspired by Shenzhen's `ipa info`
|
|
2
|
-
# https://github.com/nomad/shenzhen/blob/master/lib/shenzhen/commands/info.rb
|
|
3
|
-
|
|
4
|
-
require 'plist'
|
|
5
|
-
require 'zip'
|
|
6
|
-
require 'zip/filesystem'
|
|
7
|
-
require 'validate_ipa'
|
|
8
|
-
require 'extract_zip'
|
|
9
|
-
require 'contained_binaries_definition'
|
|
10
|
-
|
|
11
|
-
def append_entitlements(path, entitlements_hash)
|
|
12
|
-
index = 0
|
|
13
|
-
Dir.glob(path) do |file|
|
|
14
|
-
entitlements = `codesign -d --entitlements :- "#{file}" 2>&1`
|
|
15
|
-
plist_entitlements = Plist::parse_xml(entitlements)
|
|
16
|
-
if (plist_entitlements)
|
|
17
|
-
application_identifier = plist_entitlements["application-identifier"] ? plist_entitlements["application-identifier"] : index.to_s
|
|
18
|
-
entitlements_hash[application_identifier] = plist_entitlements
|
|
19
|
-
index = index + 1
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def get_entitlements(tempdir_path)
|
|
25
|
-
entitlements_hash = Hash.new
|
|
26
|
-
|
|
27
|
-
contained_binary_extensions().each { |extension|
|
|
28
|
-
append_entitlements("#{tempdir_path}/**/*.#{extension}", entitlements_hash)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (entitlements_hash.length == 0)
|
|
32
|
-
abort "No entitlements found on contained binaries"
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
return entitlements_hash.to_plist
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
command :entitlements do |c|
|
|
39
|
-
c.syntax = 'howsigned entitlements [.ipa file]'
|
|
40
|
-
c.description = 'Prints entitlements of specified .ipa in plist format'
|
|
41
|
-
c.action do |args, options|
|
|
42
|
-
file = validate_ipa(args.pop)
|
|
43
|
-
|
|
44
|
-
tempdir = ::File.new(Dir.mktmpdir)
|
|
45
|
-
extract_zip(file, tempdir)
|
|
46
|
-
|
|
47
|
-
puts get_entitlements(tempdir.path)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
|
data/lib/extract_zip.rb
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
require 'zip'
|
|
2
|
-
|
|
3
|
-
def extract_zip(file, destination)
|
|
4
|
-
FileUtils.mkdir_p(destination)
|
|
5
|
-
|
|
6
|
-
Zip::File.open(file) do |zip_file|
|
|
7
|
-
zip_file.each do |f|
|
|
8
|
-
fpath = File.join(destination, f.name)
|
|
9
|
-
zip_file.extract(f, fpath) unless File.exist?(fpath)
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
end
|
data/lib/profiles.rb
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
require 'plist'
|
|
2
|
-
require 'zip'
|
|
3
|
-
require 'zip/filesystem'
|
|
4
|
-
require 'validate_ipa'
|
|
5
|
-
require 'extract_zip'
|
|
6
|
-
|
|
7
|
-
def get_profiles(tempdir_path, only_expiration)
|
|
8
|
-
path = "#{tempdir_path}/**/*.mobileprovision"
|
|
9
|
-
profiles = Hash.new
|
|
10
|
-
index = 0
|
|
11
|
-
Dir.glob(path) do |file|
|
|
12
|
-
profile = `security cms -D -i "#{file}" 2>&1`
|
|
13
|
-
plist_profile = Plist::parse_xml(profile)
|
|
14
|
-
if plist_profile
|
|
15
|
-
app_id = plist_profile["AppIDName"] ? plist_profile["AppIDName"] : index.to_s
|
|
16
|
-
if only_expiration
|
|
17
|
-
profiles[app_id] = plist_profile["ExpirationDate"]
|
|
18
|
-
else
|
|
19
|
-
profiles[app_id] = plist_profile
|
|
20
|
-
end
|
|
21
|
-
index = index + 1
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
if (profiles.length == 0)
|
|
26
|
-
abort "No embedded provisioning profiles found"
|
|
27
|
-
end
|
|
28
|
-
return profiles.to_plist
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
command :profiles do |c|
|
|
32
|
-
c.syntax = 'howsigned profiles [.ipa file]'
|
|
33
|
-
c.description = 'Prints embedded profiles of specified .ipa in plist format'
|
|
34
|
-
c.option '--expiration', "When specified, will print only the expiration dates of embedded profiles"
|
|
35
|
-
c.action do |args, options|
|
|
36
|
-
file = validate_ipa(args.pop)
|
|
37
|
-
only_expiration = options.expiration || false
|
|
38
|
-
|
|
39
|
-
tempdir = ::File.new(Dir.mktmpdir)
|
|
40
|
-
extract_zip(file, tempdir)
|
|
41
|
-
|
|
42
|
-
puts get_profiles(tempdir.path, only_expiration)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
|
data/lib/validate_ipa.rb
DELETED
data/lib/verify.rb
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
require 'plist'
|
|
2
|
-
require 'zip'
|
|
3
|
-
require 'zip/filesystem'
|
|
4
|
-
require 'validate_ipa'
|
|
5
|
-
require 'extract_zip'
|
|
6
|
-
require 'contained_binaries_definition'
|
|
7
|
-
|
|
8
|
-
def verify_binaries(path)
|
|
9
|
-
Dir.glob(path) do |file|
|
|
10
|
-
codesign = `codesign --verify "#{file}" 2>&1`
|
|
11
|
-
if (!codesign.to_s.empty?)
|
|
12
|
-
puts codesign
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
command :verify do |c|
|
|
18
|
-
c.syntax = 'howsigned verify [.ipa file]'
|
|
19
|
-
c.description = 'Verifies the code signature of all binaries contained within the .ipa, will return nothing if signed correctly'
|
|
20
|
-
c.action do |args, options|
|
|
21
|
-
file = validate_ipa(args.pop)
|
|
22
|
-
|
|
23
|
-
tempdir = ::File.new(Dir.mktmpdir)
|
|
24
|
-
extract_zip(file, tempdir)
|
|
25
|
-
|
|
26
|
-
entitlements_hash = Hash.new
|
|
27
|
-
|
|
28
|
-
contained_binary_extensions().each { |extension|
|
|
29
|
-
verify_binaries("#{tempdir.path}/**/*.#{extension}")
|
|
30
|
-
}
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
|