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