diff --git a/lib/gitlab/ci/parsers/sbom/cyclonedx.rb b/lib/gitlab/ci/parsers/sbom/cyclonedx.rb
index 86516aa2a7a28b9e57e269c3d609ed4f019d2cf9..0e2ca97b9ccc4edeba0007b5efb8eef41e009ca9 100644
--- a/lib/gitlab/ci/parsers/sbom/cyclonedx.rb
+++ b/lib/gitlab/ci/parsers/sbom/cyclonedx.rb
@@ -18,7 +18,7 @@ def parse!
 
             return unless valid?
 
-            parse_components
+            parse_report
           rescue JSON::ParserError => e
             report.add_error("Report JSON is invalid: #{e}")
           end
@@ -54,6 +54,17 @@ def valid_schema?
             false
           end
 
+          def parse_report
+            parse_metadata_properties
+            parse_components
+          end
+
+          def parse_metadata_properties
+            properties = data.dig('metadata', 'properties')
+            source = CyclonedxProperties.parse_source(properties)
+            report.set_source(source) if source
+          end
+
           def parse_components
             data['components']&.each do |component|
               next unless supported_component_type?(component['type'])
diff --git a/lib/gitlab/ci/parsers/sbom/cyclonedx_properties.rb b/lib/gitlab/ci/parsers/sbom/cyclonedx_properties.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3dc73544208ccd410470b3ab2c81cbaac2296020
--- /dev/null
+++ b/lib/gitlab/ci/parsers/sbom/cyclonedx_properties.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Ci
+    module Parsers
+      module Sbom
+        # Parses GitLab CycloneDX metadata properties which are defined by the taxonomy at
+        # https://gitlab.com/gitlab-org/security-products/gitlab-cyclonedx-property-taxonomy
+        #
+        # This parser knows how to process schema version 1 and will not attempt to parse
+        # later versions. Each source type has it's own namespace in the property schema,
+        # and is also given its own parser. Properties are filtered by namespace,
+        # and then passed to each source parser for processing.
+        class CyclonedxProperties
+          SUPPORTED_SCHEMA_VERSION = '1'
+          GITLAB_PREFIX = 'gitlab:'
+          SOURCE_PARSERS = {
+            'dependency_scanning' => ::Gitlab::Ci::Parsers::Sbom::Source::DependencyScanning
+          }.freeze
+          SUPPORTED_PROPERTIES = %w[
+            meta:schema_version
+            dependency_scanning:category
+            dependency_scanning:input_file:path
+            dependency_scanning:source_file:path
+            dependency_scanning:package_manager:name
+            dependency_scanning:language:name
+          ].freeze
+
+          def self.parse_source(...)
+            new(...).parse_source
+          end
+
+          def initialize(properties)
+            @properties = properties
+          end
+
+          def parse_source
+            return unless properties.present?
+            return unless supported_schema_version?
+
+            source
+          end
+
+          private
+
+          attr_reader :properties
+
+          def property_data
+            @property_data ||= properties
+              .each_with_object({}) { |property, data| parse_property(property, data) }
+          end
+
+          def parse_property(property, data)
+            name = property['name']
+            value = property['value']
+
+            # The specification permits the name or value to be absent.
+            return unless name.present? && value.present?
+            return unless name.start_with?(GITLAB_PREFIX)
+
+            namespaced_name = name.delete_prefix(GITLAB_PREFIX)
+
+            return unless SUPPORTED_PROPERTIES.include?(namespaced_name)
+
+            parse_name_value_pair(namespaced_name, value, data)
+          end
+
+          def parse_name_value_pair(name, value, data)
+            # Each namespace in the property name reflects a key in the hash.
+            # A property with the name `dependency_scanning:input_file:path`
+            # and the value `package-lock.json` should be transformed into
+            # this data:
+            # {"dependency_scanning": {"input_file": {"path": "package-lock.json"}}}
+            keys = name.split(':')
+
+            # Remove last item from the keys and use it to create
+            # the initial object.
+            last = keys.pop
+
+            # Work backwards. For each key, create a new hash wrapping the previous one.
+            # Using `dependency_scanning:input_file:path` as an example:
+            #
+            # 1. memo = { "path" => "package-lock.json" } (arguments given to reduce)
+            # 2. memo = { "input_file" => memo }
+            # 3. memo = { "dependency_scanning" => memo }
+            property = keys.reverse.reduce({ last => value }) do |memo, key|
+              { key => memo }
+            end
+
+            data.deep_merge!(property)
+          end
+
+          def schema_version
+            @schema_version ||= property_data.dig('meta', 'schema_version')
+          end
+
+          def supported_schema_version?
+            schema_version == SUPPORTED_SCHEMA_VERSION
+          end
+
+          def source
+            @source ||= property_data
+              .slice(*SOURCE_PARSERS.keys)
+              .lazy
+              .filter_map { |namespace, data| SOURCE_PARSERS[namespace].source(data) }
+              .first
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/ci/parsers/sbom/source/dependency_scanning.rb b/lib/gitlab/ci/parsers/sbom/source/dependency_scanning.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ad04b3257f9654f964dcd608b543a7bef9c855f2
--- /dev/null
+++ b/lib/gitlab/ci/parsers/sbom/source/dependency_scanning.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Ci
+    module Parsers
+      module Sbom
+        module Source
+          class DependencyScanning
+            REQUIRED_ATTRIBUTES = [
+              %w[input_file path]
+            ].freeze
+
+            def self.source(...)
+              new(...).source
+            end
+
+            def initialize(data)
+              @data = data
+            end
+
+            def source
+              return unless required_attributes_present?
+
+              {
+                'type' => :dependency_scanning,
+                'data' => data,
+                'fingerprint' => fingerprint
+              }
+            end
+
+            private
+
+            attr_reader :data
+
+            def required_attributes_present?
+              REQUIRED_ATTRIBUTES.all? do |keys|
+                data.dig(*keys).present?
+              end
+            end
+
+            def fingerprint
+              Digest::SHA256.hexdigest(data.to_json)
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/ci/reports/sbom/report.rb b/lib/gitlab/ci/reports/sbom/report.rb
index a7edcd20877c7cff70c7f131da29ad52b17df8bc..dc6b3153e51a97ec477a295cf18c1ba8186cc185 100644
--- a/lib/gitlab/ci/reports/sbom/report.rb
+++ b/lib/gitlab/ci/reports/sbom/report.rb
@@ -5,25 +5,28 @@ module Ci
     module Reports
       module Sbom
         class Report
