From f9291108786704ac2459b1bfcbe32ee42864f09d Mon Sep 17 00:00:00 2001 From: Marlon Pina Tojal Date: Tue, 19 Dec 2023 10:28:28 +0100 Subject: [PATCH] add support for dependencies Signed-off-by: Marlon Pina Tojal --- lib/cyclonedx/cocoapods/bom_builder.rb | 23 +++++++-- lib/cyclonedx/cocoapods/cli_runner.rb | 5 +- lib/cyclonedx/cocoapods/podfile_analyzer.rb | 42 +++++++++++---- spec/cyclonedx/cocoapods/bom_builder_spec.rb | 51 ++++++++++++++++--- .../cocoapods/podfile_analyzer_spec.rb | 37 +++++++++++--- 5 files changed, 130 insertions(+), 28 deletions(-) diff --git a/lib/cyclonedx/cocoapods/bom_builder.rb b/lib/cyclonedx/cocoapods/bom_builder.rb index 31a4cd5..9633f5d 100644 --- a/lib/cyclonedx/cocoapods/bom_builder.rb +++ b/lib/cyclonedx/cocoapods/bom_builder.rb @@ -84,6 +84,7 @@ def add_to_bom(xml) end end xml.purl purl + xml.bomRef purl unless homepage.nil? xml.externalReferences do xml.reference(type: HOMEPAGE_REFERENCE_TYPE) do @@ -119,10 +120,12 @@ def add_to_bom(xml) class BOMBuilder NAMESPACE = 'http://cyclonedx.org/schema/bom/1.4' - attr_reader :component, :pods + attr_reader :component, :pods, :dependencies - def initialize(component: nil, pods:) - @component, @pods = component, pods + def initialize(pods:, component: nil, dependencies: nil) + @pods = pods + @component = component + @dependencies = dependencies end def bom(version: 1) @@ -136,12 +139,26 @@ def bom(version: 1) pod.add_to_bom(xml) end end + + xml.dependencies do + bom_dependencies(xml, dependencies) + end end end.to_xml end private + def bom_dependencies(xml, dependencies) + dependencies&.each do |key, array| + xml.dependency(ref: key) do + array.each do |value| + xml.dependency(ref: value) + end + end + end + end + def bom_metadata(xml) xml.metadata do xml.timestamp Time.now.getutc.strftime('%Y-%m-%dT%H:%M:%SZ') diff --git a/lib/cyclonedx/cocoapods/cli_runner.rb b/lib/cyclonedx/cocoapods/cli_runner.rb index dab5442..c603933 100644 --- a/lib/cyclonedx/cocoapods/cli_runner.rb +++ b/lib/cyclonedx/cocoapods/cli_runner.rb @@ -40,10 +40,11 @@ def run analyzer = PodfileAnalyzer.new(logger: @logger, exclude_test_targets: options[:exclude_test_targets]) podfile, lockfile = analyzer.ensure_podfile_and_lock_are_present(options) - pods = analyzer.parse_pods(podfile, lockfile) + pods, dependencies = analyzer.parse_pods(podfile, lockfile) analyzer.populate_pods_with_additional_info(pods) - bom = BOMBuilder.new(component: component_from_options(options), pods: pods).bom(version: options[:bom_version] || 1) + builder = BOMBuilder.new(pods: pods, component: component_from_options(options), dependencies: dependencies) + bom = builder.bom(version: options[:bom_version] || 1) write_bom_to_file(bom: bom, options: options) rescue StandardError => e @logger.error ([e.message] + e.backtrace).join($/) diff --git a/lib/cyclonedx/cocoapods/podfile_analyzer.rb b/lib/cyclonedx/cocoapods/podfile_analyzer.rb index 3f15fdc..f6023b4 100644 --- a/lib/cyclonedx/cocoapods/podfile_analyzer.rb +++ b/lib/cyclonedx/cocoapods/podfile_analyzer.rb @@ -41,7 +41,7 @@ def load_plugins(podfile_path) podfile_contents = File.read(podfile_path) plugin_syntax = /\s*plugin\s+['"]([^'"]+)['"]/ plugin_names = podfile_contents.scan(plugin_syntax).flatten - + plugin_names.each do |plugin_name| @logger.debug("Loading plugin #{plugin_name}") begin @@ -67,17 +67,32 @@ def ensure_podfile_and_lock_are_present(options) lockfile = ::Pod::Lockfile.from_file(options[:podfile_lock_path]) verify_synced_sandbox(lockfile) load_plugins(options[:podfile_path]) - + return ::Pod::Podfile.from_file(options[:podfile_path]), lockfile end def parse_pods(podfile, lockfile) @logger.debug "Parsing pods from #{podfile.defined_in_file}" - included_pods = create_list_of_included_pods(podfile, lockfile) - return lockfile.pod_names.select { |name| included_pods.include?(name) }.map do |name| + included_pods, dependencies = create_list_of_included_pods(podfile, lockfile) + + pods = lockfile.pod_names.select { |name| included_pods.include?(name) }.map do |name| Pod.new(name: name, version: lockfile.version(name), source: source_for_pod(podfile, lockfile, name), checksum: lockfile.checksum(name)) end + + pod_dependencies = { } + dependencies.each {|key, value| + if lockfile.pod_names.include? key + pod = Pod.new(name: key, version: lockfile.version(key), source: source_for_pod(podfile, lockfile, key), checksum: lockfile.checksum(key)) + + pod_dependencies[pod.purl] = lockfile.pod_names.select { |name| value.include?(name) }.map do |name| + pod = Pod.new(name: name, version: lockfile.version(name), source: source_for_pod(podfile, lockfile, name), checksum: lockfile.checksum(name)) + pod.purl + end + end + } + + return pods, pod_dependencies end @@ -89,7 +104,6 @@ def populate_pods_with_additional_info(pods) return pods end - private @@ -124,9 +138,12 @@ def simple_hash_of_lockfile_pods(lockfile) pods_hash end + def append_all_pod_dependencies(pods_used, pods_cache) result = pods_used original_number = 0 + dependencies_hash = { } + # Loop adding pod dependencies until we are not adding any more dependencies to the result # This brings in all the transitive dependencies of every top level pod. # Note this also handles two edge cases: @@ -134,13 +151,20 @@ def append_all_pod_dependencies(pods_used, pods_cache) # 2. Having a pod that has a platform-specific dependency that is unused for this Podfile. while result.length != original_number original_number = result.length + pods_used.each { |pod_name| - result.push(*pods_cache[pod_name]) unless !pods_cache.key?(pod_name) || pods_cache[pod_name].empty? + if pods_cache.key?(pod_name) + result.push(*pods_cache[pod_name]) + dependencies_hash[pod_name] = pods_cache[pod_name].empty? ? [] : pods_cache[pod_name] + end } + result = result.uniq + # maybe additional dependency processing needed here??? pods_used = result end - result + + return result, dependencies_hash end def create_list_of_included_pods(podfile, lockfile) @@ -152,9 +176,9 @@ def create_list_of_included_pods(podfile, lockfile) topLevelDeps = includedTargets.map(&:dependencies).flatten.uniq pods_used = topLevelDeps.map(&:name).uniq - pods_used = append_all_pod_dependencies(pods_used, pods_cache) + pods_used, dependencies = append_all_pod_dependencies(pods_used, pods_cache) - return pods_used.sort + return pods_used.sort, dependencies end diff --git a/spec/cyclonedx/cocoapods/bom_builder_spec.rb b/spec/cyclonedx/cocoapods/bom_builder_spec.rb index 517c711..ad48bfe 100644 --- a/spec/cyclonedx/cocoapods/bom_builder_spec.rb +++ b/spec/cyclonedx/cocoapods/bom_builder_spec.rb @@ -253,12 +253,27 @@ end end - RSpec.describe CycloneDX::CocoaPods::BOMBuilder do context 'when generating a BOM' do - let(:pods) { { - 'Alamofire' => '5.4.2', 'FirebaseAnalytics' => '7.10.0', 'RxSwift' => '5.1.2', 'Realm' => '5.5.1' - }.map { |name, version| CycloneDX::CocoaPods::Pod.new(name: name, version: version) } } + 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 { |name, version| CycloneDX::CocoaPods::Pod.new(name: name, version: version) } + 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#app-lib' => [], + 'pkg:cocoapods/RxSwift@5.1.2#app-lib' => [], + 'pkg:cocoapods/Realm@5.5.1#app-lib' => [] + } + end shared_examples "bom_generator" do context 'with an incorrect version' do @@ -328,7 +343,7 @@ it 'should generate component metadata when a component is available' do if bom_builder.component component_metadata = Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| - xml.metadata('xmlns': described_class::NAMESPACE) do + xml.metadata(xmlns: described_class::NAMESPACE) do component.add_to_bom(xml) end end.to_xml).at('metadata/component') @@ -348,13 +363,35 @@ components_generated_from_bom_builder = xml.at('bom/components') components = Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| - xml.components('xmlns': described_class::NAMESPACE) do + xml.components(xmlns: described_class::NAMESPACE) do bom_builder.pods.each { |pod| pod.add_to_bom(xml) } end end.to_xml).at('components') expect(components_generated_from_bom_builder).to be_equivalent_to(components) end + + it 'should generate a child dependencies node' do + expect(xml.at('bom/dependencies')).not_to be_nil + end + + it 'shoudl properly set dependencies node' do + dependencies_generated_from_bom_builder = xml.at('bom/dependencies') + + dependencies = Nokogiri::XML(Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + xml.dependencies(xmlns: described_class::NAMESPACE) do + bom_builder.dependencies.to_a.map do |key, array| + xml.dependency(ref: key) do + array.each do |value| + xml.dependency(ref: value) + end + end + end + end + end.to_xml).at('dependencies') + + expect(dependencies_generated_from_bom_builder).to be_equivalent_to(dependencies) + end end end @@ -366,7 +403,7 @@ context 'with a component' do let(:component) { CycloneDX::CocoaPods::Component.new(name: 'Application', version: '1.3.5', type: 'application') } - let(:bom_builder) { described_class.new(component: component, pods: pods) } + let(:bom_builder) { described_class.new(component: component, pods: pods, dependencies: dependencies) } it_behaves_like "bom_generator" end diff --git a/spec/cyclonedx/cocoapods/podfile_analyzer_spec.rb b/spec/cyclonedx/cocoapods/podfile_analyzer_spec.rb index 48b09af..ccc89a1 100644 --- a/spec/cyclonedx/cocoapods/podfile_analyzer_spec.rb +++ b/spec/cyclonedx/cocoapods/podfile_analyzer_spec.rb @@ -21,7 +21,6 @@ require 'cyclonedx/cocoapods/podfile_analyzer' require 'rspec' - RSpec.describe CycloneDX::CocoaPods::PodfileAnalyzer do let(:fixtures) { Pathname.new(File.expand_path('../../../fixtures/', __FILE__)) } let(:empty_podfile) { 'EmptyPodfile/Podfile' } @@ -46,10 +45,12 @@ lock_file = ::Pod::Lockfile.from_file(fixtures + (empty_podfile + '.lock')) expect(lock_file).not_to be_nil - included_pods = analyzer.parse_pods(pod_file, lock_file) + included_pods, dependencies = analyzer.parse_pods(pod_file, lock_file) pod_names = included_pods.map(&:name) expect(pod_names).to eq([]) + expect(dependencies).to eq({}) + expect(pod_names.length).to eq(dependencies.length) end it 'should find all simple pods' do @@ -60,10 +61,16 @@ lock_file = ::Pod::Lockfile.from_file(fixtures + (simple_pod + '.lock')) expect(lock_file).not_to be_nil - included_pods = analyzer.parse_pods(pod_file, lock_file) + included_pods, dependencies = analyzer.parse_pods(pod_file, lock_file) pod_names = included_pods.map(&:name) expect(pod_names).to eq(['Alamofire', 'MSAL', 'MSAL/app-lib']) + expect(dependencies).to eq({ + 'pkg:cocoapods/Alamofire@5.6.2' => [], + 'pkg:cocoapods/MSAL@1.2.1' => ['pkg:cocoapods/MSAL@1.2.1#app-lib'], + 'pkg:cocoapods/MSAL@1.2.1#app-lib' => [] + }) + expect(pod_names.length).to eq(dependencies.length) end it 'should find all pods actually used' do @@ -74,10 +81,12 @@ lock_file = ::Pod::Lockfile.from_file(fixtures + (restricted_pod + '.lock')) expect(lock_file).not_to be_nil - included_pods = analyzer.parse_pods(pod_file, lock_file) + included_pods, dependencies = analyzer.parse_pods(pod_file, lock_file) pod_names = included_pods.map(&:name) expect(pod_names).to eq(['EFQRCode']) + expect(dependencies).to eq({ 'pkg:cocoapods/EFQRCode@6.2.1' => [] }) + expect(pod_names.length).to eq(dependencies.length) end it 'should find all pods' do @@ -88,10 +97,16 @@ lock_file = ::Pod::Lockfile.from_file(fixtures + (tests_pod + '.lock')) expect(lock_file).not_to be_nil - included_pods = analyzer.parse_pods(pod_file, lock_file) + included_pods, dependencies = analyzer.parse_pods(pod_file, lock_file) pod_names = included_pods.map(&:name) expect(pod_names).to eq(['Alamofire', 'MSAL', 'MSAL/app-lib']) + expect(dependencies).to eq({ + 'pkg:cocoapods/Alamofire@5.6.2' => [], + 'pkg:cocoapods/MSAL@1.2.1' => ['pkg:cocoapods/MSAL@1.2.1#app-lib'], + 'pkg:cocoapods/MSAL@1.2.1#app-lib' => [] + }) + expect(pod_names.length).to eq(dependencies.length) end end @@ -104,10 +119,16 @@ lock_file = ::Pod::Lockfile.from_file(fixtures + (simple_pod + '.lock')) expect(lock_file).not_to be_nil - included_pods = analyzer.parse_pods(pod_file, lock_file) + included_pods, dependencies = analyzer.parse_pods(pod_file, lock_file) pod_names = included_pods.map(&:name) expect(pod_names).to eq(['Alamofire', 'MSAL', 'MSAL/app-lib']) + expect(dependencies).to eq({ + 'pkg:cocoapods/Alamofire@5.6.2' => [], + 'pkg:cocoapods/MSAL@1.2.1' => ['pkg:cocoapods/MSAL@1.2.1#app-lib'], + 'pkg:cocoapods/MSAL@1.2.1#app-lib' => [] + }) + expect(pod_names.length).to eq(dependencies.length) end it 'should not include testing pods' do @@ -118,10 +139,12 @@ lock_file = ::Pod::Lockfile.from_file(fixtures + (tests_pod + '.lock')) expect(lock_file).not_to be_nil - included_pods = analyzer.parse_pods(pod_file, lock_file) + included_pods, dependencies = analyzer.parse_pods(pod_file, lock_file) pod_names = included_pods.map(&:name) expect(pod_names).to eq(['Alamofire']) + expect(dependencies).to eq({ 'pkg:cocoapods/Alamofire@5.6.2' => [] }) + expect(pod_names.length).to eq(dependencies.length) end end end