diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetCentralPackageManagementDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetCentralPackageManagementDetector.cs
new file mode 100644
index 000000000..c36d777fa
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetCentralPackageManagementDetector.cs
@@ -0,0 +1,151 @@
+namespace Microsoft.ComponentDetection.Detectors.NuGet;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.Internal;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.Extensions.Logging;
+
+///
+/// Detects NuGet packages in Central Package Management files (Directory.Packages.props, packages.props, package.props).
+///
+public sealed class NuGetCentralPackageManagementDetector : FileComponentDetector, IExperimentalDetector
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The factory for handing back component streams to File detectors.
+ /// The factory for creating directory walkers.
+ /// The logger to use.
+ public NuGetCentralPackageManagementDetector(
+ IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
+ IObservableDirectoryWalkerFactory walkerFactory,
+ ILogger logger)
+ {
+ this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
+ this.Scanner = walkerFactory;
+ this.Logger = logger;
+ }
+
+ ///
+ public override IList SearchPatterns => ["Directory.Packages.props", "packages.props", "package.props"];
+
+ ///
+ public override string Id => "NuGetCentralPackageManagement";
+
+ ///
+ public override IEnumerable Categories =>
+ [Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet)];
+
+ ///
+ public override IEnumerable SupportedComponentTypes => [ComponentType.NuGet];
+
+ ///
+ public override int Version => 1;
+
+ ///
+ protected override Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder;
+ var propsDocument = XDocument.Load(processRequest.ComponentStream.Stream);
+
+ // Check if this is a Central Package Management file
+ if (!this.IsCentralPackageManagementFile(propsDocument))
+ {
+ this.Logger.LogDebug("File {File} is not a Central Package Management file", processRequest.ComponentStream.Location);
+ return Task.CompletedTask;
+ }
+
+ // Parse PackageVersion elements
+ var packageVersionElements = propsDocument.Descendants("PackageVersion");
+ foreach (var packageElement in packageVersionElements)
+ {
+ var packageId = packageElement.Attribute("Include")?.Value;
+ var version = packageElement.Attribute("Version")?.Value;
+
+ if (string.IsNullOrWhiteSpace(packageId) || string.IsNullOrWhiteSpace(version))
+ {
+ this.Logger.LogDebug("Skipping PackageVersion element with missing Include or Version attribute in {File}", processRequest.ComponentStream.Location);
+ continue;
+ }
+
+ var detectedComponent = new DetectedComponent(
+ new NuGetComponent(packageId, version));
+
+ // All packages in Central Package Management files are explicitly referenced
+ // since they define the centrally managed versions
+ singleFileComponentRecorder.RegisterUsage(detectedComponent, true, null, isDevelopmentDependency: false);
+
+ this.Logger.LogDebug(
+ "Detected NuGet package {PackageId} version {Version} in Central Package Management file {File}",
+ packageId,
+ version,
+ processRequest.ComponentStream.Location);
+ }
+
+ // Parse GlobalPackageReference elements
+ var globalPackageElements = propsDocument.Descendants("GlobalPackageReference");
+ foreach (var packageElement in globalPackageElements)
+ {
+ var packageId = packageElement.Attribute("Include")?.Value;
+ var version = packageElement.Attribute("Version")?.Value;
+
+ if (string.IsNullOrWhiteSpace(packageId) || string.IsNullOrWhiteSpace(version))
+ {
+ this.Logger.LogDebug("Skipping GlobalPackageReference element with missing Include or Version attribute in {File}", processRequest.ComponentStream.Location);
+ continue;
+ }
+
+ var detectedComponent = new DetectedComponent(
+ new NuGetComponent(packageId, version));
+
+ // Global package references are explicitly referenced and typically development dependencies
+ singleFileComponentRecorder.RegisterUsage(detectedComponent, true, null, isDevelopmentDependency: true);
+
+ this.Logger.LogDebug(
+ "Detected global NuGet package {PackageId} version {Version} in Central Package Management file {File}",
+ packageId,
+ version,
+ processRequest.ComponentStream.Location);
+ }
+ }
+ catch (Exception e) when (e is XmlException)
+ {
+ this.Logger.LogWarning(e, "Failed to parse Central Package Management file {File}", processRequest.ComponentStream.Location);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Determines if the props file is a Central Package Management file by checking for the
+ /// ManagePackageVersionsCentrally property or the presence of PackageVersion/GlobalPackageReference elements.
+ ///
+ /// The props file document to check.
+ /// True if this is a Central Package Management file, false otherwise.
+ private bool IsCentralPackageManagementFile(XDocument propsDocument)
+ {
+ // Check for the ManagePackageVersionsCentrally property set to true
+ var managePackageVersionsCentrally = propsDocument.Descendants("ManagePackageVersionsCentrally")
+ .FirstOrDefault()?.Value?.Trim();
+
+ if (string.Equals(managePackageVersionsCentrally, "true", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // Check for the presence of PackageVersion or GlobalPackageReference elements
+ var hasPackageVersionElements = propsDocument.Descendants("PackageVersion").Any();
+ var hasGlobalPackageElements = propsDocument.Descendants("GlobalPackageReference").Any();
+
+ return hasPackageVersionElements || hasGlobalPackageElements;
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Configs/NuGetCentralPackageManagementDetectorExperiment.cs b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Configs/NuGetCentralPackageManagementDetectorExperiment.cs
new file mode 100644
index 000000000..3d59deeee
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Configs/NuGetCentralPackageManagementDetectorExperiment.cs
@@ -0,0 +1,22 @@
+namespace Microsoft.ComponentDetection.Orchestrator.Experiments.Configs;
+
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Detectors.NuGet;
+
+///
+/// Experiment to validate NuGetCentralPackageManagementDetector against NuGetComponentDetector.
+///
+public class NuGetCentralPackageManagementDetectorExperiment : IExperimentConfiguration
+{
+ ///
+ public string Name => "NuGetCentralPackageManagementDetectorExperiment";
+
+ ///
+ public bool IsInControlGroup(IComponentDetector componentDetector) => componentDetector is NuGetComponentDetector;
+
+ ///
+ public bool IsInExperimentGroup(IComponentDetector componentDetector) => componentDetector is NuGetCentralPackageManagementDetector;
+
+ ///
+ public bool ShouldRecord(IComponentDetector componentDetector, int numComponents) => true;
+}
diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
index 1e3806ff2..3b3778c06 100644
--- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
@@ -117,6 +117,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// PIP
services.AddSingleton();
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/NuGetCentralPackageManagementDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/NuGetCentralPackageManagementDetectorTests.cs
new file mode 100644
index 000000000..a059faf06
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/NuGetCentralPackageManagementDetectorTests.cs
@@ -0,0 +1,260 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests.NuGet;
+
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.ComponentDetection.Detectors.NuGet;
+using Microsoft.ComponentDetection.TestsUtilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[TestClass]
+public class NuGetCentralPackageManagementDetectorTests : BaseDetectorTest
+{
+ [TestMethod]
+ public async Task Should_DetectPackagesInDirectoryPackagesPropsAsync()
+ {
+ var directoryPackagesProps =
+ @"
+
+ true
+
+
+
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Directory.Packages.props", directoryPackagesProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var newtonsoftComponent = new DetectedComponent(new NuGetComponent("Newtonsoft.Json", "13.0.3"));
+ var loggingComponent = new DetectedComponent(new NuGetComponent("Microsoft.Extensions.Logging", "7.0.0"));
+ var textJsonComponent = new DetectedComponent(new NuGetComponent("System.Text.Json", "7.0.3"));
+
+ detectedComponents.Should().NotBeEmpty()
+ .And.HaveCount(3)
+ .And.ContainEquivalentOf(newtonsoftComponent)
+ .And.ContainEquivalentOf(loggingComponent)
+ .And.ContainEquivalentOf(textJsonComponent);
+
+ // Verify all components are marked as explicitly referenced
+ var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First();
+ dependencyGraph.IsComponentExplicitlyReferenced(newtonsoftComponent.Component.Id).Should().BeTrue();
+ dependencyGraph.IsComponentExplicitlyReferenced(loggingComponent.Component.Id).Should().BeTrue();
+ dependencyGraph.IsComponentExplicitlyReferenced(textJsonComponent.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task Should_DetectGlobalPackageReferencesAsync()
+ {
+ var directoryPackagesProps =
+ @"
+
+ true
+
+
+
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Directory.Packages.props", directoryPackagesProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var newtonsoftComponent = new DetectedComponent(new NuGetComponent("Newtonsoft.Json", "13.0.3"));
+ var gitVersioningComponent = new DetectedComponent(new NuGetComponent("Nerdbank.GitVersioning", "3.5.109"));
+ var analyzersComponent = new DetectedComponent(new NuGetComponent("Microsoft.CodeAnalysis.Analyzers", "3.3.4"));
+
+ detectedComponents.Should().NotBeEmpty()
+ .And.HaveCount(3)
+ .And.ContainEquivalentOf(newtonsoftComponent)
+ .And.ContainEquivalentOf(gitVersioningComponent)
+ .And.ContainEquivalentOf(analyzersComponent);
+
+ // Verify all components are marked as explicitly referenced
+ var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First();
+ dependencyGraph.IsComponentExplicitlyReferenced(newtonsoftComponent.Component.Id).Should().BeTrue();
+ dependencyGraph.IsComponentExplicitlyReferenced(gitVersioningComponent.Component.Id).Should().BeTrue();
+ dependencyGraph.IsComponentExplicitlyReferenced(analyzersComponent.Component.Id).Should().BeTrue();
+
+ // Verify global package references are marked as development dependencies
+ dependencyGraph.IsDevelopmentDependency(gitVersioningComponent.Component.Id).Should().BeTrue();
+ dependencyGraph.IsDevelopmentDependency(analyzersComponent.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task Should_DetectPackagesInPackagesPropsFileAsync()
+ {
+ var packagesProps =
+ @"
+
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("packages.props", packagesProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var autoMapperComponent = new DetectedComponent(new NuGetComponent("AutoMapper", "12.0.1"));
+ var fluentAssertionsComponent = new DetectedComponent(new NuGetComponent("FluentAssertions", "6.11.0"));
+
+ detectedComponents.Should().NotBeEmpty()
+ .And.HaveCount(2)
+ .And.ContainEquivalentOf(autoMapperComponent)
+ .And.ContainEquivalentOf(fluentAssertionsComponent);
+ }
+
+ [TestMethod]
+ public async Task Should_DetectPackagesInPackagePropsFileAsync()
+ {
+ var packageProps =
+ @"
+
+ true
+
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("package.props", packageProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var serilogComponent = new DetectedComponent(new NuGetComponent("Serilog", "3.0.1"));
+
+ detectedComponents.Should().NotBeEmpty()
+ .And.HaveCount(1)
+ .And.ContainEquivalentOf(serilogComponent);
+ }
+
+ [TestMethod]
+ public async Task Should_SkipNonCentralPackageManagementFileAsync()
+ {
+ var regularProps =
+ @"
+
+ net6.0
+ value
+
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Directory.Build.props", regularProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ detectedComponents.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task Should_SkipPackageVersionElementsWithMissingAttributesAsync()
+ {
+ var directoryPackagesProps =
+ @"
+
+ true
+
+
+
+
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Directory.Packages.props", directoryPackagesProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var validComponent = new DetectedComponent(new NuGetComponent("ValidPackage", "1.0.0"));
+
+ detectedComponents.Should().NotBeEmpty()
+ .And.HaveCount(1)
+ .And.ContainEquivalentOf(validComponent);
+ }
+
+ [TestMethod]
+ public async Task Should_HandleMalformedXmlGracefullyAsync()
+ {
+ var malformedProps =
+ @"
+
+ true
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Directory.Packages.props", malformedProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ detectedComponents.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task Should_DetectPackagesWithConditionalVersionsAsync()
+ {
+ var directoryPackagesProps =
+ @"
+
+ true
+
+
+
+
+
+
+ ";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Directory.Packages.props", directoryPackagesProps)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var conditionalComponent1 = new DetectedComponent(new NuGetComponent("ConditionalPackage", "1.0.0"));
+ var conditionalComponent2 = new DetectedComponent(new NuGetComponent("ConditionalPackage", "2.0.0"));
+ var regularComponent = new DetectedComponent(new NuGetComponent("RegularPackage", "3.0.0"));
+
+ detectedComponents.Should().NotBeEmpty()
+ .And.HaveCount(3)
+ .And.ContainEquivalentOf(conditionalComponent1)
+ .And.ContainEquivalentOf(conditionalComponent2)
+ .And.ContainEquivalentOf(regularComponent);
+ }
+}
diff --git a/test/Microsoft.ComponentDetection.Orchestrator.Tests/Experiments/NuGetCentralPackageManagementDetectorExperimentTests.cs b/test/Microsoft.ComponentDetection.Orchestrator.Tests/Experiments/NuGetCentralPackageManagementDetectorExperimentTests.cs
new file mode 100644
index 000000000..11232fbd1
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Orchestrator.Tests/Experiments/NuGetCentralPackageManagementDetectorExperimentTests.cs
@@ -0,0 +1,47 @@
+namespace Microsoft.ComponentDetection.Orchestrator.Tests.Experiments;
+
+using FluentAssertions;
+using Microsoft.ComponentDetection.Detectors.NuGet;
+using Microsoft.ComponentDetection.Orchestrator.Experiments.Configs;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[TestClass]
+public class NuGetCentralPackageManagementDetectorExperimentTests
+{
+ private readonly NuGetCentralPackageManagementDetectorExperiment experiment = new();
+
+ [TestMethod]
+ public void IsInControlGroup_ReturnsTrue_ForNuGetComponentDetector()
+ {
+ var nugetDetector = new NuGetComponentDetector(null, null, null);
+ this.experiment.IsInControlGroup(nugetDetector).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void IsInControlGroup_ReturnsFalse_ForNuGetCentralPackageManagementDetector()
+ {
+ var centralPackageDetector = new NuGetCentralPackageManagementDetector(null, null, null);
+ this.experiment.IsInControlGroup(centralPackageDetector).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void IsInExperimentGroup_ReturnsTrue_ForNuGetCentralPackageManagementDetector()
+ {
+ var centralPackageDetector = new NuGetCentralPackageManagementDetector(null, null, null);
+ this.experiment.IsInExperimentGroup(centralPackageDetector).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void IsInExperimentGroup_ReturnsFalse_ForNuGetComponentDetector()
+ {
+ var nugetDetector = new NuGetComponentDetector(null, null, null);
+ this.experiment.IsInExperimentGroup(nugetDetector).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void ShouldRecord_AlwaysReturnsTrue()
+ {
+ var nugetDetector = new NuGetComponentDetector(null, null, null);
+ this.experiment.ShouldRecord(nugetDetector, 0).Should().BeTrue();
+ }
+}
diff --git a/test/Microsoft.ComponentDetection.VerificationTests/resources/nuget/central-package-management/Directory.Packages.props b/test/Microsoft.ComponentDetection.VerificationTests/resources/nuget/central-package-management/Directory.Packages.props
new file mode 100644
index 000000000..3e24d6130
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.VerificationTests/resources/nuget/central-package-management/Directory.Packages.props
@@ -0,0 +1,10 @@
+
+
+ true
+
+
+
+
+
+
+
\ No newline at end of file