diff --git a/Directory.Packages.props b/Directory.Packages.props index 8beeef793..adc0944eb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,9 +21,12 @@ + + + diff --git a/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj b/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj index f50084046..8398c1710 100644 --- a/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj +++ b/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj @@ -9,7 +9,10 @@ + + + @@ -33,4 +36,8 @@ + + + + diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetMSBuildBinaryLogComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetMSBuildBinaryLogComponentDetector.cs new file mode 100644 index 000000000..1f49a11be --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetMSBuildBinaryLogComponentDetector.cs @@ -0,0 +1,353 @@ +namespace Microsoft.ComponentDetection.Detectors.NuGet; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.Build.Locator; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.Internal; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.Extensions.Logging; + +using Task = System.Threading.Tasks.Task; + +public class NuGetMSBuildBinaryLogComponentDetector : FileComponentDetector +{ + private static readonly HashSet TopLevelPackageItemNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "PackageReference", + }; + + // the items listed below represent collection names that NuGet will resolve a package into, along with the metadata value names to get the package name and version + private static readonly Dictionary ResolvedPackageItemNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // regular restore operations + ["NativeCopyLocalItems"] = ("NuGetPackageId", "NuGetPackageVersion"), + ["ResourceCopyLocalItems"] = ("NuGetPackageId", "NuGetPackageVersion"), + ["RuntimeCopyLocalItems"] = ("NuGetPackageId", "NuGetPackageVersion"), + ["ResolvedAnalyzers"] = ("NuGetPackageId", "NuGetPackageVersion"), + ["_PackageDependenciesDesignTime"] = ("Name", "Version"), + + // implicitly added by the SDK during a publish operation + ["ResolvedAppHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"), + ["ResolvedSingleFileHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"), + ["ResolvedComHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"), + ["ResolvedIjwHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"), + }; + + // the items listed below represent top-level property names that correspond to well-known components + private static readonly Dictionary ComponentPropertyNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["NETCoreSdkVersion"] = ".NET SDK", + }; + + private static readonly object MSBuildRegistrationGate = new(); + + public NuGetMSBuildBinaryLogComponentDetector( + IObservableDirectoryWalkerFactory walkerFactory, + ILogger logger) + { + this.Scanner = walkerFactory; + this.Logger = logger; + } + + public override string Id { get; } = "NuGetMSBuildBinaryLog"; + + public override IEnumerable Categories => new[] { Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet) }; + + public override IList SearchPatterns { get; } = new List { "*.binlog", "*.buildlog" }; + + public override IEnumerable SupportedComponentTypes { get; } = new[] { ComponentType.NuGet }; + + public override int Version { get; } = 1; + + private static void ProcessResolvedComponentReference(ProjectPathToTopLevelComponents topLevelComponents, ProjectPathToComponents projectResolvedComponents, NamedNode node) + { + var doRemoveOperation = node is RemoveItem; + var doAddOperation = node is AddItem; + if (TopLevelPackageItemNames.Contains(node.Name)) + { + var projectEvaluation = node.GetNearestParent(); + if (projectEvaluation is not null) + { + foreach (var child in node.Children.OfType()) + { + var componentName = child.Name; + var topLevel = topLevelComponents.GetComponentNames(projectEvaluation.ProjectFile); + + if (doRemoveOperation) + { + topLevel.Remove(componentName); + } + + if (doAddOperation) + { + topLevel.Add(componentName); + } + } + } + } + else if (ResolvedPackageItemNames.TryGetValue(node.Name, out var metadataNames)) + { + var nameMetadata = metadataNames.NameMetadata; + var versionMetadata = metadataNames.VersionMetadata; + var originalProject = node.GetNearestParent(); + if (originalProject is not null) + { + foreach (var child in node.Children.OfType()) + { + var componentName = GetChildMetadataValue(child, nameMetadata); + var componentVersion = GetChildMetadataValue(child, versionMetadata); + if (componentName is not null && componentVersion is not null) + { + var project = originalProject; + while (project is not null) + { + var components = projectResolvedComponents.GetComponents(project.ProjectFile); + var evaluatedVersions = components.GetEvaluatedVersions(componentName); + var componentVersions = evaluatedVersions.GetComponentVersions(project.EvaluationId); + + if (doRemoveOperation) + { + componentVersions.Remove(componentVersion); + } + + if (doAddOperation) + { + componentVersions.Add(componentVersion); + } + + project = project.GetNearestParent(); + } + } + } + } + } + } + + private static void ProcessProjectProperty(ProjectPathToComponents projectResolvedComponents, Property node) + { + if (ComponentPropertyNames.TryGetValue(node.Name, out var packageName)) + { + string projectFile; + int evaluationId; + var projectEvaluation = node.GetNearestParent(); + if (projectEvaluation is not null) + { + // `.binlog` files store properties in a `ProjectEvaluation` + projectFile = projectEvaluation.ProjectFile; + evaluationId = projectEvaluation.Id; + } + else + { + // `.buildlog` files store proeprties in `Project` + var project = node.GetNearestParent(); + projectFile = project?.ProjectFile; + evaluationId = project?.EvaluationId ?? 0; + } + + if (projectFile is not null) + { + var componentVersion = node.Value; + var components = projectResolvedComponents.GetComponents(projectFile); + var evaluatedVersions = components.GetEvaluatedVersions(packageName); + var componentVersions = evaluatedVersions.GetComponentVersions(evaluationId); + + componentVersions.Add(componentVersion); + } + } + } + + private static string GetChildMetadataValue(TreeNode node, string metadataItemName) + { + var metadata = node.Children.OfType(); + var metadataValue = metadata.FirstOrDefault(m => m.Name.Equals(metadataItemName, StringComparison.OrdinalIgnoreCase))?.Value; + return metadataValue; + } + + protected override Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) + { + try + { + EnsureMSBuildIsRegistered(); + + var singleFileComponentRecorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(processRequest.ComponentStream.Location); + var extension = Path.GetExtension(processRequest.ComponentStream.Location); + var buildRoot = extension.ToLower() switch + { + ".binlog" => BinaryLog.ReadBuild(processRequest.ComponentStream.Stream), + ".buildlog" => BuildLogReader.Read(processRequest.ComponentStream.Stream), + _ => throw new NotSupportedException($"Unexpected log file extension: {extension}"), + }; + this.RecordLockfileVersion(buildRoot.FileFormatVersion); + this.ProcessBinLog(buildRoot, singleFileComponentRecorder); + } + catch (Exception e) + { + // If something went wrong, just ignore the package + this.Logger.LogError(e, "Failed to process MSBuild binary log {BinLogFile}", processRequest.ComponentStream.Location); + } + + return Task.CompletedTask; + } + + internal static void EnsureMSBuildIsRegistered() + { + lock (MSBuildRegistrationGate) + { + if (!MSBuildLocator.IsRegistered) + { + // this must happen once per process, and never again + var defaultInstance = MSBuildLocator.QueryVisualStudioInstances().First(); + MSBuildLocator.RegisterInstance(defaultInstance); + } + } + } + + protected override Task OnDetectionFinishedAsync() + { + return Task.CompletedTask; + } + + private void ProcessBinLog(Build buildRoot, ISingleFileComponentRecorder componentRecorder) + { + // maps a project path to a set of resolved dependencies + var projectTopLevelComponents = new ProjectPathToTopLevelComponents(); + var projectResolvedComponents = new ProjectPathToComponents(); + buildRoot.VisitAllChildren(node => + { + switch (node) + { + case NamedNode namedNode when namedNode is AddItem or RemoveItem: + ProcessResolvedComponentReference(projectTopLevelComponents, projectResolvedComponents, namedNode); + break; + case Property property when property.Parent is Folder folder && folder.Name == "Properties": + ProcessProjectProperty(projectResolvedComponents, property); + break; + default: + break; + } + }); + + // dependencies were resolved per project, we need to re-arrange them to be per package/version + var projectsPerComponent = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var projectPath in projectResolvedComponents.Keys.OrderBy(p => p)) + { + if (Path.GetExtension(projectPath).Equals(".sln", StringComparison.OrdinalIgnoreCase)) + { + // don't report solution files + continue; + } + + var projectComponents = projectResolvedComponents[projectPath]; + foreach (var (componentName, componentVersionsPerEvaluationid) in projectComponents.OrderBy(p => p.Key)) + { + foreach (var componentVersions in componentVersionsPerEvaluationid.OrderBy(p => p.Key).Select(kvp => kvp.Value)) + { + foreach (var componentVersion in componentVersions.OrderBy(v => v)) + { + var key = $"{componentName}/{componentVersion}"; + if (!projectsPerComponent.TryGetValue(key, out var projectPaths)) + { + projectPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + projectsPerComponent[key] = projectPaths; + } + + projectPaths.Add(projectPath); + } + } + } + } + + // report it all + foreach (var componentNameAndVersion in projectsPerComponent.Keys.OrderBy(p => p)) + { + var projectPaths = projectsPerComponent[componentNameAndVersion]; + var parts = componentNameAndVersion.Split('/', 2); + var componentName = parts[0]; + var componentVersion = parts[1]; + var component = new NuGetComponent(componentName, componentVersion); + var libraryComponent = new DetectedComponent(component); + foreach (var projectPath in projectPaths) + { + libraryComponent.FilePaths.Add(projectPath); + } + + componentRecorder.RegisterUsage(libraryComponent); + } + } + + // To make the above code easier to read, some helper types are added here. Without these, the code above would contain a type of: + // Dictionary>>> + // which isn't very descriptive. + private abstract class KeyedCollection : Dictionary + where TKey : notnull + { + protected KeyedCollection() + : base() + { + } + + protected KeyedCollection(IEqualityComparer comparer) + : base(comparer) + { + } + + protected TValue GetOrAdd(TKey key, Func valueFactory) + { + if (!this.TryGetValue(key, out var value)) + { + value = valueFactory(); + this[key] = value; + } + + return value; + } + } + + // Represents a collection of top-level components for a given project path. + private class ProjectPathToTopLevelComponents : KeyedCollection> + { + public HashSet GetComponentNames(string projectPath) => this.GetOrAdd(projectPath, () => new(StringComparer.OrdinalIgnoreCase)); + } + + // Represents a collection of evaluated components for a given project path. + private class ProjectPathToComponents : KeyedCollection + { + public ProjectPathToComponents() + : base(StringComparer.OrdinalIgnoreCase) + { + } + + public ComponentNameToEvaluatedVersions GetComponents(string projectPath) => this.GetOrAdd(projectPath, () => new()); + } + + // Represents a collection of evaluated components for a given component name. + private class ComponentNameToEvaluatedVersions : KeyedCollection + { + public ComponentNameToEvaluatedVersions() + : base(StringComparer.OrdinalIgnoreCase) + { + } + + public EvaluationIdToComponentVersions GetEvaluatedVersions(string componentName) => this.GetOrAdd(componentName, () => new()); + } + + // Represents a collection of component versions for a given evaluation id. + private class EvaluationIdToComponentVersions : KeyedCollection + { + public ComponentVersions GetComponentVersions(int evaluationId) => this.GetOrAdd(evaluationId, () => new()); + } + + // Represents a collection of version strings. + private class ComponentVersions : HashSet + { + public ComponentVersions() + : base(StringComparer.OrdinalIgnoreCase) + { + } + } +} diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index 41d16ccc5..4da100fe3 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -105,6 +105,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/MSBuildTestUtilities.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/MSBuildTestUtilities.cs new file mode 100644 index 000000000..2ca0553ac --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/MSBuildTestUtilities.cs @@ -0,0 +1,338 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using System.Xml.XPath; +using FluentAssertions; + +public static class MSBuildTestUtilities +{ + public const int TestTargetFrameworkVersion = 6; + public static readonly string TestTargetFramework = $"net{TestTargetFrameworkVersion}.0"; + + // we need to find the file `Microsoft.NETCoreSdk.BundledVersions.props` in the SDK directory + private static readonly Lazy BundledVersionsPropsPath = new(static () => + { + // get the sdk version + using var tempDir = new TemporaryProjectDirectory(); + var projectContents = @" + + + + + + "; + var projectPath = Path.Combine(tempDir.DirectoryPath, "project.csproj"); + File.WriteAllText(projectPath, projectContents); + var (exitCode, stdout, stderr) = RunProcessAsync("dotnet", $"msbuild {projectPath} /t:_ReportCurrentSdkVersion").Result; + if (exitCode != 0) + { + throw new NotSupportedException($"Failed to report the current SDK version:\n{stdout}\n{stderr}"); + } + + var matches = Regex.Matches(stdout, "_CurrentSdkVersion=(?.*)$", RegexOptions.Multiline); + if (matches.Count == 0) + { + throw new NotSupportedException($"Failed to find the current SDK version in the output:\n{stdout}"); + } + + var sdkVersionString = matches.First().Groups["SdkVersion"].Value.Trim(); + + // find the actual SDK directory + var privateCoreLibPath = typeof(object).Assembly.Location; // e.g., C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.4\System.Private.CoreLib.dll + var sdkDirectory = Path.Combine(Path.GetDirectoryName(privateCoreLibPath), "..", "..", "..", "sdk", sdkVersionString); // e.g., C:\Program Files\dotnet\sdk\8.0.204 + var bundledVersionsPropsPath = Path.Combine(sdkDirectory, "Microsoft.NETCoreSdk.BundledVersions.props"); + var normalizedPath = new FileInfo(bundledVersionsPropsPath); + return normalizedPath.FullName; + }); + + public static async Task GetBinLogStreamFromFileContentsAsync( + string defaultFilePath, + string defaultFileContents, + string targetName = null, + (string FileName, string Contents)[] additionalFiles = null, + (string Name, string Version, string TargetFramework, string AdditionalMetadataXml)[] mockedPackages = null) + { + // write all files + using var tempDir = new TemporaryProjectDirectory(); + var fullDefaultFilePath = Path.Combine(tempDir.DirectoryPath, defaultFilePath); + await File.WriteAllTextAsync(fullDefaultFilePath, defaultFileContents); + if (additionalFiles is not null) + { + foreach (var (fileName, contents) in additionalFiles) + { + var fullFilePath = Path.Combine(tempDir.DirectoryPath, fileName); + var fullFileDirectory = Path.GetDirectoryName(fullFilePath); + Directory.CreateDirectory(fullFileDirectory); + await File.WriteAllTextAsync(fullFilePath, contents); + } + } + + await MockNuGetPackagesInDirectoryAsync(tempDir, mockedPackages); + + // generate the binlog + targetName ??= "GenerateBuildDependencyFile"; + var (exitCode, stdOut, stdErr) = await RunProcessAsync("dotnet", $"build \"{fullDefaultFilePath}\" /t:{targetName} /bl:msbuild.binlog", workingDirectory: tempDir.DirectoryPath); + exitCode.Should().Be(0, $"STDOUT:\n{stdOut}\n\nSTDERR:\n{stdErr}"); + + // copy it to memory so the temporary directory can be cleaned up + var fullBinLogPath = Path.Combine(tempDir.DirectoryPath, "msbuild.binlog"); + using var binLogStream = File.OpenRead(fullBinLogPath); + var inMemoryStream = new MemoryStream(); + await binLogStream.CopyToAsync(inMemoryStream); + inMemoryStream.Position = 0; + return inMemoryStream; + } + + private static async Task MockNuGetPackagesInDirectoryAsync( + TemporaryProjectDirectory tempDir, + (string Name, string Version, string TargetFramework, string AdditionalMetadataXml)[] mockedPackages) + { + if (mockedPackages is not null) + { + var nugetConfig = @" + + + + + + + "; + await File.WriteAllTextAsync(Path.Combine(tempDir.DirectoryPath, "NuGet.Config"), nugetConfig); + var packagesPath = Path.Combine(tempDir.DirectoryPath, "local-packages"); + Directory.CreateDirectory(packagesPath); + + var mockedPackagesWithFiles = mockedPackages.Select(p => + { + return ( + p.Name, + p.Version, + p.TargetFramework, + p.AdditionalMetadataXml, + Files: new[] { ($"lib/{p.TargetFramework}/{p.Name}.dll", Array.Empty()) }); + }); + + var allPackages = mockedPackagesWithFiles.Concat(GetCommonPackages()); + + using var sha512 = SHA512.Create(); // this is used to compute the hash of each package below + foreach (var package in allPackages) + { + var nuspec = NugetTestUtilities.GetValidNuspec(package.Name, package.Version, Array.Empty()); + if (package.AdditionalMetadataXml is not null) + { + // augment the nuspec + var doc = XDocument.Parse(nuspec); + var additionalMetadata = XElement.Parse(package.AdditionalMetadataXml); + additionalMetadata = WithNamespace(additionalMetadata, doc.Root.Name.Namespace); + + var metadataElement = doc.Root.Descendants().First(e => e.Name.LocalName == "metadata"); + metadataElement.Add(additionalMetadata); + nuspec = doc.ToString(); + } + + var nupkg = await NugetTestUtilities.ZipNupkgComponentAsync(package.Name, nuspec, additionalFiles: package.Files); + + // to create a local nuget package source, we need a directory structure like this: + // local-packages/// + var packagePath = Path.Combine(packagesPath, package.Name.ToLower(), package.Version.ToLower()); + Directory.CreateDirectory(packagePath); + + // and we need the following files: + // 1. the package + var nupkgPath = Path.Combine(packagePath, $"{package.Name}.{package.Version}.nupkg".ToLower()); + using (var nupkgFileStream = File.OpenWrite(nupkgPath)) + { + await nupkg.CopyToAsync(nupkgFileStream); + } + + // 2. the nuspec + var nuspecPath = Path.Combine(packagePath, $"{package.Name}.nuspec".ToLower()); + await File.WriteAllTextAsync(nuspecPath, nuspec); + + // 3. SHA512 hash of the package + var hash = sha512.ComputeHash(File.ReadAllBytes(nupkgPath)); + var hashString = Convert.ToBase64String(hash); + var hashPath = $"{nupkgPath}.sha512"; + await File.WriteAllTextAsync(hashPath, hashString); + + // 4. a JSON metadata file + var metadata = $@"{{""version"": 2, ""contentHash"": ""{hashString}"", ""source"": null}}"; + var metadataPath = Path.Combine(packagePath, ".nupkg.metadata"); + await File.WriteAllTextAsync(metadataPath, metadata); + } + } + } + + private static XElement WithNamespace(XElement element, XNamespace ns) + { + return new XElement( + ns + element.Name.LocalName, + element.Attributes(), + element.Elements().Select(e => WithNamespace(e, ns)), + element.Value); + } + + private static IEnumerable<(string Name, string Version, string TargetFramework, string AdditionalMetadataXml, (string Path, byte[] Content)[] Files)> GetCommonPackages() + { + // to allow the tests to not require the network, we need to mock some common packages + yield return MakeWellKnownReferencePackage("Microsoft.AspNetCore.App", null); + yield return MakeWellKnownReferencePackage("Microsoft.WindowsDesktop.App", null); + + var frameworksXml = $@" + + + "; + yield return MakeWellKnownReferencePackage("Microsoft.NETCore.App", new[] { ("data/FrameworkList.xml", Encoding.UTF8.GetBytes(frameworksXml)) }); + } + + private static (string Name, string Version, string TargetFramework, string AdditionalMetadataXml, (string Path, byte[] Content)[] Files) MakeWellKnownReferencePackage(string packageName, (string Path, byte[] Content)[] files) + { + var propsDocument = XDocument.Load(BundledVersionsPropsPath.Value); + var xpathQuery = $@" + /Project/ItemGroup/KnownFrameworkReference + [ + @Include='{packageName}' and + @TargetingPackName='{packageName}.Ref' and + @TargetFramework='{TestTargetFramework}' + ] + "; + var matchingFrameworkElement = propsDocument.XPathSelectElement(xpathQuery); + if (matchingFrameworkElement is null) + { + throw new NotSupportedException($"Unable to find {packageName}.Ref"); + } + + var expectedVersion = matchingFrameworkElement.Attribute("TargetingPackVersion").Value; + return ( + $"{packageName}.Ref", + expectedVersion, + TestTargetFramework, + "", + files); + } + + public static Task<(int ExitCode, string Output, string Error)> RunProcessAsync(string fileName, string arguments = "", string workingDirectory = null) + { + var tcs = new TaskCompletionSource<(int, string, string)>(); + + var redirectInitiated = new ManualResetEventSlim(); + var process = new Process + { + StartInfo = + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, // required to redirect output + RedirectStandardOutput = true, + RedirectStandardError = true, + }, + EnableRaisingEvents = true, + }; + + if (workingDirectory is not null) + { + process.StartInfo.WorkingDirectory = workingDirectory; + } + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + process.OutputDataReceived += (_, e) => stdout.AppendLine(e.Data); + process.ErrorDataReceived += (_, e) => stderr.AppendLine(e.Data); + + process.Exited += (sender, args) => + { + // It is necessary to wait until we have invoked 'BeginXReadLine' for our redirected IO. Then, + // we must call WaitForExit to make sure we've received all OutputDataReceived/ErrorDataReceived calls + // or else we'll be returning a list we're still modifying. For paranoia, we'll start a task here rather + // than enter right back into the Process type and start a wait which isn't guaranteed to be safe. + var unused = Task.Run(() => + { + redirectInitiated.Wait(); + redirectInitiated.Dispose(); + redirectInitiated = null; + + process.WaitForExit(); + + tcs.TrySetResult((process.ExitCode, stdout.ToString(), stderr.ToString())); + process.Dispose(); + }); + }; + + if (!process.Start()) + { + throw new InvalidOperationException("Process failed to start"); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + redirectInitiated.Set(); + + return tcs.Task; + } + + private class TemporaryProjectDirectory : IDisposable + { + private const string DirectoryBuildPropsContents = @" + + + false + + + "; + + private readonly Dictionary originalEnvironment = new(); + + public TemporaryProjectDirectory() + { + var testDataPath = Path.Combine(Path.GetDirectoryName(this.GetType().Assembly.Location), "test-data"); + Directory.CreateDirectory(testDataPath); + + // ensure tests don't crawl the directory tree + File.WriteAllText(Path.Combine(testDataPath, "Directory.Build.props"), DirectoryBuildPropsContents); + File.WriteAllText(Path.Combine(testDataPath, "Directory.Build.targets"), ""); + File.WriteAllText(Path.Combine(testDataPath, "Directory.Packages.props"), ""); + + // create temporary project directory + this.DirectoryPath = Path.Combine(testDataPath, Guid.NewGuid().ToString("d")); + Directory.CreateDirectory(this.DirectoryPath); + + // ensure each project gets a fresh package cache + foreach (var envName in new[] { "NUGET_PACKAGES", "NUGET_HTTP_CACHE_PATH", "NUGET_SCRATCH", "NUGET_PLUGINS_CACHE_PATH" }) + { + this.originalEnvironment[envName] = Environment.GetEnvironmentVariable(envName); + var dir = Path.Join(this.DirectoryPath, envName); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable(envName, dir); + } + } + + public string DirectoryPath { get; } + + public void Dispose() + { + foreach (var (key, value) in this.originalEnvironment) + { + Environment.SetEnvironmentVariable(key, value); + } + + try + { + Directory.Delete(this.DirectoryPath, recursive: true); + } + catch + { + } + } + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj b/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj index efcd77335..470a9f9af 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj @@ -7,7 +7,10 @@ + + + diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetMSBuildBinaryLogComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetMSBuildBinaryLogComponentDetectorTests.cs new file mode 100644 index 000000000..00696f9f4 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetMSBuildBinaryLogComponentDetectorTests.cs @@ -0,0 +1,604 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.NuGet; +using Microsoft.ComponentDetection.Detectors.Tests.Utilities; +using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using MSBuildTask = Microsoft.Build.Logging.StructuredLogger.Task; +using Task = System.Threading.Tasks.Task; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class NuGetMSBuildBinaryLogComponentDetectorTests : BaseDetectorTest +{ + static NuGetMSBuildBinaryLogComponentDetectorTests() => NuGetMSBuildBinaryLogComponentDetector.EnsureMSBuildIsRegistered(); + + [TestMethod] + public async Task DependenciesAreReportedForEachProjectFile() + { + // the contents of `projectContents` are the root entrypoint to the detector, but MSBuild will crawl to the other project file + var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync( + projectContents: $@" + + + {MSBuildTestUtilities.TestTargetFramework} + + + + + + ", + additionalFiles: new[] + { + ("other-project/other-project.csproj", $@" + + + {MSBuildTestUtilities.TestTargetFramework} + + + + + + "), + }, + mockedPackages: new[] + { + ("Some.Package", "1.2.3", MSBuildTestUtilities.TestTargetFramework, ""), + ("Transitive.Dependency", "4.5.6", MSBuildTestUtilities.TestTargetFramework, null), + }); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // components are reported for each project file + var originalFileComponents = detectedComponents + .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project.csproj"))) + .Select(d => d.Component) + .Cast() + .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + originalFileComponents.Should().Equal("Some.Package/1.2.3", "Transitive.Dependency/4.5.6"); + + var referencedFileComponents = detectedComponents + .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/other-project/other-project.csproj"))) + .Select(d => d.Component) + .Cast() + .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + referencedFileComponents.Should().Equal("Some.Package/1.2.3", "Transitive.Dependency/4.5.6"); + } + + [TestMethod] + public async Task RemovedPackagesAreNotReported() + { + // This is a very specific scenario that should be tested, but we don't want any changes to this repo's SDK to + // change the outcome of the test, so we're doing it manually. The scenario is the SDK knowingly replacing an + // assembly from an outdated transitive package. One example common in the wild is the package + // `Microsoft.Extensions.Configuration.Json/6.0.0` which contains a transitive dependency on + // `System.Text.Json/6.0.0`, but the SDK version 6.0.424 or later pulls this reference out of the dependency + // set and replaces the .dll with a local updated copy. The end result is the `package.assets.json` file + // reports that `System.Text.Json/6.0.0` is referenced by a project, but after build and at runtime, this isn't + // the case and can lead to false positives when reporting dependencies. The way the SDK accomplishes this is + // by removing `System.Text.Json.dll` from the group `@(RuntimeCopyLocalItems)`. To accomplish this in the + // test, we're inserting a custom target that does this same action. + var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync( + projectContents: $@" + + + {MSBuildTestUtilities.TestTargetFramework} + + + + + + + + + + + + ", + mockedPackages: new[] + { + ("Some.Package", "1.2.3", MSBuildTestUtilities.TestTargetFramework, ""), + ("Transitive.Dependency", "4.5.6", MSBuildTestUtilities.TestTargetFramework, null), + }); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + + var packages = detectedComponents + .Select(d => d.Component) + .Cast() + .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + packages.Should().Equal("Some.Package/1.2.3"); + } + + [TestMethod] + public async Task PackagesReportedFromSeparateProjectsDoNotOverlap() + { + // In this test, a top-level solution file references two projects which have no relationship between them. + // The result should be that each project only reports its own dependencies. + var slnContents = @" +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 17 +VisualStudioVersion = 17.0.31808.319 +MinimumVisualStudioVersion = 15.0.26124.0 +Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""project1"", ""project1\project1.csproj"", ""{782E0C0A-10D3-444D-9640-263D03D2B20C}"" +EndProject +Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""project2"", ""project2\project2.csproj"", ""{CBA73BF8-C922-4DD7-A41D-88CD22914356}"" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {782E0C0A-10D3-444D-9640-263D03D2B20C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {782E0C0A-10D3-444D-9640-263D03D2B20C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {782E0C0A-10D3-444D-9640-263D03D2B20C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {782E0C0A-10D3-444D-9640-263D03D2B20C}.Release|Any CPU.Build.0 = Release|Any CPU + {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal +"; + using var binLogStream = await MSBuildTestUtilities.GetBinLogStreamFromFileContentsAsync( + defaultFilePath: "solution.sln", + defaultFileContents: slnContents, + additionalFiles: new[] + { + ("project1/project1.csproj", $@" + + + {MSBuildTestUtilities.TestTargetFramework} + + + + + + "), + ("project2/project2.csproj", $@" + + + {MSBuildTestUtilities.TestTargetFramework} + + + + + + "), + }, + mockedPackages: new[] + { + ("Package.A", "1.2.3", MSBuildTestUtilities.TestTargetFramework, ""), + ("Package.B", "4.5.6", MSBuildTestUtilities.TestTargetFramework, ""), + }); + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("msbuild.binlog", binLogStream) + .ExecuteDetectorAsync(); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + + var project1Components = detectedComponents + .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project1.csproj"))) + .Select(d => d.Component) + .Cast() + .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + project1Components.Should().Equal("Package.A/1.2.3"); + + var project2Components = detectedComponents + .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project2.csproj"))) + .Select(d => d.Component) + .Cast() + .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + project2Components.Should().Equal("Package.B/4.5.6"); + + var solutionComponents = detectedComponents + .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/solution.sln"))) + .Select(d => d.Component) + .Cast() + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + solutionComponents.Should().BeEmpty(); + } + + [TestMethod] + public async Task PackagesAreReportedWhenConditionedOnTargetFramework() + { + // This test simulates a project with multiple TFMs where the same package is imported in each, but with a + // different version. To avoid building the entire project, we can fake what MSBuild does by resolving + // packages with each TFM. I manually verified that a "real" invocation of MSBuild with the "Build" target + // produces the same shape of the binlog as this test generates. + + // The end result is that _all_ packages are reported, regardless of the TFM invokation they came from, and in + // this case that is good, because we really only care about what packages were used in the build and what + // project file they came from. + var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync( + projectContents: $@" + + + netstandard2.0;{MSBuildTestUtilities.TestTargetFramework} + + + + + + + + + + + ", + targetName: "TEST_GenerateBuildDependencyFileForTargetFrameworks", + mockedPackages: new[] + { + ("NETStandard.Library", "2.0.3", "netstandard2.0", ""), + ("Some.Package", "1.2.3", "netstandard2.0", null), + ("Some.Package", "4.5.6", MSBuildTestUtilities.TestTargetFramework, null), + }); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + + var packages = detectedComponents + .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project.csproj"))) + .Select(d => d.Component) + .Cast() + .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly + .OrderBy(c => c.Name).ThenBy(c => c.Version) + .Select(c => $"{c.Name}/{c.Version}"); + packages.Should().Equal("NETStandard.Library/2.0.3", "Some.Package/1.2.3", "Some.Package/4.5.6"); + } + + [TestMethod] + public async Task PackagesAreReportedWhenConflictResolutionRemovesPackages() + { + // This comes from a real-world scenario where the project file looks like this: + // + // + // + // net472;net8.0 + // + // + // + // + // + // + // During a build, the project is evantually evaluated twice: once for `net472` and once for `net8.0`. In the + // `net472` scenario, `System.Text.Json/6.0.0` is added to the output, but in the `net8.0` scenario, that + // package is added but then removed by the conflict resolution logic in the SDK. We need to ensure that the + // `RemoveItem` from the `net8.0` evaluation doesn't affect the `net472` evaluation. The end result is that we + // _should_ report the package because it was used in at least one project evaluation. + // + // To make this easy to test, custom targets have been added to the test project below that do exactly what the + // SDK does, just without an expernsive build invocation; remove an item from the item group, but only for one + // of the target frameworks. The end result is a binary log that mimics the shape of the real-world example + // given above. + var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync( + projectContents: $@" + + + netstandard2.0;{MSBuildTestUtilities.TestTargetFramework} + + + + + + + + + + + + + + + ", + targetName: "TEST_GenerateBuildDependencyFileForTargetFrameworks", + mockedPackages: new[] + { + ("NETStandard.Library", "2.0.3", "netstandard2.0", ""), + ("Some.Package", "1.2.3", "netstandard2.0", null), + }); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + + var packages = detectedComponents + .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project.csproj"))) + .Select(d => d.Component) + .Cast() + .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly + .OrderBy(c => c.Name).ThenBy(c => c.Version) + .Select(c => $"{c.Name}/{c.Version}"); + packages.Should().Equal("NETStandard.Library/2.0.3", "Some.Package/1.2.3"); + } + + [TestMethod] + public async Task PackagesImplicitlyAddedBySdkDuringPublishAreAdded() + { + // When a project is published, the SDK will add references to some AppHost specific packages. Doing an actual + // publish operation here would be too slow, so a mock in-memory binary log is used that has the same shape + // (although trimmed down) of a real publish log. + var binlog = new Build() + { + Succeeded = true, + Children = + { + new Project() + { + ProjectFile = "project.csproj", + Children = + { + new Target() + { + Name = "ResolveFrameworkReferences", + Children = + { + // ResolvedAppHostPack + new MSBuildTask() + { + Name = "GetPackageDirectory", + Children = + { + new Folder() + { + Name = "OutputItems", + Children = + { + new AddItem() + { + Name = "ResolvedAppHostPack", + Children = + { + new Item() + { + Name = "AppHost", + Children = + { + new Metadata() + { + Name = "NuGetPackageId", + Value = "Microsoft.NETCore.App.Host.win-x64", + }, + new Metadata() + { + Name = "NuGetPackageVersion", + Value = "6.0.33", + }, + }, + }, + }, + }, + }, + }, + }, + }, + + // ResolvedSingleFileHostPack + new MSBuildTask() + { + Name = "GetPackageDirectory", + Children = + { + new Folder() + { + Name = "OutputItems", + Children = + { + new AddItem() + { + Name = "ResolvedSingleFileHostPack", + Children = + { + new Item() + { + Name = "SingleFileHost", + Children = + { + new Metadata() + { + Name = "NuGetPackageId", + Value = "Microsoft.NETCore.App.Host.win-x64", + }, + new Metadata() + { + Name = "NuGetPackageVersion", + Value = "6.0.33", + }, + }, + }, + }, + }, + }, + }, + }, + }, + + // ResolvedComHostPack + new MSBuildTask() + { + Name = "GetPackageDirectory", + Children = + { + new Folder() + { + Name = "OutputItems", + Children = + { + new AddItem() + { + Name = "ResolvedComHostPack", + Children = + { + new Item() + { + Name = "ComHost", + Children = + { + new Metadata() + { + Name = "NuGetPackageId", + Value = "Microsoft.NETCore.App.Host.win-x64", + }, + new Metadata() + { + Name = "NuGetPackageVersion", + Value = "6.0.33", + }, + }, + }, + }, + }, + }, + }, + }, + }, + + // ResolvedIjwHostPack + new MSBuildTask() + { + Name = "GetPackageDirectory", + Children = + { + new Folder() + { + Name = "OutputItems", + Children = + { + new AddItem() + { + Name = "ResolvedIjwHostPack", + Children = + { + new Item() + { + Name = "IjwHost", + Children = + { + new Metadata() + { + Name = "NuGetPackageId", + Value = "Microsoft.NETCore.App.Host.win-x64", + }, + new Metadata() + { + Name = "NuGetPackageVersion", + Value = "6.0.33", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + // in-memory logs need to be `.buildlog` + using var tempFile = new TemporaryFile(".buildlog"); + Serialization.Write(binlog, tempFile.FilePath); + using var binLogStream = File.OpenRead(tempFile.FilePath); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(tempFile.FilePath, binLogStream) + .ExecuteDetectorAsync(); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + var components = detectedComponents + .Select(d => d.Component) + .Cast() + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + components.Should().Equal("Microsoft.NETCore.App.Host.win-x64/6.0.33"); + } + + [TestMethod] + public async Task DotNetSDKVersionIsReported() + { + var binlog = new Build() + { + Succeeded = true, + }; + + binlog.EvaluationFolder.Children.Add( + new Project() + { + ProjectFile = "project.csproj", + Children = + { + new Folder() + { + Name = "Properties", + Children = + { + new Property() + { + Name = "NETCoreSdkVersion", + Value = "6.0.789", + }, + }, + }, + }, + }); + + // in-memory logs need to be `.buildlog` + using var tempFile = new TemporaryFile(".buildlog"); + Serialization.Write(binlog, tempFile.FilePath); + using var binLogStream = File.OpenRead(tempFile.FilePath); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(tempFile.FilePath, binLogStream) + .ExecuteDetectorAsync(); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + var components = detectedComponents + .Select(d => d.Component) + .Cast() + .OrderBy(c => c.Name) + .Select(c => $"{c.Name}/{c.Version}"); + components.Should().Equal(".NET SDK/6.0.789"); + } + + private async Task<(IndividualDetectorScanResult ScanResult, IComponentRecorder ComponentRecorder)> ExecuteDetectorAndGetBinLogAsync( + string projectContents, + string targetName = null, + (string FileName, string Content)[] additionalFiles = null, + (string Name, string Version, string TargetFramework, string DependenciesXml)[] mockedPackages = null) + { + using var binLogStream = await MSBuildTestUtilities.GetBinLogStreamFromFileContentsAsync("project.csproj", projectContents, targetName: targetName, additionalFiles: additionalFiles, mockedPackages: mockedPackages); + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("msbuild.binlog", binLogStream) + .ExecuteDetectorAsync(); + return (scanResult, componentRecorder); + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs index 37fa45ce4..ea576713c 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs @@ -3,6 +3,7 @@ using System.IO; using System.IO.Compression; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.TestsUtilities; @@ -60,7 +61,7 @@ public static string GetValidNuspec(string componentName, string version, string return GetTemplatedNuspec(componentName, version, authors); } - public static async Task ZipNupkgComponentAsync(string filename, string content) + public static async Task ZipNupkgComponentAsync(string filename, string content, (string Path, byte[] Contents)[] additionalFiles = null) { var stream = new MemoryStream(); @@ -68,10 +69,21 @@ public static async Task ZipNupkgComponentAsync(string filename, string { var entry = archive.CreateEntry($"{filename}.nuspec"); - using var entryStream = entry.Open(); - - var templateBytes = Encoding.UTF8.GetBytes(content); - await entryStream.WriteAsync(templateBytes); + using (var entryStream = entry.Open()) + { + var templateBytes = Encoding.UTF8.GetBytes(content); + await entryStream.WriteAsync(templateBytes); + } + + if (additionalFiles is not null) + { + foreach (var file in additionalFiles) + { + var additionalEntry = archive.CreateEntry(file.Path); + using var additionalEntryStream = additionalEntry.Open(); + await additionalEntryStream.WriteAsync(file.Contents, CancellationToken.None); + } + } } stream.Seek(0, SeekOrigin.Begin); diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TemporaryFile.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TemporaryFile.cs new file mode 100644 index 000000000..f19776cba --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TemporaryFile.cs @@ -0,0 +1,41 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests.Utilities; + +using System; +using System.IO; + +public sealed class TemporaryFile : IDisposable +{ + static TemporaryFile() + { + TemporaryDirectory = Path.Combine(Path.GetDirectoryName(typeof(TemporaryFile).Assembly.Location), "temporary-files"); + Directory.CreateDirectory(TemporaryDirectory); + } + + // Creates a temporary file in the test directory with the optional given file extension. The test/debug directory + // is used to avoid polluting the user's temp directory and so that a `git clean` operation will remove any + // remaining files. + public TemporaryFile(string extension = null) + { + if (extension is not null && !extension.StartsWith(".")) + { + throw new ArgumentException("Extension must start with a period.", nameof(extension)); + } + + this.FilePath = Path.Combine(TemporaryDirectory, $"{Guid.NewGuid():d}{extension}"); + } + + private static string TemporaryDirectory { get; } + + public string FilePath { get; } + + public void Dispose() + { + try + { + File.Delete(this.FilePath); + } + catch + { + } + } +}