From 3eab9783391e03b50bfad72bd44eea946de1a87c Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Thu, 5 Mar 2026 18:07:59 -0800 Subject: [PATCH 1/6] Detect self-contained projects in DotNetComponentDetector Report whether a .NET project target is self-contained by appending -selfcontained to the projectType (e.g. application-selfcontained). Uses a heuristic based on the assets file: for each target framework, if any PackageDownload name starts with a FrameworkReference name followed by .Runtime, the target is considered self-contained. This covers both SelfContained=true and PublishAot=true scenarios, as both result in runtime package downloads in the assets file. This relationship is not a guarantee. The actual relationship is defined by the SDK's KnownFramework items, but we can't read those from build assets. This convention has been followed for all in-support framework versions and should be acceptable. --- .../dotnet/DotNetComponentDetector.cs | 49 +++++- .../DotNetComponentDetectorTests.cs | 163 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs index 8770ce3bb..b5e1f4049 100644 --- a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs @@ -202,8 +202,10 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID foreach (var target in lockFile.Targets ?? []) { var targetFramework = target.TargetFramework?.GetShortFolderName(); + var isSelfContained = this.IsSelfContained(lockFile.PackageSpec, targetFramework); + var targetTypeWithSelfContained = this.GetTargetTypeWithSelfContained(targetType, isSelfContained); - componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework, targetType))); + componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework, targetTypeWithSelfContained))); } } @@ -247,6 +249,51 @@ private bool IsApplication(string assemblyPath) return peReader.PEHeaders.IsExe; } + private bool IsSelfContained(global::NuGet.ProjectModel.PackageSpec packageSpec, string? targetFramework) + { + if (packageSpec?.TargetFrameworks == null || string.IsNullOrWhiteSpace(targetFramework)) + { + return false; + } + + var targetFrameworkInfo = packageSpec.TargetFrameworks.FirstOrDefault(tf => tf.FrameworkName?.GetShortFolderName() == targetFramework); + if (targetFrameworkInfo == null) + { + return false; + } + + var frameworkReferences = targetFrameworkInfo.FrameworkReferences; + var packageDownloads = targetFrameworkInfo.DownloadDependencies; + + if (frameworkReferences == null || frameworkReferences.Count == 0 || packageDownloads.IsDefaultOrEmpty) + { + return false; + } + + foreach (var frameworkRef in frameworkReferences) + { + var frameworkName = frameworkRef.Name; + var hasRuntimeDownload = packageDownloads.Any(pd => pd.Name.StartsWith($"{frameworkName}.Runtime", StringComparison.OrdinalIgnoreCase)); + + if (hasRuntimeDownload) + { + return true; + } + } + + return false; + } + + private string? GetTargetTypeWithSelfContained(string? targetType, bool isSelfContained) + { + if (string.IsNullOrWhiteSpace(targetType)) + { + return targetType; + } + + return isSelfContained ? $"{targetType}-selfcontained" : targetType; + } + /// /// Recursively get the sdk version from the project directory or parent directories. /// diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs index d4941512a..43f556498 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs @@ -3,6 +3,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using System.Reactive.Linq; @@ -14,7 +15,9 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System.Threading.Tasks; using AwesomeAssertions; using global::NuGet.Frameworks; +using global::NuGet.LibraryModel; using global::NuGet.ProjectModel; +using global::NuGet.Versioning; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.DotNet; @@ -181,6 +184,19 @@ public void ClearMocks() } private static string ProjectAssets(string projectName, string outputPath, string projectPath, params string[] targetFrameworks) + { + return ProjectAssetsWithSelfContained(projectName, outputPath, projectPath, selfContainedTargetFrameworks: null, targetFrameworks); + } + + /// + /// Creates a project assets JSON string for testing, with optional self-contained configuration. + /// + /// Name of the project. + /// Output path for the project. + /// Path to the project file. + /// Set of target frameworks that should be configured as self-contained. If null, none are self-contained. + /// Target frameworks for the project. + private static string ProjectAssetsWithSelfContained(string projectName, string outputPath, string projectPath, ISet selfContainedTargetFrameworks, params string[] targetFrameworks) { LockFileFormat format = new(); LockFile lockFile = new(); @@ -203,6 +219,27 @@ private static string ProjectAssets(string projectName, string outputPath, strin }, }; + foreach (var tfm in targetFrameworks) + { + var isSelfContained = selfContainedTargetFrameworks != null && selfContainedTargetFrameworks.Contains(tfm); + + var tfi = new TargetFrameworkInformation + { + FrameworkName = NuGetFramework.Parse(tfm), + FrameworkReferences = new HashSet + { + new FrameworkDependency("Microsoft.NETCore.App", FrameworkDependencyFlags.All), + }, + DownloadDependencies = isSelfContained + ? ImmutableArray.Create( + new DownloadDependency("Microsoft.NETCore.App.Ref", new VersionRange(new NuGetVersion("8.0.0"))), + new DownloadDependency("Microsoft.NETCore.App.Runtime.win-x64", new VersionRange(new NuGetVersion("8.0.0")))) + : ImmutableArray.Empty, + }; + + lockFile.PackageSpec.TargetFrameworks.Add(tfi); + } + format.Write(textWriter, lockFile); return textWriter.ToString(); } @@ -745,4 +782,130 @@ public async Task TestDotNetDetectorRebasePaths(string additionalPathSegment) discoveredComponents.Where(component => component.Component.Id == "4.5.6 net6.0 library - DotNet").Should().ContainSingle(); discoveredComponents.Where(component => component.Component.Id == "4.5.6 netstandard2.0 library - DotNet").Should().ContainSingle(); } + + [TestMethod] + public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // Self-contained project (simulates SelfContained=true): net8.0 has runtime downloads + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorSelfContainedWithPublishAot() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // PublishAot project also results in runtime downloads in the assets file + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorNotSelfContained() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // Non-self-contained: no runtime downloads + var applicationAssets = ProjectAssets("application", applicationOutputPath, applicationProjectPath, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorMultiTargetWithMixedSelfContained() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // Multi-target: net8.0 is self-contained, net6.0 is not + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, "net8.0", "net6.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net6.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net6.0 application - DotNet").Should().ContainSingle(); + } } From c2cbfec13dc9036cc41868f430f7d5e28bf81618 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Fri, 6 Mar 2026 08:37:32 -0800 Subject: [PATCH 2/6] Fix docs and framework comparison --- docs/detectors/dotnet.md | 10 +++++++++- .../TypedComponent/DotNetComponent.cs | 5 ++++- .../dotnet/DotNetComponentDetector.cs | 11 ++++++----- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/detectors/dotnet.md b/docs/detectors/dotnet.md index fbeccef5f..a3a0ad1a2 100644 --- a/docs/detectors/dotnet.md +++ b/docs/detectors/dotnet.md @@ -24,6 +24,14 @@ and have unreported vulnerabilities. `TargetFramework` is determined from the ` the type of the project is determined by locating the project's output assembly in a subdirectory of the output path and reading the PE COFF header's characteristics for `IMAGE_FILE_EXECUTABLE_IMAGE`[2]. +The `ProjectType` value is further qualified with a `-selfcontained` suffix (e.g. `application-selfcontained` +or `library-selfcontained`) when the project is detected as self-contained. A project is considered +self-contained when its `project.assets.json` indicates that a framework reference (e.g. +`Microsoft.NETCore.App`) has a corresponding runtime package download (e.g. +`Microsoft.NETCore.App.Runtime.*`) listed in the target framework's `downloadDependencies`. Self-contained +applications bundle the .NET runtime and are responsible for servicing it, so this distinction is important +for vulnerability tracking. + [1]: https://learn.microsoft.com/en-us/dotnet/core/tools/global-json [2]: https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#characteristics @@ -33,4 +41,4 @@ If the `dotnet` executable is not on the path the detector may fail to locate th project. The detector will fallback to parsing the `global.json` in this case if it is present. Detection of the output type is done by locating the output assembly under the output path specified in `project.assets.json`. Some build systems may place project intermediates in a different location. In this -case the project type will be reported as `unknown`. \ No newline at end of file +case the project type will be reported as `unknown` and the `-selfcontained` suffix will not be appended. \ No newline at end of file diff --git a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs index ee0e3c7de..f40afa49e 100644 --- a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs +++ b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs @@ -38,7 +38,10 @@ public DotNetComponent(string sdkVersion, string targetFramework = null, string public string TargetFramework { get; set; } /// - /// Project type: application, library. Null in the case of global.json or if no project output could be discovered. + /// Project type: application, library, application-selfcontained, library-selfcontained. + /// Null in the case of global.json or if no project output could be discovered. + /// The "-selfcontained" suffix is appended when the project bundles the .NET runtime + /// (i.e. the target framework has a runtime package download matching a framework reference). /// [JsonPropertyName("projectType")] public string ProjectType { get; set; } diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs index b5e1f4049..d8b90384e 100644 --- a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs @@ -10,6 +10,7 @@ namespace Microsoft.ComponentDetection.Detectors.DotNet; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using global::NuGet.Frameworks; using global::NuGet.ProjectModel; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.Internal; @@ -201,11 +202,11 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID var componentReporter = this.ComponentRecorder.CreateSingleFileComponentRecorder(projectPath); foreach (var target in lockFile.Targets ?? []) { - var targetFramework = target.TargetFramework?.GetShortFolderName(); + var targetFramework = target.TargetFramework; var isSelfContained = this.IsSelfContained(lockFile.PackageSpec, targetFramework); var targetTypeWithSelfContained = this.GetTargetTypeWithSelfContained(targetType, isSelfContained); - componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework, targetTypeWithSelfContained))); + componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework?.GetShortFolderName(), targetTypeWithSelfContained))); } } @@ -249,14 +250,14 @@ private bool IsApplication(string assemblyPath) return peReader.PEHeaders.IsExe; } - private bool IsSelfContained(global::NuGet.ProjectModel.PackageSpec packageSpec, string? targetFramework) + private bool IsSelfContained(PackageSpec packageSpec, NuGetFramework? targetFramework) { - if (packageSpec?.TargetFrameworks == null || string.IsNullOrWhiteSpace(targetFramework)) + if (packageSpec?.TargetFrameworks == null || targetFramework == null) { return false; } - var targetFrameworkInfo = packageSpec.TargetFrameworks.FirstOrDefault(tf => tf.FrameworkName?.GetShortFolderName() == targetFramework); + var targetFrameworkInfo = packageSpec.TargetFrameworks.FirstOrDefault(tf => tf.FrameworkName == targetFramework); if (targetFrameworkInfo == null) { return false; From aac07c2832674fa36b14a56419147e76709ae70c Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Fri, 6 Mar 2026 11:09:25 -0800 Subject: [PATCH 3/6] Improved self-contained tests to use real builds --- .../DotNetComponentDetectorTests.cs | 366 ++++++++++++++---- 1 file changed, 293 insertions(+), 73 deletions(-) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs index 43f556498..0a61a8254 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs @@ -3,7 +3,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System; using System.Collections.Generic; -using System.Collections.Immutable; +using System.Diagnostics; using System.IO; using System.Linq; using System.Reactive.Linq; @@ -33,6 +33,14 @@ public class DotNetComponentDetectorTests : BaseDetectorTest + /// The short NuGet TFM (e.g. "net8.0") that the test assembly itself targets. + /// Used by real-restore tests so they don't break when the SDK drops an older framework. + /// + private static readonly string CurrentTfm = NuGetFramework.Parse( + Assembly.GetExecutingAssembly().GetCustomAttribute().FrameworkName) + .GetShortFolderName(); + private readonly Mock> mockLogger = new(); // uses ExecuteCommandAsync @@ -208,7 +216,22 @@ private static string ProjectAssetsWithSelfContained(string projectName, string outputPath += Path.DirectorySeparatorChar; } - lockFile.Targets = targetFrameworks.Select(tfm => new LockFileTarget() { TargetFramework = NuGetFramework.Parse(tfm) }).ToList(); + var targets = new List(); + foreach (var tfm in targetFrameworks) + { + var framework = NuGetFramework.Parse(tfm); + var isSelfContained = selfContainedTargetFrameworks != null && selfContainedTargetFrameworks.Contains(tfm); + + targets.Add(new LockFileTarget { TargetFramework = framework }); + + // Self-contained projects have an additional RID-qualified target in their assets file + if (isSelfContained) + { + targets.Add(new LockFileTarget { TargetFramework = framework, RuntimeIdentifier = "win-x64" }); + } + } + + lockFile.Targets = targets; lockFile.PackageSpec = new() { RestoreMetadata = new() @@ -226,15 +249,13 @@ private static string ProjectAssetsWithSelfContained(string projectName, string var tfi = new TargetFrameworkInformation { FrameworkName = NuGetFramework.Parse(tfm), - FrameworkReferences = new HashSet - { + FrameworkReferences = + [ new FrameworkDependency("Microsoft.NETCore.App", FrameworkDependencyFlags.All), - }, + ], DownloadDependencies = isSelfContained - ? ImmutableArray.Create( - new DownloadDependency("Microsoft.NETCore.App.Ref", new VersionRange(new NuGetVersion("8.0.0"))), - new DownloadDependency("Microsoft.NETCore.App.Runtime.win-x64", new VersionRange(new NuGetVersion("8.0.0")))) - : ImmutableArray.Empty, + ? [new DownloadDependency("Microsoft.NETCore.App.Ref", new VersionRange(new NuGetVersion("8.0.0"))), new DownloadDependency("Microsoft.NETCore.App.Runtime.win-x64", new VersionRange(new NuGetVersion("8.0.0")))] + : [], }; lockFile.PackageSpec.TargetFrameworks.Add(tfi); @@ -274,6 +295,56 @@ private static Stream StreamFromString(string content) return stream; } + /// + /// Writes a .csproj (with isolation files so the repo's build config doesn't interfere), + /// runs dotnet restore, and returns the path to the generated project.assets.json. + /// The caller is responsible for cleaning up if desired. + /// + private static string RestoreProjectAndGetAssetsPath(string projectDir, string csproj) + { + Directory.CreateDirectory(projectDir); + + // Isolation files so the test project is not affected by the repo's + // Directory.Build.props / .targets / Directory.Packages.props. + File.WriteAllText(Path.Combine(projectDir, "Directory.Build.props"), ""); + File.WriteAllText(Path.Combine(projectDir, "Directory.Build.targets"), ""); + File.WriteAllText(Path.Combine(projectDir, "Directory.Packages.props"), ""); + + // Minimal source file so restore doesn't complain. + File.WriteAllText(Path.Combine(projectDir, "Program.cs"), "return;"); + + // The project definition supplied by the test. + var csprojPath = Path.Combine(projectDir, "test.csproj"); + File.WriteAllText(csprojPath, csproj); + + var psi = new ProcessStartInfo("dotnet", $"restore \"{csprojPath}\"") + { + WorkingDirectory = projectDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = Process.Start(psi); + process.WaitForExit(60_000); + + if (process.ExitCode != 0) + { + var stderr = process.StandardError.ReadToEnd(); + var stdout = process.StandardOutput.ReadToEnd(); + throw new InvalidOperationException( + $"dotnet restore failed (exit {process.ExitCode}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); + } + + var assetsPath = Path.Combine(projectDir, "obj", "project.assets.json"); + if (!File.Exists(assetsPath)) + { + throw new FileNotFoundException("project.assets.json was not generated by dotnet restore.", assetsPath); + } + + return assetsPath; + } + [TestMethod] public async Task TestDotNetDetectorWithNoFiles_ReturnsSuccessfullyAsync() { @@ -786,99 +857,249 @@ public async Task TestDotNetDetectorRebasePaths(string additionalPathSegment) [TestMethod] public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty() { - var globalJson = GlobalJson("4.5.6"); - var globalJsonDir = Path.Combine(RootDir, "path"); - this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + // Emit a self-contained .csproj, restore it, and use the real project.assets.json. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-selfcontained-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + Exe + win-x64 + true + + + """; - this.SetCommandResult(0, "4.5.6"); + var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); - var applicationProjectName = "application"; - var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); - this.AddFile(applicationProjectPath, null); - var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); - var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + // Parse the restored assets file to extract paths the detector will use. + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; - // Self-contained project (simulates SelfContained=true): net8.0 has runtime downloads - var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, "net8.0"); - var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); - this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + // Trim trailing separator so mock filesystem paths are consistent. + // The detector will fall back to projectAssetsDirectory (derived from the + // stream location, which has no trailing sep) for EnumerateFiles. + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); - var (scanResult, componentRecorder) = await this.DetectorTestUtility - .WithFile(applicationAssetsPath, applicationAssets) - .ExecuteDetectorAsync(); + // Verify the restored assets have a RID-qualified target (e.g. net8.0/win-x64). + lockFile.Targets.Should().Contain(t => t.RuntimeIdentifier != null, "self-contained restore should produce a RID-qualified target"); - scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); - var detectedComponents = componentRecorder.GetDetectedComponents(); - var discoveredComponents = detectedComponents.ToArray(); - discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + this.AddFile(projectPath, null); + + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), applicationAssemblyStream); + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + + // Both the plain TFM and RID-qualified targets map to the same framework. + // The detector should report application-selfcontained for this framework. + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} application-selfcontained - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } } [TestMethod] - public async Task TestDotNetDetectorSelfContainedWithPublishAot() + public async Task TestDotNetDetectorSelfContainedLibrary() { - var globalJson = GlobalJson("4.5.6"); - var globalJsonDir = Path.Combine(RootDir, "path"); - this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + // A library can also be self-contained when it sets SelfContained + RuntimeIdentifier. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-selfcontained-lib-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + win-x64 + true + + + """; - this.SetCommandResult(0, "4.5.6"); + var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); - var applicationProjectName = "application"; - var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); - this.AddFile(applicationProjectPath, null); - var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); - var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); - // PublishAot project also results in runtime downloads in the assets file - var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, "net8.0"); - var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); - this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + // Self-contained library should also have a RID-qualified target. + lockFile.Targets.Should().Contain(t => t.RuntimeIdentifier != null, "self-contained restore should produce a RID-qualified target"); - var (scanResult, componentRecorder) = await this.DetectorTestUtility - .WithFile(applicationAssetsPath, applicationAssets) - .ExecuteDetectorAsync(); + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); - scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + this.AddFile(projectPath, null); - var detectedComponents = componentRecorder.GetDetectedComponents(); - var discoveredComponents = detectedComponents.ToArray(); - discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + var libraryAssemblyStream = File.OpenRead(typeof(DotNetComponent).Assembly.Location); + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), libraryAssemblyStream); + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} library-selfcontained - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } + } + + [TestMethod] + public async Task TestDotNetDetectorSelfContainedWithPublishAot() + { + // PublishAot also causes runtime download dependencies in the assets file. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-aot-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + Exe + true + + + """; + + var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); + + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); + + // PublishAot projects should have download dependencies for the runtime in their assets. + var tfmInfo = lockFile.PackageSpec.TargetFrameworks + .FirstOrDefault(tf => tf.FrameworkName == NuGetFramework.Parse(CurrentTfm)); + tfmInfo.Should().NotBeNull(); + tfmInfo.DownloadDependencies.Should().Contain( + dd => dd.Name.StartsWith("Microsoft.NETCore.App.Runtime", StringComparison.OrdinalIgnoreCase), + "PublishAot should trigger a runtime package download"); + + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); + + this.AddFile(projectPath, null); + + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), applicationAssemblyStream); + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} application-selfcontained - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } } [TestMethod] public async Task TestDotNetDetectorNotSelfContained() { - var globalJson = GlobalJson("4.5.6"); - var globalJsonDir = Path.Combine(RootDir, "path"); - this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + // Framework-dependent app — no RuntimeIdentifier, no SelfContained. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-fdd-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + Exe + + + """; - this.SetCommandResult(0, "4.5.6"); + var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); - var applicationProjectName = "application"; - var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); - this.AddFile(applicationProjectPath, null); - var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); - var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); - // Non-self-contained: no runtime downloads - var applicationAssets = ProjectAssets("application", applicationOutputPath, applicationProjectPath, "net8.0"); - var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); - this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + // Framework-dependent should NOT have RID-qualified targets. + lockFile.Targets.Should().NotContain( + t => t.RuntimeIdentifier != null, + "framework-dependent restore should not produce RID-qualified targets"); - var (scanResult, componentRecorder) = await this.DetectorTestUtility - .WithFile(applicationAssetsPath, applicationAssets) - .ExecuteDetectorAsync(); + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); - scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + this.AddFile(projectPath, null); - var detectedComponents = componentRecorder.GetDetectedComponents(); - var discoveredComponents = detectedComponents.ToArray(); - discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application - DotNet").Should().ContainSingle(); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), applicationAssemblyStream); + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} application - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } } [TestMethod] public async Task TestDotNetDetectorMultiTargetWithMixedSelfContained() { + // NuGet restore applies RuntimeIdentifier globally in cross-targeting, so a real + // restore can't produce per-TFM mixed self-contained/framework-dependent targets. + // Use the synthetic helper which correctly models per-TFM download dependencies + // and RID-qualified targets. var globalJson = GlobalJson("4.5.6"); var globalJsonDir = Path.Combine(RootDir, "path"); this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); @@ -891,7 +1112,7 @@ public async Task TestDotNetDetectorMultiTargetWithMixedSelfContained() var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); - // Multi-target: net8.0 is self-contained, net6.0 is not + // Multi-target: net8.0 is self-contained (has runtime downloads + RID target), net6.0 is not var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, "net8.0", "net6.0"); var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); @@ -903,8 +1124,7 @@ public async Task TestDotNetDetectorMultiTargetWithMixedSelfContained() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); - var detectedComponents = componentRecorder.GetDetectedComponents(); - var discoveredComponents = detectedComponents.ToArray(); + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); discoveredComponents.Where(component => component.Component.Id == "4.5.6 net6.0 application - DotNet").Should().ContainSingle(); } From bdb70a4ce039bacdad4c5d374142ea857adcd554 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Fri, 6 Mar 2026 17:13:55 -0800 Subject: [PATCH 4/6] Address feedback --- docs/detectors/dotnet.md | 14 +- .../TypedComponent/DotNetComponent.cs | 7 +- .../dotnet/DotNetComponentDetector.cs | 12 +- .../DotNetComponentDetectorTests.cs | 201 ++++++++++++++---- 4 files changed, 185 insertions(+), 49 deletions(-) diff --git a/docs/detectors/dotnet.md b/docs/detectors/dotnet.md index a3a0ad1a2..fbdcdf199 100644 --- a/docs/detectors/dotnet.md +++ b/docs/detectors/dotnet.md @@ -26,11 +26,15 @@ output path and reading the PE COFF header's characteristics for `IMAGE_FILE_EXE The `ProjectType` value is further qualified with a `-selfcontained` suffix (e.g. `application-selfcontained` or `library-selfcontained`) when the project is detected as self-contained. A project is considered -self-contained when its `project.assets.json` indicates that a framework reference (e.g. -`Microsoft.NETCore.App`) has a corresponding runtime package download (e.g. -`Microsoft.NETCore.App.Runtime.*`) listed in the target framework's `downloadDependencies`. Self-contained -applications bundle the .NET runtime and are responsible for servicing it, so this distinction is important -for vulnerability tracking. +self-contained when either: +- Its `project.assets.json` indicates that a framework reference (e.g. `Microsoft.NETCore.App`) has a + corresponding runtime package download (e.g. `Microsoft.NETCore.App.Runtime.*`) listed in the target + framework's `downloadDependencies`. This covers `SelfContained=true` scenarios. +- The target references `Microsoft.DotNet.ILCompiler`, which indicates native AOT compilation + (`PublishAot=true`) and therefore an implicitly self-contained deployment. + +Self-contained applications bundle the .NET runtime and are responsible for servicing it, so this +distinction is important for vulnerability tracking. [1]: https://learn.microsoft.com/en-us/dotnet/core/tools/global-json [2]: https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#characteristics diff --git a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs index f40afa49e..cb69b8c71 100644 --- a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs +++ b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs @@ -38,10 +38,11 @@ public DotNetComponent(string sdkVersion, string targetFramework = null, string public string TargetFramework { get; set; } /// - /// Project type: application, library, application-selfcontained, library-selfcontained. - /// Null in the case of global.json or if no project output could be discovered. + /// Project type: application, library, application-selfcontained, library-selfcontained, or unknown. + /// Set to "unknown" when the project output could not be discovered (e.g. global.json or missing output assembly). /// The "-selfcontained" suffix is appended when the project bundles the .NET runtime - /// (i.e. the target framework has a runtime package download matching a framework reference). + /// (i.e. the target framework has a runtime package download matching a framework reference, + /// or the target references Microsoft.DotNet.ILCompiler indicating native AOT). /// [JsonPropertyName("projectType")] public string ProjectType { get; set; } diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs index d8b90384e..871dba663 100644 --- a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs @@ -203,7 +203,7 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID foreach (var target in lockFile.Targets ?? []) { var targetFramework = target.TargetFramework; - var isSelfContained = this.IsSelfContained(lockFile.PackageSpec, targetFramework); + var isSelfContained = this.IsSelfContained(lockFile.PackageSpec, targetFramework, target); var targetTypeWithSelfContained = this.GetTargetTypeWithSelfContained(targetType, isSelfContained); componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework?.GetShortFolderName(), targetTypeWithSelfContained))); @@ -250,8 +250,16 @@ private bool IsApplication(string assemblyPath) return peReader.PEHeaders.IsExe; } - private bool IsSelfContained(PackageSpec packageSpec, NuGetFramework? targetFramework) + private bool IsSelfContained(PackageSpec packageSpec, NuGetFramework? targetFramework, LockFileTarget target) { + // PublishAot projects reference Microsoft.DotNet.ILCompiler, which implies + // native AOT compilation and therefore a self-contained deployment. + if (target?.Libraries != null && + target.Libraries.Any(lib => "Microsoft.DotNet.ILCompiler".Equals(lib.Name, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + if (packageSpec?.TargetFrameworks == null || targetFramework == null) { return false; diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs index 0a61a8254..79a0d60a5 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs @@ -3,7 +3,6 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Reactive.Linq; @@ -18,6 +17,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using global::NuGet.LibraryModel; using global::NuGet.ProjectModel; using global::NuGet.Versioning; +using Microsoft.ComponentDetection.Common; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.DotNet; @@ -47,6 +47,8 @@ public class DotNetComponentDetectorTests : BaseDetectorTest mockCommandLineInvocationService = new(); private readonly CommandLineExecutionResult commandLineExecutionResult = new(); + private readonly ICommandLineInvocationService realCommandLineService = new CommandLineInvocationService(); + // uses Exists, EnumerateFiles private readonly Mock mockDirectoryUtilityService = new(); @@ -193,7 +195,7 @@ public void ClearMocks() private static string ProjectAssets(string projectName, string outputPath, string projectPath, params string[] targetFrameworks) { - return ProjectAssetsWithSelfContained(projectName, outputPath, projectPath, selfContainedTargetFrameworks: null, targetFrameworks); + return ProjectAssetsWithSelfContained(projectName, outputPath, projectPath, selfContainedTargetFrameworks: null, aotTargetFrameworks: null, targetFrameworks); } /// @@ -202,9 +204,10 @@ private static string ProjectAssets(string projectName, string outputPath, strin /// Name of the project. /// Output path for the project. /// Path to the project file. - /// Set of target frameworks that should be configured as self-contained. If null, none are self-contained. + /// Set of target frameworks that should be configured as self-contained (via runtime download dependencies). If null, none are self-contained. + /// Set of target frameworks that should include a Microsoft.DotNet.ILCompiler reference (AOT). If null, none are AOT. /// Target frameworks for the project. - private static string ProjectAssetsWithSelfContained(string projectName, string outputPath, string projectPath, ISet selfContainedTargetFrameworks, params string[] targetFrameworks) + private static string ProjectAssetsWithSelfContained(string projectName, string outputPath, string projectPath, ISet selfContainedTargetFrameworks, ISet aotTargetFrameworks = null, params string[] targetFrameworks) { LockFileFormat format = new(); LockFile lockFile = new(); @@ -221,8 +224,17 @@ private static string ProjectAssetsWithSelfContained(string projectName, string { var framework = NuGetFramework.Parse(tfm); var isSelfContained = selfContainedTargetFrameworks != null && selfContainedTargetFrameworks.Contains(tfm); + var isAot = aotTargetFrameworks != null && aotTargetFrameworks.Contains(tfm); + + var target = new LockFileTarget { TargetFramework = framework }; + + // AOT projects have a Microsoft.DotNet.ILCompiler library in targets + if (isAot) + { + target.Libraries.Add(new LockFileTargetLibrary { Name = "Microsoft.DotNet.ILCompiler", Version = new NuGetVersion("8.0.0"), Type = "package" }); + } - targets.Add(new LockFileTarget { TargetFramework = framework }); + targets.Add(target); // Self-contained projects have an additional RID-qualified target in their assets file if (isSelfContained) @@ -300,40 +312,34 @@ private static Stream StreamFromString(string content) /// runs dotnet restore, and returns the path to the generated project.assets.json. /// The caller is responsible for cleaning up if desired. /// - private static string RestoreProjectAndGetAssetsPath(string projectDir, string csproj) + private async Task RestoreProjectAndGetAssetsPathAsync(string projectDir, string csproj) { Directory.CreateDirectory(projectDir); // Isolation files so the test project is not affected by the repo's - // Directory.Build.props / .targets / Directory.Packages.props. - File.WriteAllText(Path.Combine(projectDir, "Directory.Build.props"), ""); - File.WriteAllText(Path.Combine(projectDir, "Directory.Build.targets"), ""); - File.WriteAllText(Path.Combine(projectDir, "Directory.Packages.props"), ""); + // Directory.Build.props / .targets / Directory.Packages.props / global.json. + await File.WriteAllTextAsync(Path.Combine(projectDir, "Directory.Build.props"), ""); + await File.WriteAllTextAsync(Path.Combine(projectDir, "Directory.Build.targets"), ""); + await File.WriteAllTextAsync(Path.Combine(projectDir, "Directory.Packages.props"), ""); + await File.WriteAllTextAsync(Path.Combine(projectDir, "global.json"), "{}"); // Minimal source file so restore doesn't complain. - File.WriteAllText(Path.Combine(projectDir, "Program.cs"), "return;"); + await File.WriteAllTextAsync(Path.Combine(projectDir, "Program.cs"), "return;"); // The project definition supplied by the test. var csprojPath = Path.Combine(projectDir, "test.csproj"); - File.WriteAllText(csprojPath, csproj); - - var psi = new ProcessStartInfo("dotnet", $"restore \"{csprojPath}\"") - { - WorkingDirectory = projectDir, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; + await File.WriteAllTextAsync(csprojPath, csproj); - using var process = Process.Start(psi); - process.WaitForExit(60_000); + var result = await this.realCommandLineService.ExecuteCommandAsync( + "dotnet", + default, + new DirectoryInfo(projectDir), + $"restore \"{csprojPath}\""); - if (process.ExitCode != 0) + if (result.ExitCode != 0) { - var stderr = process.StandardError.ReadToEnd(); - var stdout = process.StandardOutput.ReadToEnd(); throw new InvalidOperationException( - $"dotnet restore failed (exit {process.ExitCode}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); + $"dotnet restore failed (exit {result.ExitCode}).\nstdout:\n{result.StdOut}\nstderr:\n{result.StdErr}"); } var assetsPath = Path.Combine(projectDir, "obj", "project.assets.json"); @@ -872,7 +878,7 @@ public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty() """; - var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); // Parse the restored assets file to extract paths the detector will use. var lockFileFormat = new LockFileFormat(); @@ -936,7 +942,7 @@ public async Task TestDotNetDetectorSelfContainedLibrary() """; - var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); var lockFileFormat = new LockFileFormat(); var lockFile = lockFileFormat.Read(assetsPath); @@ -978,7 +984,9 @@ public async Task TestDotNetDetectorSelfContainedLibrary() [TestMethod] public async Task TestDotNetDetectorSelfContainedWithPublishAot() { - // PublishAot also causes runtime download dependencies in the assets file. + // PublishAot implies native AOT compilation (self-contained). + // The detector recognises this via the Microsoft.DotNet.ILCompiler reference + // that the SDK injects at restore time, regardless of RuntimeIdentifier. var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-aot-" + Guid.NewGuid().ToString("N")); try { @@ -992,20 +1000,17 @@ public async Task TestDotNetDetectorSelfContainedWithPublishAot() """; - var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); var lockFileFormat = new LockFileFormat(); var lockFile = lockFileFormat.Read(assetsPath); var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); - // PublishAot projects should have download dependencies for the runtime in their assets. - var tfmInfo = lockFile.PackageSpec.TargetFrameworks - .FirstOrDefault(tf => tf.FrameworkName == NuGetFramework.Parse(CurrentTfm)); - tfmInfo.Should().NotBeNull(); - tfmInfo.DownloadDependencies.Should().Contain( - dd => dd.Name.StartsWith("Microsoft.NETCore.App.Runtime", StringComparison.OrdinalIgnoreCase), - "PublishAot should trigger a runtime package download"); + // PublishAot projects should have Microsoft.DotNet.ILCompiler in targets. + lockFile.Targets.Should().Contain( + t => t.Libraries.Any(lib => lib.Name.Equals("Microsoft.DotNet.ILCompiler", StringComparison.OrdinalIgnoreCase)), + "PublishAot should produce an ILCompiler reference in the targets"); var globalJson = GlobalJson("4.5.6"); this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); @@ -1052,7 +1057,7 @@ public async Task TestDotNetDetectorNotSelfContained() """; - var assetsPath = RestoreProjectAndGetAssetsPath(projectDir, csproj); + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); var lockFileFormat = new LockFileFormat(); var lockFile = lockFileFormat.Read(assetsPath); @@ -1093,6 +1098,124 @@ public async Task TestDotNetDetectorNotSelfContained() } } + [TestMethod] + public async Task TestDotNetDetectorSyntheticSelfContainedApplication() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, aotTargetFrameworks: null, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorSyntheticSelfContainedLibrary() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var libraryProjectName = "library"; + var libraryProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{libraryProjectName}.csproj"); + this.AddFile(libraryProjectPath, null); + var libraryOutputPath = Path.Combine(Path.GetDirectoryName(libraryProjectPath), "obj"); + var libraryAssetsPath = Path.Combine(libraryOutputPath, "project.assets.json"); + + var libraryAssets = ProjectAssetsWithSelfContained("library", libraryOutputPath, libraryProjectPath, new HashSet { "net8.0" }, aotTargetFrameworks: null, "net8.0"); + var libraryAssemblyStream = File.OpenRead(typeof(DotNetComponent).Assembly.Location); + this.AddFile(Path.Combine(libraryOutputPath, "Release", "net8.0", "library.dll"), libraryAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(libraryAssetsPath, libraryAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 library-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorSyntheticAotApplication() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // AOT: ILCompiler in targets, no framework reference + download dependency needed + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, selfContainedTargetFrameworks: null, new HashSet { "net8.0" }, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorSyntheticNotSelfContained() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // Framework-dependent: no self-contained, no AOT + var applicationAssets = ProjectAssets("application", applicationOutputPath, applicationProjectPath, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application - DotNet").Should().ContainSingle(); + } + [TestMethod] public async Task TestDotNetDetectorMultiTargetWithMixedSelfContained() { @@ -1113,7 +1236,7 @@ public async Task TestDotNetDetectorMultiTargetWithMixedSelfContained() var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); // Multi-target: net8.0 is self-contained (has runtime downloads + RID target), net6.0 is not - var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, "net8.0", "net6.0"); + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, aotTargetFrameworks: null, "net8.0", "net6.0"); var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); this.AddFile(Path.Combine(applicationOutputPath, "Release", "net6.0", "application.dll"), applicationAssemblyStream); From 30514faf888f7c7099e6e3294dd28206073bd556 Mon Sep 17 00:00:00 2001 From: Greg Villicana <58237075+grvillic@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:08:31 -0700 Subject: [PATCH 5/6] Update test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../DotNetComponentDetectorTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs index c3ca36aaa..b23c674f5 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs @@ -901,9 +901,14 @@ public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty() this.AddFile(projectPath, null); - var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); - this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), applicationAssemblyStream); + using (var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location)) + { + var memoryStream = new MemoryStream(); + applicationAssemblyStream.CopyTo(memoryStream); + memoryStream.Position = 0; + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), memoryStream); + } var assetsContent = await File.ReadAllTextAsync(assetsPath); var (scanResult, componentRecorder) = await this.detectorTestUtility From f17fbf32635780da478b0427a2454038b10afa19 Mon Sep 17 00:00:00 2001 From: Greg Villicana Date: Sun, 8 Mar 2026 16:38:23 -0700 Subject: [PATCH 6/6] nit --- .../DotNetComponentDetectorTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs index b23c674f5..e6483b995 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs @@ -904,11 +904,12 @@ public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty() using (var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location)) { var memoryStream = new MemoryStream(); - applicationAssemblyStream.CopyTo(memoryStream); + await applicationAssemblyStream.CopyToAsync(memoryStream); memoryStream.Position = 0; this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), memoryStream); } + var assetsContent = await File.ReadAllTextAsync(assetsPath); var (scanResult, componentRecorder) = await this.detectorTestUtility