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

- 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:
StellaOps Bot
2025-12-07 00:27:33 +02:00
parent 9bd6a73926
commit 0de92144d2
229 changed files with 32351 additions and 1481 deletions

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

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

View File

@@ -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
};
}

View File

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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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();
}
}

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -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>

View File

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