cyclonedx-cocoapods 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # This file is part of CycloneDX CocoaPods
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the “License”);
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an “AS IS” BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
19
+ #
20
+
21
+ require 'optparse'
22
+ require 'logger'
23
+ require 'cocoapods'
24
+
25
+ require_relative 'component'
26
+ require_relative 'pod'
27
+ require_relative 'pod_attributes'
28
+ require_relative 'source'
29
+ require_relative 'bom_builder'
30
+
31
+ module CycloneDX
32
+ module CocoaPods
33
+ class PodfileParsingError < StandardError; end
34
+ class BOMOutputError < StandardError; end
35
+
36
+ class CLIRunner
37
+ def run
38
+ begin
39
+ setup_logger # Needed in case we have errors while processing CLI parameters
40
+ options = parseOptions
41
+ setup_logger(verbose: options[:verbose])
42
+ @logger.debug "Running cyclonedx-cocoapods with options: #{options}"
43
+
44
+ podfile, lockfile = ensure_podfile_and_lock_are_present(options)
45
+ pods = parse_pods(podfile, lockfile)
46
+
47
+ populate_pods_with_additional_info(pods)
48
+
49
+ bom = BOMBuilder.new(component: component_from_options(options), pods: pods).bom(version: options[:bom_version] || 1)
50
+ write_bom_to_file(bom: bom, options: options)
51
+ rescue StandardError => e
52
+ @logger.error ([e.message] + e.backtrace).join($/)
53
+ exit 1
54
+ end
55
+ end
56
+
57
+
58
+ private
59
+
60
+ def parseOptions
61
+ parsedOptions = {}
62
+ component_types = Component::VALID_COMPONENT_TYPES
63
+ OptionParser.new do |options|
64
+ options.banner = <<~BANNER
65
+ Usage: cyclonedx-cocoapods [options]
66
+ Generates a BOM with the given parameters. BOM component metadata is only generated if the component's name and version are provided using the --name and --version parameters.
67
+ BANNER
68
+
69
+ options.on('--[no-]verbose', 'Run verbosely') do |v|
70
+ parsedOptions[:verbose] = v
71
+ end
72
+ options.on('-p', '--path path', '(Optional) Path to CocoaPods project directory, current directory if missing') do |path|
73
+ parsedOptions[:path] = path
74
+ end
75
+ options.on('-o', '--output bom_file_path', '(Optional) Path to output the bom.xml file to') do |bom_file_path|
76
+ parsedOptions[:bom_file_path] = bom_file_path
77
+ end
78
+ options.on('-b', '--bom-version bom_version', Integer, '(Optional) Version of the generated BOM, 1 if not provided') do |version|
79
+ parsedOptions[:bom_version] = version
80
+ end
81
+ options.on('-g', '--group group', '(Optional) Group of the component for which the BOM is generated') do |group|
82
+ parsedOptions[:group] = group
83
+ end
84
+ options.on('-n', '--name name', '(Optional, if specified version and type are also required) Name of the component for which the BOM is generated') do |name|
85
+ parsedOptions[:name] = name
86
+ end
87
+ options.on('-v', '--version version', '(Optional) Version of the component for which the BOM is generated') do |version|
88
+ begin
89
+ Gem::Version.new(version)
90
+ parsedOptions[:version] = version
91
+ rescue StandardError => e
92
+ raise OptionParser::InvalidArgument, e.message
93
+ end
94
+ end
95
+ options.on('-t', '--type type', "(Optional) Type of the component for which the BOM is generated (one of #{component_types.join('|')})") do |type|
96
+ raise OptionParser::InvalidArgument, "Invalid value for component's type (#{type}). It must be one of #{component_types.join('|')}" unless component_types.include?(type)
97
+ parsedOptions[:type] = type
98
+ end
99
+ options.on_tail('-h', '--help', 'Show help message') do
100
+ puts options
101
+ exit
102
+ end
103
+ end.parse!
104
+
105
+ raise OptionParser::InvalidArgument, 'You must also specify --version and --type if --name is provided' if !parsedOptions[:name].nil? && (parsedOptions[:version].nil? || parsedOptions[:type].nil?)
106
+ return parsedOptions
107
+ end
108
+
109
+
110
+ def component_from_options(options)
111
+ Component.new(group: options[:group], name: options[:name], version: options[:version], type: options[:type]) if options[:name]
112
+ end
113
+
114
+
115
+ def setup_logger(verbose: true)
116
+ @logger ||= Logger.new($stdout)
117
+ @logger.level = verbose ? Logger::DEBUG : Logger::INFO
118
+ end
119
+
120
+
121
+ def ensure_podfile_and_lock_are_present(options)
122
+ project_dir = Pathname.new(options[:path] || Dir.pwd)
123
+ raise PodfileParsingError, "#{options[:path]} is not a valid directory." unless File.directory?(project_dir)
124
+ options[:podfile_path] = project_dir + 'Podfile'
125
+ raise PodfileParsingError, "Missing Podfile in #{project_dir}. Please use the --path option if not running from the CocoaPods project directory." unless File.exist?(options[:podfile_path])
126
+ options[:podfile_lock_path] = project_dir + 'Podfile.lock'
127
+ raise PodfileParsingError, "Missing Podfile.lock, please run 'pod install' before generating BOM" unless File.exist?(options[:podfile_lock_path])
128
+
129
+ initialize_cocoapods_config(project_dir)
130
+
131
+ lockfile = ::Pod::Lockfile.from_file(options[:podfile_lock_path])
132
+ verify_synced_sandbox(lockfile)
133
+
134
+ return ::Pod::Podfile.from_file(options[:podfile_path]), lockfile
135
+ end
136
+
137
+
138
+ def initialize_cocoapods_config(project_dir)
139
+ ::Pod::Config.instance.installation_root = project_dir
140
+ end
141
+
142
+
143
+ def verify_synced_sandbox(lockfile)
144
+ manifestFile = ::Pod::Config.instance.sandbox.manifest
145
+ raise PodfileParsingError, "Missing Manifest.lock, please run 'pod install' before generating BOM" if manifestFile.nil?
146
+ raise PodfileParsingError, "The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation." unless lockfile == manifestFile
147
+ end
148
+
149
+
150
+ def cocoapods_repository_source(podfile, lockfile, pod_name)
151
+ @source_manager ||= create_source_manager(podfile)
152
+ return Source::CocoaPodsRepository.searchable_source(url: lockfile.spec_repo(pod_name), source_manager: @source_manager)
153
+ end
154
+
155
+
156
+ def git_source(lockfile, pod_name)
157
+ checkout_options = lockfile.checkout_options_for_pod_named(pod_name)
158
+ url = checkout_options[:git]
159
+ [:tag, :branch, :commit].each do |type|
160
+ return Source::GitRepository.new(url: url, type: type, label: checkout_options[type]) if checkout_options[type]
161
+ end
162
+ return Source::GitRepository.new(url: url)
163
+ end
164
+
165
+
166
+ def source_for_pod(podfile, lockfile, pod_name)
167
+ root_name = pod_name.split('/').first
168
+ return cocoapods_repository_source(podfile, lockfile, root_name) unless lockfile.spec_repo(root_name).nil?
169
+ return git_source(lockfile, root_name) unless lockfile.checkout_options_for_pod_named(root_name).nil?
170
+ return Source::LocalPod.new(path: lockfile.to_hash['EXTERNAL SOURCES'][root_name][:path]) if lockfile.to_hash['EXTERNAL SOURCES'][root_name][:path]
171
+ return Source::Podspec.new(url: lockfile.to_hash['EXTERNAL SOURCES'][root_name][:podspec]) if lockfile.to_hash['EXTERNAL SOURCES'][root_name][:podspec]
172
+ return nil
173
+ end
174
+
175
+
176
+ def parse_pods(podfile, lockfile)
177
+ @logger.debug "Parsing pods from #{podfile.defined_in_file}"
178
+ return lockfile.pod_names.map do |name|
179
+ Pod.new(name: name, version: lockfile.version(name), source: source_for_pod(podfile, lockfile, name), checksum: lockfile.checksum(name))
180
+ end
181
+ end
182
+
183
+
184
+ def create_source_manager(podfile)
185
+ sourceManager = ::Pod::Source::Manager.new(::Pod::Config::instance.repos_dir)
186
+ @logger.debug "Parsing sources from #{podfile.defined_in_file}"
187
+ podfile.sources.each do |source|
188
+ @logger.debug "Ensuring #{source} is available for searches"
189
+ sourceManager.find_or_create_source_with_url(source)
190
+ end
191
+ @logger.debug "Source manager successfully created with all needed sources"
192
+ return sourceManager
193
+ end
194
+
195
+
196
+ def populate_pods_with_additional_info(pods)
197
+ pods.each do |pod|
198
+ @logger.debug "Completing information for #{pod.name}"
199
+ pod.complete_information_from_source
200
+ end
201
+ return pods
202
+ end
203
+
204
+
205
+ def write_bom_to_file(bom:, options:)
206
+ bom_file_path = Pathname.new(options[:bom_file_path] || './bom.xml').expand_path
207
+ bom_dir = bom_file_path.dirname
208
+
209
+ begin
210
+ FileUtils.mkdir_p(bom_dir) unless bom_dir.directory?
211
+ rescue
212
+ raise BOMOutputError, "Unable to create the BOM output directory at #{bom_dir}"
213
+ end
214
+
215
+ begin
216
+ File.open(bom_file_path, 'w') { |file| file.write(bom) }
217
+ @logger.info "BOM written to #{bom_file_path}"
218
+ rescue
219
+ raise BOMOutputError, "Unable to write the BOM to #{bom_file_path}"
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,37 @@
1
+ #
2
+ # This file is part of CycloneDX CocoaPods
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the “License”);
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an “AS IS” BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # SPDX-License-Identifier: Apache-2.0
17
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
18
+ #
19
+
20
+ module CycloneDX
21
+ module CocoaPods
22
+ class Component
23
+ VALID_COMPONENT_TYPES = %w[application framework library container operating-system device firmware file].freeze
24
+
25
+ attr_reader :group, :name, :version, :type
26
+
27
+ def initialize(group: nil, name:, version:, type:)
28
+ raise ArgumentError, "Group, if specified, must be non empty" if !group.nil? && group.to_s.strip.empty?
29
+ raise ArgumentError, "Name must be non empty" if name.nil? || name.to_s.strip.empty?
30
+ Gem::Version.new(version) # To check that the version string is well formed
31
+ raise ArgumentError, "#{type} is not valid component type (#{VALID_COMPONENT_TYPES.join('|')})" unless VALID_COMPONENT_TYPES.include?(type)
32
+
33
+ @group, @name, @version, @type = group, name, version, type
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,44 @@
1
+ #
2
+ # This file is part of CycloneDX CocoaPods
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the “License”);
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an “AS IS” BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # SPDX-License-Identifier: Apache-2.0
17
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
18
+ #
19
+
20
+ require 'json'
21
+
22
+ module CycloneDX
23
+ module CocoaPods
24
+ class Pod
25
+ class License
26
+ SPDX_LICENSES = JSON.parse(File.read("#{__dir__}/spdx-licenses.json")).freeze
27
+ IDENTIFIER_TYPES = [:id, :name].freeze
28
+
29
+ attr_reader :identifier
30
+ attr_reader :identifier_type
31
+ attr_accessor :text
32
+ attr_accessor :url
33
+
34
+ def initialize(identifier:)
35
+ raise ArgumentError, "License identifier must be non empty" if identifier.nil? || identifier.to_s.strip.empty?
36
+
37
+ @identifier = SPDX_LICENSES.find { |license_id| license_id.downcase == identifier.to_s.downcase }
38
+ @identifier_type = @identifier.nil? ? :name : :id
39
+ @identifier ||= identifier
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,108 @@
1
+ #
2
+ # This file is part of CycloneDX CocoaPods
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the “License”);
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an “AS IS” BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # SPDX-License-Identifier: Apache-2.0
17
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
18
+ #
19
+
20
+ require 'rubygems/version'
21
+ require_relative 'license'
22
+
23
+ module CycloneDX
24
+ module CocoaPods
25
+ class Pod
26
+ attr_reader :name # xs:normalizedString
27
+ attr_reader :version # xs:normalizedString
28
+ attr_reader :source # Anything responding to :source_qualifier
29
+ attr_reader :homepage # xs:anyURI - https://cyclonedx.org/docs/1.4/#type_externalReference
30
+ attr_reader :checksum # https://cyclonedx.org/docs/1.4/#type_hashValue (We only use SHA-1 hashes - length == 40)
31
+ attr_reader :author # xs:normalizedString
32
+ attr_reader :description # xs:normalizedString
33
+ attr_reader :license # https://cyclonedx.org/docs/1.4/#type_licenseType
34
+ # We don't currently support several licenses or license expressions https://spdx.github.io/spdx-spec/appendix-IV-SPDX-license-expressions/
35
+ def initialize(name:, version:, source: nil, checksum: nil)
36
+ raise ArgumentError, "Name must be non empty" if name.nil? || name.to_s.empty?
37
+ raise ArgumentError, "Name shouldn't contain spaces" if name.to_s.include?(' ')
38
+ raise ArgumentError, "Name shouldn't start with a dot" if name.to_s.start_with?('.')
39
+ # `pod create` also enforces no plus sign, but more than 500 public pods have a plus in the root name.
40
+ # https://github.com/CocoaPods/CocoaPods/blob/9461b346aeb8cba6df71fd4e71661688138ec21b/lib/cocoapods/command/lib/create.rb#L35
41
+
42
+ Gem::Version.new(version) # To check that the version string is well formed
43
+ raise ArgumentError, "Invalid pod source" unless source.nil? || source.respond_to?(:source_qualifier)
44
+ raise ArgumentError, "#{checksum} is not valid SHA-1 hash" unless checksum.nil? || checksum =~ /[a-fA-F0-9]{40}/
45
+ @name, @version, @source, @checksum = name.to_s, version, source, checksum
46
+ end
47
+
48
+ def root_name
49
+ @name.split('/').first
50
+ end
51
+
52
+ def populate(attributes)
53
+ attributes.transform_keys!(&:to_sym)
54
+ populate_author(attributes)
55
+ populate_description(attributes)
56
+ populate_license(attributes)
57
+ populate_homepage(attributes)
58
+ self
59
+ end
60
+
61
+ def to_s
62
+ "Pod<#{name}, #{version.to_s}>"
63
+ end
64
+
65
+ private
66
+
67
+ def populate_author(attributes)
68
+ authors = attributes[:author] || attributes[:authors]
69
+ case authors
70
+ when String
71
+ @author = authors
72
+ when Array
73
+ @author = authors.join(', ')
74
+ when Hash
75
+ @author = authors.map { |name, email| "#{name} <#{email}>" }.join(', ')
76
+ else
77
+ @author = nil
78
+ end
79
+ end
80
+
81
+ def populate_description(attributes)
82
+ @description = attributes[:description] || attributes[:summary]
83
+ end
84
+
85
+ def populate_license(attributes)
86
+ case attributes[:license]
87
+ when String
88
+ @license = License.new(identifier: attributes[:license])
89
+ when Hash
90
+ attributes[:license].transform_keys!(&:to_sym)
91
+ identifier = attributes[:license][:type]
92
+ unless identifier.nil? || identifier.to_s.strip.empty?
93
+ @license = License.new(identifier: identifier)
94
+ @license.text = attributes[:license][:text]
95
+ else
96
+ @license = nil
97
+ end
98
+ else
99
+ @license = nil
100
+ end
101
+ end
102
+
103
+ def populate_homepage(attributes)
104
+ @homepage = attributes[:homepage]
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,72 @@
1
+ #
2
+ # This file is part of CycloneDX CocoaPods
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the “License”);
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an “AS IS” BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # SPDX-License-Identifier: Apache-2.0
17
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
18
+ #
19
+
20
+ module CycloneDX
21
+ module CocoaPods
22
+ class SearchError < StandardError; end
23
+
24
+ module Source
25
+ class CocoaPodsRepository
26
+ attr_accessor :source_manager
27
+
28
+ def self.searchable_source(url:, source_manager:)
29
+ source = CocoaPodsRepository.new(url: url)
30
+ source.source_manager = source_manager
31
+ return source
32
+ end
33
+
34
+ def attributes_for(pod:)
35
+ specification_sets = @source_manager.search_by_name("^#{Regexp.escape(pod.root_name)}$")
36
+ raise SearchError, "No pod found named #{pod.name}" if specification_sets.length == 0
37
+ raise SearchError, "More than one pod found named #{pod.name}" if specification_sets.length > 1
38
+
39
+ paths = specification_sets[0].specification_paths_for_version(pod.version)
40
+ raise SearchError, "Version #{pod.version} not found for pod #{pod.name}" if paths.length == 0
41
+
42
+ ::Pod::Specification.from_file(paths[0]).attributes_hash
43
+ end
44
+ end
45
+
46
+ class GitRepository
47
+ def attributes_for(pod:)
48
+ ::Pod::Config.instance.sandbox.specification(pod.name).attributes_hash
49
+ end
50
+ end
51
+
52
+ class LocalPod
53
+ def attributes_for(pod:)
54
+ ::Pod::Config.instance.sandbox.specification(pod.name).attributes_hash
55
+ end
56
+ end
57
+
58
+ class Podspec
59
+ def attributes_for(pod:)
60
+ ::Pod::Config.instance.sandbox.specification(pod.name).attributes_hash
61
+ end
62
+ end
63
+ end
64
+
65
+
66
+ class Pod
67
+ def complete_information_from_source
68
+ populate(source.attributes_for(pod: self))
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,59 @@
1
+ #
2
+ # This file is part of CycloneDX CocoaPods
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the “License”);
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an “AS IS” BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # SPDX-License-Identifier: Apache-2.0
17
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
18
+ #
19
+
20
+ module CycloneDX
21
+ module CocoaPods
22
+ module Source
23
+ class CocoaPodsRepository
24
+ attr_reader :url
25
+
26
+ def initialize(url:)
27
+ @url = url
28
+ end
29
+ end
30
+
31
+ class GitRepository
32
+ VALID_TYPES = [:branch, :tag, :commit].freeze
33
+
34
+ attr_reader :url, :type, :label
35
+
36
+ def initialize(url:, type: nil, label: nil)
37
+ raise ArgumentError, "Invalid checkout information" if !type.nil? && !VALID_TYPES.include?(type)
38
+ @url, @type, @label = url, type, label
39
+ end
40
+ end
41
+
42
+ class LocalPod
43
+ attr_reader :path
44
+
45
+ def initialize(path:)
46
+ @path = path
47
+ end
48
+ end
49
+
50
+ class Podspec
51
+ attr_reader :url
52
+
53
+ def initialize(url:)
54
+ @url = url
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end