diff --git a/.rubocop.yml b/.rubocop.yml index 00a5c64..fdec64a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -35,7 +35,15 @@ Metrics/BlockLength: # Allow some long methods because breaking them up doesn't help anything. Metrics/MethodLength: - AllowedMethods: ['parse_options', 'add_to_bom', 'append_all_pod_dependencies', 'xml_add_evidence'] + AllowedMethods: + - 'parse_options' + - 'add_to_bom' + - 'append_all_pod_dependencies' + - 'xml_add_evidence' + - 'generate_json_metadata' + - 'generate_json_evidence' + - 'to_json_component' + - 'to_json_manufacturer' Metrics/AbcSize: AllowedMethods: ['parse_options', 'add_to_bom', 'source_for_pod'] diff --git a/lib/cyclonedx/cocoapods/bom_builder.rb b/lib/cyclonedx/cocoapods/bom_builder.rb index 7c8c173..64f686a 100644 --- a/lib/cyclonedx/cocoapods/bom_builder.rb +++ b/lib/cyclonedx/cocoapods/bom_builder.rb @@ -150,7 +150,57 @@ def add_to_bom(xml, manifest_path, trim_strings_length = 0) end end + def to_json_component(manifest_path, trim_strings_length = 0) + { + type: 'library', + 'bom-ref': purl, + author: trim(author, trim_strings_length), + publisher: trim(author, trim_strings_length), + name: name, + version: version.to_s, + description: description, + hashes: generate_json_hashes, + licenses: generate_json_licenses, + purl: purl, + externalReferences: generate_json_external_references, + evidence: generate_json_evidence(manifest_path) + }.compact + end + + def generate_json_external_references + refs = [] + refs << { type: HOMEPAGE_REFERENCE_TYPE, url: homepage } if homepage + refs.empty? ? nil : refs + end + + def generate_json_evidence(manifest_path) + { + identity: { + field: 'purl', + confidence: 0.6, + methods: [ + { + technique: 'manifest-analysis', + confidence: 0.6, + value: manifest_path + } + ] + } + } + end + class License + def to_json_component + { + license: { + id: identifier_type == :id ? identifier : nil, + name: identifier_type == :name ? identifier : nil, + text: text, + url: url + }.compact + } + end + def add_to_bom(xml) xml.license do xml.id identifier if identifier_type == :id @@ -160,6 +210,20 @@ def add_to_bom(xml) end end end + + private + + def generate_json_licenses + license ? [license.to_json_component] : nil + end + + def generate_json_hashes + checksum ? [{ alg: CHECKSUM_ALGORITHM, content: checksum }] : nil + end + + def trim(str, trim_strings_length) + trim_strings_length.zero? ? str : str&.slice(0, trim_strings_length) + end end class Component @@ -187,6 +251,27 @@ def add_to_bom(xml) xml.purl bomref end end + + def to_json_component + { + type: type, + 'bom-ref': bomref, + group: group, + name: name, + version: version, + purl: bomref, + externalReferences: generate_json_external_references + }.compact + end + + private + + def generate_json_external_references + refs = [] + refs << { type: 'build-system', url: build_system } if build_system + refs << { type: 'vcs', url: vcs } if vcs + refs.empty? ? nil : refs + end end # Represents manufacturer information in a CycloneDX BOM @@ -202,8 +287,30 @@ def add_to_bom(xml) end end + def to_json_manufacturer + return nil if all_attributes_nil? + + { + name: name, + url: url, + contact: generate_json_contact + }.compact + end + private + def generate_json_contact + return nil if contact_info_nil? + + [ + { + name: contact_name, + email: email, + phone: phone + }.compact + ] + end + def all_attributes_nil? [name, url, contact_name, email, phone].all?(&:nil?) end @@ -242,35 +349,97 @@ def initialize(pods:, manifest_path:, component: nil, dependencies: nil, manufac @manufacturer = manufacturer end - def bom(version: 1, trim_strings_length: 0) - unless version.to_i.positive? - raise ArgumentError, - "Incorrect version: #{version} should be an integer greater than 0" - end - - unless trim_strings_length.is_a?(Integer) && (trim_strings_length.positive? || trim_strings_length.zero?) - raise ArgumentError, - "Incorrect string length: #{trim_strings_length} should be an integer greater than 0" - end + def bom(version: 1, trim_strings_length: 0, format: :xml) + validate_version(version) + validate_trim_length(trim_strings_length) + validate_format(format) - unchecked_bom(version: version, trim_strings_length: trim_strings_length) + unchecked_bom(version: version, trim_strings_length: trim_strings_length, format: format) end private + def validate_version(version) + return if version.to_i.positive? + + raise ArgumentError, "Incorrect version: #{version} should be an integer greater than 0" + end + + def validate_trim_length(trim_strings_length) + return if trim_strings_length.is_a?(Integer) && (trim_strings_length.positive? || trim_strings_length.zero?) + + raise ArgumentError, "Incorrect string length: #{trim_strings_length} should be an integer greater than 0" + end + + def validate_format(format) + return if %i[xml json].include?(format) + + raise ArgumentError, "Incorrect format: #{format} should be either :xml or :json" + end + # does not verify parameters because the public method does that. - def unchecked_bom(version: 1, trim_strings_length: 0) + def unchecked_bom(version:, trim_strings_length:, format:) + case format + when :json + generate_json(version: version, trim_strings_length: trim_strings_length) + when :xml + generate_xml(version: version, trim_strings_length: trim_strings_length) + end + end + + def generate_xml(version:, trim_strings_length:) Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| xml.bom(xmlns: NAMESPACE, version: version.to_i.to_s, serialNumber: "urn:uuid:#{SecureRandom.uuid}") do bom_metadata(xml) - bom_components(xml, pods, manifest_path, trim_strings_length) - bom_dependencies(xml, dependencies) end end.to_xml end + def generate_json(version:, trim_strings_length:) + { + '$schema': 'https://cyclonedx.org/schema/bom-1.6.schema.json', + bomFormat: 'CycloneDX', + specVersion: '1.6', + serialNumber: "urn:uuid:#{SecureRandom.uuid}", + version: version.to_s, + metadata: generate_json_metadata, + components: generate_json_components(trim_strings_length), + dependencies: generate_json_dependencies + }.to_json + end + + def generate_json_metadata + { + timestamp: Time.now.getutc.strftime('%Y-%m-%dT%H:%M:%SZ'), + tools: { + components: [{ + type: 'application', + group: 'CycloneDX', + name: 'cyclonedx-cocoapods', + version: VERSION + }] + }, + component: component&.to_json_component, + manufacturer: manufacturer&.to_json_manufacturer + }.compact + end + + def generate_json_components(trim_strings_length) + pods.map { |pod| pod.to_json_component(manifest_path, trim_strings_length) } + end + + def generate_json_dependencies + return nil unless dependencies + + dependencies.map do |ref, deps| + { + ref: ref, + dependsOn: deps.sort + } + end + end def bom_components(xml, pods, manifest_path, trim_strings_length) xml.components do pods.each do |pod| diff --git a/lib/cyclonedx/cocoapods/cli_runner.rb b/lib/cyclonedx/cocoapods/cli_runner.rb index cd9616a..4006793 100644 --- a/lib/cyclonedx/cocoapods/cli_runner.rb +++ b/lib/cyclonedx/cocoapods/cli_runner.rb @@ -38,6 +38,7 @@ class CLIRunner def run setup_logger # Needed in case we have errors while processing CLI parameters options = parse_options + determine_output_format(options) setup_logger(verbose: options[:verbose]) @logger.debug "Running cyclonedx-cocoapods with options: #{options}" @@ -78,7 +79,8 @@ def parse_options parsed_options[:path] = path end options.on('-o', '--output bom_file_path', - 'Path to output the bom.xml file to (default: "bom.xml")') do |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') do |bom_file_path| parsed_options[:bom_file_path] = bom_file_path end options.on('-b', '--bom-version bom_version', Integer, @@ -163,6 +165,10 @@ def parse_options parsed_options end + def determine_output_format(options) + options[:format] = options[:bom_file_path]&.end_with?('.json') ? :json : :xml + end + def analyze(options) analyzer, dependencies, lockfile, podfile, pods = analyze_podfile(options) podspec = analyze_podspec(options) @@ -203,7 +209,8 @@ def build_and_write_bom(options, component, manufacturer, pods, manifest_path, d builder = BOMBuilder.new(pods: pods, manifest_path: manifest_path, component: component, manufacturer: manufacturer, dependencies: dependencies) bom = builder.bom(version: options[:bom_version] || 1, - trim_strings_length: options[:trim_strings_length] || 0) + trim_strings_length: options[:trim_strings_length] || 0, + format: options[:format]) write_bom_to_file(bom: bom, options: options) end diff --git a/spec/cyclonedx/cocoapods/bom_builder_spec.rb b/spec/cyclonedx/cocoapods/bom_builder_spec.rb index 1eb24ca..9943ce4 100644 --- a/spec/cyclonedx/cocoapods/bom_builder_spec.rb +++ b/spec/cyclonedx/cocoapods/bom_builder_spec.rb @@ -42,6 +42,12 @@ pod.add_to_bom(xml, 'unused.lock') end.to_xml) end + let(:json) do + pod.to_json_component('unused.lock') + end + let(:json_short) do + pod.to_json_component('unused.lock', 7) + end let(:shortXML) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| @@ -54,21 +60,46 @@ expect(xml.at('/component')).not_to be_nil expect(xml.at('/component')['type']).to eql('library') end + context 'for JSON' do + it 'should generate a component of type library' do + expect(json[:type]).not_to be_nil + expect(json[:type]).to eql('library') + end + end it 'should generate a correct component name' do expect(xml.at('/component/name')).not_to be_nil expect(xml.at('/component/name').text).to eql(pod.name) end + context 'for JSON' do + it 'should generate a correct component name' do + expect(json[:name]).not_to be_nil + expect(json[:name]).to eql(pod.name) + end + end it 'should generate a correct component version' do expect(xml.at('/component/version')).not_to be_nil expect(xml.at('/component/version').text).to eql(pod.version.to_s) end + context 'for JSON' do + it 'should generate a correct component version' do + expect(json[:version]).not_to be_nil + expect(json[:version]).to eql(pod.version.to_s) + end + end + it 'should generate a correct component purl' do expect(xml.at('/component/purl')).not_to be_nil expect(xml.at('/component/purl').text).to eql(pod.purl) end + context 'for JSON' do + it 'should generate a correct component purl' do + expect(json[:purl]).not_to be_nil + expect(json[:purl]).to eql(pod.purl) + end + end context 'when shortening to a limited string length' do it 'should truncate the purl to the right number of characters' do @@ -82,6 +113,13 @@ expect(xml.at('/component/author')).to be_nil expect(xml.at('/component/publisher')).to be_nil end + + context 'for JSON' do + it 'shouldn\'t generate a component author' do + expect(json[:author]).to be_nil + expect(json[:publisher]).to be_nil + end + end end context 'when having an author' do @@ -96,6 +134,15 @@ expect(xml.at('/component/publisher').text).to eql(pod.author) end + context 'for JSON' do + it 'should generate a correct component author' do + expect(json[:author]).not_to be_nil + expect(json[:author]).to eql(pod.author) + expect(json[:publisher]).not_to be_nil + expect(json[:publisher]).to eql(pod.author) + end + end + context 'when shortening to a limited string length' do it 'should truncate the author to the right number of characters' do expect(shortXML.at('/component/author')).not_to be_nil @@ -103,6 +150,15 @@ expect(shortXML.at('/component/publisher')).not_to be_nil expect(shortXML.at('/component/publisher').text).to eql('Darth V') end + + context 'for JSON' do + it 'should truncate the author to the right number of characters' do + expect(json_short[:author]).not_to be_nil + expect(json_short[:author]).to eql('Darth V') + expect(json_short[:publisher]).not_to be_nil + expect(json_short[:publisher]).to eql('Darth V') + end + end end end @@ -110,6 +166,12 @@ it 'shouldn\'t generate a component description' do expect(xml.at('/component/description')).to be_nil end + + context 'for JSON' do + it 'shouldn\'t generate a component description' do + expect(json[:description]).to be_nil + end + end end context 'when having a description' do @@ -121,6 +183,13 @@ expect(xml.at('/component/description')).not_to be_nil expect(xml.at('/component/description').text).to eql(pod.description) end + + context 'for JSON' do + it 'should generate a correct component description' do + expect(json[:description]).not_to be_nil + expect(json[:description]).to eql(pod.description) + end + end end context 'when having a null byte description' do @@ -140,6 +209,237 @@ it 'shouldn\'t generate a component hash' do expect(xml.at('/component/hashes')).to be_nil end + + context 'for JSON' do + it 'shouldn\'t generate a component hash' do + expect(json[:hashes]).to be_nil + end + end + end + + context 'when having a checksum' do + it 'should generate a correct component hash' do + expect(xml.at('/component/hashes/hash')).not_to be_nil + # CocoaPods always uses SHA-1 + expect(xml.at('/component/hashes/hash')['alg']).to eq(described_class::CHECKSUM_ALGORITHM) + expect(xml.at('/component/hashes/hash').text).to eql(pod.checksum) + end + context 'for JSON' do + it 'should generate a correct component hash' do + expect(json[:hashes]).not_to be_nil + expect(json[:hashes][0][:alg]).to eq(described_class::CHECKSUM_ALGORITHM) + expect(json[:hashes][0][:content]).to eql(pod.checksum) + end + end + end + + context 'when not having a license' do + it 'shouldn\'t generate a license list' do + expect(xml.at('/component/licenses')).to be_nil + end + context 'for JSON' do + it 'shouldn\'t generate a license list' do + expect(json[:licenses]).to be_nil + end + end + end + + context 'when having a license' do + let(:pod) do + described_class.new(name: pod_name, version: pod_version, checksum: checksum).populate(license: 'MIT') + end + + it 'should generate a child licenses node' do + expect(xml.at('/component/licenses')).not_to be_nil + end + context 'for JSON' do + it 'should generate a correct license list' do + expect(json[:licenses]).not_to be_nil + end + end + + it 'should properly delegate license node generation' do + license_generated_from_pod = xml.xpath('/component/licenses/license')[0] + + license = Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + pod.license.add_to_bom(xml) + end.to_xml).at('/license') + + expect(license_generated_from_pod).to be_equivalent_to(license) + end + end + + context 'when not having a homepage' do + it 'shouldn\'t generate an external references list' do + expect(xml.at('/component/externalReferences')).to be_nil + end + context 'for JSON' do + it 'shouldn\'t generate an external references list' do + expect(json[:externalReferences]).to be_nil + end + end + end + + context 'when having a homepage' do + let(:pod) do + described_class.new(name: pod_name, version: pod_version, checksum: checksum).populate(homepage: homepage) + end + + it 'should properly generate a component external references list' do + expect(xml.at('/component/externalReferences')).not_to be_nil + expect(xml.at('/component/externalReferences/reference')).not_to be_nil + actual = xml.at('/component/externalReferences/reference')['type'] + expect(actual).to eq(described_class::HOMEPAGE_REFERENCE_TYPE) + expect(xml.at('/component/externalReferences/reference/url')).not_to be_nil + expect(xml.at('/component/externalReferences/reference/url').text).to eq(homepage) + end + + context 'for JSON' do + it 'should properly generate a component external references list' do + expect(json[:externalReferences]).not_to be_nil + expect(json[:externalReferences].length).to eq(1) + expect(json[:externalReferences][0][:type]).to eq(described_class::HOMEPAGE_REFERENCE_TYPE) + expect(json[:externalReferences][0][:url]).to eq(homepage) + end + end + end + end + + context 'when generating a pod component in a BOM for JSON' do + it 'should generate a root component of type library' do + expect(xml.at('/component')).not_to be_nil + expect(xml.at('/component')['type']).to eql('library') + end + context 'for JSON' do + it 'should generate a root component of type library' do + expect(json[:type]).to eql('library') + end + end + + it 'should generate a correct component name' do + expect(xml.at('/component/name')).not_to be_nil + expect(xml.at('/component/name').text).to eql(pod.name) + end + + context 'for JSON' do + it 'should generate a correct component name' do + expect(json[:name]).to eql(pod.name) + end + end + + it 'should generate a correct component version' do + expect(xml.at('/component/version')).not_to be_nil + expect(xml.at('/component/version').text).to eql(pod.version.to_s) + end + + context 'for JSON' do + it 'should generate a correct component version' do + expect(json[:version]).to eql(pod.version.to_s) + end + end + + it 'should generate a correct component purl' do + expect(xml.at('/component/purl')).not_to be_nil + expect(xml.at('/component/purl').text).to eql(pod.purl) + end + context 'for JSON' do + it 'should generate a correct component purl' do + expect(json[:purl]).to eql(pod.purl) + end + end + + context 'when shortening to a limited string length' do + it 'should truncate the purl to the right number of characters' do + expect(shortXML.at('/component/purl')).not_to be_nil + expect(shortXML.at('/component/purl').text).to eql('pkg:coc') + end + end + + context 'when not having an author' do + it 'shouldn\'t generate a component author' do + expect(xml.at('/component/author')).to be_nil + expect(xml.at('/component/publisher')).to be_nil + end + context 'for JSON' do + it 'shouldn\'t generate a component author' do + expect(json[:author]).to be_nil + expect(json[:publisher]).to be_nil + end + end + end + + context 'when having an author' do + let(:pod) do + described_class.new(name: pod_name, version: pod_version, checksum: checksum).populate(author: author) + end + + it 'should generate a correct component author' do + expect(xml.at('/component/author')).not_to be_nil + expect(xml.at('/component/author').text).to eql(pod.author) + expect(xml.at('/component/publisher')).not_to be_nil + expect(xml.at('/component/publisher').text).to eql(pod.author) + end + context 'for JSON' do + it 'should generate a correct component author' do + expect(json[:author]).to eql(pod.author) + expect(json[:publisher]).to eql(pod.author) + end + end + + context 'when shortening to a limited string length' do + it 'should truncate the author to the right number of characters' do + expect(shortXML.at('/component/author')).not_to be_nil + expect(shortXML.at('/component/author').text).to eql('Darth V') + expect(shortXML.at('/component/publisher')).not_to be_nil + expect(shortXML.at('/component/publisher').text).to eql('Darth V') + end + context 'for JSON' do + it 'should truncate the author to the right number of characters' do + expect(json_short[:author]).to eql('Darth V') + expect(json_short[:publisher]).to eql('Darth V') + end + end + end + end + + context 'when not having a description' do + it 'shouldn\'t generate a component description' do + expect(xml.at('/component/description')).to be_nil + end + context 'for JSON' do + it 'shouldn\'t generate a component description' do + expect(json[:description]).to be_nil + end + end + end + + context 'when having a description' do + let(:pod) do + described_class.new(name: pod_name, version: pod_version, checksum: checksum).populate(summary: summary) + end + + it 'should generate a correct component description' do + expect(xml.at('/component/description')).not_to be_nil + expect(xml.at('/component/description').text).to eql(pod.description) + end + context 'for JSON' do + it 'should generate a correct component description' do + expect(json[:description]).to eql(pod.description) + end + end + end + + context 'when not having a checksum' do + let(:pod) { described_class.new(name: pod_name, version: pod_version) } + + it 'shouldn\'t generate a component hash' do + expect(xml.at('/component/hashes')).to be_nil + end + context 'for JSON' do + it 'shouldn\'t generate a component hash' do + expect(json[:hashes]).to be_nil + end + end end context 'when having a checksum' do @@ -149,12 +449,22 @@ expect(xml.at('/component/hashes/hash')['alg']).to eq(described_class::CHECKSUM_ALGORITHM) expect(xml.at('/component/hashes/hash').text).to eql(pod.checksum) end + context 'for JSON' do + it 'should generate a correct component hash' do + expect(json[:hashes]).to eq([{ alg: described_class::CHECKSUM_ALGORITHM, content: pod.checksum }]) + end + end end context 'when not having a license' do it 'shouldn\'t generate a license list' do expect(xml.at('/component/licenses')).to be_nil end + context 'for JSON' do + it 'shouldn\'t generate a license list' do + expect(json[:licenses]).to be_nil + end + end end context 'when having a license' do @@ -165,6 +475,11 @@ it 'should generate a child licenses node' do expect(xml.at('/component/licenses')).not_to be_nil end + context 'for JSON' do + it 'should generate a child licenses node' do + expect(json[:licenses]).to eq([{ license: { id: 'MIT' } }]) + end + end it 'should properly delegate license node generation' do license_generated_from_pod = xml.xpath('/component/licenses/license')[0] @@ -181,6 +496,11 @@ it 'shouldn\'t generate an external references list' do expect(xml.at('/component/externalReferences')).to be_nil end + context 'for JSON' do + it 'shouldn\'t generate an external references list' do + expect(json[:externalReferences]).to be_nil + end + end end context 'when having a homepage' do @@ -196,6 +516,11 @@ expect(xml.at('/component/externalReferences/reference/url')).not_to be_nil expect(xml.at('/component/externalReferences/reference/url').text).to eq(homepage) end + context 'for JSON' do + it 'should properly generate a component external references list' do + expect(json[:externalReferences]).to eq([{ type: described_class::HOMEPAGE_REFERENCE_TYPE, url: homepage }]) + end + end end end end @@ -209,21 +534,41 @@ license.add_to_bom(xml) end.to_xml) end + let(:json) do + license.to_json_component + end it 'should generate a root license element' do expect(xml.at('/license')).not_to be_nil end + context 'for JSON' do + it 'should generate a root license element' do + expect(json[:license]).not_to be_nil + end + end it 'should generate a correct license identifier' do expect(xml.at('/license/id')).not_to be_nil expect(xml.at('/license/id').text).to eq(license.identifier) expect(xml.at('/license/name')).to be_nil end + context 'for JSON' do + it 'should generate a correct license identifier' do + expect(json[:license][:id]).to eq(license.identifier) + expect(json[:license][:name]).to be_nil + end + end it 'should not create text or url elements' do expect(xml.at('/license/text')).to be_nil expect(xml.at('/license/url')).to be_nil end + context 'for JSON' do + it 'should not create text or url elements' do + expect(json[:license][:text]).to be_nil + expect(json[:license][:url]).to be_nil + end + end context 'which includes text' do let(:license) do @@ -236,6 +581,11 @@ expect(xml.at('/license/text')).not_to be_nil expect(xml.at('/license/text').text).to eq(license.text) end + context 'for JSON' do + it 'should create text element' do + expect(json[:license][:text]).to eq(license.text) + end + end end context 'which includes url' do @@ -249,6 +599,11 @@ expect(xml.at('/license/url')).not_to be_nil expect(xml.at('/license/url').text).to eq(license.url) end + context 'for JSON' do + it 'should create text element' do + expect(json[:license][:url]).to eq(license.url) + end + end end end end @@ -277,6 +632,9 @@ let(:xml) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') { |xml| component.add_to_bom(xml) }.to_xml) end + let(:json) do + component.to_json_component + end it_behaves_like 'component' @@ -285,6 +643,12 @@ expect(xml.at('/component')['bom-ref']).to eq('pkg:generic/Application@1.3.5') expect(xml.at('/component/purl').text).to eq('pkg:generic/Application@1.3.5') end + context 'for JSON' do + it 'should not generate any group element' do + expect(json[:group]).to be_nil + expect(json[:'bom-ref']).to eq('pkg:generic/Application@1.3.5') + end + end end context 'without a group and type library' do @@ -324,6 +688,9 @@ let(:xml) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') { |xml| component.add_to_bom(xml) }.to_xml) end + let(:json) do + component.to_json_component + end it_behaves_like 'component' @@ -333,6 +700,13 @@ expect(xml.at('/component')['bom-ref']).to eq('pkg:generic/application-group/Application@1.3.5') expect(xml.at('/component/purl').text).to eq('pkg:generic/application-group/Application@1.3.5') end + context 'for JSON' do + it 'should generate a proper group element' do + expect(json[:group]).to eq(component.group) + expect(json[:'bom-ref']).to eq('pkg:generic/application-group/Application@1.3.5') + expect(json[:purl]).to eq('pkg:generic/application-group/Application@1.3.5') + end + end end ## this test is just for completeness, the group is not used for libraries @@ -343,6 +717,9 @@ let(:xml) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') { |xml| component.add_to_bom(xml) }.to_xml) end + let(:json) do + component.to_json_component + end it_behaves_like 'component' @@ -352,6 +729,12 @@ expect(xml.at('/component')['bom-ref']).to eq('pkg:generic/application-group/Application@1.3.5') expect(xml.at('/component/purl').text).to eq('pkg:generic/application-group/Application@1.3.5') end + context 'for JSON' do + it 'should generate a proper group element' do + expect(json[:group]).to eq(component.group) + expect(json[:'bom-ref']).to eq('pkg:generic/application-group/Application@1.3.5') + end + end end context 'with a vcs' do @@ -362,6 +745,9 @@ let(:xml) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') { |xml| component.add_to_bom(xml) }.to_xml) end + let(:json) do + component.to_json_component + end it_behaves_like 'component' @@ -372,6 +758,13 @@ expect(xml.at('/component/externalReferences/reference')['type']).to eq('vcs') expect(xml.at('/component/externalReferences/reference/url').text).to eq(component.vcs) end + context 'for JSON' do + it 'should generate a proper external references for vcs' do + expect(json[:group]).to eq(component.group) + expect(json[:'bom-ref']).to eq('pkg:generic/application-group/Application@1.3.5') + expect(json[:externalReferences]).to eq([{ type: 'vcs', url: component.vcs }]) + end + end end context 'with a build system' do @@ -382,6 +775,9 @@ let(:xml) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') { |xml| component.add_to_bom(xml) }.to_xml) end + let(:json) do + component.to_json_component + end it_behaves_like 'component' @@ -392,6 +788,13 @@ expect(xml.at('/component/externalReferences/reference')['type']).to eq('build-system') expect(xml.at('/component/externalReferences/reference/url').text).to eq(component.build_system) end + context 'for JSON' do + it 'should generate a proper external reference element for build-systems' do + expect(json[:group]).to eq(component.group) + expect(json[:'bom-ref']).to eq('pkg:generic/application-group/Application@1.3.5') + expect(json[:externalReferences]).to eq([{ type: 'build-system', url: component.build_system }]) + end + end end end end @@ -402,6 +805,11 @@ it 'should generate a root manufacturer element' do expect(xml.at('/manufacturer')).not_to be_nil end + context 'for JSON' do + it 'should generate a root manufacturer element' do + expect(json).not_to be_nil + end + end it 'should generate proper manufacturer information' do expect(xml.at('/manufacturer/name')).not_to be_nil if manufacturer.name @@ -409,6 +817,12 @@ expect(xml.at('/manufacturer/url')).not_to be_nil if manufacturer.url expect(xml.at('/manufacturer/url')&.text).to eq(manufacturer.url) if manufacturer.url end + context 'for JSON' do + it 'should generate proper manufacturer information' do + expect(json[:name]).to eq(manufacturer.name) if manufacturer.name + expect(json[:url]).to eq(manufacturer.url) if manufacturer.url + end + end end context 'without contact information' do @@ -416,12 +830,19 @@ let(:xml) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') { |xml| manufacturer.add_to_bom(xml) }.to_xml) end - + let(:json) do + manufacturer.to_json_manufacturer + end it_behaves_like 'manufacturer' it 'should not generate any contact element' do expect(xml.at('/manufacturer/contact')).to be_nil end + context 'for JSON' do + it 'should not generate any contact element' do + expect(json[:contact]).to be_nil + end + end end context 'with contact information' do @@ -437,6 +858,9 @@ let(:xml) do Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') { |xml| manufacturer.add_to_bom(xml) }.to_xml) end + let(:json) do + manufacturer.to_json_manufacturer + end it_behaves_like 'manufacturer' @@ -446,6 +870,13 @@ expect(xml.at('/manufacturer/contact/email').text).to eq(manufacturer.email) expect(xml.at('/manufacturer/contact/phone').text).to eq(manufacturer.phone) end + context 'for JSON' do + it 'should generate proper contact elements' do + expect(json[:contact][0][:name]).to eq(manufacturer.contact_name) + expect(json[:contact][0][:email]).to eq(manufacturer.email) + expect(json[:contact][0][:phone]).to eq(manufacturer.phone) + end + end end end end @@ -453,7 +884,7 @@ RSpec.describe CycloneDX::CocoaPods::BOMBuilder do context 'when generating a BOM' do # Important: these pods are NOT in alphabetical order; they will be sorted in output - let(:pods) do + let(:pods) do { 'Alamofire' => '5.6.2', 'FirebaseAnalytics' => '7.10.0', @@ -899,4 +1330,109 @@ it_behaves_like 'bom_generator' end end + + context 'when generating a JSON BOM' do + let(:pods) do + { + 'Alamofire' => '5.6.2', + 'FirebaseAnalytics' => '7.10.0', + 'RxSwift' => '5.1.2', + 'Realm' => '5.5.1', + 'MSAL' => '1.2.1', + 'MSAL/app-lib' => '1.2.1' + }.map do |name, version| + pod = CycloneDX::CocoaPods::Pod.new(name: name, version: version) + pod.populate(author: 'Chewbacca') + pod + end + end + let(:bom_builder) { described_class.new(pods: pods, manifest_path: 'sample_manifest.lock') } + let(:version) { Random.rand(1..100) } + let(:bom_json) { JSON.parse(bom_builder.bom(version: version, format: :json), symbolize_names: true) } + + it 'should generate proper root level attributes' do + expect(bom_json[:bomFormat]).to eq('CycloneDX') + expect(bom_json[:specVersion]).to eq('1.6') + expect(bom_json[:version]).to eq(version.to_s) + expect(bom_json[:serialNumber]).to match(/urn:uuid:.*/) + end + + it 'should include metadata with timestamp and tools' do + expect(bom_json[:metadata][:timestamp]).not_to be_nil + expect(bom_json[:metadata][:tools][:components][0][:group]).to eq('CycloneDX') + expect(bom_json[:metadata][:tools][:components][0][:name]).to eq('cyclonedx-cocoapods') + expect(bom_json[:metadata][:tools][:components][0][:version]).to eq(CycloneDX::CocoaPods::VERSION) + end + + it 'should generate components in alphabetical order' do + component_purls = bom_json[:components].map { |c| c[:purl] } + expect(component_purls).to eq(component_purls.sort) + end + + it 'should properly generate pod components' do + expect(bom_json[:components].length).to eq(pods.length) + expect(bom_json[:components].first).to include( + type: 'library', + name: 'Alamofire', + version: '5.6.2', + author: 'Chewbacca', + publisher: 'Chewbacca', + purl: 'pkg:cocoapods/Alamofire@5.6.2' + ) + end + + context 'when asked to shorten strings' do + let(:short_json) do + JSON.parse( + bom_builder.bom(version: version, format: :json, trim_strings_length: 6), + symbolize_names: true + ) + end + + it 'should properly trim the author, publisher, and purl' do + expect(short_json[:components].first).to include( + author: 'Chewba', + publisher: 'Chewba', + purl: 'pkg:cocoapods/Alamofire@5.6.2' + ) + end + end + + context 'with dependencies' do + let(:component) do + CycloneDX::CocoaPods::Component.new( + name: 'Application', + version: '1.3.5', + type: 'application' + ) + end + let(:dependencies) do + { + 'pkg:cocoapods/Alamofire@5.6.2' => [], + 'pkg:cocoapods/MSAL@1.2.1' => ['pkg:cocoapods/MSAL@1.2.1#app-lib'], + 'pkg:cocoapods/FirebaseAnalytics@7.10.0' => [], + 'pkg:cocoapods/RxSwift@5.1.2' => [], + 'pkg:cocoapods/Realm@5.5.1' => [] + } + end + let(:bom_builder) do + described_class.new( + component: component, + manifest_path: 'sample_manifest.lock', + pods: pods, + dependencies: dependencies + ) + end + + it 'should generate dependencies in alphabetical order' do + dependency_refs = bom_json[:dependencies].map { |d| d[:ref] } + expect(dependency_refs).to eq(dependency_refs.sort) + end + + it 'should properly generate nested dependencies' do + msal_dependency = bom_json[:dependencies].find { |d| d[:ref] == 'pkg:cocoapods/MSAL@1.2.1' } + expect(msal_dependency[:dependsOn]).to eq(['pkg:cocoapods/MSAL@1.2.1#app-lib']) + end + end + end end