diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs index a35e006e0..b670b7ab3 100644 --- a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs @@ -5,6 +5,7 @@ namespace Microsoft.ComponentDetection.Detectors.NuGet; using System.IO; using System.Linq; using System.Reactive.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml; @@ -20,6 +21,7 @@ public class NuGetComponentDetector : FileComponentDetector private static readonly IEnumerable LowConfidencePackages = new[] { "Newtonsoft.Json" }; public const string NugetConfigFileName = "nuget.config"; + public const string NugetLockfileName = "packages.lock.json"; private readonly IList repositoryPathKeyNames = new List { "repositorypath", "globalpackagesfolder" }; @@ -37,7 +39,15 @@ public NuGetComponentDetector( public override IEnumerable Categories => new[] { Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet) }; - public override IList SearchPatterns { get; } = new List { "*.nupkg", "*.nuspec", NugetConfigFileName, "paket.lock" }; + public override IList SearchPatterns { get; } + = new List + { + "*.nupkg", + "*.nuspec", + NugetConfigFileName, + NugetLockfileName, + "paket.lock", + }; public override IEnumerable SupportedComponentTypes { get; } = new[] { ComponentType.NuGet }; @@ -105,6 +115,12 @@ private async Task ProcessFileAsync(ProcessRequest processRequest) else if ("paket.lock".Equals(stream.Pattern, StringComparison.OrdinalIgnoreCase)) { this.ParsePaketLock(processRequest); + return; + } + else if (NugetLockfileName.Equals(stream.Pattern, StringComparison.OrdinalIgnoreCase)) + { + await this.ParseNugetLockfileAsync(processRequest); + return; } else { @@ -174,6 +190,41 @@ private void ParsePaketLock(ProcessRequest processRequest) } } + private async Task ParseNugetLockfileAsync(ProcessRequest processRequest) + { + var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; + var stream = processRequest.ComponentStream; + + NuGetLockfileShape lockfile; + try + { + lockfile = await JsonSerializer.DeserializeAsync(stream.Stream).ConfigureAwait(false); + } + catch (Exception e) + { + this.Logger.LogError(e, "Error loading NuGet lockfile from {Location}", stream.Location); + singleFileComponentRecorder.RegisterPackageParseFailure(stream.Location); + return; + } + + if (lockfile.Version != 1) + { + // only version 1 is supported + this.Logger.LogError("Unsupported NuGet lockfile version {Version}", lockfile.Version); + singleFileComponentRecorder.RegisterPackageParseFailure(stream.Location); + return; + } + + foreach (var framework in lockfile.Dependencies.Values) + { + foreach (var (name, value) in framework) + { + var component = new NuGetComponent(name, value.Resolved); + singleFileComponentRecorder.RegisterUsage(new DetectedComponent(component)); + } + } + } + private IList GetRepositoryPathsFromNugetConfig(IComponentStream componentStream) { var potentialPaths = new List(); diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetLockfileShape.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetLockfileShape.cs new file mode 100644 index 000000000..06bc663e7 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetLockfileShape.cs @@ -0,0 +1,22 @@ +namespace Microsoft.ComponentDetection.Detectors.NuGet; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +internal record NuGetLockfileShape +{ + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("dependencies")] + public Dictionary> Dependencies { get; set; } = new(); + + public record PackageShape + { + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("resolved")] + public string Resolved { get; set; } + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetComponentDetectorTests.cs index 29c24ba69..0f84cb537 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetComponentDetectorTests.cs @@ -12,6 +12,7 @@ using Microsoft.ComponentDetection.Contracts.Internal; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.NuGet; +using Microsoft.ComponentDetection.Detectors.Tests.Utilities; using Microsoft.ComponentDetection.TestsUtilities; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -23,14 +24,17 @@ public class NuGetComponentDetectorTests : BaseDetectorTest { private static readonly IEnumerable DetectorSearchPattern = - new List { "*.nupkg", "*.nuspec", "nuget.config", "paket.lock" }; + new List { "*.nupkg", "*.nuspec", "nuget.config", "packages.lock.json", "paket.lock" }; - private readonly Mock> mockLogger; + private ILogger logger; - public NuGetComponentDetectorTests() + public TestContext TestContext { get; set; } + + [TestInitialize] + public void Setup() { - this.mockLogger = new Mock>(); - this.DetectorTestUtility.AddServiceMock(this.mockLogger); + this.logger = new TestLogger(this.TestContext); + this.DetectorTestUtility.AddService(this.logger); } [TestMethod] @@ -114,6 +118,57 @@ public async Task TestNugetDetector_ReturnsValidMixedComponentAsync() Assert.AreEqual(2, componentRecorder.GetDetectedComponents().Count()); } + [TestMethod] + public async Task TestNugetDetector_ReturnsPackagesLockfileAsync() + { + var lockfile = @"{ + ""version"": 1, + ""dependencies"": { + ""net7.0"": { + ""Azure.Core"": { + ""type"": ""Direct"", + ""requested"": ""[1.25.0, )"", + ""resolved"": ""1.25.0"", + ""contentHash"": ""X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA=="", + ""dependencies"": { + ""Microsoft.Bcl.AsyncInterfaces"": ""1.1.1"", + ""System.Diagnostics.DiagnosticSource"": ""4.6.0"", + ""System.Memory.Data"": ""1.0.2"", + ""System.Numerics.Vectors"": ""4.5.0"", + ""System.Text.Encodings.Web"": ""4.7.2"", + ""System.Text.Json"": ""4.7.2"", + ""System.Threading.Tasks.Extensions"": ""4.5.4"" + } + } + }, + ""net6.0"": { + ""Azure.Data.Tables"": { + ""type"": ""Direct"", + ""requested"": ""[12.5.0, )"", + ""resolved"": ""12.5.0"", + ""contentHash"": ""XeIxPf+rF1NXkX3NJSB0ZTNgU233vyPXGmaFsR0lUVibtWP/lj+Qu1FcPxoslURcX0KC+UgTb226nqVdHjoweQ=="", + ""dependencies"": { + ""Azure.Core"": ""1.22.0"", + ""System.Text.Json"": ""4.7.2"" + } + } + } + } +}"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("packages.lock.json", lockfile) + .ExecuteDetectorAsync(); + + Assert.AreEqual(ProcessingResultCode.Success, scanResult.ResultCode); + + // should be 2 components found; one per framework + var components = new HashSet(componentRecorder.GetDetectedComponents().Select(x => x.Component.Id)); + Assert.AreEqual(2, components.Count); + Assert.IsTrue(components.Contains("Azure.Core 1.25.0 - NuGet")); + Assert.IsTrue(components.Contains("Azure.Data.Tables 12.5.0 - NuGet")); + } + [TestMethod] public async Task TestNugetDetector_ReturnsValidPaketComponentAsync() { @@ -170,16 +225,9 @@ public async Task TestNugetDetector_HandlesMalformedComponentsInComponentListAsy .WithFile("test.nuspec", nuspec) .WithFile("test.nupkg", validNupkg) .WithFile("malformed.nupkg", malformedNupkg) - .AddServiceMock(this.mockLogger) + .AddService(this.logger) .ExecuteDetectorAsync(); - this.mockLogger.Verify(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - (Func)It.IsAny())); - Assert.AreEqual(ProcessingResultCode.Success, scanResult.ResultCode); Assert.AreEqual(2, componentRecorder.GetDetectedComponents().Count()); } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TestLogger.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TestLogger.cs new file mode 100644 index 000000000..eadaeb4a4 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TestLogger.cs @@ -0,0 +1,26 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests.Utilities; + +using System; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +internal class TestLogger : ILogger, IDisposable +{ + private readonly TestContext context; + + public TestLogger(TestContext context) + => this.context = context; + + public IDisposable BeginScope(TState state) + where TState : notnull + => this; + + public void Dispose() + { + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + => this.context.WriteLine($"{logLevel} ({eventId}): {formatter(state, exception)}"); +} diff --git a/test/Microsoft.ComponentDetection.TestsUtilities/DetectorTestUtilityBuilder.cs b/test/Microsoft.ComponentDetection.TestsUtilities/DetectorTestUtilityBuilder.cs index 9c3c353db..36dd12e14 100644 --- a/test/Microsoft.ComponentDetection.TestsUtilities/DetectorTestUtilityBuilder.cs +++ b/test/Microsoft.ComponentDetection.TestsUtilities/DetectorTestUtilityBuilder.cs @@ -49,6 +49,13 @@ public DetectorTestUtilityBuilder() public DetectorTestUtilityBuilder WithFile(string fileName, string fileContents, IEnumerable searchPatterns = null, string fileLocation = null) => this.WithFile(fileName, fileContents.ToStream(), searchPatterns, fileLocation); + public DetectorTestUtilityBuilder AddService(TService it) + where TService : class + { + this.serviceCollection.AddSingleton(it); + return this; + } + public DetectorTestUtilityBuilder AddServiceMock(Mock mock) where TMock : class {