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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 94583389df0b0759430446202650c8c4b7453a65
4
- data.tar.gz: 2e5ca873274a910c2a365f1cb3deda596a11e49e
2
+ SHA256:
3
+ metadata.gz: 6b35357e3a7d5fede023bc0b0010c8cda63ba024fdb3de8526a4b703385b2728
4
+ data.tar.gz: 2a207720c330ad476eff671a8e9f6d3c480bede84e4cd334ca0c31edf7e3badb
5
5
  SHA512:
6
- metadata.gz: 50dee3d0f89a6b349f37f02dae303ff72a65675ce005ef73f3148590d800e40a5facda3579320ca6c2b4b3bf4e9769833b907bbffe3a638c3e4665a439a3433a
7
- data.tar.gz: 36a5b835f2caa5d2511b456da921b741e45d2c7657f593000174eaab49358fe309944a68c1109db34c84cd265dfaf3365662e3c0c45995ffaa6652faa4b586a2
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
@@ -0,0 +1,9 @@
1
+ #! /usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/gem_tasks"
5
+ task default: :test
6
+
7
+ task :test do
8
+ ruby "-Ilib:test", *Dir["test/**/*_test.rb"]
9
+ end
data/bin/howsigned CHANGED
@@ -1,13 +1,114 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
- require 'rubygems'
4
- require 'commander/import'
4
+ require "optparse"
5
+ require "json"
6
+ require "howsigned"
5
7
 
6
- require 'entitlements'
7
- require 'profiles'
8
- require 'verify'
9
- require 'compare'
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
- program :version, '0.0.9'
12
- program :description, 'Utility to determine codesigning on contained binaries in an .ipa'
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)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Howsigned
4
+ VERSION = "2.0.0"
5
+ end
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.9
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: 2017-11-16 00:00:00.000000000 Z
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.1'
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: '3.1'
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: '1.1'
41
- - !ruby/object:Gem::Dependency
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.3'
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.3'
55
- description: Utility to see how the contained binaries within an .ipa are signed
56
- email: mmacdougall@etsy.com
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/compare.rb
64
- - lib/contained_binaries_definition.rb
65
- - lib/entitlements.rb
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
- post_install_message:
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: '0'
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
- rubyforge_project:
91
- rubygems_version: 2.6.11
92
- signing_key:
89
+ rubygems_version: 3.5.22
90
+ signing_key:
93
91
  specification_version: 4
94
- summary: Utility to see how the contained binaries within an .ipa are signed
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
@@ -1,4 +0,0 @@
1
- def contained_binary_extensions
2
- return ["appex",
3
- "app"]
4
- 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
@@ -1,4 +0,0 @@
1
- def validate_ipa(file)
2
- puts "Missing or unspecified .ipa file" and abort unless file and ::File.exist?(file)
3
- return file
4
- end
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
-