Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/detectors/dotnet.md
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`.
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public async Task<CommandLineExecutionResult> ExecuteCommandAsync(
CancellationToken cancellationToken = default,
params string[] parameters)
{
var isCommandLocatable = await this.CanCommandBeLocatedAsync(command, additionalCandidateCommands);
var isCommandLocatable = await this.CanCommandBeLocatedAsync(command, additionalCandidateCommands, workingDirectory, parameters);
if (!isCommandLocatable)
{
throw new InvalidOperationException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,7 @@ public enum ComponentType : byte

[EnumMember]
Swift = 18,

[EnumMember]
DotNet = 19,
}
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()
{

Check warning on line 10 in src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs#L9-L10

Added lines #L9 - L10 were not covered by tests
/* Reserved for deserialization */
}

Check warning on line 12 in src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs#L12

Added line #L12 was not covered by tests

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.");

Check warning on line 18 in src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs#L17-L18

Added lines #L17 - L18 were not covered by tests
}

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}";
}
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];

public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.DotNet];

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);

var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath;

if (!this.fileUtilityService.Exists(projectPath))
{

Check warning on line 92 in src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs#L92

Added line #L92 was not covered by tests
// 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);
}

Check warning on line 95 in src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs#L94-L95

Added lines #L94 - L95 were not covered by tests

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))
{

Check warning on line 164 in src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs#L164

Added line #L164 was not covered by tests
// not expected
return null;

Check warning on line 166 in src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs#L166

Added line #L166 was not covered by tests
}

if (this.sdkVersionCache.TryGetValue(projectDirectory, out var sdkVersion))
{
return sdkVersion;

Check warning on line 171 in src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs#L170-L171

Added lines #L170 - L171 were not covered by tests
}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Microsoft.ComponentDetection.Detectors.Yarn;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Globbing;
using global::DotNet.Globbing;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.Internal;
using Microsoft.ComponentDetection.Contracts.TypedComponent;
Expand Down
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions;
using Microsoft.ComponentDetection.Detectors.CocoaPods;
using Microsoft.ComponentDetection.Detectors.Conan;
using Microsoft.ComponentDetection.Detectors.Dockerfile;
using Microsoft.ComponentDetection.Detectors.DotNet;
using Microsoft.ComponentDetection.Detectors.Go;
using Microsoft.ComponentDetection.Detectors.Gradle;
using Microsoft.ComponentDetection.Detectors.Ivy;
Expand Down Expand Up @@ -65,6 +66,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
services.AddSingleton<IExperimentConfiguration, SimplePipExperiment>();
services.AddSingleton<IExperimentConfiguration, RustCliDetectorExperiment>();
services.AddSingleton<IExperimentConfiguration, Go117DetectorExperiment>();
services.AddSingleton<IExperimentConfiguration, DotNetDetectorExperiment>();

// Detectors
// CocoaPods
Expand All @@ -79,6 +81,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
// Dockerfile
services.AddSingleton<IComponentDetector, DockerfileComponentDetector>();

// DotNet
services.AddSingleton<IComponentDetector, DotNetComponentDetector>();

// Go
services.AddSingleton<IComponentDetector, GoComponentDetector>();
services.AddSingleton<IComponentDetector, Go117ComponentDetector>();
Expand Down
Loading
Loading