cyclonedx-cocoapods 1.4.1 → 2.0.1

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
2
  SHA256:
3
- metadata.gz: 84ed77501efec7ca77fce507dd1dbc4a29ffb4b8cf45fc6b942eafe3901af95a
4
- data.tar.gz: 85204bb25786de11c3dc7ec302d016431ebecd7078149081a6b294ba65f756aa
3
+ metadata.gz: a00a26d3a6f6e7ce71f6c44dbc4eb4dcd046e4c39e5f9a8d8355bf85af85cf16
4
+ data.tar.gz: d3972e003471c6c71c43c3b83b1373b85efc6b8f78c124d281c23dc9184b83d1
5
5
  SHA512:
6
- metadata.gz: 7f4b84eb0a11f7f6488fe9fccef7806e786db41ea647806046b729b39952172175df7b8884b17c60e5cac0b246a9bfc6e56d8e53f69b3ad9521c9cde0f19726b
7
- data.tar.gz: f719564347931af554dbc2705022a548405bbd95320db5c094f5616166a46ecd830d8fddedcaebd9e24fd76ff6e8be2e1917d3a3be8bc6ede3f21e77e763af77
6
+ metadata.gz: a042823ac822253618165386e3de44e12921f381ea71ae368b75201765ec9d4b2787fbce3bd23cd33cd8bf956c4bacf9929fe0205b5d72991204179223fc0377
7
+ data.tar.gz: c0665793c4cad2ede693213dbaa866e163af3e44d74e0c96903369a5cc6d5d4706d6cefd900d656553a27f7b451fcabc82e7ef414f7d5b9a4c9538db3d6dff6d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.0.1]
8
+
9
+ ### Fixed
10
+ - Fixed JSON output to use an integer for the bom file version number. ([Issue #89](https://github.com/CycloneDX/cyclonedx-cocoapods/issues/89)) [@macblazer](https://github.com/macblazer).
11
+
12
+ ## [2.0.0]
13
+
14
+ ### Added
15
+ - Added JSON output if the specified `output` has a `.json` suffix. ([Issue #62](https://github.com/CycloneDX/cyclonedx-cocoapods/issues/62)) [@jeremylong](https://github.com/jeremylong).
16
+ - Added CLI options to set manufacturer metadata about the component being scanned (five separate parameters). ([Issue #72](https://github.com/CycloneDX/cyclonedx-cocoapods/issues/72)) [@jeremylong](https://github.com/jeremylong).
17
+ - Added CLI options to set the VCS URL and build URL of the component being scanned. ([PR #82](https://github.com/CycloneDX/cyclonedx-cocoapods/pull/82)) [@jeremylong](https://github.com/jeremylong).
18
+
19
+ ### Changed
20
+ - Updated to use v1.6 of the CycloneDX specification. ([PR #81](https://github.com/CycloneDX/cyclonedx-cocoapods/pull/81)) [@jeremylong](https://github.com/jeremylong).
21
+ - Updated to use newer `tools` section elements. ([PR #80](https://github.com/CycloneDX/cyclonedx-cocoapods/pull/80)) [@jeremylong](https://github.com/jeremylong).
22
+ - Updated to use a purl for the `bom-ref` of the component being scanned. When analyzing an app the purl will start with `pkg:generic`. ([PR #84](https://github.com/CycloneDX/cyclonedx-cocoapods/pull/84)) [@jeremylong](https://github.com/jeremylong).
23
+ - Changed the short `-b` CLI parameter to specify the build URL instead of the bom file version. Use `--bom-version` to specify the bom file version if needed. ([PR #82](https://github.com/CycloneDX/cyclonedx-cocoapods/pull/82)) [@jeremylong](https://github.com/jeremylong).
24
+ - Changed the short `-s` CLI parameter to specify the source VCS URL instead of the shortened string lengths. Use `--shortened-strings` to specify the max length of strings if needed. ([PR #82](https://github.com/CycloneDX/cyclonedx-cocoapods/pull/82)) [@jeremylong](https://github.com/jeremylong).
25
+
26
+ ### Fixed
27
+ - Fixed XML output when Pod description contains a null byte. ([Issue #85](https://github.com/CycloneDX/cyclonedx-cocoapods/issues/85)) [@fnxpt](https://github.com/fnxpt).
28
+
7
29
  ## [1.4.1]
8
30
 
9
31
  ### Changed
data/README.md CHANGED
@@ -9,7 +9,9 @@
9
9
 
10
10
  # CycloneDX CocoaPods (Objective-C/Swift)
11
11
 
12
- The CycloneDX CocoaPods Gem creates a valid CycloneDX software bill-of-material document from all [CocoaPods](https://cocoapods.org/) project dependencies. CycloneDX is a lightweight BoM specification that is easily created, human readable, and simple to parse.
12
+ The CycloneDX CocoaPods Gem creates a valid CycloneDX software bill-of-material document from all
13
+ [CocoaPods](https://cocoapods.org/) project dependencies. CycloneDX is a lightweight BOM specification
14
+ that is easily created, human readable, and simple to parse.
13
15
 
14
16
  ## Installation
15
17
 
@@ -21,18 +23,19 @@ The CycloneDX CocoaPods Gem creates a valid CycloneDX software bill-of-material
21
23
 
22
24
  ### From Source
23
25
 
24
- First, clone/copy the source code from GitHub. Then in the source code directory run these commands (substituting the actual version number for `x.x.x`):
26
+ First, clone/copy the source code from GitHub. Then in the source code directory run these
27
+ commands (substituting the actual version number for `x.x.x`):
25
28
 
26
29
  ```shell
27
30
  gem build cyclonedx-cocoapods.gemspec
28
31
  gem install cyclonedx-cocoapods-x.x.x.gem
29
32
  ```
30
33
 
31
- Building from source requires Ruby 2.4.0 or newer.
34
+ Building from source requires Ruby 2.6.3 or newer.
32
35
 
33
36
  ## Compatibility
34
37
 
35
- *cyclonedx-cocoapods* aims to produce SBOMs according to the latest CycloneDX specification, which currently is [1.5](https://cyclonedx.org/docs/1.5/xml/).
38
+ *cyclonedx-cocoapods* aims to produce SBOMs according to the latest CycloneDX specification, which currently is [1.6](https://cyclonedx.org/docs/1.6/xml/).
36
39
  You can use the [CycloneDX CLI](https://github.com/CycloneDX/cyclonedx-cli#convert-command) to convert between multiple BOM formats or specification versions.
37
40
 
38
41
  ## Usage
@@ -49,38 +52,53 @@ OPTIONS
49
52
 
50
53
  BOM Generation
51
54
  -p, --path path Path to CocoaPods project directory (default: current directory)
52
- -o, --output bom_file_path Path to output the bom.xml file to (default: "bom.xml")
53
- -b, --bom-version bom_version Version of the generated BOM (default: "1")
55
+ -o, --output bom_file_path Path to output the bom file to (default: "bom.xml"); if a *.json file is specified the output format will be JSON
56
+ --bom-version bom_version Version of the generated BOM (default: "1")
54
57
  -x, --exclude-test-targets Eliminate Podfile targets whose name contains the word "test"
55
- -s, --shortened-strings length Trim author, publisher, and purl to <length> characters; this may cause data loss but can improve compatibility with other systems
58
+ --shortened-strings length Trim author, publisher, and purl to <length> characters; this may cause data loss but can improve compatibility with other systems
56
59
 
57
60
  Component Metadata
61
+ If a podspec file is present the name, version, and type do not need to be specified as they will be set automatically.
58
62
  -n, --name name (If specified version and type are also required) Name of the component for which the BOM is generated
59
63
  -v, --version version Version of the component for which the BOM is generated
60
64
  -t, --type type Type of the component for which the BOM is generated (one of application|framework|library|container|operating-system|device|firmware|file)
61
65
  -g, --group group Group of the component for which the BOM is generated
66
+ -s, --source source_url Optional: The version control system URL of the component for the BOM is generated
67
+ -b, --build build_url Optional: The build URL of the component for which the BOM is generated
68
+
69
+ Manufacturer Metadata
70
+ --manufacturer-name name Name of the manufacturer
71
+ --manufacturer-url url URL of the manufacturer
72
+ --manufacturer-contact-name name
73
+ Name of the manufacturer contact
74
+ --manufacturer-email email Email of the manufacturer contact
75
+ --manufacturer-phone phone Phone number of the manufacturer contact
62
76
  ```
63
77
 
64
- **Output:** BoM file at specified location, `./bom.xml` if not specified
78
+ **Output:** BOM file at specified location, `./bom.xml` if not specified
65
79
 
66
80
  ### Example
67
81
 
68
82
  ```shell
69
- % cyclonedx-cocoapods --path /path/to/cocoapods/project --output /path/to/bom.xml --version 6
83
+ % cyclonedx-cocoapods --path /path/to/cocoapods/project --output /path/to/bom.xml --version 6
70
84
  ```
71
85
 
72
86
  #### Specific example
73
87
 
74
- This repo contains a file named `example_bom.xml` that was generated with this tool.
88
+ This repo contains files named `example_bom.xml` and `example_bom.json` that were generated with this tool.
75
89
 
76
- It represents the open source [PodsUpdater application](https://github.com/kizitonwose/PodsUpdater). The PodsUpdater code was checked out,
77
- then these two commands were run in the checked out code directory.
90
+ They represent the open source [PodsUpdater application](https://github.com/kizitonwose/PodsUpdater). The PodsUpdater
91
+ code was checked out, then these three commands were run in the checked out code directory.
78
92
 
79
93
  ```shell
80
94
  % pod install
81
- % cyclonedx-cocoapods -n "kizitonwose/PodsUpdater" -v 1.0.3 -t application --output example_bom.xml
95
+ % cyclonedx-cocoapods -n "kizitonwose-PodsUpdater" -v 1.0.3 -t application -s https://github.com/kizitonwose/PodsUpdater --output example_bom.xml
96
+ % cyclonedx-cocoapods -n "kizitonwose-PodsUpdater" -v 1.0.3 -t application -s https://github.com/kizitonwose/PodsUpdater --output example_bom.json
82
97
  ```
83
98
 
99
+ The JSON file here has also been run through a JSON formatter for easier reading by humans. The original JSON
100
+ output is one long line with no extra whitespace - great for computers, but difficult for humans.
101
+
84
102
  ### A Note About CocoaPod Subspecs
85
103
 
86
104
  Many CocoaPods make use of [subspec functionality](https://guides.cocoapods.org/syntax/podspec.html#subspec).
@@ -127,7 +127,8 @@ module CycloneDX
127
127
  xml_add_author(xml, trim_strings_length)
128
128
  xml.name_ name
129
129
  xml.version version.to_s
130
- xml.description { xml.cdata description } unless description.nil?
130
+ # Use `dump` to escape non-printing characters, then remove the starting/trailing double-quotes from `dump`.
131
+ xml.description { xml.cdata description.dump[1..-2] } unless description.nil?
131
132
  unless checksum.nil?
132
133
  xml.hashes do
133
134
  xml.hash_(checksum, alg: CHECKSUM_ALGORITHM)
@@ -149,7 +150,57 @@ module CycloneDX
149
150
  end
150
151
  end
151
152
 
153
+ def to_json_component(manifest_path, trim_strings_length = 0)
154
+ {
155
+ type: 'library',
156
+ 'bom-ref': purl,
157
+ author: trim(author, trim_strings_length),
158
+ publisher: trim(author, trim_strings_length),
159
+ name: name,
160
+ version: version.to_s,
161
+ description: description,
162
+ hashes: generate_json_hashes,
163
+ licenses: generate_json_licenses,
164
+ purl: purl,
165
+ externalReferences: generate_json_external_references,
166
+ evidence: generate_json_evidence(manifest_path)
167
+ }.compact
168
+ end
169
+
170
+ def generate_json_external_references
171
+ refs = []
172
+ refs << { type: HOMEPAGE_REFERENCE_TYPE, url: homepage } if homepage
173
+ refs.empty? ? nil : refs
174
+ end
175
+
176
+ def generate_json_evidence(manifest_path)
177
+ {
178
+ identity: {
179
+ field: 'purl',
180
+ confidence: 0.6,
181
+ methods: [
182
+ {
183
+ technique: 'manifest-analysis',
184
+ confidence: 0.6,
185
+ value: manifest_path
186
+ }
187
+ ]
188
+ }
189
+ }
190
+ end
191
+
152
192
  class License
193
+ def to_json_component
194
+ {
195
+ license: {
196
+ id: identifier_type == :id ? identifier : nil,
197
+ name: identifier_type == :name ? identifier : nil,
198
+ text: text,
199
+ url: url
200
+ }.compact
201
+ }
202
+ end
203
+
153
204
  def add_to_bom(xml)
154
205
  xml.license do
155
206
  xml.id identifier if identifier_type == :id
@@ -159,6 +210,20 @@ module CycloneDX
159
210
  end
160
211
  end
161
212
  end
213
+
214
+ private
215
+
216
+ def generate_json_licenses
217
+ license ? [license.to_json_component] : nil
218
+ end
219
+
220
+ def generate_json_hashes
221
+ checksum ? [{ alg: CHECKSUM_ALGORITHM, content: checksum }] : nil
222
+ end
223
+
224
+ def trim(str, trim_strings_length)
225
+ trim_strings_length.zero? ? str : str&.slice(0, trim_strings_length)
226
+ end
162
227
  end
163
228
 
164
229
  class Component
@@ -167,52 +232,215 @@ module CycloneDX
167
232
  xml.group group unless group.nil?
168
233
  xml.name_ name
169
234
  xml.version version
235
+
236
+ if !build_system.nil? || !vcs.nil?
237
+ xml.externalReferences do
238
+ if build_system
239
+ xml.reference(type: 'build-system') do
240
+ xml.url build_system
241
+ end
242
+ end
243
+
244
+ if vcs
245
+ xml.reference(type: 'vcs') do
246
+ xml.url vcs
247
+ end
248
+ end
249
+ end
250
+ end
251
+ xml.purl bomref
252
+ end
253
+ end
254
+
255
+ def to_json_component
256
+ {
257
+ type: type,
258
+ 'bom-ref': bomref,
259
+ group: group,
260
+ name: name,
261
+ version: version,
262
+ purl: bomref,
263
+ externalReferences: generate_json_external_references
264
+ }.compact
265
+ end
266
+
267
+ private
268
+
269
+ def generate_json_external_references
270
+ refs = []
271
+ refs << { type: 'build-system', url: build_system } if build_system
272
+ refs << { type: 'vcs', url: vcs } if vcs
273
+ refs.empty? ? nil : refs
274
+ end
275
+ end
276
+
277
+ # Represents manufacturer information in a CycloneDX BOM
278
+ # Handles generation of manufacturer XML elements including basic info and contact details
279
+ # Used when generating BOM metadata for CycloneDX specification
280
+ class Manufacturer
281
+ def add_to_bom(xml)
282
+ return if all_attributes_nil?
283
+
284
+ xml.manufacturer do
285
+ add_basic_info(xml)
286
+ add_contact_info(xml)
287
+ end
288
+ end
289
+
290
+ def to_json_manufacturer
291
+ return nil if all_attributes_nil?
292
+
293
+ {
294
+ name: name,
295
+ url: url,
296
+ contact: generate_json_contact
297
+ }.compact
298
+ end
299
+
300
+ private
301
+
302
+ def generate_json_contact
303
+ return nil if contact_info_nil?
304
+
305
+ [
306
+ {
307
+ name: contact_name,
308
+ email: email,
309
+ phone: phone
310
+ }.compact
311
+ ]
312
+ end
313
+
314
+ def all_attributes_nil?
315
+ [name, url, contact_name, email, phone].all?(&:nil?)
316
+ end
317
+
318
+ def add_basic_info(xml)
319
+ xml.name_ name unless name.nil?
320
+ xml.url url unless url.nil?
321
+ end
322
+
323
+ def add_contact_info(xml)
324
+ return if contact_info_nil?
325
+
326
+ xml.contact do
327
+ xml.name_ contact_name unless contact_name.nil?
328
+ xml.email email unless email.nil?
329
+ xml.phone phone unless phone.nil?
170
330
  end
171
331
  end
332
+
333
+ def contact_info_nil?
334
+ contact_name.nil? && email.nil? && phone.nil?
335
+ end
172
336
  end
173
337
 
174
338
  # Turns the internal model data into an XML bom.
175
339
  class BOMBuilder
176
- NAMESPACE = 'http://cyclonedx.org/schema/bom/1.5'
340
+ NAMESPACE = 'http://cyclonedx.org/schema/bom/1.6'
177
341
 
178
- attr_reader :component, :pods, :manifest_path, :dependencies
342
+ attr_reader :component, :pods, :manifest_path, :dependencies, :manufacturer
179
343
 
180
- def initialize(pods:, manifest_path:, component: nil, dependencies: nil)
344
+ def initialize(pods:, manifest_path:, component: nil, dependencies: nil, manufacturer: nil)
181
345
  @pods = pods.sort_by(&:purl)
182
346
  @manifest_path = manifest_path
183
347
  @component = component
184
348
  @dependencies = dependencies&.sort
349
+ @manufacturer = manufacturer
185
350
  end
186
351
 
187
- def bom(version: 1, trim_strings_length: 0)
188
- unless version.to_i.positive?
189
- raise ArgumentError,
190
- "Incorrect version: #{version} should be an integer greater than 0"
191
- end
192
-
193
- unless trim_strings_length.is_a?(Integer) && (trim_strings_length.positive? || trim_strings_length.zero?)
194
- raise ArgumentError,
195
- "Incorrect string length: #{trim_strings_length} should be an integer greater than 0"
196
- end
352
+ def bom(version: 1, trim_strings_length: 0, format: :xml)
353
+ validate_version(version)
354
+ validate_trim_length(trim_strings_length)
355
+ validate_format(format)
197
356
 
198
- unchecked_bom(version: version, trim_strings_length: trim_strings_length)
357
+ unchecked_bom(version: version, trim_strings_length: trim_strings_length, format: format)
199
358
  end
200
359
 
201
360
  private
202
361
 
362
+ def validate_version(version)
363
+ return if version.to_i.positive?
364
+
365
+ raise ArgumentError, "Incorrect version: #{version} should be an integer greater than 0"
366
+ end
367
+
368
+ def validate_trim_length(trim_strings_length)
369
+ return if trim_strings_length.is_a?(Integer) && (trim_strings_length.positive? || trim_strings_length.zero?)
370
+
371
+ raise ArgumentError, "Incorrect string length: #{trim_strings_length} should be an integer greater than 0"
372
+ end
373
+
374
+ def validate_format(format)
375
+ return if %i[xml json].include?(format)
376
+
377
+ raise ArgumentError, "Incorrect format: #{format} should be either :xml or :json"
378
+ end
379
+
203
380
  # does not verify parameters because the public method does that.
204
- def unchecked_bom(version: 1, trim_strings_length: 0)
381
+ def unchecked_bom(version:, trim_strings_length:, format:)
382
+ case format
383
+ when :json
384
+ generate_json(version: version, trim_strings_length: trim_strings_length)
385
+ when :xml
386
+ generate_xml(version: version, trim_strings_length: trim_strings_length)
387
+ end
388
+ end
389
+
390
+ def generate_xml(version:, trim_strings_length:)
205
391
  Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
206
392
  xml.bom(xmlns: NAMESPACE, version: version.to_i.to_s, serialNumber: "urn:uuid:#{SecureRandom.uuid}") do
207
393
  bom_metadata(xml)
208
-
209
394
  bom_components(xml, pods, manifest_path, trim_strings_length)
210
-
211
395
  bom_dependencies(xml, dependencies)
212
396
  end
213
397
  end.to_xml
214
398
  end
215
399
 
400
+ def generate_json(version:, trim_strings_length:)
401
+ {
402
+ '$schema': 'https://cyclonedx.org/schema/bom-1.6.schema.json',
403
+ bomFormat: 'CycloneDX',
404
+ specVersion: '1.6',
405
+ serialNumber: "urn:uuid:#{SecureRandom.uuid}",
406
+ version: version.to_i,
407
+ metadata: generate_json_metadata,
408
+ components: generate_json_components(trim_strings_length),
409
+ dependencies: generate_json_dependencies
410
+ }.to_json
411
+ end
412
+
413
+ def generate_json_metadata
414
+ {
415
+ timestamp: Time.now.getutc.strftime('%Y-%m-%dT%H:%M:%SZ'),
416
+ tools: {
417
+ components: [{
418
+ type: 'application',
419
+ group: 'CycloneDX',
420
+ name: 'cyclonedx-cocoapods',
421
+ version: VERSION
422
+ }]
423
+ },
424
+ component: component&.to_json_component,
425
+ manufacturer: manufacturer&.to_json_manufacturer
426
+ }.compact
427
+ end
428
+
429
+ def generate_json_components(trim_strings_length)
430
+ pods.map { |pod| pod.to_json_component(manifest_path, trim_strings_length) }
431
+ end
432
+
433
+ def generate_json_dependencies
434
+ return nil unless dependencies
435
+
436
+ dependencies.map do |ref, deps|
437
+ {
438
+ ref: ref,
439
+ dependsOn: deps.sort
440
+ }
441
+ end
442
+ end
443
+
216
444
  def bom_components(xml, pods, manifest_path, trim_strings_length)
217
445
  xml.components do
218
446
  pods.each do |pod|
@@ -238,15 +466,18 @@ module CycloneDX
238
466
  xml.timestamp Time.now.getutc.strftime('%Y-%m-%dT%H:%M:%SZ')
239
467
  bom_tools(xml)
240
468
  component&.add_to_bom(xml)
469
+ manufacturer&.add_to_bom(xml)
241
470
  end
242
471
  end
243
472
 
244
473
  def bom_tools(xml)
245
474
  xml.tools do
246
- xml.tool do
247
- xml.vendor 'CycloneDX'
248
- xml.name_ 'cyclonedx-cocoapods'
249
- xml.version VERSION
475
+ xml.components do
476
+ xml.component(type: 'application') do
477
+ xml.group 'CycloneDX'
478
+ xml.name_ 'cyclonedx-cocoapods'
479
+ xml.version VERSION
480
+ end
250
481
  end
251
482
  end
252
483
  end
@@ -25,7 +25,9 @@ require 'optparse'
25
25
 
26
26
  require_relative 'bom_builder'
27
27
  require_relative 'component'
28
+ require_relative 'manufacturer'
28
29
  require_relative 'podfile_analyzer'
30
+ require_relative 'podspec_analyzer'
29
31
 
30
32
  module CycloneDX
31
33
  module CocoaPods
@@ -36,12 +38,13 @@ module CycloneDX
36
38
  def run
37
39
  setup_logger # Needed in case we have errors while processing CLI parameters
38
40
  options = parse_options
41
+ determine_output_format(options)
39
42
  setup_logger(verbose: options[:verbose])
40
43
  @logger.debug "Running cyclonedx-cocoapods with options: #{options}"
41
44
 
42
- component, pods, manifest_path, dependencies = analyze(options)
45
+ component, manufacturer, pods, manifest_path, dependencies = analyze(options)
43
46
 
44
- build_and_write_bom(options, component, pods, manifest_path, dependencies)
47
+ build_and_write_bom(options, component, manufacturer, pods, manifest_path, dependencies)
45
48
  rescue StandardError => e
46
49
  @logger.error ([e.message] + e.backtrace).join($INPUT_RECORD_SEPARATOR)
47
50
  exit 1
@@ -76,7 +79,8 @@ module CycloneDX
76
79
  parsed_options[:path] = path
77
80
  end
78
81
  options.on('-o', '--output bom_file_path',
79
- 'Path to output the bom.xml file to (default: "bom.xml")') do |bom_file_path|
82
+ 'Path to output the bom file to (default: "bom.xml"); ' \
83
+ 'if a *.json file is specified the output format will be JSON') do |bom_file_path|
80
84
  parsed_options[:bom_file_path] = bom_file_path
81
85
  end
82
86
  options.on('-b', '--bom-version bom_version', Integer,
@@ -94,6 +98,8 @@ module CycloneDX
94
98
  end
95
99
 
96
100
  options.separator("\n Component Metadata\n")
101
+ options.separator(' If a podspec file is present the name, version, and type do not ' \
102
+ "need to be specified as they will be set automatically.\n")
97
103
  options.on('-n', '--name name',
98
104
  '(If specified version and type are also required) Name of the ' \
99
105
  'component for which the BOM is generated') do |name|
@@ -118,6 +124,37 @@ module CycloneDX
118
124
  options.on('-g', '--group group', 'Group of the component for which the BOM is generated') do |group|
119
125
  parsed_options[:group] = group
120
126
  end
127
+ options.on('-s', '--source source_url',
128
+ 'Optional: The version control system URL of the component for the BOM is generated') do |vcs|
129
+ parsed_options[:vcs] = vcs
130
+ end
131
+ options.on('-b', '--build build_url',
132
+ 'Optional: The build URL of the component for which the BOM is generated') do |build|
133
+ parsed_options[:build] = build
134
+ end
135
+
136
+ # Add this section after the "Component Metadata" options group
137
+ options.separator("\n Manufacturer Metadata\n")
138
+ options.on('--manufacturer-name name',
139
+ 'Name of the manufacturer') do |name|
140
+ parsed_options[:manufacturer_name] = name
141
+ end
142
+ options.on('--manufacturer-url url',
143
+ 'URL of the manufacturer') do |url|
144
+ parsed_options[:manufacturer_url] = url
145
+ end
146
+ options.on('--manufacturer-contact-name name',
147
+ 'Name of the manufacturer contact') do |contact_name|
148
+ parsed_options[:manufacturer_contact_name] = contact_name
149
+ end
150
+ options.on('--manufacturer-email email',
151
+ 'Email of the manufacturer contact') do |email|
152
+ parsed_options[:manufacturer_email] = email
153
+ end
154
+ options.on('--manufacturer-phone phone',
155
+ 'Phone number of the manufacturer contact') do |phone|
156
+ parsed_options[:manufacturer_phone] = phone
157
+ end
121
158
  end.parse!
122
159
 
123
160
  if !parsed_options[:name].nil? && (parsed_options[:version].nil? || parsed_options[:type].nil?)
@@ -128,13 +165,15 @@ module CycloneDX
128
165
  parsed_options
129
166
  end
130
167
 
131
- def analyze(options)
132
- analyzer = PodfileAnalyzer.new(logger: @logger, exclude_test_targets: options[:exclude_test_targets])
133
- podfile, lockfile = analyzer.ensure_podfile_and_lock_are_present(options)
134
- pods, dependencies = analyzer.parse_pods(podfile, lockfile)
135
- analyzer.populate_pods_with_additional_info(pods)
168
+ def determine_output_format(options)
169
+ options[:format] = options[:bom_file_path]&.end_with?('.json') ? :json : :xml
170
+ end
136
171
 
137
- component = component_from_options(options)
172
+ def analyze(options)
173
+ analyzer, dependencies, lockfile, podfile, pods = analyze_podfile(options)
174
+ podspec = analyze_podspec(options)
175
+ component = component_from_options(options, podspec)
176
+ manufacturer = manufacturer_from_options(options)
138
177
 
139
178
  unless component.nil?
140
179
  # add top level pods to main component
@@ -148,22 +187,89 @@ module CycloneDX
148
187
  manifest_path = Pathname.pwd.basename + manifest_path.relative_path_from(Pathname.pwd)
149
188
  end
150
189
 
151
- [component, pods, manifest_path, dependencies]
190
+ [component, manufacturer, pods, manifest_path, dependencies]
191
+ end
192
+
193
+ def analyze_podspec(options)
194
+ spec_analyzer = PodspecAnalyzer.new(logger: @logger)
195
+ podspec = spec_analyzer.ensure_podspec_is_present(options)
196
+ spec = spec_analyzer.parse_podspec(podspec) unless podspec.nil?
197
+ spec
152
198
  end
153
199
 
154
- def build_and_write_bom(options, component, pods, manifest_path, dependencies)
200
+ def analyze_podfile(options)
201
+ analyzer = PodfileAnalyzer.new(logger: @logger, exclude_test_targets: options[:exclude_test_targets])
202
+ podfile, lockfile = analyzer.ensure_podfile_and_lock_are_present(options)
203
+ pods, dependencies = analyzer.parse_pods(podfile, lockfile)
204
+ analyzer.populate_pods_with_additional_info(pods)
205
+ [analyzer, dependencies, lockfile, podfile, pods]
206
+ end
207
+
208
+ def build_and_write_bom(options, component, manufacturer, pods, manifest_path, dependencies)
155
209
  builder = BOMBuilder.new(pods: pods, manifest_path: manifest_path,
156
- component: component, dependencies: dependencies)
210
+ component: component, manufacturer: manufacturer, dependencies: dependencies)
157
211
  bom = builder.bom(version: options[:bom_version] || 1,
158
- trim_strings_length: options[:trim_strings_length] || 0)
212
+ trim_strings_length: options[:trim_strings_length] || 0,
213
+ format: options[:format])
159
214
  write_bom_to_file(bom: bom, options: options)
160
215
  end
161
216
 
162
- def component_from_options(options)
163
- return unless options[:name]
217
+ def component_from_options(options, podspec)
218
+ return unless options[:name] || !podspec.nil?
219
+
220
+ ensure_options_match(options, podspec)
164
221
 
165
222
  Component.new(group: options[:group], name: options[:name], version: options[:version],
166
- type: options[:type])
223
+ type: options[:type], build_system: options[:build], vcs: options[:vcs])
224
+ end
225
+
226
+ def manufacturer_from_options(options)
227
+ Manufacturer.new(
228
+ name: options[:manufacturer_name],
229
+ url: options[:manufacturer_url],
230
+ contact_name: options[:manufacturer_contact_name],
231
+ email: options[:manufacturer_email],
232
+ phone: options[:manufacturer_phone]
233
+ )
234
+ end
235
+
236
+ def ensure_options_match(options, podspec)
237
+ validate_name_option(options, podspec)
238
+ validate_version_option(options, podspec)
239
+ validate_group_option(options, podspec)
240
+ validate_type_option(options, podspec)
241
+ end
242
+
243
+ def validate_name_option(options, podspec)
244
+ if !podspec.nil? && options[:name] && options[:name] != podspec.name
245
+ raise OptionParser::InvalidArgument,
246
+ "Component name '#{options[:name]}' does not match podspec name '#{podspec.name}'"
247
+ end
248
+ options[:name] ||= podspec&.name
249
+ end
250
+
251
+ def validate_version_option(options, podspec)
252
+ if !podspec.nil? && options[:version] && options[:version] != podspec.version.to_s
253
+ raise OptionParser::InvalidArgument,
254
+ "Component version '#{options[:version]}' does not match podspec version '#{podspec.version}'"
255
+ end
256
+ options[:version] ||= podspec&.version&.to_s
257
+ end
258
+
259
+ def validate_type_option(options, podspec)
260
+ if !podspec.nil? && options[:type] && ptions[:type] != 'library'
261
+ raise OptionParser::InvalidArgument, "Component type must be 'library' when using a podspec"
262
+ end
263
+
264
+ return if podspec.nil?
265
+
266
+ options[:type] = 'cocoapods'
267
+ end
268
+
269
+ def validate_group_option(options, podspec)
270
+ return if podspec.nil?
271
+ raise OptionParser::InvalidArgument, 'Component group must not be specified when using a podspec' unless
272
+ options[:group].nil?
167
273
  end
168
274
 
169
275
  def setup_logger(verbose: true)
@@ -21,29 +21,89 @@
21
21
 
22
22
  module CycloneDX
23
23
  module CocoaPods
24
+ # Represents a software component in the CycloneDX BOM specification
25
+ #
26
+ # A component is a self-contained unit of software that can be used as a building block
27
+ # in the architecture of a software system. Components can be of different types like
28
+ # libraries, frameworks, or applications.
29
+ #
30
+ # @attr_reader [String, nil] group The group/organization identifier of the component
31
+ # @attr_reader [String] name The name of the component
32
+ # @attr_reader [String] version The version string of the component
33
+ # @attr_reader [String] type The type of component (must be one of VALID_COMPONENT_TYPES)
34
+ # @attr_reader [String] bomref The unique reference ID for this component in the BOM
35
+ # @attr_reader [String, nil] build_system The build system information
36
+ # @attr_reader [String, nil] vcs The version control system information
37
+ #
38
+ # @example Creating a new component
39
+ # component = Component.new(
40
+ # name: "AFNetworking",
41
+ # version: "4.0.1",
42
+ # type: "library"
43
+ # )
24
44
  class Component
25
45
  VALID_COMPONENT_TYPES = %w[application framework library container operating-system device firmware file].freeze
26
46
 
27
- attr_reader :group, :name, :version, :type, :bomref
47
+ attr_reader :group, :name, :version, :type, :bomref, :build_system, :vcs
28
48
 
29
- def initialize(name:, version:, type:, group: nil)
30
- raise ArgumentError, 'Group, if specified, must be non empty' if !group.nil? && group.to_s.strip.empty?
31
- raise ArgumentError, 'Name must be non empty' if name.nil? || name.to_s.strip.empty?
49
+ def initialize(name:, version:, type:, group: nil, build_system: nil, vcs: nil)
50
+ # cocoapods is a special case to correctly build a purl
51
+ package_type = type == 'cocoapods' ? 'cocoapods' : 'generic'
52
+ @type = type == 'cocoapods' ? 'library' : type
32
53
 
33
- Gem::Version.new(version) # To check that the version string is well formed
34
- unless VALID_COMPONENT_TYPES.include?(type)
35
- raise ArgumentError, "#{type} is not valid component type (#{VALID_COMPONENT_TYPES.join('|')})"
36
- end
54
+ validate_attributes(name, version, @type, group)
37
55
 
38
56
  @group = group
39
57
  @name = name
40
58
  @version = version
41
- @type = type
42
- @bomref = "#{name}@#{version}"
59
+ @build_system = build_system
60
+ @vcs = vcs
61
+ @bomref = build_purl(package_type, name, group, version)
62
+ end
63
+
64
+ private
65
+
66
+ def build_purl(package_type, name, group, version)
67
+ if group.nil?
68
+ purl_name, subpath = parse_name(name)
69
+ else
70
+ purl_name = "#{CGI.escape(group)}/#{CGI.escape(name)}"
71
+ subpath = ''
72
+ end
73
+ "pkg:#{package_type}/#{purl_name}@#{CGI.escape(version.to_s)}#{subpath}"
74
+ end
75
+
76
+ private
43
77
 
44
- return if group.nil?
78
+ def validate_attributes(name, version, type, group)
79
+ raise ArgumentError, 'Group, if specified, must be non-empty' if exists_and_blank(group)
80
+ raise ArgumentError, 'Name must be non-empty' if missing(name)
81
+
82
+ Gem::Version.new(version) # To check that the version string is well-formed
83
+ return if VALID_COMPONENT_TYPES.include?(type)
84
+
85
+ raise ArgumentError, "#{type} is not valid component type (#{VALID_COMPONENT_TYPES.join('|')})"
86
+ end
87
+
88
+ def parse_name(name)
89
+ purls = name.split('/')
90
+ purl_name = CGI.escape(purls[0])
91
+ subpath = if purls.length > 1
92
+ "##{name.split('/').drop(1).map do |component|
93
+ CGI.escape(component)
94
+ end.join('/')}"
95
+ else
96
+ ''
97
+ end
98
+ [purl_name, subpath]
99
+ end
100
+
101
+ def missing(str)
102
+ str.nil? || str.to_s.strip.empty?
103
+ end
45
104
 
46
- @bomref = "#{group}/#{@bomref}"
105
+ def exists_and_blank(str)
106
+ !str.nil? && str.to_s.strip.empty?
47
107
  end
48
108
  end
49
109
  end
@@ -32,7 +32,7 @@ module CycloneDX
32
32
  attr_accessor :text, :url
33
33
 
34
34
  def initialize(identifier:)
35
- raise ArgumentError, 'License identifier must be non empty' if identifier.nil? || identifier.to_s.strip.empty?
35
+ raise ArgumentError, 'License identifier must be non-empty' if identifier.nil? || identifier.to_s.strip.empty?
36
36
 
37
37
  @identifier = SPDX_LICENSES.find { |license_id| license_id.downcase == identifier.to_s.downcase }
38
38
  @identifier_type = @identifier.nil? ? :name : :id
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of CycloneDX CocoaPods
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # SPDX-License-Identifier: Apache-2.0
19
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
20
+ #
21
+
22
+ module CycloneDX
23
+ module CocoaPods
24
+ # Represents manufacturer information in a CycloneDX BOM
25
+ #
26
+ # The Manufacturer class holds details about the manufacturer of a component,
27
+ # including company information and contact details.
28
+ #
29
+ # @attr_reader [String] name The name of the manufacturing organization
30
+ # @attr_reader [String] url The URL of the manufacturer's website
31
+ # @attr_reader [String] contact_name Name of the manufacturer contact person
32
+ # @attr_reader [String] email Email address of the manufacturer contact
33
+ # @attr_reader [String] phone Phone number of the manufacturer contact
34
+ #
35
+ # @example Creating a manufacturer with basic info
36
+ # manufacturer = Manufacturer.new(
37
+ # name: "ACME Corp",
38
+ # url: "https://acme.example"
39
+ # )
40
+ #
41
+ # @example Creating a manufacturer with full contact details
42
+ # manufacturer = Manufacturer.new(
43
+ # name: "ACME Corp",
44
+ # url: "https://acme.example",
45
+ # contact_name: "John Doe",
46
+ # email: "john@acme.example",
47
+ # phone: "+1-555-123-4567"
48
+ # )
49
+ class Manufacturer
50
+ attr_reader :name, :url, :contact_name, :email, :phone
51
+
52
+ def initialize(name: nil, url: nil, contact_name: nil, email: nil, phone: nil)
53
+ validate_parameters(name, url, contact_name, email, phone)
54
+
55
+ @name = name
56
+ @url = url
57
+ @contact_name = contact_name
58
+ @email = email
59
+ @phone = phone
60
+ end
61
+
62
+ private
63
+
64
+ def validate_parameters(name, url, contact_name, email, phone)
65
+ raise ArgumentError, 'name, if specified, must be non-empty' if blank(name)
66
+ raise ArgumentError, 'URL, if specified, must be non-empty' if blank(url)
67
+ raise ArgumentError, 'Contact name, if specified, must be non-empty' if blank(contact_name)
68
+ raise ArgumentError, 'Email, if specified, must be non-empty' if blank(email)
69
+ raise ArgumentError, 'Phone, if specified, must be non-empty' if blank(phone)
70
+ end
71
+
72
+ def blank(str)
73
+ !str.nil? && str.to_s.strip.empty?
74
+ end
75
+ end
76
+ end
77
+ end
@@ -31,20 +31,20 @@ module CycloneDX
31
31
  attr_reader :version
32
32
  # Anything responding to :source_qualifier
33
33
  attr_reader :source
34
- # xs:anyURI - https://cyclonedx.org/docs/1.5/xml/#type_externalReference
34
+ # xs:anyURI - https://cyclonedx.org/docs/1.6/xml/#type_externalReference
35
35
  attr_reader :homepage
36
- # https://cyclonedx.org/docs/1.5/xml/#type_hashValue (We only use SHA-1 hashes - length == 40)
36
+ # https://cyclonedx.org/docs/1.6/xml/#type_hashValue (We only use SHA-1 hashes - length == 40)
37
37
  attr_reader :checksum
38
38
  # xs:normalizedString
39
39
  attr_reader :author
40
40
  # xs:normalizedString
41
41
  attr_reader :description
42
- # https://cyclonedx.org/docs/1.5/xml/#type_licenseType
42
+ # https://cyclonedx.org/docs/1.6/xml/#type_licenseType
43
43
  # We don't currently support several licenses or license expressions https://spdx.github.io/spdx-spec/appendix-IV-SPDX-license-expressions/
44
44
  attr_reader :license
45
45
 
46
46
  def initialize(name:, version:, source: nil, checksum: nil)
47
- raise ArgumentError, 'Name must be non empty' if name.nil? || name.to_s.empty?
47
+ raise ArgumentError, 'Name must be non-empty' if name.nil? || name.to_s.empty?
48
48
  raise ArgumentError, "Name shouldn't contain spaces" if name.to_s.include?(' ')
49
49
  raise ArgumentError, "Name shouldn't start with a dot" if name.to_s.start_with?('.')
50
50
 
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of CycloneDX CocoaPods
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the “License”);
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an “AS IS” BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # SPDX-License-Identifier: Apache-2.0
19
+ # Copyright (c) OWASP Foundation. All Rights Reserved.
20
+ #
21
+
22
+ require 'cocoapods'
23
+ require 'cocoapods-core'
24
+ require 'logger'
25
+
26
+ require_relative 'pod'
27
+ require_relative 'pod_attributes'
28
+ require_relative 'source'
29
+
30
+ module CycloneDX
31
+ module CocoaPods
32
+ class PodspecParsingError < StandardError; end
33
+
34
+ # Analyzes CocoaPods podspec files to extract component information for CycloneDX BOM generation
35
+ #
36
+ # The PodspecAnalyzer is responsible for:
37
+ # - Validating and loading podspec files from a given path
38
+ # - Parsing podspec contents to extract pod metadata
39
+ # - Converting podspec source information into standardized Source objects
40
+ #
41
+ # @example
42
+ # analyzer = PodspecAnalyzer.new(logger: Logger.new(STDOUT))
43
+ # podspec = analyzer.ensure_podspec_is_present(path: '/path/to/project')
44
+ # pod = analyzer.parse_podspec(podspec)
45
+ #
46
+ class PodspecAnalyzer
47
+ def initialize(logger:)
48
+ @logger = logger
49
+ end
50
+
51
+ def ensure_podspec_is_present(options)
52
+ project_dir = Pathname.new(options[:path] || Dir.pwd)
53
+ validate_options(project_dir, options)
54
+ initialize_cocoapods_config(project_dir)
55
+
56
+ options[:podspec_path].nil? ? nil : ::Pod::Specification.from_file(options[:podspec_path])
57
+ end
58
+
59
+ def parse_podspec(podspec)
60
+ return nil if podspec.nil?
61
+
62
+ @logger.debug "Parsing podspec from #{podspec.defined_in_file}"
63
+
64
+ Pod.new(
65
+ name: podspec.name,
66
+ version: podspec.version.to_s,
67
+ source: source_from_podspec(podspec),
68
+ checksum: nil
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def validate_options(project_dir, options)
75
+ raise PodspecParsingError, "#{options[:path]} is not a valid directory." unless File.directory?(project_dir)
76
+
77
+ podspec_files = Dir.glob("#{project_dir}/*.podspec{.json,}")
78
+ options[:podspec_path] = podspec_files.first unless podspec_files.empty?
79
+ end
80
+
81
+ def initialize_cocoapods_config(project_dir)
82
+ ::Pod::Config.instance = nil
83
+ ::Pod::Config.instance.installation_root = project_dir
84
+ end
85
+
86
+ def source_from_podspec(podspec)
87
+ return unless podspec.source[:git]
88
+
89
+ Source::GitRepository.new(
90
+ url: podspec.source[:git],
91
+ type: determine_git_ref_type(podspec.source),
92
+ label: determine_git_ref_label(podspec.source)
93
+ )
94
+ end
95
+
96
+ def determine_git_ref_type(source)
97
+ return :tag if source[:tag]
98
+ return :commit if source[:commit]
99
+ return :branch if source[:branch]
100
+
101
+ nil
102
+ end
103
+
104
+ def determine_git_ref_label(source)
105
+ source[:tag] || source[:commit] || source[:branch]
106
+ end
107
+ end
108
+ end
109
+ end
@@ -21,6 +21,6 @@
21
21
 
22
22
  module CycloneDX
23
23
  module CocoaPods
24
- VERSION = '1.4.1'
24
+ VERSION = '2.0.1'
25
25
  end
26
26
  end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cyclonedx-cocoapods
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - José González
8
8
  - Kyle Hammond
9
- autorequire:
10
9
  bindir: exe
11
10
  cert_chain: []
12
- date: 2024-11-18 00:00:00.000000000 Z
11
+ date: 2025-02-09 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: cocoapods
@@ -71,9 +70,11 @@ files:
71
70
  - lib/cyclonedx/cocoapods/cli_runner.rb
72
71
  - lib/cyclonedx/cocoapods/component.rb
73
72
  - lib/cyclonedx/cocoapods/license.rb
73
+ - lib/cyclonedx/cocoapods/manufacturer.rb
74
74
  - lib/cyclonedx/cocoapods/pod.rb
75
75
  - lib/cyclonedx/cocoapods/pod_attributes.rb
76
76
  - lib/cyclonedx/cocoapods/podfile_analyzer.rb
77
+ - lib/cyclonedx/cocoapods/podspec_analyzer.rb
77
78
  - lib/cyclonedx/cocoapods/source.rb
78
79
  - lib/cyclonedx/cocoapods/spdx-licenses.json
79
80
  - lib/cyclonedx/cocoapods/version.rb
@@ -83,7 +84,6 @@ licenses:
83
84
  metadata:
84
85
  homepage_uri: https://github.com/CycloneDX/cyclonedx-cocoapods
85
86
  source_code_uri: https://github.com/CycloneDX/cyclonedx-cocoapods.git
86
- post_install_message:
87
87
  rdoc_options: []
88
88
  require_paths:
89
89
  - lib
@@ -98,8 +98,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
98
  - !ruby/object:Gem::Version
99
99
  version: '0'
100
100
  requirements: []
101
- rubygems_version: 3.5.23
102
- signing_key:
101
+ rubygems_version: 3.6.2
103
102
  specification_version: 4
104
103
  summary: CycloneDX software bill-of-material (SBOM) generation utility
105
104
  test_files: []