-
Notifications
You must be signed in to change notification settings - Fork 113
Add DotNetComponent #1363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add DotNetComponent #1363
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
04fd4ee
Add DotNetComponent
ericstj c16984c
Fix parent directory identification on linux.
ericstj a623268
Address feedback
ericstj 679fefa
Additional tests
ericstj fa5c6c4
Trim trailing directory separator
ericstj 342e415
Fix path trimming
ericstj 596ff65
Add doc for dotnet detector
ericstj 0d69459
Merge branch 'main' into DotNetComponentExperiment
grvillic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # DotNet SDK Detection | ||
|
|
||
| ## Requirements | ||
|
|
||
| DotNet SDK Detection depends on `project.assets.json` files in the project output / intermediates. | ||
|
|
||
| ## Detection Strategy | ||
|
|
||
| The `project.assets.json` will be produced when a project is restored and built. From this we can locate | ||
| the project file that was built. From the project file location we probe for the .NET SDK version used. | ||
| We look up the directory path to find a `global.json`[1] then will run `dotnet --version` in that | ||
| directory to determine which version of the .NET SDK it will select. If no `global.json` is found, then | ||
| probing will stop when the detector encounters `SourceDirectory`, `SourceFileRoot`, or the root of the drive | ||
| and proceed to run `dotnet --version` in that directory. Repositories control the version of the .NET SDK | ||
| used to build their project by either preinstalling on their build machine or container, or acquiring during | ||
| the build pipeline. The .NET SDK version used is important as this version selects redistributable content | ||
| that becomes part of the application (the dotnet host, runtime for self-contained or AOT apps, build tools | ||
| which generate source, etc). | ||
|
|
||
| In addition to recording the SDK version used, the detector will report the framework versions targeted by | ||
| the project as `TargetFramework` values as well as the type of project `application` or `library`. These | ||
| are important because applications may be built to target old framework versions which may be out of support | ||
| and have unreported vulnerabilities. `TargetFramework` is determined from the `project.assets.json` while | ||
| 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]. | ||
|
|
||
| [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 | ||
|
|
||
| ## Known Limitations | ||
|
|
||
| If the `dotnet` executable is not on the path the detector may fail to locate the version used to build the | ||
| 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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,4 +59,7 @@ public enum ComponentType : byte | |
|
|
||
| [EnumMember] | ||
| Swift = 18, | ||
|
|
||
| [EnumMember] | ||
| DotNet = 19, | ||
| } | ||
47 changes: 47 additions & 0 deletions
47
src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| namespace Microsoft.ComponentDetection.Contracts.TypedComponent; | ||
|
|
||
| using System; | ||
|
|
||
| public class DotNetComponent : TypedComponent | ||
| { | ||
| private const string UnknownValue = "unknown"; | ||
|
|
||
| private DotNetComponent() | ||
| { | ||
| /* Reserved for deserialization */ | ||
| } | ||
|
|
||
| public DotNetComponent(string sdkVersion, string targetFramework = null, string projectType = null) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(sdkVersion) && string.IsNullOrWhiteSpace(targetFramework)) | ||
| { | ||
| throw new ArgumentNullException(nameof(sdkVersion), $"Either {nameof(sdkVersion)} or {nameof(targetFramework)} of component type {nameof(DotNetComponent)} must be specified."); | ||
| } | ||
|
|
||
| this.SdkVersion = string.IsNullOrWhiteSpace(sdkVersion) ? UnknownValue : sdkVersion; | ||
| this.TargetFramework = string.IsNullOrWhiteSpace(targetFramework) ? UnknownValue : targetFramework; | ||
| this.ProjectType = string.IsNullOrWhiteSpace(projectType) ? UnknownValue : projectType; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// SDK Version detected, could be null if no global.json exists and no dotnet is on the path. | ||
| /// </summary> | ||
| public string SdkVersion { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Target framework for this instance. Null in the case of global.json. | ||
| /// </summary> | ||
| public string TargetFramework { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Project type: application, library. Null in the case of global.json or if no project output could be discovered. | ||
| /// </summary> | ||
| public string ProjectType { get; set; } | ||
|
|
||
| public override ComponentType Type => ComponentType.DotNet; | ||
|
|
||
| /// <summary> | ||
| /// Provides an id like `{SdkVersion} - {TargetFramework} - {ProjectType} - dotnet` where unspecified values are represented as 'unknown'. | ||
| /// </summary> | ||
| public override string Id => $"{this.SdkVersion} {this.TargetFramework} {this.ProjectType} - {this.Type}"; | ||
| } | ||
216 changes: 216 additions & 0 deletions
216
src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| namespace Microsoft.ComponentDetection.Detectors.DotNet; | ||
|
|
||
| #nullable enable | ||
| using System; | ||
| using System.Collections.Concurrent; | ||
| using System.Collections.Generic; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using System.Reflection.PortableExecutable; | ||
| using System.Text.Json; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using global::NuGet.ProjectModel; | ||
| using Microsoft.ComponentDetection.Contracts; | ||
| using Microsoft.ComponentDetection.Contracts.Internal; | ||
| using Microsoft.ComponentDetection.Contracts.TypedComponent; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| public class DotNetComponentDetector : FileComponentDetector, IExperimentalDetector | ||
| { | ||
| private const string GlobalJsonFileName = "global.json"; | ||
| private readonly ICommandLineInvocationService commandLineInvocationService; | ||
| private readonly IDirectoryUtilityService directoryUtilityService; | ||
| private readonly IFileUtilityService fileUtilityService; | ||
| private readonly IPathUtilityService pathUtilityService; | ||
| private readonly LockFileFormat lockFileFormat = new(); | ||
| private readonly ConcurrentDictionary<string, string?> sdkVersionCache = []; | ||
| private string? sourceDirectory; | ||
| private string? sourceFileRootDirectory; | ||
|
|
||
| public DotNetComponentDetector( | ||
| IComponentStreamEnumerableFactory componentStreamEnumerableFactory, | ||
| ICommandLineInvocationService commandLineInvocationService, | ||
| IDirectoryUtilityService directoryUtilityService, | ||
| IFileUtilityService fileUtilityService, | ||
| IPathUtilityService pathUtilityService, | ||
| IObservableDirectoryWalkerFactory walkerFactory, | ||
| ILogger<DotNetComponentDetector> logger) | ||
| { | ||
| this.commandLineInvocationService = commandLineInvocationService; | ||
| this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; | ||
| this.directoryUtilityService = directoryUtilityService; | ||
| this.fileUtilityService = fileUtilityService; | ||
| this.pathUtilityService = pathUtilityService; | ||
| this.Scanner = walkerFactory; | ||
| this.Logger = logger; | ||
| } | ||
|
|
||
| public override string Id => "DotNet"; | ||
|
|
||
| public override IList<string> SearchPatterns { get; } = [LockFileFormat.AssetsFileName]; | ||
grvillic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.DotNet]; | ||
|
|
||
ericstj marked this conversation as resolved.
Show resolved
Hide resolved
ericstj marked this conversation as resolved.
Show resolved
Hide resolved
grvillic marked this conversation as resolved.
Show resolved
Hide resolved
grvillic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| public override int Version { get; } = 1; | ||
|
|
||
| public override IEnumerable<string> Categories => ["DotNet"]; | ||
|
|
||
| private string? NormalizeDirectory(string? path) => string.IsNullOrEmpty(path) ? path : Path.TrimEndingDirectorySeparator(this.pathUtilityService.NormalizePath(path)); | ||
|
|
||
| private async Task<string?> RunDotNetVersionAsync(string workingDirectoryPath, CancellationToken cancellationToken) | ||
| { | ||
| var workingDirectory = new DirectoryInfo(workingDirectoryPath); | ||
|
|
||
| var process = await this.commandLineInvocationService.ExecuteCommandAsync("dotnet", ["dotnet.exe"], workingDirectory, cancellationToken, "--version").ConfigureAwait(false); | ||
|
|
||
| if (process.ExitCode != 0) | ||
| { | ||
| // debug only - it could be that dotnet is not actually on the path and specified directly by the build scripts. | ||
| this.Logger.LogDebug("Failed to invoke 'dotnet --version'. Return: {Return} StdErr: {StdErr} StdOut: {StdOut}.", process.ExitCode, process.StdErr, process.StdOut); | ||
| return null; | ||
| } | ||
|
|
||
| return process.StdOut.Trim(); | ||
| } | ||
|
|
||
| public override Task<IndividualDetectorScanResult> ExecuteDetectorAsync(ScanRequest request, CancellationToken cancellationToken = default) | ||
| { | ||
| this.sourceDirectory = this.NormalizeDirectory(request.SourceDirectory.FullName); | ||
| this.sourceFileRootDirectory = this.NormalizeDirectory(request.SourceFileRoot?.FullName); | ||
|
|
||
| return base.ExecuteDetectorAsync(request, cancellationToken); | ||
| } | ||
|
|
||
| protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary<string, string> detectorArgs, CancellationToken cancellationToken = default) | ||
| { | ||
| var lockFile = this.lockFileFormat.Read(processRequest.ComponentStream.Stream, processRequest.ComponentStream.Location); | ||
ericstj marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; | ||
|
|
||
| if (!this.fileUtilityService.Exists(projectPath)) | ||
| { | ||
| // Could be the assets file was not actually from this build | ||
| this.Logger.LogWarning("Project path {ProjectPath} specified by {ProjectAssetsPath} does not exist.", projectPath, processRequest.ComponentStream.Location); | ||
| } | ||
|
|
||
| var projectDirectory = this.pathUtilityService.GetParentDirectory(projectPath); | ||
| var sdkVersion = await this.GetSdkVersionAsync(projectDirectory, cancellationToken); | ||
|
|
||
| var projectName = lockFile.PackageSpec.RestoreMetadata.ProjectName; | ||
| var projectOutputPath = lockFile.PackageSpec.RestoreMetadata.OutputPath; | ||
|
|
||
| if (!this.directoryUtilityService.Exists(projectOutputPath)) | ||
| { | ||
| this.Logger.LogWarning("Project output path {ProjectOutputPath} specified by {ProjectAssetsPath} does not exist.", projectOutputPath, processRequest.ComponentStream.Location); | ||
| } | ||
|
|
||
| var targetType = this.GetProjectType(projectOutputPath, projectName, cancellationToken); | ||
|
|
||
| var componentReporter = this.ComponentRecorder.CreateSingleFileComponentRecorder(projectPath); | ||
| foreach (var target in lockFile.Targets) | ||
| { | ||
| var targetFramework = target.TargetFramework?.GetShortFolderName(); | ||
|
|
||
| componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework, targetType))); | ||
| } | ||
| } | ||
|
|
||
| private string? GetProjectType(string projectOutputPath, string projectName, CancellationToken cancellationToken) | ||
| { | ||
| if (this.directoryUtilityService.Exists(projectOutputPath)) | ||
| { | ||
| var namePattern = (projectName ?? "*") + ".dll"; | ||
|
|
||
| // look for the compiled output, first as dll then as exe. | ||
| var candidates = this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern, SearchOption.AllDirectories) | ||
| .Concat(this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern, SearchOption.AllDirectories)); | ||
| foreach (var candidate in candidates) | ||
| { | ||
| try | ||
| { | ||
| return this.IsApplication(candidate) ? "application" : "library"; | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| this.Logger.LogWarning("Failed to open output assembly {AssemblyPath} error {Message}.", candidate, e.Message); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private bool IsApplication(string assemblyPath) | ||
| { | ||
| using var peReader = new PEReader(this.fileUtilityService.MakeFileStream(assemblyPath)); | ||
|
|
||
| // despite the name `IsExe` this is actually based of the CoffHeader Characteristics | ||
| return peReader.PEHeaders.IsExe; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Recursively get the sdk version from the project directory or parent directories. | ||
| /// </summary> | ||
| /// <param name="projectDirectory">Directory to start the search.</param> | ||
| /// <param name="cancellationToken">Cancellation token to halt the search.</param> | ||
| /// <returns>Sdk version found, or null if no version can be detected.</returns> | ||
| private async Task<string?> GetSdkVersionAsync(string? projectDirectory, CancellationToken cancellationToken) | ||
| { | ||
| // normalize since we need to use as a key | ||
| projectDirectory = this.NormalizeDirectory(projectDirectory); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(projectDirectory)) | ||
| { | ||
| // not expected | ||
| return null; | ||
| } | ||
|
|
||
| if (this.sdkVersionCache.TryGetValue(projectDirectory, out var sdkVersion)) | ||
| { | ||
| return sdkVersion; | ||
|
Check warning on line 171 in src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs
|
||
| } | ||
|
|
||
| var parentDirectory = this.pathUtilityService.GetParentDirectory(projectDirectory); | ||
| var globalJsonPath = Path.Combine(projectDirectory, GlobalJsonFileName); | ||
|
|
||
| if (this.fileUtilityService.Exists(globalJsonPath)) | ||
| { | ||
| sdkVersion = await this.RunDotNetVersionAsync(projectDirectory, cancellationToken); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(sdkVersion)) | ||
| { | ||
| var globalJson = await JsonDocument.ParseAsync(this.fileUtilityService.MakeFileStream(globalJsonPath), cancellationToken: cancellationToken); | ||
| if (globalJson.RootElement.TryGetProperty("sdk", out var sdk)) | ||
| { | ||
| if (sdk.TryGetProperty("version", out var version)) | ||
| { | ||
| sdkVersion = version.GetString(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| var globalJsonComponent = new DetectedComponent(new DotNetComponent(sdkVersion)); | ||
| var recorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(globalJsonPath); | ||
| recorder.RegisterUsage(globalJsonComponent, isExplicitReferencedDependency: true); | ||
| } | ||
| else if (projectDirectory.Equals(this.sourceDirectory, StringComparison.OrdinalIgnoreCase) || | ||
| projectDirectory.Equals(this.sourceFileRootDirectory, StringComparison.OrdinalIgnoreCase) || | ||
| string.IsNullOrEmpty(parentDirectory) || | ||
| projectDirectory.Equals(parentDirectory, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| // if we are at the source directory, source file root, or have reached a root directory, run `dotnet --version` | ||
| // this could fail if dotnet is not on the path, or if the global.json is malformed | ||
| sdkVersion = await this.RunDotNetVersionAsync(projectDirectory, cancellationToken); | ||
| } | ||
| else | ||
| { | ||
| // recurse up the directory tree | ||
| sdkVersion = await this.GetSdkVersionAsync(parentDirectory, cancellationToken); | ||
| } | ||
|
|
||
| this.sdkVersionCache[projectDirectory] = sdkVersion; | ||
|
|
||
| return sdkVersion; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
src/Microsoft.ComponentDetection.Orchestrator/Experiments/DotNetDetectorExperiment.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| namespace Microsoft.ComponentDetection.Orchestrator.Experiments.Configs; | ||
|
|
||
| using Microsoft.ComponentDetection.Contracts; | ||
| using Microsoft.ComponentDetection.Detectors.DotNet; | ||
|
|
||
| /// <summary> | ||
| /// Validating the <see cref="DotNetDetectorExperiment"/>. | ||
| /// </summary> | ||
| public class DotNetDetectorExperiment : IExperimentConfiguration | ||
| { | ||
| /// <inheritdoc /> | ||
| public string Name => "DotNetDetector"; | ||
|
|
||
| /// <inheritdoc /> | ||
| public bool IsInControlGroup(IComponentDetector componentDetector) => false; | ||
|
|
||
| /// <inheritdoc /> | ||
| public bool IsInExperimentGroup(IComponentDetector componentDetector) => componentDetector is DotNetComponentDetector; | ||
|
|
||
| /// <inheritdoc /> | ||
| public bool ShouldRecord(IComponentDetector componentDetector, int numComponents) => true; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.