feat(api): Implement Console Export Client and Models
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
- Added ConsoleExportClient for managing export requests and responses. - Introduced ConsoleExportRequest and ConsoleExportResponse models. - Implemented methods for creating and retrieving exports with appropriate headers. feat(crypto): Add Software SM2/SM3 Cryptography Provider - Implemented SmSoftCryptoProvider for software-only SM2/SM3 cryptography. - Added support for signing and verification using SM2 algorithm. - Included hashing functionality with SM3 algorithm. - Configured options for loading keys from files and environment gate checks. test(crypto): Add unit tests for SmSoftCryptoProvider - Created comprehensive tests for signing, verifying, and hashing functionalities. - Ensured correct behavior for key management and error handling. feat(api): Enhance Console Export Models - Expanded ConsoleExport models to include detailed status and event types. - Added support for various export formats and notification options. test(time): Implement TimeAnchorPolicyService tests - Developed tests for TimeAnchorPolicyService to validate time anchors. - Covered scenarios for anchor validation, drift calculation, and policy enforcement.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a declared .NET package dependency with full coordinates and metadata.
|
||||
/// Used across MSBuild (.csproj), packages.config, and lock file parsers.
|
||||
/// </summary>
|
||||
internal sealed record DotNetDependencyDeclaration
|
||||
{
|
||||
/// <summary>
|
||||
/// Package identifier (e.g., "Newtonsoft.Json", "Microsoft.Extensions.Logging").
|
||||
/// </summary>
|
||||
public required string PackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version string. May contain property placeholders (e.g., "$(SerilogVersion)") that need resolution.
|
||||
/// Can also be a version range (e.g., "[1.0,2.0)").
|
||||
/// </summary>
|
||||
public required string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target framework(s) for this dependency.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TargetFrameworks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a development-only dependency (PrivateAssets="all").
|
||||
/// </summary>
|
||||
public bool IsDevelopmentDependency { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include assets from this package.
|
||||
/// </summary>
|
||||
public string? IncludeAssets { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to exclude assets from this package.
|
||||
/// </summary>
|
||||
public string? ExcludeAssets { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assets that should not flow to parent project.
|
||||
/// </summary>
|
||||
public string? PrivateAssets { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression for conditional PackageReference.
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of this declaration.
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path locator relative to the project root.
|
||||
/// </summary>
|
||||
public string? Locator { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates how the version was resolved.
|
||||
/// </summary>
|
||||
public DotNetVersionSource VersionSource { get; init; } = DotNetVersionSource.Direct;
|
||||
|
||||
/// <summary>
|
||||
/// Original property name if version came from a property (e.g., "SerilogVersion").
|
||||
/// </summary>
|
||||
public string? VersionProperty { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether version is fully resolved (no remaining $(...) placeholders).
|
||||
/// </summary>
|
||||
public bool IsVersionResolved => Version is not null &&
|
||||
!Version.Contains("$(", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a unique key for deduplication.
|
||||
/// </summary>
|
||||
public string Key => BuildKey(PackageId, Version ?? "*");
|
||||
|
||||
/// <summary>
|
||||
/// Returns the package coordinate as "PackageId@Version".
|
||||
/// </summary>
|
||||
public string Coordinate => Version is null
|
||||
? PackageId
|
||||
: $"{PackageId}@{Version}";
|
||||
|
||||
private static string BuildKey(string packageId, string version)
|
||||
=> $"{packageId}@{version}".ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the source of version resolution.
|
||||
/// </summary>
|
||||
internal enum DotNetVersionSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Version declared directly in the PackageReference.
|
||||
/// </summary>
|
||||
Direct,
|
||||
|
||||
/// <summary>
|
||||
/// Version inherited from Directory.Build.props.
|
||||
/// </summary>
|
||||
DirectoryBuildProps,
|
||||
|
||||
/// <summary>
|
||||
/// Version resolved from Central Package Management (Directory.Packages.props).
|
||||
/// </summary>
|
||||
CentralPackageManagement,
|
||||
|
||||
/// <summary>
|
||||
/// Version resolved from a property placeholder.
|
||||
/// </summary>
|
||||
Property,
|
||||
|
||||
/// <summary>
|
||||
/// Version resolved from packages.lock.json.
|
||||
/// </summary>
|
||||
LockFile,
|
||||
|
||||
/// <summary>
|
||||
/// Version from legacy packages.config.
|
||||
/// </summary>
|
||||
PackagesConfig,
|
||||
|
||||
/// <summary>
|
||||
/// Version could not be resolved.
|
||||
/// </summary>
|
||||
Unresolved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps dependency scopes to risk levels for security analysis.
|
||||
/// </summary>
|
||||
internal static class DotNetScopeClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps .NET dependency characteristics to a risk level.
|
||||
/// </summary>
|
||||
public static string GetRiskLevel(DotNetDependencyDeclaration dependency)
|
||||
{
|
||||
if (dependency.IsDevelopmentDependency)
|
||||
{
|
||||
return "development";
|
||||
}
|
||||
|
||||
// Check PrivateAssets for development-only patterns
|
||||
if (!string.IsNullOrEmpty(dependency.PrivateAssets))
|
||||
{
|
||||
var privateAssets = dependency.PrivateAssets.ToLowerInvariant();
|
||||
if (privateAssets.Contains("all", StringComparison.Ordinal) ||
|
||||
privateAssets.Contains("runtime", StringComparison.Ordinal))
|
||||
{
|
||||
return "development";
|
||||
}
|
||||
}
|
||||
|
||||
// Default to production
|
||||
return "production";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the dependency is likely a direct (not transitive) dependency.
|
||||
/// </summary>
|
||||
public static bool IsDirect(DotNetDependencyDeclaration dependency)
|
||||
{
|
||||
// In .NET, all PackageReference entries are direct dependencies
|
||||
// Transitive dependencies only appear in lock files with "type": "Transitive"
|
||||
return dependency.VersionSource is not DotNetVersionSource.LockFile ||
|
||||
dependency.Source?.Contains("Direct", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a project reference within a .NET solution.
|
||||
/// </summary>
|
||||
internal sealed record DotNetProjectReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Relative path to the referenced project.
|
||||
/// </summary>
|
||||
public required string ProjectPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression if conditional.
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file where this reference was declared.
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a framework reference (shared framework).
|
||||
/// </summary>
|
||||
internal sealed record DotNetFrameworkReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Framework name (e.g., "Microsoft.AspNetCore.App").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression if conditional.
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// Represents unified project metadata from .NET project files (.csproj, .fsproj, .vbproj).
|
||||
/// </summary>
|
||||
internal sealed record DotNetProjectMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Project file name (e.g., "MyProject.csproj").
|
||||
/// </summary>
|
||||
public string? ProjectName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target framework(s) for this project.
|
||||
/// Single framework in TargetFramework or multiple in TargetFrameworks.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TargetFrameworks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// SDK type (e.g., "Microsoft.NET.Sdk", "Microsoft.NET.Sdk.Web").
|
||||
/// Null for legacy-style projects.
|
||||
/// </summary>
|
||||
public string? Sdk { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an SDK-style project.
|
||||
/// </summary>
|
||||
public bool IsSdkStyle => !string.IsNullOrEmpty(Sdk);
|
||||
|
||||
/// <summary>
|
||||
/// Output type (Exe, Library, WinExe, etc.).
|
||||
/// </summary>
|
||||
public string? OutputType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assembly name if explicitly set.
|
||||
/// </summary>
|
||||
public string? AssemblyName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root namespace if explicitly set.
|
||||
/// </summary>
|
||||
public string? RootNamespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Project version if set.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package ID for NuGet packaging.
|
||||
/// </summary>
|
||||
public string? PackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Project properties.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Properties { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package dependencies.
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetDependencyDeclaration> PackageReferences { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Project references within the solution.
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetProjectReference> ProjectReferences { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Framework references (shared frameworks).
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetFrameworkReference> FrameworkReferences { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Source file path relative to the root.
|
||||
/// </summary>
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Central Package Management is enabled.
|
||||
/// </summary>
|
||||
public bool ManagePackageVersionsCentrally { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Project type (SDK style, legacy, etc.).
|
||||
/// </summary>
|
||||
public DotNetProjectType ProjectType { get; init; } = DotNetProjectType.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Directory.Build.props if applicable.
|
||||
/// </summary>
|
||||
public DotNetDirectoryBuildReference? DirectoryBuildProps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Directory.Packages.props if applicable.
|
||||
/// </summary>
|
||||
public DotNetDirectoryBuildReference? DirectoryPackagesProps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Declared licenses for the project.
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetProjectLicenseInfo> Licenses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective assembly name.
|
||||
/// </summary>
|
||||
public string? GetEffectiveAssemblyName()
|
||||
=> AssemblyName ?? ProjectName?.Replace(".csproj", string.Empty)
|
||||
.Replace(".fsproj", string.Empty)
|
||||
.Replace(".vbproj", string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the primary target framework (first in list).
|
||||
/// </summary>
|
||||
public string? GetPrimaryTargetFramework()
|
||||
=> TargetFrameworks.Length > 0 ? TargetFrameworks[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// .NET project type classification.
|
||||
/// </summary>
|
||||
internal enum DotNetProjectType
|
||||
{
|
||||
Unknown,
|
||||
SdkStyle,
|
||||
LegacyStyle,
|
||||
LegacyPackagesConfig
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a reference to Directory.Build.props or Directory.Packages.props.
|
||||
/// </summary>
|
||||
internal sealed record DotNetDirectoryBuildReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Absolute path to the file.
|
||||
/// </summary>
|
||||
public required string AbsolutePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path from the project.
|
||||
/// </summary>
|
||||
public string? RelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the file was successfully resolved.
|
||||
/// </summary>
|
||||
public bool IsResolved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolved metadata from the file.
|
||||
/// </summary>
|
||||
public DotNetDirectoryBuildMetadata? ResolvedMetadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata extracted from Directory.Build.props or similar.
|
||||
/// </summary>
|
||||
internal sealed record DotNetDirectoryBuildMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Properties defined in this file.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Properties { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package versions defined (for Directory.Packages.props).
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetPackageVersion> PackageVersions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Import statements for further resolution chain.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Imports { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Path to this file.
|
||||
/// </summary>
|
||||
public string? SourcePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a PackageVersion entry from Directory.Packages.props.
|
||||
/// </summary>
|
||||
internal sealed record DotNetPackageVersion
|
||||
{
|
||||
/// <summary>
|
||||
/// Package identifier.
|
||||
/// </summary>
|
||||
public required string PackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version or version range.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression if conditional.
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License information extracted from project file metadata.
|
||||
/// Note: For nuspec-based license info, see DotNetLicenseInfo in DotNetFileCaches.cs
|
||||
/// </summary>
|
||||
internal sealed record DotNetProjectLicenseInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// SPDX license expression if PackageLicenseExpression is used.
|
||||
/// </summary>
|
||||
public string? Expression { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// License file path if PackageLicenseFile is used.
|
||||
/// </summary>
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// License URL if PackageLicenseUrl is used (deprecated).
|
||||
/// </summary>
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized SPDX identifier.
|
||||
/// </summary>
|
||||
public string? NormalizedSpdxId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level of the normalization.
|
||||
/// </summary>
|
||||
public DotNetProjectLicenseConfidence Confidence { get; init; } = DotNetProjectLicenseConfidence.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for license normalization.
|
||||
/// </summary>
|
||||
internal enum DotNetProjectLicenseConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// No license information available.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Low confidence (URL match only).
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence (name match).
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence (SPDX expression declared).
|
||||
/// </summary>
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents global.json SDK configuration.
|
||||
/// </summary>
|
||||
internal sealed record DotNetGlobalJson
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK version specified.
|
||||
/// </summary>
|
||||
public string? SdkVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Roll-forward policy.
|
||||
/// </summary>
|
||||
public string? RollForward { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow prerelease SDKs.
|
||||
/// </summary>
|
||||
public bool? AllowPrerelease { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MSBuild SDKs specified.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> MsBuildSdks { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source path.
|
||||
/// </summary>
|
||||
public string? SourcePath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
/// <summary>
|
||||
/// Detects assemblies that have been bundled using ILMerge, ILRepack, or similar tools.
|
||||
/// These tools embed multiple assemblies into a single executable.
|
||||
/// </summary>
|
||||
internal static class ILMergedAssemblyDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes an assembly for signs of ILMerge/ILRepack bundling.
|
||||
/// Uses file-based heuristics to avoid loading assemblies into the current domain.
|
||||
/// </summary>
|
||||
public static ILMergeDetectionResult Analyze(string assemblyPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath))
|
||||
{
|
||||
return ILMergeDetectionResult.NotMerged;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var indicators = new List<string>();
|
||||
var embeddedAssemblies = new List<string>();
|
||||
var isMerged = false;
|
||||
|
||||
// Read file bytes to search for patterns
|
||||
var fileBytes = File.ReadAllBytes(assemblyPath);
|
||||
var fileContent = System.Text.Encoding.UTF8.GetString(fileBytes);
|
||||
|
||||
// Check for Costura.Fody patterns (embedded assembly resources)
|
||||
var costuraMatches = CountOccurrences(fileContent, "costura.");
|
||||
if (costuraMatches > 0)
|
||||
{
|
||||
isMerged = true;
|
||||
indicators.Add($"Costura.Fody pattern detected ({costuraMatches} occurrences)");
|
||||
}
|
||||
|
||||
// Check for embedded .dll resource names
|
||||
var dllResourceCount = CountEmbeddedDllPatterns(fileBytes);
|
||||
if (dllResourceCount > 5)
|
||||
{
|
||||
isMerged = true;
|
||||
indicators.Add($"Found {dllResourceCount} potential embedded assembly patterns");
|
||||
}
|
||||
|
||||
// Check for ILMerge/ILRepack markers
|
||||
if (fileContent.Contains("ILMerge", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
isMerged = true;
|
||||
indicators.Add("ILMerge marker detected");
|
||||
}
|
||||
|
||||
if (fileContent.Contains("ILRepack", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
isMerged = true;
|
||||
indicators.Add("ILRepack marker detected");
|
||||
}
|
||||
|
||||
// Check for AssemblyLoader type (common in merged assemblies)
|
||||
if (fileContent.Contains("AssemblyLoader", StringComparison.Ordinal) &&
|
||||
fileContent.Contains("ResolveAssembly", StringComparison.Ordinal))
|
||||
{
|
||||
isMerged = true;
|
||||
indicators.Add("Assembly loader pattern detected");
|
||||
}
|
||||
|
||||
// Check file size - merged assemblies are typically larger
|
||||
var fileInfo = new FileInfo(assemblyPath);
|
||||
if (fileInfo.Length > 5 * 1024 * 1024) // > 5MB
|
||||
{
|
||||
indicators.Add($"Large assembly size: {fileInfo.Length / (1024 * 1024)}MB");
|
||||
}
|
||||
|
||||
return new ILMergeDetectionResult(
|
||||
isMerged,
|
||||
isMerged ? DetermineBundlingTool(indicators) : BundlingTool.None,
|
||||
indicators.ToImmutableArray(),
|
||||
embeddedAssemblies.ToImmutableArray(),
|
||||
NormalizePath(assemblyPath));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return ILMergeDetectionResult.NotMerged;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return ILMergeDetectionResult.NotMerged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks multiple assemblies for bundling.
|
||||
/// </summary>
|
||||
public static ImmutableArray<ILMergeDetectionResult> AnalyzeMany(
|
||||
IEnumerable<string> assemblyPaths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<ILMergeDetectionResult>();
|
||||
|
||||
foreach (var path in assemblyPaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = Analyze(path);
|
||||
if (result.IsMerged)
|
||||
{
|
||||
results.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string content, string pattern)
|
||||
{
|
||||
var count = 0;
|
||||
var index = 0;
|
||||
|
||||
while ((index = content.IndexOf(pattern, index, StringComparison.OrdinalIgnoreCase)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += pattern.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountEmbeddedDllPatterns(byte[] fileBytes)
|
||||
{
|
||||
// Look for ".dll" followed by null terminator patterns
|
||||
// which often indicate embedded resource names
|
||||
var count = 0;
|
||||
var dllPattern = new byte[] { 0x2E, 0x64, 0x6C, 0x6C }; // ".dll"
|
||||
|
||||
for (var i = 0; i < fileBytes.Length - dllPattern.Length; i++)
|
||||
{
|
||||
var match = true;
|
||||
for (var j = 0; j < dllPattern.Length; j++)
|
||||
{
|
||||
if (fileBytes[i + j] != dllPattern[j])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static BundlingTool DetermineBundlingTool(List<string> indicators)
|
||||
{
|
||||
var indicatorText = string.Join(" ", indicators).ToLowerInvariant();
|
||||
|
||||
if (indicatorText.Contains("costura", StringComparison.Ordinal))
|
||||
{
|
||||
return BundlingTool.CosturaFody;
|
||||
}
|
||||
|
||||
if (indicatorText.Contains("ilrepack", StringComparison.Ordinal))
|
||||
{
|
||||
return BundlingTool.ILRepack;
|
||||
}
|
||||
|
||||
if (indicatorText.Contains("ilmerge", StringComparison.Ordinal))
|
||||
{
|
||||
return BundlingTool.ILMerge;
|
||||
}
|
||||
|
||||
return BundlingTool.Unknown;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of ILMerge detection.
|
||||
/// </summary>
|
||||
internal sealed record ILMergeDetectionResult(
|
||||
bool IsMerged,
|
||||
BundlingTool Tool,
|
||||
ImmutableArray<string> Indicators,
|
||||
ImmutableArray<string> EmbeddedAssemblies,
|
||||
string? AssemblyPath)
|
||||
{
|
||||
public static readonly ILMergeDetectionResult NotMerged = new(
|
||||
false,
|
||||
BundlingTool.None,
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known bundling tools.
|
||||
/// </summary>
|
||||
internal enum BundlingTool
|
||||
{
|
||||
None,
|
||||
Unknown,
|
||||
ILMerge,
|
||||
ILRepack,
|
||||
CosturaFody
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
/// <summary>
|
||||
/// Detects .NET single-file applications where assemblies and resources
|
||||
/// are bundled into a single executable.
|
||||
/// </summary>
|
||||
internal static class SingleFileAppDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Magic bytes that indicate a single-file bundle (apphost signature).
|
||||
/// </summary>
|
||||
private static readonly byte[] BundleSignature = ".net core bundle"u8.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Alternative bundle marker used in some versions.
|
||||
/// </summary>
|
||||
private static readonly byte[] BundleMarker = [0x0E, 0x4E, 0x65, 0x74, 0x20, 0x43, 0x6F, 0x72, 0x65];
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a file to determine if it's a .NET single-file application.
|
||||
/// </summary>
|
||||
public static SingleFileDetectionResult Analyze(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return SingleFileDetectionResult.NotSingleFile;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var fileLength = stream.Length;
|
||||
|
||||
// Single-file apps are typically larger (contain bundled assemblies)
|
||||
if (fileLength < 1024 * 100) // Less than 100KB unlikely to be single-file
|
||||
{
|
||||
return SingleFileDetectionResult.NotSingleFile;
|
||||
}
|
||||
|
||||
var indicators = new List<string>();
|
||||
var isSingleFile = false;
|
||||
|
||||
// Check file header for MZ (PE executable)
|
||||
var headerBuffer = new byte[2];
|
||||
if (stream.Read(headerBuffer, 0, 2) != 2 || headerBuffer[0] != 0x4D || headerBuffer[1] != 0x5A)
|
||||
{
|
||||
return SingleFileDetectionResult.NotSingleFile;
|
||||
}
|
||||
|
||||
// Seek to end region to find bundle marker
|
||||
// Bundle manifest is typically at the end of the file
|
||||
var searchLength = Math.Min(fileLength, 64 * 1024); // Search last 64KB
|
||||
var searchStart = fileLength - searchLength;
|
||||
|
||||
stream.Seek(searchStart, SeekOrigin.Begin);
|
||||
var searchBuffer = new byte[searchLength];
|
||||
var bytesRead = stream.Read(searchBuffer, 0, (int)searchLength);
|
||||
|
||||
// Look for bundle signature
|
||||
var signatureIndex = IndexOf(searchBuffer, BundleSignature, bytesRead);
|
||||
if (signatureIndex >= 0)
|
||||
{
|
||||
isSingleFile = true;
|
||||
indicators.Add("Bundle signature found: '.net core bundle'");
|
||||
}
|
||||
|
||||
// Look for bundle marker
|
||||
if (!isSingleFile)
|
||||
{
|
||||
var markerIndex = IndexOf(searchBuffer, BundleMarker, bytesRead);
|
||||
if (markerIndex >= 0)
|
||||
{
|
||||
isSingleFile = true;
|
||||
indicators.Add("Bundle marker found");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for embedded resource patterns typical of single-file apps
|
||||
var embeddedPatterns = CountEmbeddedPatterns(searchBuffer, bytesRead);
|
||||
if (embeddedPatterns > 5)
|
||||
{
|
||||
isSingleFile = true;
|
||||
indicators.Add($"Found {embeddedPatterns} embedded assembly patterns");
|
||||
}
|
||||
|
||||
// Estimate bundled assembly count from file size
|
||||
var estimatedAssemblies = EstimateBundledAssemblyCount(fileLength);
|
||||
|
||||
return new SingleFileDetectionResult(
|
||||
isSingleFile,
|
||||
indicators.ToImmutableArray(),
|
||||
estimatedAssemblies,
|
||||
fileLength,
|
||||
NormalizePath(filePath));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return SingleFileDetectionResult.NotSingleFile;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return SingleFileDetectionResult.NotSingleFile;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks multiple files for single-file bundling.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SingleFileDetectionResult> AnalyzeMany(
|
||||
IEnumerable<string> filePaths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<SingleFileDetectionResult>();
|
||||
|
||||
foreach (var path in filePaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = Analyze(path);
|
||||
if (result.IsSingleFile)
|
||||
{
|
||||
results.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] buffer, byte[] pattern, int bufferLength)
|
||||
{
|
||||
if (pattern.Length == 0 || bufferLength < pattern.Length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var maxIndex = bufferLength - pattern.Length;
|
||||
for (var i = 0; i <= maxIndex; i++)
|
||||
{
|
||||
var found = true;
|
||||
for (var j = 0; j < pattern.Length; j++)
|
||||
{
|
||||
if (buffer[i + j] != pattern[j])
|
||||
{
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int CountEmbeddedPatterns(byte[] buffer, int bufferLength)
|
||||
{
|
||||
// Count occurrences of ".dll" or "System." patterns
|
||||
var count = 0;
|
||||
var dllPattern = ".dll"u8.ToArray();
|
||||
var systemPattern = "System."u8.ToArray();
|
||||
|
||||
var index = 0;
|
||||
while ((index = IndexOf(buffer[index..bufferLength], dllPattern, bufferLength - index)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index++;
|
||||
if (index >= bufferLength - dllPattern.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
index = 0;
|
||||
while ((index = IndexOf(buffer[index..bufferLength], systemPattern, bufferLength - index)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index++;
|
||||
if (index >= bufferLength - systemPattern.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int EstimateBundledAssemblyCount(long fileSize)
|
||||
{
|
||||
// Rough estimate: average .NET assembly is ~50-100KB
|
||||
// Single-file overhead is ~5MB for runtime
|
||||
const long runtimeOverhead = 5 * 1024 * 1024;
|
||||
const long averageAssemblySize = 75 * 1024;
|
||||
|
||||
if (fileSize <= runtimeOverhead)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)((fileSize - runtimeOverhead) / averageAssemblySize);
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of single-file app detection.
|
||||
/// </summary>
|
||||
internal sealed record SingleFileDetectionResult(
|
||||
bool IsSingleFile,
|
||||
ImmutableArray<string> Indicators,
|
||||
int EstimatedBundledAssemblies,
|
||||
long FileSize,
|
||||
string? FilePath)
|
||||
{
|
||||
public static readonly SingleFileDetectionResult NotSingleFile = new(
|
||||
false,
|
||||
[],
|
||||
0,
|
||||
0,
|
||||
null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file size in a human-readable format.
|
||||
/// </summary>
|
||||
public string HumanReadableSize => FileSize switch
|
||||
{
|
||||
< 1024 => $"{FileSize} B",
|
||||
< 1024 * 1024 => $"{FileSize / 1024.0:F1} KB",
|
||||
< 1024 * 1024 * 1024 => $"{FileSize / (1024.0 * 1024):F1} MB",
|
||||
_ => $"{FileSize / (1024.0 * 1024 * 1024):F1} GB"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Parses global.json files for .NET SDK version configuration.
|
||||
/// </summary>
|
||||
internal static class GlobalJsonParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard file name.
|
||||
/// </summary>
|
||||
public const string FileName = "global.json";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a global.json file asynchronously.
|
||||
/// </summary>
|
||||
public static async ValueTask<GlobalJsonResult> ParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return GlobalJsonResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
using var document = await JsonDocument.ParseAsync(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ParseDocument(document, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return GlobalJsonResult.Empty;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return GlobalJsonResult.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return GlobalJsonResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses global.json content.
|
||||
/// </summary>
|
||||
public static GlobalJsonResult Parse(string content, string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return GlobalJsonResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(content, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
return ParseDocument(document, sourcePath);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return GlobalJsonResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static GlobalJsonResult ParseDocument(JsonDocument document, string? sourcePath)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return GlobalJsonResult.Empty;
|
||||
}
|
||||
|
||||
string? sdkVersion = null;
|
||||
string? rollForward = null;
|
||||
bool? allowPrerelease = null;
|
||||
var msBuildSdks = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Parse sdk section
|
||||
if (root.TryGetProperty("sdk", out var sdkElement) && sdkElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (sdkElement.TryGetProperty("version", out var versionElement) &&
|
||||
versionElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
sdkVersion = versionElement.GetString();
|
||||
}
|
||||
|
||||
if (sdkElement.TryGetProperty("rollForward", out var rollForwardElement) &&
|
||||
rollForwardElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
rollForward = rollForwardElement.GetString();
|
||||
}
|
||||
|
||||
if (sdkElement.TryGetProperty("allowPrerelease", out var prereleaseElement))
|
||||
{
|
||||
allowPrerelease = prereleaseElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Parse msbuild-sdks section
|
||||
if (root.TryGetProperty("msbuild-sdks", out var msBuildSdksElement) &&
|
||||
msBuildSdksElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in msBuildSdksElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
msBuildSdks[property.Name] = property.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new GlobalJsonResult(
|
||||
sdkVersion,
|
||||
rollForward,
|
||||
allowPrerelease,
|
||||
msBuildSdks.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
NormalizePath(sourcePath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the nearest global.json file by traversing up from a project directory.
|
||||
/// </summary>
|
||||
public static string? FindNearest(string startPath, string? rootPath = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(startPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var currentDirectory = File.Exists(startPath)
|
||||
? Path.GetDirectoryName(startPath)
|
||||
: startPath;
|
||||
|
||||
if (string.IsNullOrEmpty(currentDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedRoot = !string.IsNullOrEmpty(rootPath)
|
||||
? Path.GetFullPath(rootPath)
|
||||
: null;
|
||||
|
||||
var depth = 0;
|
||||
const int maxDepth = 10;
|
||||
|
||||
while (!string.IsNullOrEmpty(currentDirectory) && depth < maxDepth)
|
||||
{
|
||||
// Stop at root boundary
|
||||
if (normalizedRoot is not null)
|
||||
{
|
||||
var normalizedCurrent = Path.GetFullPath(currentDirectory);
|
||||
if (!normalizedCurrent.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(currentDirectory, FileName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
var parentDirectory = Path.GetDirectoryName(currentDirectory);
|
||||
if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDirectory)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a global.json file.
|
||||
/// </summary>
|
||||
internal sealed record GlobalJsonResult(
|
||||
string? SdkVersion,
|
||||
string? RollForward,
|
||||
bool? AllowPrerelease,
|
||||
ImmutableDictionary<string, string> MsBuildSdks,
|
||||
string? SourcePath)
|
||||
{
|
||||
public static readonly GlobalJsonResult Empty = new(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
null);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a specific SDK version is pinned.
|
||||
/// </summary>
|
||||
public bool HasPinnedSdkVersion => !string.IsNullOrEmpty(SdkVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Whether MSBuild SDKs are specified.
|
||||
/// </summary>
|
||||
public bool HasMsBuildSdks => MsBuildSdks.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Converts to the project metadata model.
|
||||
/// </summary>
|
||||
public DotNetGlobalJson ToMetadata() => new()
|
||||
{
|
||||
SdkVersion = SdkVersion,
|
||||
RollForward = RollForward,
|
||||
AllowPrerelease = AllowPrerelease,
|
||||
MsBuildSdks = MsBuildSdks,
|
||||
SourcePath = SourcePath
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Parses NuGet.config files for package source and credential configuration.
|
||||
/// </summary>
|
||||
internal static class NuGetConfigParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard file names (case variations).
|
||||
/// </summary>
|
||||
public static readonly string[] FileNames =
|
||||
[
|
||||
"NuGet.config",
|
||||
"nuget.config",
|
||||
"NuGet.Config"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NuGet.config file asynchronously.
|
||||
/// </summary>
|
||||
public static async ValueTask<NuGetConfigResult> ParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return NuGetConfigResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return NuGetConfigResult.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return NuGetConfigResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses NuGet.config content.
|
||||
/// </summary>
|
||||
public static NuGetConfigResult Parse(string content, string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return NuGetConfigResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = XDocument.Parse(content);
|
||||
var root = document.Root;
|
||||
if (root is null || root.Name.LocalName != "configuration")
|
||||
{
|
||||
return NuGetConfigResult.Empty;
|
||||
}
|
||||
|
||||
var packageSources = new List<NuGetPackageSource>();
|
||||
var disabledSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var config = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var packageSourceCredentials = new Dictionary<string, NuGetCredential>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Parse packageSources
|
||||
var packageSourcesElement = root.Element("packageSources");
|
||||
if (packageSourcesElement is not null)
|
||||
{
|
||||
foreach (var add in packageSourcesElement.Elements("add"))
|
||||
{
|
||||
var key = add.Attribute("key")?.Value;
|
||||
var value = add.Attribute("value")?.Value;
|
||||
var protocolVersion = add.Attribute("protocolVersion")?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
packageSources.Add(new NuGetPackageSource(
|
||||
key,
|
||||
value,
|
||||
protocolVersion,
|
||||
IsEnabled: true));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle clear element
|
||||
if (packageSourcesElement.Element("clear") is not null)
|
||||
{
|
||||
// Clear indicates that inherited sources should be ignored
|
||||
config["packageSources.clear"] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
// Parse disabledPackageSources
|
||||
var disabledElement = root.Element("disabledPackageSources");
|
||||
if (disabledElement is not null)
|
||||
{
|
||||
foreach (var add in disabledElement.Elements("add"))
|
||||
{
|
||||
var key = add.Attribute("key")?.Value;
|
||||
var value = add.Attribute("value")?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(key) &&
|
||||
value?.Equals("true", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
disabledSources.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update source enabled status
|
||||
for (var i = 0; i < packageSources.Count; i++)
|
||||
{
|
||||
var source = packageSources[i];
|
||||
if (disabledSources.Contains(source.Name))
|
||||
{
|
||||
packageSources[i] = source with { IsEnabled = false };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse packageSourceCredentials
|
||||
var credentialsElement = root.Element("packageSourceCredentials");
|
||||
if (credentialsElement is not null)
|
||||
{
|
||||
foreach (var sourceElement in credentialsElement.Elements())
|
||||
{
|
||||
var sourceName = sourceElement.Name.LocalName;
|
||||
string? username = null;
|
||||
string? password = null;
|
||||
var isClearTextPassword = false;
|
||||
|
||||
foreach (var add in sourceElement.Elements("add"))
|
||||
{
|
||||
var key = add.Attribute("key")?.Value;
|
||||
var value = add.Attribute("value")?.Value;
|
||||
|
||||
switch (key?.ToLowerInvariant())
|
||||
{
|
||||
case "username":
|
||||
username = value;
|
||||
break;
|
||||
case "clearTextPassword":
|
||||
password = value;
|
||||
isClearTextPassword = true;
|
||||
break;
|
||||
case "password":
|
||||
password = "[encrypted]"; // Don't expose encrypted passwords
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(username))
|
||||
{
|
||||
packageSourceCredentials[sourceName] = new NuGetCredential(
|
||||
sourceName,
|
||||
username,
|
||||
HasPassword: !string.IsNullOrEmpty(password),
|
||||
isClearTextPassword);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse config section
|
||||
var configElement = root.Element("config");
|
||||
if (configElement is not null)
|
||||
{
|
||||
foreach (var add in configElement.Elements("add"))
|
||||
{
|
||||
var key = add.Attribute("key")?.Value;
|
||||
var value = add.Attribute("value")?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
config[key] = value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse packageRestore section
|
||||
var restoreElement = root.Element("packageRestore");
|
||||
if (restoreElement is not null)
|
||||
{
|
||||
foreach (var add in restoreElement.Elements("add"))
|
||||
{
|
||||
var key = add.Attribute("key")?.Value;
|
||||
var value = add.Attribute("value")?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
config[$"packageRestore.{key}"] = value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new NuGetConfigResult(
|
||||
packageSources.ToImmutableArray(),
|
||||
packageSourceCredentials.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
config.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
NormalizePath(sourcePath));
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return NuGetConfigResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the nearest NuGet.config file by traversing up from a directory.
|
||||
/// </summary>
|
||||
public static string? FindNearest(string startPath, string? rootPath = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(startPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var currentDirectory = File.Exists(startPath)
|
||||
? Path.GetDirectoryName(startPath)
|
||||
: startPath;
|
||||
|
||||
if (string.IsNullOrEmpty(currentDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedRoot = !string.IsNullOrEmpty(rootPath)
|
||||
? Path.GetFullPath(rootPath)
|
||||
: null;
|
||||
|
||||
var depth = 0;
|
||||
const int maxDepth = 10;
|
||||
|
||||
while (!string.IsNullOrEmpty(currentDirectory) && depth < maxDepth)
|
||||
{
|
||||
// Stop at root boundary
|
||||
if (normalizedRoot is not null)
|
||||
{
|
||||
var normalizedCurrent = Path.GetFullPath(currentDirectory);
|
||||
if (!normalizedCurrent.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var fileName in FileNames)
|
||||
{
|
||||
var filePath = Path.Combine(currentDirectory, fileName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
var parentDirectory = Path.GetDirectoryName(currentDirectory);
|
||||
if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDirectory)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a NuGet.config file.
|
||||
/// </summary>
|
||||
internal sealed record NuGetConfigResult(
|
||||
ImmutableArray<NuGetPackageSource> PackageSources,
|
||||
ImmutableDictionary<string, NuGetCredential> Credentials,
|
||||
ImmutableDictionary<string, string> Config,
|
||||
string? SourcePath)
|
||||
{
|
||||
public static readonly NuGetConfigResult Empty = new(
|
||||
[],
|
||||
ImmutableDictionary<string, NuGetCredential>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets enabled package sources only.
|
||||
/// </summary>
|
||||
public ImmutableArray<NuGetPackageSource> EnabledSources
|
||||
=> PackageSources.Where(s => s.IsEnabled).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Whether any custom (non-nuget.org) sources are configured.
|
||||
/// </summary>
|
||||
public bool HasCustomSources => PackageSources.Any(s =>
|
||||
!s.Url.Contains("nuget.org", StringComparison.OrdinalIgnoreCase) &&
|
||||
!s.Url.Contains("api.nuget.org", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Whether credentials are configured for any source.
|
||||
/// </summary>
|
||||
public bool HasCredentials => Credentials.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the global packages folder if configured.
|
||||
/// </summary>
|
||||
public string? GlobalPackagesFolder =>
|
||||
Config.TryGetValue("globalPackagesFolder", out var folder) ? folder : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a NuGet package source.
|
||||
/// </summary>
|
||||
internal sealed record NuGetPackageSource(
|
||||
string Name,
|
||||
string Url,
|
||||
string? ProtocolVersion,
|
||||
bool IsEnabled)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this is the official nuget.org source.
|
||||
/// </summary>
|
||||
public bool IsNuGetOrg =>
|
||||
Url.Contains("nuget.org", StringComparison.OrdinalIgnoreCase) ||
|
||||
Url.Contains("api.nuget.org", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a local file path source.
|
||||
/// </summary>
|
||||
public bool IsLocalPath =>
|
||||
!Url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents credentials for a NuGet source.
|
||||
/// </summary>
|
||||
internal sealed record NuGetCredential(
|
||||
string SourceName,
|
||||
string Username,
|
||||
bool HasPassword,
|
||||
bool IsClearTextPassword);
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Conflicts;
|
||||
|
||||
/// <summary>
|
||||
/// Detects version conflicts in .NET dependencies across multiple projects.
|
||||
/// Identifies diamond dependency issues and version mismatches.
|
||||
/// </summary>
|
||||
internal sealed class DotNetVersionConflictDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects conflicts in a collection of dependencies.
|
||||
/// </summary>
|
||||
public ConflictDetectionResult Detect(IEnumerable<DotNetDependencyDeclaration> dependencies)
|
||||
{
|
||||
if (dependencies is null)
|
||||
{
|
||||
return ConflictDetectionResult.Empty;
|
||||
}
|
||||
|
||||
var packageGroups = dependencies
|
||||
.Where(d => !string.IsNullOrEmpty(d.Version))
|
||||
.GroupBy(d => d.PackageId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var conflicts = new List<VersionConflict>();
|
||||
|
||||
foreach (var group in packageGroups)
|
||||
{
|
||||
var versions = group
|
||||
.Select(d => d.Version!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (versions.Count > 1)
|
||||
{
|
||||
var locations = group
|
||||
.Where(d => !string.IsNullOrEmpty(d.Locator))
|
||||
.Select(d => new ConflictLocation(
|
||||
d.Locator!,
|
||||
d.Version!,
|
||||
d.Source ?? "unknown"))
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
conflicts.Add(new VersionConflict(
|
||||
group.Key,
|
||||
versions.ToImmutableArray(),
|
||||
locations,
|
||||
DetermineConflictSeverity(versions)));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort conflicts by severity then by package ID
|
||||
conflicts.Sort((a, b) =>
|
||||
{
|
||||
var severityCompare = b.Severity.CompareTo(a.Severity);
|
||||
return severityCompare != 0 ? severityCompare : string.CompareOrdinal(a.PackageId, b.PackageId);
|
||||
});
|
||||
|
||||
return new ConflictDetectionResult(conflicts.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects conflicts from multiple lock files.
|
||||
/// </summary>
|
||||
public ConflictDetectionResult DetectFromLockFiles(
|
||||
IEnumerable<LockFiles.PackagesLockResult> lockFiles)
|
||||
{
|
||||
var allDependencies = lockFiles
|
||||
.SelectMany(lf => lf.ToDeclarations())
|
||||
.ToList();
|
||||
|
||||
return Detect(allDependencies);
|
||||
}
|
||||
|
||||
private static ConflictSeverity DetermineConflictSeverity(List<string> versions)
|
||||
{
|
||||
if (versions.Count <= 1)
|
||||
{
|
||||
return ConflictSeverity.None;
|
||||
}
|
||||
|
||||
// Parse versions to determine severity
|
||||
var parsedVersions = versions
|
||||
.Select(TryParseVersion)
|
||||
.Where(v => v is not null)
|
||||
.Cast<Version>()
|
||||
.ToList();
|
||||
|
||||
if (parsedVersions.Count < 2)
|
||||
{
|
||||
return ConflictSeverity.Low; // Couldn't parse versions
|
||||
}
|
||||
|
||||
// Check for major version differences
|
||||
var majorVersions = parsedVersions.Select(v => v.Major).Distinct().ToList();
|
||||
if (majorVersions.Count > 1)
|
||||
{
|
||||
return ConflictSeverity.High; // Major version conflict
|
||||
}
|
||||
|
||||
// Check for minor version differences
|
||||
var minorVersions = parsedVersions.Select(v => v.Minor).Distinct().ToList();
|
||||
if (minorVersions.Count > 1)
|
||||
{
|
||||
return ConflictSeverity.Medium; // Minor version conflict
|
||||
}
|
||||
|
||||
return ConflictSeverity.Low; // Patch-level differences only
|
||||
}
|
||||
|
||||
private static Version? TryParseVersion(string versionString)
|
||||
{
|
||||
if (string.IsNullOrEmpty(versionString))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove pre-release suffixes for version comparison
|
||||
var normalized = versionString.Split('-')[0].Split('+')[0];
|
||||
|
||||
return Version.TryParse(normalized, out var version) ? version : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of conflict detection.
|
||||
/// </summary>
|
||||
internal sealed record ConflictDetectionResult(
|
||||
ImmutableArray<VersionConflict> Conflicts)
|
||||
{
|
||||
public static readonly ConflictDetectionResult Empty = new([]);
|
||||
|
||||
/// <summary>
|
||||
/// Whether any conflicts were detected.
|
||||
/// </summary>
|
||||
public bool HasConflicts => Conflicts.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the highest severity among all conflicts.
|
||||
/// </summary>
|
||||
public ConflictSeverity MaxSeverity =>
|
||||
Conflicts.Length > 0 ? Conflicts.Max(c => c.Severity) : ConflictSeverity.None;
|
||||
|
||||
/// <summary>
|
||||
/// Gets conflicts above a certain severity threshold.
|
||||
/// </summary>
|
||||
public ImmutableArray<VersionConflict> GetConflictsAbove(ConflictSeverity threshold)
|
||||
=> Conflicts.Where(c => c.Severity >= threshold).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets high-severity conflicts.
|
||||
/// </summary>
|
||||
public ImmutableArray<VersionConflict> HighSeverityConflicts
|
||||
=> GetConflictsAbove(ConflictSeverity.High);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all affected package IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AffectedPackages
|
||||
=> Conflicts.Select(c => c.PackageId).Distinct().ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a version conflict for a package.
|
||||
/// </summary>
|
||||
internal sealed record VersionConflict(
|
||||
string PackageId,
|
||||
ImmutableArray<string> Versions,
|
||||
ImmutableArray<ConflictLocation> Locations,
|
||||
ConflictSeverity Severity)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a human-readable description of the conflict.
|
||||
/// </summary>
|
||||
public string Description =>
|
||||
$"{PackageId} has {Versions.Length} different versions: {string.Join(", ", Versions)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Location where a specific version of a package is declared.
|
||||
/// </summary>
|
||||
internal sealed record ConflictLocation(
|
||||
string Path,
|
||||
string Version,
|
||||
string Source);
|
||||
|
||||
/// <summary>
|
||||
/// Severity level of a version conflict.
|
||||
/// </summary>
|
||||
internal enum ConflictSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// No conflict.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Low severity - patch version differences.
|
||||
/// </summary>
|
||||
Low = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Medium severity - minor version differences.
|
||||
/// </summary>
|
||||
Medium = 2,
|
||||
|
||||
/// <summary>
|
||||
/// High severity - major version differences.
|
||||
/// </summary>
|
||||
High = 3
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers .NET build-related files including project files, props files,
|
||||
/// lock files, and configuration files within a directory structure.
|
||||
/// </summary>
|
||||
internal sealed class DotNetBuildFileDiscovery
|
||||
{
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
private static readonly string[] ProjectExtensions =
|
||||
[
|
||||
"*.csproj",
|
||||
"*.fsproj",
|
||||
"*.vbproj"
|
||||
];
|
||||
|
||||
private static readonly string[] SpecialFiles =
|
||||
[
|
||||
"Directory.Build.props",
|
||||
"Directory.Build.targets",
|
||||
"Directory.Packages.props",
|
||||
"packages.config",
|
||||
"packages.lock.json",
|
||||
"global.json",
|
||||
"nuget.config",
|
||||
"NuGet.Config"
|
||||
];
|
||||
|
||||
private static readonly string[] SolutionExtensions =
|
||||
[
|
||||
"*.sln",
|
||||
"*.slnf"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all .NET build files in a directory.
|
||||
/// </summary>
|
||||
public DiscoveryResult Discover(string rootPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return DiscoveryResult.Empty;
|
||||
}
|
||||
|
||||
var projectFiles = new List<DiscoveredFile>();
|
||||
var propsFiles = new List<DiscoveredFile>();
|
||||
var lockFiles = new List<DiscoveredFile>();
|
||||
var configFiles = new List<DiscoveredFile>();
|
||||
var solutionFiles = new List<DiscoveredFile>();
|
||||
var legacyPackagesConfigs = new List<DiscoveredFile>();
|
||||
|
||||
// Discover project files
|
||||
foreach (var pattern in ProjectExtensions)
|
||||
{
|
||||
foreach (var file in EnumerateFilesSafe(rootPath, pattern))
|
||||
{
|
||||
projectFiles.Add(CreateDiscoveredFile(rootPath, file, DotNetFileType.Project));
|
||||
}
|
||||
}
|
||||
|
||||
// Discover solution files
|
||||
foreach (var pattern in SolutionExtensions)
|
||||
{
|
||||
foreach (var file in EnumerateFilesSafe(rootPath, pattern))
|
||||
{
|
||||
solutionFiles.Add(CreateDiscoveredFile(rootPath, file, DotNetFileType.Solution));
|
||||
}
|
||||
}
|
||||
|
||||
// Discover special files
|
||||
foreach (var specialFile in SpecialFiles)
|
||||
{
|
||||
foreach (var file in EnumerateFilesSafe(rootPath, specialFile))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
var fileType = ClassifySpecialFile(fileName);
|
||||
|
||||
switch (fileType)
|
||||
{
|
||||
case DotNetFileType.DirectoryBuildProps:
|
||||
case DotNetFileType.DirectoryPackagesProps:
|
||||
propsFiles.Add(CreateDiscoveredFile(rootPath, file, fileType));
|
||||
break;
|
||||
case DotNetFileType.PackagesLockJson:
|
||||
lockFiles.Add(CreateDiscoveredFile(rootPath, file, fileType));
|
||||
break;
|
||||
case DotNetFileType.PackagesConfig:
|
||||
legacyPackagesConfigs.Add(CreateDiscoveredFile(rootPath, file, fileType));
|
||||
break;
|
||||
case DotNetFileType.GlobalJson:
|
||||
case DotNetFileType.NuGetConfig:
|
||||
configFiles.Add(CreateDiscoveredFile(rootPath, file, fileType));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all results for deterministic output
|
||||
projectFiles.Sort((a, b) => string.CompareOrdinal(a.RelativePath, b.RelativePath));
|
||||
propsFiles.Sort((a, b) => string.CompareOrdinal(a.RelativePath, b.RelativePath));
|
||||
lockFiles.Sort((a, b) => string.CompareOrdinal(a.RelativePath, b.RelativePath));
|
||||
configFiles.Sort((a, b) => string.CompareOrdinal(a.RelativePath, b.RelativePath));
|
||||
solutionFiles.Sort((a, b) => string.CompareOrdinal(a.RelativePath, b.RelativePath));
|
||||
legacyPackagesConfigs.Sort((a, b) => string.CompareOrdinal(a.RelativePath, b.RelativePath));
|
||||
|
||||
return new DiscoveryResult(
|
||||
projectFiles.ToImmutableArray(),
|
||||
solutionFiles.ToImmutableArray(),
|
||||
propsFiles.ToImmutableArray(),
|
||||
lockFiles.ToImmutableArray(),
|
||||
configFiles.ToImmutableArray(),
|
||||
legacyPackagesConfigs.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a directory appears to contain a .NET project or solution.
|
||||
/// </summary>
|
||||
public bool ContainsDotNetFiles(string rootPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for project files
|
||||
foreach (var pattern in ProjectExtensions)
|
||||
{
|
||||
if (EnumerateFilesSafe(rootPath, pattern).Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for solution files
|
||||
foreach (var pattern in SolutionExtensions)
|
||||
{
|
||||
if (EnumerateFilesSafe(rootPath, pattern).Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string pattern)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(rootPath, pattern, Enumeration);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static DiscoveredFile CreateDiscoveredFile(string rootPath, string filePath, DotNetFileType fileType)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootPath, filePath).Replace('\\', '/');
|
||||
return new DiscoveredFile(filePath, relativePath, fileType);
|
||||
}
|
||||
|
||||
private static DotNetFileType ClassifySpecialFile(string fileName) => fileName.ToLowerInvariant() switch
|
||||
{
|
||||
"directory.build.props" => DotNetFileType.DirectoryBuildProps,
|
||||
"directory.build.targets" => DotNetFileType.DirectoryBuildTargets,
|
||||
"directory.packages.props" => DotNetFileType.DirectoryPackagesProps,
|
||||
"packages.config" => DotNetFileType.PackagesConfig,
|
||||
"packages.lock.json" => DotNetFileType.PackagesLockJson,
|
||||
"global.json" => DotNetFileType.GlobalJson,
|
||||
"nuget.config" => DotNetFileType.NuGetConfig,
|
||||
_ => DotNetFileType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of file discovery.
|
||||
/// </summary>
|
||||
internal sealed record DiscoveryResult(
|
||||
ImmutableArray<DiscoveredFile> ProjectFiles,
|
||||
ImmutableArray<DiscoveredFile> SolutionFiles,
|
||||
ImmutableArray<DiscoveredFile> PropsFiles,
|
||||
ImmutableArray<DiscoveredFile> LockFiles,
|
||||
ImmutableArray<DiscoveredFile> ConfigFiles,
|
||||
ImmutableArray<DiscoveredFile> LegacyPackagesConfigs)
|
||||
{
|
||||
public static readonly DiscoveryResult Empty = new([], [], [], [], [], []);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all discovered files.
|
||||
/// </summary>
|
||||
public ImmutableArray<DiscoveredFile> AllFiles
|
||||
{
|
||||
get
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DiscoveredFile>();
|
||||
builder.AddRange(ProjectFiles);
|
||||
builder.AddRange(SolutionFiles);
|
||||
builder.AddRange(PropsFiles);
|
||||
builder.AddRange(LockFiles);
|
||||
builder.AddRange(ConfigFiles);
|
||||
builder.AddRange(LegacyPackagesConfigs);
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether any .NET files were discovered.
|
||||
/// </summary>
|
||||
public bool HasFiles => ProjectFiles.Length > 0 || SolutionFiles.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the discovery found legacy packages.config files.
|
||||
/// </summary>
|
||||
public bool HasLegacyPackagesConfig => LegacyPackagesConfigs.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether Central Package Management files were found.
|
||||
/// </summary>
|
||||
public bool HasCentralPackageManagement =>
|
||||
PropsFiles.Any(f => f.FileType == DotNetFileType.DirectoryPackagesProps);
|
||||
|
||||
/// <summary>
|
||||
/// Gets Directory.Build.props files.
|
||||
/// </summary>
|
||||
public ImmutableArray<DiscoveredFile> DirectoryBuildPropsFiles =>
|
||||
PropsFiles.Where(f => f.FileType == DotNetFileType.DirectoryBuildProps).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets Directory.Packages.props files.
|
||||
/// </summary>
|
||||
public ImmutableArray<DiscoveredFile> DirectoryPackagesPropsFiles =>
|
||||
PropsFiles.Where(f => f.FileType == DotNetFileType.DirectoryPackagesProps).ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered file.
|
||||
/// </summary>
|
||||
internal sealed record DiscoveredFile(
|
||||
string AbsolutePath,
|
||||
string RelativePath,
|
||||
DotNetFileType FileType);
|
||||
|
||||
/// <summary>
|
||||
/// Types of .NET build files.
|
||||
/// </summary>
|
||||
internal enum DotNetFileType
|
||||
{
|
||||
Unknown,
|
||||
Project,
|
||||
Solution,
|
||||
DirectoryBuildProps,
|
||||
DirectoryBuildTargets,
|
||||
DirectoryPackagesProps,
|
||||
PackagesConfig,
|
||||
PackagesLockJson,
|
||||
GlobalJson,
|
||||
NuGetConfig
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Xml.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Inheritance;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Directory.Packages.props files for NuGet Central Package Management (CPM).
|
||||
/// </summary>
|
||||
internal static class CentralPackageManagementParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard file name for CPM.
|
||||
/// </summary>
|
||||
public const string FileName = "Directory.Packages.props";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Directory.Packages.props file asynchronously.
|
||||
/// </summary>
|
||||
public static async ValueTask<CentralPackageManagementResult> ParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return CentralPackageManagementResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return CentralPackageManagementResult.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return CentralPackageManagementResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Directory.Packages.props content.
|
||||
/// </summary>
|
||||
public static CentralPackageManagementResult Parse(string content, string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return CentralPackageManagementResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = XDocument.Parse(content);
|
||||
var root = document.Root;
|
||||
if (root is null || root.Name.LocalName != "Project")
|
||||
{
|
||||
return CentralPackageManagementResult.Empty;
|
||||
}
|
||||
|
||||
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var packageVersions = new List<DotNetPackageVersion>();
|
||||
var globalPackageReferences = new List<DotNetDependencyDeclaration>();
|
||||
|
||||
// Parse PropertyGroup elements
|
||||
foreach (var propertyGroup in root.Elements("PropertyGroup"))
|
||||
{
|
||||
foreach (var property in propertyGroup.Elements())
|
||||
{
|
||||
var name = property.Name.LocalName;
|
||||
var value = property.Value?.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(value) &&
|
||||
!properties.ContainsKey(name))
|
||||
{
|
||||
properties[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ItemGroup elements for PackageVersion entries
|
||||
foreach (var itemGroup in root.Elements("ItemGroup"))
|
||||
{
|
||||
// Parse PackageVersion items
|
||||
foreach (var packageVersion in itemGroup.Elements("PackageVersion"))
|
||||
{
|
||||
var include = packageVersion.Attribute("Include")?.Value;
|
||||
var version = packageVersion.Attribute("Version")?.Value
|
||||
?? packageVersion.Element("Version")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(include))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var condition = packageVersion.Attribute("Condition")?.Value
|
||||
?? itemGroup.Attribute("Condition")?.Value;
|
||||
|
||||
packageVersions.Add(new DotNetPackageVersion
|
||||
{
|
||||
PackageId = include.Trim(),
|
||||
Version = version?.Trim() ?? string.Empty,
|
||||
Condition = condition
|
||||
});
|
||||
}
|
||||
|
||||
// Parse GlobalPackageReference items (global packages applied to all projects)
|
||||
foreach (var globalRef in itemGroup.Elements("GlobalPackageReference"))
|
||||
{
|
||||
var include = globalRef.Attribute("Include")?.Value;
|
||||
var version = globalRef.Attribute("Version")?.Value
|
||||
?? globalRef.Element("Version")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(include))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var condition = globalRef.Attribute("Condition")?.Value
|
||||
?? itemGroup.Attribute("Condition")?.Value;
|
||||
|
||||
var privateAssets = globalRef.Attribute("PrivateAssets")?.Value
|
||||
?? globalRef.Element("PrivateAssets")?.Value;
|
||||
|
||||
var includeAssets = globalRef.Attribute("IncludeAssets")?.Value
|
||||
?? globalRef.Element("IncludeAssets")?.Value;
|
||||
|
||||
globalPackageReferences.Add(new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = include.Trim(),
|
||||
Version = version?.Trim(),
|
||||
Condition = condition,
|
||||
PrivateAssets = privateAssets,
|
||||
IncludeAssets = includeAssets,
|
||||
IsDevelopmentDependency = privateAssets?.Equals("all", StringComparison.OrdinalIgnoreCase) == true,
|
||||
Source = "Directory.Packages.props",
|
||||
Locator = NormalizePath(sourcePath),
|
||||
VersionSource = DotNetVersionSource.CentralPackageManagement
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var isEnabled = properties.TryGetValue("ManagePackageVersionsCentrally", out var enabled) &&
|
||||
enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new CentralPackageManagementResult(
|
||||
isEnabled,
|
||||
properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
packageVersions.ToImmutableArray(),
|
||||
globalPackageReferences.ToImmutableArray(),
|
||||
NormalizePath(sourcePath));
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return CentralPackageManagementResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the nearest Directory.Packages.props file by traversing up from the project directory.
|
||||
/// </summary>
|
||||
public static string? FindNearest(string projectPath, string? rootPath = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(projectPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectDirectory = Path.GetDirectoryName(projectPath);
|
||||
if (string.IsNullOrEmpty(projectDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedRoot = !string.IsNullOrEmpty(rootPath)
|
||||
? Path.GetFullPath(rootPath)
|
||||
: null;
|
||||
|
||||
var currentDirectory = projectDirectory;
|
||||
var depth = 0;
|
||||
const int maxDepth = 10;
|
||||
|
||||
while (!string.IsNullOrEmpty(currentDirectory) && depth < maxDepth)
|
||||
{
|
||||
// Stop at root boundary
|
||||
if (normalizedRoot is not null)
|
||||
{
|
||||
var normalizedCurrent = Path.GetFullPath(currentDirectory);
|
||||
if (!normalizedCurrent.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(currentDirectory, FileName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
var parentDirectory = Path.GetDirectoryName(currentDirectory);
|
||||
if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDirectory)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a Directory.Packages.props file.
|
||||
/// </summary>
|
||||
internal sealed record CentralPackageManagementResult(
|
||||
bool IsEnabled,
|
||||
ImmutableDictionary<string, string> Properties,
|
||||
ImmutableArray<DotNetPackageVersion> PackageVersions,
|
||||
ImmutableArray<DotNetDependencyDeclaration> GlobalPackageReferences,
|
||||
string? SourcePath)
|
||||
{
|
||||
public static readonly CentralPackageManagementResult Empty = new(
|
||||
false,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the version for a package from CPM.
|
||||
/// </summary>
|
||||
public bool TryGetVersion(string packageId, out string? version)
|
||||
{
|
||||
version = null;
|
||||
|
||||
foreach (var pv in PackageVersions)
|
||||
{
|
||||
if (string.Equals(pv.PackageId, packageId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
version = pv.Version;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all package versions as a lookup dictionary.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> GetVersionLookup()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var pv in PackageVersions)
|
||||
{
|
||||
if (!builder.ContainsKey(pv.PackageId))
|
||||
{
|
||||
builder[pv.PackageId] = pv.Version;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Parsing;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Inheritance;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves Directory.Build.props inheritance chains by traversing from a project
|
||||
/// directory up to the root, collecting properties from each level.
|
||||
/// </summary>
|
||||
internal sealed class DirectoryBuildPropsResolver
|
||||
{
|
||||
private const int MaxChainDepth = 10;
|
||||
|
||||
private static readonly string[] DirectoryBuildFileNames =
|
||||
[
|
||||
"Directory.Build.props",
|
||||
"Directory.Build.targets"
|
||||
];
|
||||
|
||||
private readonly Dictionary<string, DotNetDirectoryBuildMetadata> _cache = new(
|
||||
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the Directory.Build.props chain for a project.
|
||||
/// </summary>
|
||||
/// <param name="projectPath">Path to the project file (.csproj).</param>
|
||||
/// <param name="rootPath">Root path to stop traversal (optional).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Resolved directory build reference with full chain.</returns>
|
||||
public async ValueTask<DirectoryBuildChainResult> ResolveChainAsync(
|
||||
string projectPath,
|
||||
string? rootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(projectPath))
|
||||
{
|
||||
return DirectoryBuildChainResult.Empty;
|
||||
}
|
||||
|
||||
var projectDirectory = Path.GetDirectoryName(projectPath);
|
||||
if (string.IsNullOrEmpty(projectDirectory))
|
||||
{
|
||||
return DirectoryBuildChainResult.Empty;
|
||||
}
|
||||
|
||||
var chain = new List<DirectoryBuildChainEntry>();
|
||||
var mergedProperties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var currentDirectory = projectDirectory;
|
||||
var depth = 0;
|
||||
|
||||
// Normalize root path for comparison
|
||||
var normalizedRoot = !string.IsNullOrEmpty(rootPath)
|
||||
? Path.GetFullPath(rootPath)
|
||||
: null;
|
||||
|
||||
while (!string.IsNullOrEmpty(currentDirectory) && depth < MaxChainDepth)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Stop at root boundary
|
||||
if (normalizedRoot is not null)
|
||||
{
|
||||
var normalizedCurrent = Path.GetFullPath(currentDirectory);
|
||||
if (!normalizedCurrent.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var fileName in DirectoryBuildFileNames)
|
||||
{
|
||||
var filePath = Path.Combine(currentDirectory, fileName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var metadata = await GetOrParseAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
chain.Add(new DirectoryBuildChainEntry(
|
||||
NormalizePath(filePath),
|
||||
fileName,
|
||||
metadata,
|
||||
depth));
|
||||
|
||||
// Merge properties (earlier files have higher priority)
|
||||
foreach (var (key, value) in metadata.Properties)
|
||||
{
|
||||
if (!mergedProperties.ContainsKey(key))
|
||||
{
|
||||
mergedProperties[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
var parentDirectory = Path.GetDirectoryName(currentDirectory);
|
||||
if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDirectory)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return new DirectoryBuildChainResult(
|
||||
chain.ToImmutableArray(),
|
||||
mergedProperties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the nearest Directory.Build.props file.
|
||||
/// </summary>
|
||||
public string? FindNearest(string projectPath, string? rootPath = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(projectPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectDirectory = Path.GetDirectoryName(projectPath);
|
||||
if (string.IsNullOrEmpty(projectDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedRoot = !string.IsNullOrEmpty(rootPath)
|
||||
? Path.GetFullPath(rootPath)
|
||||
: null;
|
||||
|
||||
var currentDirectory = projectDirectory;
|
||||
var depth = 0;
|
||||
|
||||
while (!string.IsNullOrEmpty(currentDirectory) && depth < MaxChainDepth)
|
||||
{
|
||||
// Stop at root boundary
|
||||
if (normalizedRoot is not null)
|
||||
{
|
||||
var normalizedCurrent = Path.GetFullPath(currentDirectory);
|
||||
if (!normalizedCurrent.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(currentDirectory, "Directory.Build.props");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
var parentDirectory = Path.GetDirectoryName(currentDirectory);
|
||||
if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDirectory)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async ValueTask<DotNetDirectoryBuildMetadata> GetOrParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(filePath);
|
||||
|
||||
if (_cache.TryGetValue(normalizedPath, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var metadata = await DirectoryBuildPropsParser.ParseAsync(filePath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_cache[normalizedPath] = metadata;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the internal cache.
|
||||
/// </summary>
|
||||
public void ClearCache() => _cache.Clear();
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving a Directory.Build.props chain.
|
||||
/// </summary>
|
||||
internal sealed record DirectoryBuildChainResult(
|
||||
ImmutableArray<DirectoryBuildChainEntry> Chain,
|
||||
ImmutableDictionary<string, string> MergedProperties)
|
||||
{
|
||||
public static readonly DirectoryBuildChainResult Empty = new(
|
||||
[],
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Whether any Directory.Build.props files were found.
|
||||
/// </summary>
|
||||
public bool HasChain => Chain.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nearest Directory.Build.props entry.
|
||||
/// </summary>
|
||||
public DirectoryBuildChainEntry? Nearest => Chain.Length > 0 ? Chain[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in a Directory.Build.props chain.
|
||||
/// </summary>
|
||||
internal sealed record DirectoryBuildChainEntry(
|
||||
string Path,
|
||||
string FileName,
|
||||
DotNetDirectoryBuildMetadata Metadata,
|
||||
int Depth);
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Parsing;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.PropertyResolution;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Inheritance;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an effective project by merging properties and resolving versions from
|
||||
/// Directory.Build.props, Directory.Packages.props, and the project file itself.
|
||||
/// </summary>
|
||||
internal sealed class EffectiveProjectBuilder
|
||||
{
|
||||
private readonly DirectoryBuildPropsResolver _directoryBuildResolver;
|
||||
private readonly Dictionary<string, CentralPackageManagementResult> _cpmCache = new(
|
||||
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
|
||||
|
||||
public EffectiveProjectBuilder()
|
||||
{
|
||||
_directoryBuildResolver = new DirectoryBuildPropsResolver();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an effective project with all properties and versions resolved.
|
||||
/// </summary>
|
||||
/// <param name="projectPath">Path to the project file.</param>
|
||||
/// <param name="rootPath">Root path boundary for inheritance chain resolution.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async ValueTask<EffectiveProjectResult> BuildAsync(
|
||||
string projectPath,
|
||||
string? rootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(projectPath) || !File.Exists(projectPath))
|
||||
{
|
||||
return EffectiveProjectResult.Empty;
|
||||
}
|
||||
|
||||
// Parse the project file
|
||||
var project = await MsBuildProjectParser.ParseAsync(projectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (project == MsBuildProjectParser.Empty)
|
||||
{
|
||||
return EffectiveProjectResult.Empty;
|
||||
}
|
||||
|
||||
// Resolve Directory.Build.props chain
|
||||
var directoryBuildChain = await _directoryBuildResolver
|
||||
.ResolveChainAsync(projectPath, rootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Find and parse Directory.Packages.props
|
||||
var cpmResult = await ResolveCpmAsync(projectPath, rootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Merge all properties
|
||||
var effectiveProperties = MergeProperties(project, directoryBuildChain, cpmResult);
|
||||
|
||||
// Create property resolver
|
||||
var propertyResolver = new MsBuildPropertyResolver(
|
||||
effectiveProperties,
|
||||
directoryBuildChain.Chain.Select(e => e.Metadata.Properties));
|
||||
|
||||
// Resolve package references
|
||||
var resolvedPackages = ResolvePackageReferences(
|
||||
project.PackageReferences,
|
||||
propertyResolver,
|
||||
cpmResult);
|
||||
|
||||
// Check for legacy packages.config
|
||||
var packagesConfig = await TryParsePackagesConfigAsync(projectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new EffectiveProjectResult(
|
||||
project,
|
||||
effectiveProperties,
|
||||
resolvedPackages,
|
||||
packagesConfig?.Packages ?? [],
|
||||
directoryBuildChain,
|
||||
cpmResult,
|
||||
NormalizePath(projectPath));
|
||||
}
|
||||
|
||||
private async ValueTask<CentralPackageManagementResult> ResolveCpmAsync(
|
||||
string projectPath,
|
||||
string? rootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cpmPath = CentralPackageManagementParser.FindNearest(projectPath, rootPath);
|
||||
if (string.IsNullOrEmpty(cpmPath))
|
||||
{
|
||||
return CentralPackageManagementResult.Empty;
|
||||
}
|
||||
|
||||
var normalizedPath = Path.GetFullPath(cpmPath);
|
||||
if (_cpmCache.TryGetValue(normalizedPath, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var result = await CentralPackageManagementParser.ParseAsync(cpmPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_cpmCache[normalizedPath] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> MergeProperties(
|
||||
DotNetProjectMetadata project,
|
||||
DirectoryBuildChainResult directoryBuildChain,
|
||||
CentralPackageManagementResult cpmResult)
|
||||
{
|
||||
var merged = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Start with Directory.Build.props properties (lower priority)
|
||||
foreach (var (key, value) in directoryBuildChain.MergedProperties)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
// Add Directory.Packages.props properties
|
||||
foreach (var (key, value) in cpmResult.Properties)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
// Project properties have highest priority
|
||||
foreach (var (key, value) in project.Properties)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return merged.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ImmutableArray<DotNetDependencyDeclaration> ResolvePackageReferences(
|
||||
ImmutableArray<DotNetDependencyDeclaration> packageReferences,
|
||||
MsBuildPropertyResolver propertyResolver,
|
||||
CentralPackageManagementResult cpmResult)
|
||||
{
|
||||
var resolved = new List<DotNetDependencyDeclaration>();
|
||||
var cpmVersions = cpmResult.GetVersionLookup();
|
||||
|
||||
foreach (var package in packageReferences)
|
||||
{
|
||||
var resolvedPackage = ResolvePackage(package, propertyResolver, cpmVersions, cpmResult.IsEnabled);
|
||||
resolved.Add(resolvedPackage);
|
||||
}
|
||||
|
||||
// Add global package references from CPM
|
||||
foreach (var globalRef in cpmResult.GlobalPackageReferences)
|
||||
{
|
||||
resolved.Add(globalRef);
|
||||
}
|
||||
|
||||
return resolved.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static DotNetDependencyDeclaration ResolvePackage(
|
||||
DotNetDependencyDeclaration package,
|
||||
MsBuildPropertyResolver propertyResolver,
|
||||
ImmutableDictionary<string, string> cpmVersions,
|
||||
bool cpmEnabled)
|
||||
{
|
||||
// If version is not set and CPM is enabled, try to get from CPM
|
||||
if (string.IsNullOrEmpty(package.Version) && cpmEnabled)
|
||||
{
|
||||
if (cpmVersions.TryGetValue(package.PackageId, out var cpmVersion))
|
||||
{
|
||||
return package with
|
||||
{
|
||||
Version = cpmVersion,
|
||||
VersionSource = DotNetVersionSource.CentralPackageManagement
|
||||
};
|
||||
}
|
||||
|
||||
return package with
|
||||
{
|
||||
VersionSource = DotNetVersionSource.Unresolved
|
||||
};
|
||||
}
|
||||
|
||||
// If version contains property placeholder, resolve it
|
||||
if (!string.IsNullOrEmpty(package.Version) &&
|
||||
package.Version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return propertyResolver.ResolveDependency(package);
|
||||
}
|
||||
|
||||
return package;
|
||||
}
|
||||
|
||||
private static async ValueTask<PackagesConfigResult?> TryParsePackagesConfigAsync(
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projectDirectory = Path.GetDirectoryName(projectPath);
|
||||
if (string.IsNullOrEmpty(projectDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var packagesConfigPath = Path.Combine(projectDirectory, PackagesConfigParser.FileName);
|
||||
if (!File.Exists(packagesConfigPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await PackagesConfigParser.ParseAsync(packagesConfigPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all internal caches.
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
_directoryBuildResolver.ClearCache();
|
||||
_cpmCache.Clear();
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building an effective project.
|
||||
/// </summary>
|
||||
internal sealed record EffectiveProjectResult(
|
||||
DotNetProjectMetadata Project,
|
||||
ImmutableDictionary<string, string> EffectiveProperties,
|
||||
ImmutableArray<DotNetDependencyDeclaration> ResolvedPackages,
|
||||
ImmutableArray<DotNetDependencyDeclaration> LegacyPackages,
|
||||
DirectoryBuildChainResult DirectoryBuildChain,
|
||||
CentralPackageManagementResult CentralPackageManagement,
|
||||
string? SourcePath)
|
||||
{
|
||||
public static readonly EffectiveProjectResult Empty = new(
|
||||
MsBuildProjectParser.Empty,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
[],
|
||||
[],
|
||||
DirectoryBuildChainResult.Empty,
|
||||
CentralPackageManagementResult.Empty,
|
||||
null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all package dependencies (SDK-style + legacy).
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetDependencyDeclaration> AllPackages
|
||||
{
|
||||
get
|
||||
{
|
||||
if (LegacyPackages.Length == 0)
|
||||
{
|
||||
return ResolvedPackages;
|
||||
}
|
||||
|
||||
return ResolvedPackages.AddRange(LegacyPackages);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether Central Package Management is enabled for this project.
|
||||
/// </summary>
|
||||
public bool IsCpmEnabled => CentralPackageManagement.IsEnabled ||
|
||||
EffectiveProperties.TryGetValue("ManagePackageVersionsCentrally", out var value) &&
|
||||
value.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets packages with unresolved versions.
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetDependencyDeclaration> UnresolvedPackages
|
||||
=> ResolvedPackages.Where(p => p.VersionSource == DotNetVersionSource.Unresolved ||
|
||||
!p.IsVersionResolved).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary target framework.
|
||||
/// </summary>
|
||||
public string? PrimaryTargetFramework => Project.GetPrimaryTargetFramework();
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.LockFiles;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates discovery and parsing of .NET lock files (packages.lock.json).
|
||||
/// </summary>
|
||||
internal sealed class DotNetLockFileCollector
|
||||
{
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Collects all lock files from a root directory.
|
||||
/// </summary>
|
||||
public async ValueTask<LockFileCollectionResult> CollectAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return LockFileCollectionResult.Empty;
|
||||
}
|
||||
|
||||
var lockFiles = Directory
|
||||
.EnumerateFiles(rootPath, PackagesLockJsonParser.FileName, Enumeration)
|
||||
.OrderBy(static path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (lockFiles.Length == 0)
|
||||
{
|
||||
return LockFileCollectionResult.Empty;
|
||||
}
|
||||
|
||||
var results = new List<LockFileEntry>();
|
||||
var allDependencies = new Dictionary<string, LockedDependency>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var lockFilePath in lockFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await PackagesLockJsonParser.ParseAsync(lockFilePath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result == PackagesLockResult.Empty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativePath = GetRelativePath(rootPath, lockFilePath);
|
||||
|
||||
results.Add(new LockFileEntry(
|
||||
lockFilePath,
|
||||
relativePath,
|
||||
result));
|
||||
|
||||
// Aggregate dependencies (first occurrence wins for deduplication)
|
||||
foreach (var dep in result.Dependencies)
|
||||
{
|
||||
var key = $"{dep.PackageId}@{dep.ResolvedVersion}@{dep.TargetFramework}";
|
||||
if (!allDependencies.ContainsKey(key))
|
||||
{
|
||||
allDependencies[key] = dep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new LockFileCollectionResult(
|
||||
results.ToImmutableArray(),
|
||||
allDependencies.Values.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the lock file associated with a specific project file.
|
||||
/// </summary>
|
||||
public static string? FindForProject(string projectPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(projectPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectDirectory = Path.GetDirectoryName(projectPath);
|
||||
if (string.IsNullOrEmpty(projectDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lockFilePath = Path.Combine(projectDirectory, PackagesLockJsonParser.FileName);
|
||||
return File.Exists(lockFilePath) ? lockFilePath : null;
|
||||
}
|
||||
|
||||
private static string GetRelativePath(string rootPath, string fullPath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(rootPath, fullPath);
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of collecting lock files from a directory.
|
||||
/// </summary>
|
||||
internal sealed record LockFileCollectionResult(
|
||||
ImmutableArray<LockFileEntry> LockFiles,
|
||||
ImmutableArray<LockedDependency> AllDependencies)
|
||||
{
|
||||
public static readonly LockFileCollectionResult Empty = new([], []);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique direct dependencies.
|
||||
/// </summary>
|
||||
public ImmutableArray<LockedDependency> DirectDependencies
|
||||
=> AllDependencies.Where(d => d.IsDirect).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique transitive dependencies.
|
||||
/// </summary>
|
||||
public ImmutableArray<LockedDependency> TransitiveDependencies
|
||||
=> AllDependencies.Where(d => d.IsTransitive).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique package IDs with their resolved versions.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> GetVersionMap()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var dep in AllDependencies)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(dep.ResolvedVersion) && !builder.ContainsKey(dep.PackageId))
|
||||
{
|
||||
builder[dep.PackageId] = dep.ResolvedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts all locked dependencies to dependency declarations.
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetDependencyDeclaration> ToDeclarations()
|
||||
{
|
||||
return AllDependencies.Select(d => new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = d.PackageId,
|
||||
Version = d.ResolvedVersion,
|
||||
TargetFrameworks = !string.IsNullOrEmpty(d.TargetFramework) ? [d.TargetFramework] : [],
|
||||
IsDevelopmentDependency = false,
|
||||
Source = d.IsDirect ? "packages.lock.json (Direct)" : "packages.lock.json (Transitive)",
|
||||
Locator = d.SourcePath,
|
||||
VersionSource = DotNetVersionSource.LockFile
|
||||
}).ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry representing a single lock file.
|
||||
/// </summary>
|
||||
internal sealed record LockFileEntry(
|
||||
string AbsolutePath,
|
||||
string RelativePath,
|
||||
PackagesLockResult ParsedResult);
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.LockFiles;
|
||||
|
||||
/// <summary>
|
||||
/// Parses packages.lock.json files generated by NuGet for locked dependency versions.
|
||||
/// </summary>
|
||||
internal static class PackagesLockJsonParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard file name.
|
||||
/// </summary>
|
||||
public const string FileName = "packages.lock.json";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a packages.lock.json file asynchronously.
|
||||
/// </summary>
|
||||
public static async ValueTask<PackagesLockResult> ParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return PackagesLockResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
using var document = await JsonDocument.ParseAsync(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ParseDocument(document, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return PackagesLockResult.Empty;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return PackagesLockResult.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return PackagesLockResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses packages.lock.json content.
|
||||
/// </summary>
|
||||
public static PackagesLockResult Parse(string content, string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return PackagesLockResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(content, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
return ParseDocument(document, sourcePath);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return PackagesLockResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static PackagesLockResult ParseDocument(JsonDocument document, string? sourcePath)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return PackagesLockResult.Empty;
|
||||
}
|
||||
|
||||
// Get version
|
||||
var version = root.TryGetProperty("version", out var versionElement) &&
|
||||
versionElement.ValueKind == JsonValueKind.Number
|
||||
? versionElement.GetInt32()
|
||||
: 1;
|
||||
|
||||
var dependencies = new List<LockedDependency>();
|
||||
|
||||
// Parse dependencies by target framework
|
||||
if (root.TryGetProperty("dependencies", out var depsElement) &&
|
||||
depsElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var tfmProperty in depsElement.EnumerateObject())
|
||||
{
|
||||
var targetFramework = tfmProperty.Name;
|
||||
|
||||
if (tfmProperty.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var packageProperty in tfmProperty.Value.EnumerateObject())
|
||||
{
|
||||
var dependency = ParseDependency(packageProperty, targetFramework, sourcePath);
|
||||
if (dependency is not null)
|
||||
{
|
||||
dependencies.Add(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new PackagesLockResult(
|
||||
version,
|
||||
dependencies.ToImmutableArray(),
|
||||
NormalizePath(sourcePath));
|
||||
}
|
||||
|
||||
private static LockedDependency? ParseDependency(
|
||||
JsonProperty property,
|
||||
string targetFramework,
|
||||
string? sourcePath)
|
||||
{
|
||||
var packageId = property.Name;
|
||||
if (string.IsNullOrEmpty(packageId) || property.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = property.Value;
|
||||
|
||||
var type = value.TryGetProperty("type", out var typeElement) &&
|
||||
typeElement.ValueKind == JsonValueKind.String
|
||||
? typeElement.GetString()
|
||||
: null;
|
||||
|
||||
var requested = value.TryGetProperty("requested", out var requestedElement) &&
|
||||
requestedElement.ValueKind == JsonValueKind.String
|
||||
? requestedElement.GetString()
|
||||
: null;
|
||||
|
||||
var resolved = value.TryGetProperty("resolved", out var resolvedElement) &&
|
||||
resolvedElement.ValueKind == JsonValueKind.String
|
||||
? resolvedElement.GetString()
|
||||
: null;
|
||||
|
||||
var contentHash = value.TryGetProperty("contentHash", out var hashElement) &&
|
||||
hashElement.ValueKind == JsonValueKind.String
|
||||
? hashElement.GetString()
|
||||
: null;
|
||||
|
||||
// Parse transitive dependencies
|
||||
var transitiveDeps = new List<string>();
|
||||
if (value.TryGetProperty("dependencies", out var depsElement) &&
|
||||
depsElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var depProperty in depsElement.EnumerateObject())
|
||||
{
|
||||
transitiveDeps.Add($"{depProperty.Name}:{depProperty.Value.GetString() ?? ""}");
|
||||
}
|
||||
}
|
||||
|
||||
var isDirect = string.Equals(type, "Direct", StringComparison.OrdinalIgnoreCase);
|
||||
var isTransitive = string.Equals(type, "Transitive", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new LockedDependency(
|
||||
packageId.Trim(),
|
||||
resolved?.Trim(),
|
||||
requested?.Trim(),
|
||||
targetFramework,
|
||||
isDirect,
|
||||
isTransitive,
|
||||
contentHash,
|
||||
transitiveDeps.ToImmutableArray(),
|
||||
NormalizePath(sourcePath));
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a packages.lock.json file.
|
||||
/// </summary>
|
||||
internal sealed record PackagesLockResult(
|
||||
int Version,
|
||||
ImmutableArray<LockedDependency> Dependencies,
|
||||
string? SourcePath)
|
||||
{
|
||||
public static readonly PackagesLockResult Empty = new(0, [], null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets direct dependencies only.
|
||||
/// </summary>
|
||||
public ImmutableArray<LockedDependency> DirectDependencies
|
||||
=> Dependencies.Where(d => d.IsDirect).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets transitive dependencies only.
|
||||
/// </summary>
|
||||
public ImmutableArray<LockedDependency> TransitiveDependencies
|
||||
=> Dependencies.Where(d => d.IsTransitive).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets dependencies for a specific target framework.
|
||||
/// </summary>
|
||||
public ImmutableArray<LockedDependency> GetByTargetFramework(string targetFramework)
|
||||
=> Dependencies.Where(d => string.Equals(d.TargetFramework, targetFramework,
|
||||
StringComparison.OrdinalIgnoreCase)).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Converts locked dependencies to dependency declarations.
|
||||
/// </summary>
|
||||
public ImmutableArray<DotNetDependencyDeclaration> ToDeclarations()
|
||||
{
|
||||
return Dependencies.Select(d => new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = d.PackageId,
|
||||
Version = d.ResolvedVersion,
|
||||
TargetFrameworks = !string.IsNullOrEmpty(d.TargetFramework) ? [d.TargetFramework] : [],
|
||||
IsDevelopmentDependency = false,
|
||||
Source = d.IsDirect ? "packages.lock.json (Direct)" : "packages.lock.json (Transitive)",
|
||||
Locator = SourcePath,
|
||||
VersionSource = DotNetVersionSource.LockFile
|
||||
}).ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a locked dependency from packages.lock.json.
|
||||
/// </summary>
|
||||
internal sealed record LockedDependency(
|
||||
string PackageId,
|
||||
string? ResolvedVersion,
|
||||
string? RequestedVersion,
|
||||
string TargetFramework,
|
||||
bool IsDirect,
|
||||
bool IsTransitive,
|
||||
string? ContentHash,
|
||||
ImmutableArray<string> Dependencies,
|
||||
string? SourcePath);
|
||||
@@ -0,0 +1,483 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Xml.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Parses SDK-style and legacy .NET project files (.csproj, .fsproj, .vbproj).
|
||||
/// Uses LINQ to XML for lightweight parsing without full MSBuild evaluation.
|
||||
/// </summary>
|
||||
internal static class MsBuildProjectParser
|
||||
{
|
||||
private static readonly XNamespace MsBuildNamespace = "http://schemas.microsoft.com/developer/msbuild/2003";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a project file asynchronously.
|
||||
/// </summary>
|
||||
public static async ValueTask<DotNetProjectMetadata> ParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses project file content.
|
||||
/// </summary>
|
||||
public static DotNetProjectMetadata Parse(string content, string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = XDocument.Parse(content);
|
||||
var root = document.Root;
|
||||
if (root is null || root.Name.LocalName != "Project")
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var isSdkStyle = IsSdkStyleProject(root);
|
||||
var ns = isSdkStyle ? XNamespace.None : MsBuildNamespace;
|
||||
|
||||
var properties = ParseProperties(root, ns);
|
||||
var packageReferences = ParsePackageReferences(root, ns, sourcePath);
|
||||
var projectReferences = ParseProjectReferences(root, ns, sourcePath);
|
||||
var frameworkReferences = ParseFrameworkReferences(root, ns);
|
||||
var targetFrameworks = ParseTargetFrameworks(properties);
|
||||
var licenses = ParseLicenses(properties);
|
||||
|
||||
var projectName = !string.IsNullOrEmpty(sourcePath)
|
||||
? Path.GetFileName(sourcePath)
|
||||
: null;
|
||||
|
||||
var projectType = DetermineProjectType(root, ns, sourcePath);
|
||||
|
||||
return new DotNetProjectMetadata
|
||||
{
|
||||
ProjectName = projectName,
|
||||
Sdk = GetSdk(root),
|
||||
TargetFrameworks = targetFrameworks,
|
||||
OutputType = properties.GetValueOrDefault("OutputType"),
|
||||
AssemblyName = properties.GetValueOrDefault("AssemblyName"),
|
||||
RootNamespace = properties.GetValueOrDefault("RootNamespace"),
|
||||
Version = properties.GetValueOrDefault("Version"),
|
||||
PackageId = properties.GetValueOrDefault("PackageId"),
|
||||
Properties = properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
PackageReferences = packageReferences.ToImmutableArray(),
|
||||
ProjectReferences = projectReferences.ToImmutableArray(),
|
||||
FrameworkReferences = frameworkReferences.ToImmutableArray(),
|
||||
SourcePath = NormalizePath(sourcePath),
|
||||
ManagePackageVersionsCentrally = properties.GetValueOrDefault("ManagePackageVersionsCentrally")
|
||||
?.Equals("true", StringComparison.OrdinalIgnoreCase) == true,
|
||||
ProjectType = projectType,
|
||||
Licenses = licenses.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty project metadata for failed parsing.
|
||||
/// </summary>
|
||||
public static DotNetProjectMetadata Empty { get; } = new();
|
||||
|
||||
private static bool IsSdkStyleProject(XElement root)
|
||||
{
|
||||
// SDK-style projects have Sdk attribute on Project element
|
||||
// or use <Sdk Name="..." /> element
|
||||
if (root.Attribute("Sdk") is not null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for <Sdk Name="..." /> element
|
||||
if (root.Elements("Sdk").Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if there's no namespace (SDK-style projects don't use the MSBuild namespace)
|
||||
return root.Name.Namespace == XNamespace.None;
|
||||
}
|
||||
|
||||
private static string? GetSdk(XElement root)
|
||||
{
|
||||
// Check Sdk attribute first
|
||||
var sdkAttribute = root.Attribute("Sdk");
|
||||
if (sdkAttribute is not null)
|
||||
{
|
||||
return sdkAttribute.Value;
|
||||
}
|
||||
|
||||
// Check for <Sdk Name="..." /> element
|
||||
var sdkElement = root.Element("Sdk");
|
||||
return sdkElement?.Attribute("Name")?.Value;
|
||||
}
|
||||
|
||||
private static DotNetProjectType DetermineProjectType(XElement root, XNamespace ns, string? sourcePath)
|
||||
{
|
||||
if (IsSdkStyleProject(root))
|
||||
{
|
||||
return DotNetProjectType.SdkStyle;
|
||||
}
|
||||
|
||||
// Check for packages.config in the same directory
|
||||
if (!string.IsNullOrEmpty(sourcePath))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(sourcePath);
|
||||
if (!string.IsNullOrEmpty(directory) && File.Exists(Path.Combine(directory, "packages.config")))
|
||||
{
|
||||
return DotNetProjectType.LegacyPackagesConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return DotNetProjectType.LegacyStyle;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseProperties(XElement root, XNamespace ns)
|
||||
{
|
||||
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var propertyGroup in root.Elements(ns + "PropertyGroup"))
|
||||
{
|
||||
foreach (var property in propertyGroup.Elements())
|
||||
{
|
||||
var name = property.Name.LocalName;
|
||||
var value = property.Value?.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
// Only set if not already defined (first wins)
|
||||
if (!properties.ContainsKey(name))
|
||||
{
|
||||
properties[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static List<DotNetDependencyDeclaration> ParsePackageReferences(
|
||||
XElement root,
|
||||
XNamespace ns,
|
||||
string? sourcePath)
|
||||
{
|
||||
var references = new List<DotNetDependencyDeclaration>();
|
||||
|
||||
foreach (var itemGroup in root.Elements(ns + "ItemGroup"))
|
||||
{
|
||||
foreach (var packageRef in itemGroup.Elements(ns + "PackageReference"))
|
||||
{
|
||||
var packageId = packageRef.Attribute("Include")?.Value
|
||||
?? packageRef.Attribute("Update")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(packageId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Version can be attribute or child element
|
||||
var version = packageRef.Attribute("Version")?.Value
|
||||
?? packageRef.Element(ns + "Version")?.Value;
|
||||
|
||||
var condition = packageRef.Attribute("Condition")?.Value
|
||||
?? itemGroup.Attribute("Condition")?.Value;
|
||||
|
||||
var includeAssets = packageRef.Attribute("IncludeAssets")?.Value
|
||||
?? packageRef.Element(ns + "IncludeAssets")?.Value;
|
||||
|
||||
var excludeAssets = packageRef.Attribute("ExcludeAssets")?.Value
|
||||
?? packageRef.Element(ns + "ExcludeAssets")?.Value;
|
||||
|
||||
var privateAssets = packageRef.Attribute("PrivateAssets")?.Value
|
||||
?? packageRef.Element(ns + "PrivateAssets")?.Value;
|
||||
|
||||
var isDevelopmentDependency = privateAssets?.Equals("all", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
references.Add(new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = packageId.Trim(),
|
||||
Version = version?.Trim(),
|
||||
Condition = condition,
|
||||
IncludeAssets = includeAssets,
|
||||
ExcludeAssets = excludeAssets,
|
||||
PrivateAssets = privateAssets,
|
||||
IsDevelopmentDependency = isDevelopmentDependency,
|
||||
Source = "csproj",
|
||||
Locator = NormalizePath(sourcePath),
|
||||
VersionSource = DetermineVersionSource(version)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
private static List<DotNetProjectReference> ParseProjectReferences(
|
||||
XElement root,
|
||||
XNamespace ns,
|
||||
string? sourcePath)
|
||||
{
|
||||
var references = new List<DotNetProjectReference>();
|
||||
|
||||
foreach (var itemGroup in root.Elements(ns + "ItemGroup"))
|
||||
{
|
||||
foreach (var projectRef in itemGroup.Elements(ns + "ProjectReference"))
|
||||
{
|
||||
var includePath = projectRef.Attribute("Include")?.Value;
|
||||
if (string.IsNullOrEmpty(includePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var condition = projectRef.Attribute("Condition")?.Value
|
||||
?? itemGroup.Attribute("Condition")?.Value;
|
||||
|
||||
references.Add(new DotNetProjectReference
|
||||
{
|
||||
ProjectPath = NormalizePath(includePath) ?? includePath,
|
||||
Condition = condition,
|
||||
Source = NormalizePath(sourcePath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
private static List<DotNetFrameworkReference> ParseFrameworkReferences(XElement root, XNamespace ns)
|
||||
{
|
||||
var references = new List<DotNetFrameworkReference>();
|
||||
|
||||
foreach (var itemGroup in root.Elements(ns + "ItemGroup"))
|
||||
{
|
||||
foreach (var frameworkRef in itemGroup.Elements(ns + "FrameworkReference"))
|
||||
{
|
||||
var include = frameworkRef.Attribute("Include")?.Value;
|
||||
if (string.IsNullOrEmpty(include))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var condition = frameworkRef.Attribute("Condition")?.Value
|
||||
?? itemGroup.Attribute("Condition")?.Value;
|
||||
|
||||
references.Add(new DotNetFrameworkReference
|
||||
{
|
||||
Name = include.Trim(),
|
||||
Condition = condition
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ParseTargetFrameworks(Dictionary<string, string> properties)
|
||||
{
|
||||
// Check TargetFrameworks (plural) first
|
||||
if (properties.TryGetValue("TargetFrameworks", out var tfms) && !string.IsNullOrEmpty(tfms))
|
||||
{
|
||||
return tfms.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
// Fall back to TargetFramework (singular)
|
||||
if (properties.TryGetValue("TargetFramework", out var tfm) && !string.IsNullOrEmpty(tfm))
|
||||
{
|
||||
return [tfm.Trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static List<DotNetProjectLicenseInfo> ParseLicenses(Dictionary<string, string> properties)
|
||||
{
|
||||
var licenses = new List<DotNetProjectLicenseInfo>();
|
||||
|
||||
var expression = properties.GetValueOrDefault("PackageLicenseExpression");
|
||||
var file = properties.GetValueOrDefault("PackageLicenseFile");
|
||||
var url = properties.GetValueOrDefault("PackageLicenseUrl");
|
||||
|
||||
if (!string.IsNullOrEmpty(expression) || !string.IsNullOrEmpty(file) || !string.IsNullOrEmpty(url))
|
||||
{
|
||||
var confidence = !string.IsNullOrEmpty(expression)
|
||||
? DotNetProjectLicenseConfidence.High
|
||||
: !string.IsNullOrEmpty(url)
|
||||
? DotNetProjectLicenseConfidence.Low
|
||||
: DotNetProjectLicenseConfidence.Medium;
|
||||
|
||||
licenses.Add(new DotNetProjectLicenseInfo
|
||||
{
|
||||
Expression = expression,
|
||||
File = file,
|
||||
Url = url,
|
||||
NormalizedSpdxId = expression, // SPDX expressions are already normalized
|
||||
Confidence = confidence
|
||||
});
|
||||
}
|
||||
|
||||
return licenses;
|
||||
}
|
||||
|
||||
private static DotNetVersionSource DetermineVersionSource(string? version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
// No version - might come from CPM
|
||||
return DotNetVersionSource.Unresolved;
|
||||
}
|
||||
|
||||
if (version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return DotNetVersionSource.Property;
|
||||
}
|
||||
|
||||
return DotNetVersionSource.Direct;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Directory.Build.props files.
|
||||
/// </summary>
|
||||
internal static class DirectoryBuildPropsParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard file names to search for.
|
||||
/// </summary>
|
||||
public static readonly string[] FileNames =
|
||||
[
|
||||
"Directory.Build.props",
|
||||
"Directory.Build.targets"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Directory.Build.props file asynchronously.
|
||||
/// </summary>
|
||||
public static async ValueTask<DotNetDirectoryBuildMetadata> ParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Directory.Build.props content.
|
||||
/// </summary>
|
||||
public static DotNetDirectoryBuildMetadata Parse(string content, string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = XDocument.Parse(content);
|
||||
var root = document.Root;
|
||||
if (root is null || root.Name.LocalName != "Project")
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var imports = new List<string>();
|
||||
|
||||
// Parse PropertyGroup elements
|
||||
foreach (var propertyGroup in root.Elements("PropertyGroup"))
|
||||
{
|
||||
foreach (var property in propertyGroup.Elements())
|
||||
{
|
||||
var name = property.Name.LocalName;
|
||||
var value = property.Value?.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(value) &&
|
||||
!properties.ContainsKey(name))
|
||||
{
|
||||
properties[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Import elements
|
||||
foreach (var import in root.Elements("Import"))
|
||||
{
|
||||
var project = import.Attribute("Project")?.Value;
|
||||
if (!string.IsNullOrEmpty(project))
|
||||
{
|
||||
imports.Add(project);
|
||||
}
|
||||
}
|
||||
|
||||
return new DotNetDirectoryBuildMetadata
|
||||
{
|
||||
Properties = properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
Imports = imports.ToImmutableArray(),
|
||||
SourcePath = sourcePath?.Replace('\\', '/')
|
||||
};
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty metadata for failed parsing.
|
||||
/// </summary>
|
||||
public static DotNetDirectoryBuildMetadata Empty { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Xml.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Parses legacy packages.config files from .NET Framework projects.
|
||||
/// </summary>
|
||||
internal static class PackagesConfigParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard file name.
|
||||
/// </summary>
|
||||
public const string FileName = "packages.config";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a packages.config file asynchronously.
|
||||
/// </summary>
|
||||
public static async ValueTask<PackagesConfigResult> ParseAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return PackagesConfigResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return PackagesConfigResult.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return PackagesConfigResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses packages.config content.
|
||||
/// </summary>
|
||||
public static PackagesConfigResult Parse(string content, string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return PackagesConfigResult.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = XDocument.Parse(content);
|
||||
var root = document.Root;
|
||||
if (root is null || root.Name.LocalName != "packages")
|
||||
{
|
||||
return PackagesConfigResult.Empty;
|
||||
}
|
||||
|
||||
var packages = new List<DotNetDependencyDeclaration>();
|
||||
|
||||
foreach (var packageElement in root.Elements("package"))
|
||||
{
|
||||
var id = packageElement.Attribute("id")?.Value;
|
||||
var version = packageElement.Attribute("version")?.Value;
|
||||
var targetFramework = packageElement.Attribute("targetFramework")?.Value;
|
||||
var developmentDependency = packageElement.Attribute("developmentDependency")?.Value;
|
||||
var allowedVersions = packageElement.Attribute("allowedVersions")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDevelopmentDependency = developmentDependency?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
packages.Add(new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = id.Trim(),
|
||||
Version = version?.Trim(),
|
||||
TargetFrameworks = !string.IsNullOrEmpty(targetFramework)
|
||||
? [targetFramework]
|
||||
: [],
|
||||
IsDevelopmentDependency = isDevelopmentDependency,
|
||||
Source = "packages.config",
|
||||
Locator = NormalizePath(sourcePath),
|
||||
VersionSource = DotNetVersionSource.PackagesConfig
|
||||
});
|
||||
}
|
||||
|
||||
return new PackagesConfigResult(
|
||||
packages.ToImmutableArray(),
|
||||
NormalizePath(sourcePath));
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return PackagesConfigResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a packages.config file.
|
||||
/// </summary>
|
||||
internal sealed record PackagesConfigResult(
|
||||
ImmutableArray<DotNetDependencyDeclaration> Packages,
|
||||
string? SourcePath)
|
||||
{
|
||||
public static readonly PackagesConfigResult Empty = new([], null);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.PropertyResolution;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves MSBuild property placeholders ($(PropertyName)) in .NET project metadata.
|
||||
/// Supports property chain resolution from Directory.Build.props and environment variables.
|
||||
/// </summary>
|
||||
internal sealed partial class MsBuildPropertyResolver
|
||||
{
|
||||
private const int MaxRecursionDepth = 10;
|
||||
private static readonly Regex PropertyPattern = GetPropertyPattern();
|
||||
|
||||
private readonly ImmutableDictionary<string, string> _projectProperties;
|
||||
private readonly ImmutableArray<ImmutableDictionary<string, string>> _propertyChain;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a property resolver with the given property sources.
|
||||
/// </summary>
|
||||
/// <param name="projectProperties">Properties from the current project.</param>
|
||||
/// <param name="inheritedProperties">Properties from parent Directory.Build.props files, ordered from nearest to root.</param>
|
||||
public MsBuildPropertyResolver(
|
||||
ImmutableDictionary<string, string>? projectProperties = null,
|
||||
IEnumerable<ImmutableDictionary<string, string>>? inheritedProperties = null)
|
||||
{
|
||||
_projectProperties = projectProperties ?? ImmutableDictionary<string, string>.Empty;
|
||||
_propertyChain = inheritedProperties?.ToImmutableArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a resolver from project metadata and its Directory.Build.props chain.
|
||||
/// </summary>
|
||||
public static MsBuildPropertyResolver FromProject(DotNetProjectMetadata project)
|
||||
{
|
||||
var inheritedProps = new List<ImmutableDictionary<string, string>>();
|
||||
|
||||
// Add Directory.Build.props properties
|
||||
if (project.DirectoryBuildProps?.ResolvedMetadata is { } dbp)
|
||||
{
|
||||
inheritedProps.Add(dbp.Properties);
|
||||
}
|
||||
|
||||
return new MsBuildPropertyResolver(project.Properties, inheritedProps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all property placeholders in the given string.
|
||||
/// </summary>
|
||||
/// <param name="value">String containing $(Property) placeholders.</param>
|
||||
/// <returns>Resolved string with all placeholders replaced.</returns>
|
||||
public MsBuildResolutionResult Resolve(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return MsBuildResolutionResult.Empty;
|
||||
}
|
||||
|
||||
if (!value.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return new MsBuildResolutionResult(value, true, []);
|
||||
}
|
||||
|
||||
var unresolvedProperties = new List<string>();
|
||||
var resolved = ResolveInternal(value, 0, unresolvedProperties);
|
||||
|
||||
return new MsBuildResolutionResult(
|
||||
resolved,
|
||||
unresolvedProperties.Count == 0,
|
||||
unresolvedProperties.ToImmutableArray());
|
||||
}
|
||||
|
||||
private string ResolveInternal(string value, int depth, List<string> unresolved)
|
||||
{
|
||||
if (depth >= MaxRecursionDepth)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return PropertyPattern.Replace(value, match =>
|
||||
{
|
||||
var propertyName = match.Groups[1].Value;
|
||||
|
||||
if (TryGetProperty(propertyName, out var propertyValue))
|
||||
{
|
||||
// Recursively resolve nested properties
|
||||
if (propertyValue.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return ResolveInternal(propertyValue, depth + 1, unresolved);
|
||||
}
|
||||
return propertyValue;
|
||||
}
|
||||
|
||||
// Handle built-in MSBuild properties
|
||||
if (TryGetBuiltInProperty(propertyName, out var builtInValue))
|
||||
{
|
||||
return builtInValue;
|
||||
}
|
||||
|
||||
// Try environment variables
|
||||
if (TryGetEnvironmentVariable(propertyName, out var envValue))
|
||||
{
|
||||
return envValue;
|
||||
}
|
||||
|
||||
unresolved.Add(propertyName);
|
||||
return match.Value; // Keep original placeholder
|
||||
});
|
||||
}
|
||||
|
||||
private bool TryGetProperty(string name, out string value)
|
||||
{
|
||||
// First check project properties
|
||||
if (_projectProperties.TryGetValue(name, out value!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then check inherited properties in order
|
||||
foreach (var inheritedProps in _propertyChain)
|
||||
{
|
||||
if (inheritedProps.TryGetValue(name, out value!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetBuiltInProperty(string name, out string value)
|
||||
{
|
||||
// Handle common MSBuild built-in properties
|
||||
value = name switch
|
||||
{
|
||||
"MSBuildProjectDirectory" => ".",
|
||||
"MSBuildProjectFile" => "project.csproj",
|
||||
"MSBuildProjectName" => "project",
|
||||
"MSBuildProjectExtension" => ".csproj",
|
||||
"MSBuildThisFileDirectory" => ".",
|
||||
"Configuration" => "Release",
|
||||
"Platform" => "AnyCPU",
|
||||
"OutputPath" => "bin/$(Configuration)/",
|
||||
"IntermediateOutputPath" => "obj/$(Configuration)/",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
return !string.IsNullOrEmpty(value);
|
||||
}
|
||||
|
||||
private static bool TryGetEnvironmentVariable(string name, out string value)
|
||||
{
|
||||
// Try to get environment variable
|
||||
value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
|
||||
return !string.IsNullOrEmpty(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a dependency declaration, resolving version and other placeholders.
|
||||
/// </summary>
|
||||
public DotNetDependencyDeclaration ResolveDependency(DotNetDependencyDeclaration dependency)
|
||||
{
|
||||
var versionResult = Resolve(dependency.Version);
|
||||
|
||||
return dependency with
|
||||
{
|
||||
Version = versionResult.ResolvedValue,
|
||||
VersionSource = versionResult.IsFullyResolved
|
||||
? DotNetVersionSource.Property
|
||||
: DotNetVersionSource.Unresolved,
|
||||
VersionProperty = dependency.Version?.Contains("$(", StringComparison.Ordinal) == true
|
||||
? ExtractPropertyName(dependency.Version)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractPropertyName(string value)
|
||||
{
|
||||
var match = PropertyPattern.Match(value);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\$\(([^)]+)\)", RegexOptions.Compiled)]
|
||||
private static partial Regex GetPropertyPattern();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an MSBuild property resolution operation.
|
||||
/// </summary>
|
||||
internal sealed record MsBuildResolutionResult(
|
||||
string ResolvedValue,
|
||||
bool IsFullyResolved,
|
||||
ImmutableArray<string> UnresolvedProperties)
|
||||
{
|
||||
public static readonly MsBuildResolutionResult Empty = new(string.Empty, true, []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing MSBuild property dictionaries from various sources.
|
||||
/// </summary>
|
||||
internal sealed class MsBuildPropertyBuilder
|
||||
{
|
||||
private readonly Dictionary<string, string> _properties = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a property if it doesn't already exist.
|
||||
/// </summary>
|
||||
public MsBuildPropertyBuilder Add(string name, string? value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value) && !_properties.ContainsKey(name))
|
||||
{
|
||||
_properties[name] = value;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds project metadata as properties.
|
||||
/// </summary>
|
||||
public MsBuildPropertyBuilder AddProjectMetadata(DotNetProjectMetadata project)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(project.ProjectName))
|
||||
{
|
||||
Add("MSBuildProjectName", Path.GetFileNameWithoutExtension(project.ProjectName));
|
||||
Add("MSBuildProjectFile", project.ProjectName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(project.AssemblyName))
|
||||
{
|
||||
Add("AssemblyName", project.AssemblyName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(project.RootNamespace))
|
||||
{
|
||||
Add("RootNamespace", project.RootNamespace);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(project.Version))
|
||||
{
|
||||
Add("Version", project.Version);
|
||||
Add("PackageVersion", project.Version);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(project.PackageId))
|
||||
{
|
||||
Add("PackageId", project.PackageId);
|
||||
}
|
||||
|
||||
var tfm = project.GetPrimaryTargetFramework();
|
||||
if (!string.IsNullOrEmpty(tfm))
|
||||
{
|
||||
Add("TargetFramework", tfm);
|
||||
}
|
||||
|
||||
if (project.TargetFrameworks.Length > 0)
|
||||
{
|
||||
Add("TargetFrameworks", string.Join(';', project.TargetFrameworks));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all properties from an existing dictionary.
|
||||
/// </summary>
|
||||
public MsBuildPropertyBuilder AddRange(IReadOnlyDictionary<string, string>? properties)
|
||||
{
|
||||
if (properties is null) return this;
|
||||
|
||||
foreach (var (key, value) in properties)
|
||||
{
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds properties from Directory.Build.props metadata.
|
||||
/// </summary>
|
||||
public MsBuildPropertyBuilder AddDirectoryBuildProps(DotNetDirectoryBuildMetadata? metadata)
|
||||
{
|
||||
if (metadata is null) return this;
|
||||
|
||||
return AddRange(metadata.Properties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an immutable property dictionary.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Build()
|
||||
=> _properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -284,7 +284,7 @@ internal sealed record TomlValue(
|
||||
ImmutableArray<TomlValue>? ArrayItems = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a nested value from an inline table.
|
||||
/// Gets a nested string value from an inline table.
|
||||
/// </summary>
|
||||
public string? GetNestedString(string key)
|
||||
{
|
||||
@@ -293,7 +293,9 @@ internal sealed record TomlValue(
|
||||
return null;
|
||||
}
|
||||
|
||||
return TableValue.TryGetValue(key, out var value) ? value.StringValue : null;
|
||||
return TableValue.TryGetValue(key, out var value) && value.Kind == TomlValueKind.String
|
||||
? value.StringValue
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,9 +3,10 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CycloneDX;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CycloneDX;
|
||||
using CycloneDX.Models;
|
||||
using CycloneDX.Models.Vulnerabilities;
|
||||
using JsonSerializer = CycloneDX.Json.Serializer;
|
||||
@@ -112,8 +113,10 @@ public sealed class CycloneDxComposer
|
||||
? root
|
||||
: null;
|
||||
|
||||
request.AdditionalProperties?.TryGetValue("stellaops:composition.manifest", out var compositionUri);
|
||||
request.AdditionalProperties?.TryGetValue("stellaops:composition.recipe", out var compositionRecipeUri);
|
||||
string? compositionUri = null;
|
||||
string? compositionRecipeUri = null;
|
||||
request.AdditionalProperties?.TryGetValue("stellaops:composition.manifest", out compositionUri);
|
||||
request.AdditionalProperties?.TryGetValue("stellaops:composition.recipe", out compositionRecipeUri);
|
||||
|
||||
return new CycloneDxArtifact
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user