-          attr_reader :components, :sources, :errors
+          attr_reader :components, :source, :errors
 
           def initialize
             @components = []
             @errors = []
-            @sources = []
           end
 
           def add_error(error)
             errors << error
           end
 
-          def add_source(source)
-            sources << Source.new(source)
+          def set_source(source)
+            self.source = Source.new(source)
           end
 
           def add_component(component)
             components << Component.new(component)
           end
+
+          private
+
+          attr_writer :source
         end
       end
     end
diff --git a/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_properties_spec.rb b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_properties_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c99cfa94aa62f17ab6b25098c6240fa2234e1a62
--- /dev/null
+++ b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_properties_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Parsers::Sbom::CyclonedxProperties do
+  subject(:parse_source) { described_class.parse_source(properties) }
+
+  context 'when properties are nil' do
+    let(:properties) { nil }
+
+    it { is_expected.to be_nil }
+  end
+
+  context 'when report does not have gitlab properties' do
+    let(:properties) { ['name' => 'foo', 'value' => 'bar'] }
+
+    it { is_expected.to be_nil }
+  end
+
+  context 'when schema_version is missing' do
+    let(:properties) do
+      [
+        { 'name' => 'gitlab:dependency_scanning:dependency_file', 'value' => 'package-lock.json' },
+        { 'name' => 'gitlab:dependency_scanning:package_manager_name', 'value' => 'npm' },
+        { 'name' => 'gitlab:dependency_scanning:language', 'value' => 'JavaScript' }
+      ]
+    end
+
+    it { is_expected.to be_nil }
+  end
+
+  context 'when schema version is unsupported' do
+    let(:properties) do
+      [
+        { 'name' => 'gitlab:meta:schema_version', 'value' => '2' },
+        { 'name' => 'gitlab:dependency_scanning:dependency_file', 'value' => 'package-lock.json' },
+        { 'name' => 'gitlab:dependency_scanning:package_manager_name', 'value' => 'npm' },
+        { 'name' => 'gitlab:dependency_scanning:language', 'value' => 'JavaScript' }
+      ]
+    end
+
+    it { is_expected.to be_nil }
+  end
+
+  context 'when no dependency_scanning properties are present' do
+    let(:properties) do
+      [
+        { 'name' => 'gitlab:meta:schema_version', 'value' => '1' }
+      ]
+    end
+
+    it 'does not call dependency_scanning parser' do
+      expect(Gitlab::Ci::Parsers::Sbom::Source::DependencyScanning).not_to receive(:parse_source)
+
+      parse_source
+    end
+  end
+
+  context 'when dependency_scanning properties are present' do
+    let(:properties) do
+      [
+        { 'name' => 'gitlab:meta:schema_version', 'value' => '1' },
+        { 'name' => 'gitlab:dependency_scanning:category', 'value' => 'development' },
+        { 'name' => 'gitlab:dependency_scanning:input_file:path', 'value' => 'package-lock.json' },
+        { 'name' => 'gitlab:dependency_scanning:source_file:path', 'value' => 'package.json' },
+        { 'name' => 'gitlab:dependency_scanning:package_manager:name', 'value' => 'npm' },
+        { 'name' => 'gitlab:dependency_scanning:language:name', 'value' => 'JavaScript' },
+        { 'name' => 'gitlab:dependency_scanning:unsupported_property', 'value' => 'Should be ignored' }
+      ]
+    end
+
+    let(:expected_input) do
+      {
+        'category' => 'development',
+        'input_file' => { 'path' => 'package-lock.json' },
+        'source_file' => { 'path' => 'package.json' },
+        'package_manager' => { 'name' => 'npm' },
+        'language' => { 'name' => 'JavaScript' }
+      }
+    end
+
+    it 'passes only supported properties to the dependency scanning parser' do
+      expect(Gitlab::Ci::Parsers::Sbom::Source::DependencyScanning).to receive(:source).with(expected_input)
+
+      parse_source
+    end
+  end
+end
diff --git a/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb
index c58e11063b86e0c71f7c5c47f768fbc1414e4b3f..cb6d8b62f941a80a2a55dfd5002d5fbf51b3a927 100644
--- a/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb
@@ -8,6 +8,7 @@
   let(:raw_report_data) { report_data.to_json }
   let(:report_valid?) { true }
   let(:validator_errors) { [] }
+  let(:properties_parser) { class_double('Gitlab::Ci::Parsers::Sbom::CyclonedxProperties') }
 
   let(:base_report_data) do
     {
@@ -24,6 +25,9 @@
       allow(validator).to receive(:valid?).and_return(report_valid?)
       allow(validator).to receive(:errors).and_return(validator_errors)
     end
+
+    allow(properties_parser).to receive(:parse_source)
+    stub_const('Gitlab::Ci::Parsers::Sbom::CyclonedxProperties', properties_parser)
   end
 
   context 'when report JSON is invalid' do
@@ -107,4 +111,25 @@
       parse!
     end
   end
+
+  context 'when report has metadata properties' do
+    let(:report_data) { base_report_data.merge({ 'metadata' => { 'properties' => properties } }) }
+
+    let(:properties) do
+      [
+        { 'name' => 'gitlab:meta:schema_version', 'value' => '1' },
+        { 'name' => 'gitlab:dependency_scanning:category', 'value' => 'development' },
+        { 'name' => 'gitlab:dependency_scanning:input_file:path', 'value' => 'package-lock.json' },
+        { 'name' => 'gitlab:dependency_scanning:source_file:path', 'value' => 'package.json' },
+        { 'name' => 'gitlab:dependency_scanning:package_manager:name', 'value' => 'npm' },
+        { 'name' => 'gitlab:dependency_scanning:language:name', 'value' => 'JavaScript' }
+      ]
+    end
+
+    it 'passes them to the properties parser' do
+      expect(properties_parser).to receive(:parse_source).with(properties)
+
+      parse!
+    end
+  end
 end
diff --git a/spec/lib/gitlab/ci/parsers/sbom/source/dependency_scanning_spec.rb b/spec/lib/gitlab/ci/parsers/sbom/source/dependency_scanning_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..30114b17cac1d87cbbd5159a1db04595c9c07453
--- /dev/null
+++ b/spec/lib/gitlab/ci/parsers/sbom/source/dependency_scanning_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Parsers::Sbom::Source::DependencyScanning do
+  subject { described_class.source(property_data) }
+
+  context 'when all property data is present' do
+    let(:property_data) do
+      {
+        'category' => 'development',
+        'input_file' => { 'path' => 'package-lock.json' },
+        'source_file' => { 'path' => 'package.json' },
+        'package_manager' => { 'name' => 'npm' },
+        'language' => { 'name' => 'JavaScript' }
+      }
+    end
+
+    it 'returns expected source data' do
+      is_expected.to eq({
+        'type' => :dependency_scanning,
+        'data' => property_data,
+        'fingerprint' => '4dbcb747e6f0fb3ed4f48d96b777f1d64acdf43e459fdfefad404e55c004a188'
+      })
+    end
+  end
+
+  context 'when required properties are missing' do
+    let(:property_data) do
+      {
+        'category' => 'development',
+        'source_file' => { 'path' => 'package.json' },
+        'package_manager' => { 'name' => 'npm' },
+        'language' => { 'name' => 'JavaScript' }
+      }
+    end
+
+    it { is_expected.to be_nil }
+  end
+end
diff --git a/spec/lib/gitlab/ci/reports/sbom/report_spec.rb b/spec/lib/gitlab/ci/reports/sbom/report_spec.rb
index fe1025d16bf416e7e08b84e89ef855cb4089335a..d7a285ab13c1fa611b415ff228ddfb5adacbffc1 100644
--- a/spec/lib/gitlab/ci/reports/sbom/report_spec.rb
+++ b/spec/lib/gitlab/ci/reports/sbom/report_spec.rb
@@ -14,35 +14,24 @@
     end
   end
 
-  describe '#add_source' do
-    let_it_be(:sources) do
-      [
-        {
-          'type' => :dependency_file,
-          'data' => {
-            'input_file' => { 'name' => 'package-lock.json' },
-            'package_manager' => { 'name' => 'npm' },
-            'language' => { 'name' => 'JavaScript' }
-          },
-          'fingerprint' => '4ee1623c8f3ddd152b3c1fc340b3ece3cbcf807efa2726307ea34e7d6d36a6c1'
+  describe '#set_source' do
+    let_it_be(:source) do
+      {
+        'type' => :dependency_scanning,
+        'data' => {
+          'input_file' => { 'path' => 'package-lock.json' },
+          'source_file' => { 'path' => 'package.json' },
+          'package_manager' => { 'name' => 'npm' },
+          'language' => { 'name' => 'JavaScript' }
         },
-        {
-          'type' => :dependency_file,
-          'data' => {
-            'input_file' => { 'name' => 'go.sum' },
-            'package_manager' => { 'name' => 'go' },
-            'language' => { 'name' => 'Go' }
-          },
-          'fingerprint' => 'e78eee13d87248d5b7e3df21de67365a4996b3a547e033b8e8b180b24c300fd8'
-        }
-      ]
+        'fingerprint' => 'c01df1dc736c1148717e053edbde56cb3a55d3e31f87cea955945b6f67c17d42'
+      }
     end
 
-    it 'stores each source with the given attributes' do
-      sources.each { |source| report.add_source(source) }
+    it 'stores the source' do
+      report.set_source(source)
 
-      expect(report.sources.size).to eq(2)
-      expect(report.sources).to all(be_a(Gitlab::Ci::Reports::Sbom::Source))
+      expect(report.source).to be_a(Gitlab::Ci::Reports::Sbom::Source)
     end
   end
 
diff --git a/spec/lib/gitlab/ci/reports/sbom/source_spec.rb b/spec/lib/gitlab/ci/reports/sbom/source_spec.rb
index f84ed85b651f20d21a1e58d6811e1931ca42e949..2d6434534a0ba865618b3cb9cc5b07c7e195a82e 100644
--- a/spec/lib/gitlab/ci/reports/sbom/source_spec.rb
+++ b/spec/lib/gitlab/ci/reports/sbom/source_spec.rb
@@ -5,27 +5,25 @@
 RSpec.describe Gitlab::Ci::Reports::Sbom::Source do
   let(:attributes) do
     {
-      'type' => :dependency_file,
+      'type' => :dependency_scanning,
       'data' => {
-        'input_file' => { 'name' => 'package-lock.json' },
+        'category' => 'development',
+        'input_file' => { 'path' => 'package-lock.json' },
+        'source_file' => { 'path' => 'package.json' },
         'package_manager' => { 'name' => 'npm' },
         'language' => { 'name' => 'JavaScript' }
       },
-      'fingerprint' => '4ee1623c8f3ddd152b3c1fc340b3ece3cbcf807efa2726307ea34e7d6d36a6c1'
+      'fingerprint' => '4dbcb747e6f0fb3ed4f48d96b777f1d64acdf43e459fdfefad404e55c004a188'
     }
   end
 
-  subject { described_class.new(**attributes) }
+  subject { described_class.new(attributes) }
 
   it 'has correct attributes' do
     expect(subject).to have_attributes(
-      source_type: :dependency_file,
-      data: {
-        'input_file' => { 'name' => 'package-lock.json' },
-        'package_manager' => { 'name' => 'npm' },
-        'language' => { 'name' => 'JavaScript' }
-      },
-      fingerprint: '4ee1623c8f3ddd152b3c1fc340b3ece3cbcf807efa2726307ea34e7d6d36a6c1'
+      source_type: attributes['type'],
+      data: attributes['data'],
+      fingerprint: attributes['fingerprint']
     )
   end
 end