up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
|
||||
|
||||
@@ -8,30 +9,295 @@ public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
public string DisplayName => ".NET Analyzer (preview)";
|
||||
|
||||
private DotNetAnalyzerOptions _options = new();
|
||||
|
||||
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
if (packages.Count == 0)
|
||||
_options = DotNetAnalyzerOptions.Load(context);
|
||||
|
||||
// Collect from deps.json files (installed packages)
|
||||
var installedPackages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect declared dependencies from build files
|
||||
var declaredCollector = new DotNetDeclaredDependencyCollector(context, _options);
|
||||
var declaredPackages = await declaredCollector.CollectAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect bundling signals (bounded candidate selection per Decision D3)
|
||||
var bundlingCollector = new DotNetBundlingSignalCollector(context);
|
||||
var bundlingSignals = bundlingCollector.Collect(cancellationToken);
|
||||
|
||||
if (installedPackages.Count > 0)
|
||||
{
|
||||
return;
|
||||
// Merge mode: we have installed packages from deps.json
|
||||
EmitMergedPackages(writer, installedPackages, declaredPackages, bundlingSignals, cancellationToken);
|
||||
}
|
||||
else if (declaredPackages.Count > 0)
|
||||
{
|
||||
// Fallback mode: no deps.json, emit declared-only packages
|
||||
EmitDeclaredOnlyPackages(writer, declaredPackages, cancellationToken);
|
||||
|
||||
// If bundling signals detected without deps.json, emit synthetic bundle markers
|
||||
EmitBundlingOnlySignals(writer, bundlingSignals, cancellationToken);
|
||||
}
|
||||
else if (bundlingSignals.Count > 0)
|
||||
{
|
||||
// Only bundling signals detected (rare case)
|
||||
EmitBundlingOnlySignals(writer, bundlingSignals, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitMergedPackages(
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<DotNetPackage> installedPackages,
|
||||
IReadOnlyList<DotNetDeclaredPackage> declaredPackages,
|
||||
IReadOnlyList<BundlingSignal> bundlingSignals,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Build lookup for declared packages: key = normalizedId::version
|
||||
var declaredLookup = new Dictionary<string, DotNetDeclaredPackage>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var declared in declaredPackages)
|
||||
{
|
||||
if (declared.IsVersionResolved && !string.IsNullOrEmpty(declared.Version))
|
||||
{
|
||||
var key = $"{declared.NormalizedId}::{declared.Version}";
|
||||
if (!declaredLookup.ContainsKey(key))
|
||||
{
|
||||
declaredLookup[key] = declared;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var package in packages)
|
||||
// Build set of matched declared packages
|
||||
var matchedDeclared = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Flag to track if we've attached bundling signals to first entrypoint package
|
||||
var bundlingAttached = false;
|
||||
|
||||
// Emit installed packages, tagging those without declared records
|
||||
foreach (var package in installedPackages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var lookupKey = $"{package.NormalizedId}::{package.Version}";
|
||||
var hasDeclaredRecord = declaredLookup.ContainsKey(lookupKey);
|
||||
|
||||
if (hasDeclaredRecord)
|
||||
{
|
||||
matchedDeclared.Add(lookupKey);
|
||||
}
|
||||
|
||||
var metadata = package.Metadata.ToList();
|
||||
if (!hasDeclaredRecord)
|
||||
{
|
||||
// Tag installed package that has no corresponding declared record
|
||||
metadata.Add(new KeyValuePair<string, string?>("declared.missing", "true"));
|
||||
}
|
||||
|
||||
// Attach bundling signals to entrypoint packages
|
||||
if (!bundlingAttached && bundlingSignals.Count > 0 && package.UsedByEntrypoint)
|
||||
{
|
||||
foreach (var signal in bundlingSignals)
|
||||
{
|
||||
foreach (var kvp in signal.ToMetadata())
|
||||
{
|
||||
metadata.Add(kvp);
|
||||
}
|
||||
}
|
||||
|
||||
bundlingAttached = true;
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: package.Metadata,
|
||||
metadata: metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: package.UsedByEntrypoint);
|
||||
}
|
||||
|
||||
// If no entrypoint package found but bundling signals exist, emit synthetic bundle marker
|
||||
if (!bundlingAttached && bundlingSignals.Count > 0)
|
||||
{
|
||||
EmitBundlingOnlySignals(writer, bundlingSignals, cancellationToken);
|
||||
}
|
||||
|
||||
// Emit declared packages that have no corresponding installed package
|
||||
foreach (var declared in declaredPackages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!declared.IsVersionResolved || string.IsNullOrEmpty(declared.Version))
|
||||
{
|
||||
// Unresolved version - always emit as declared-only with explicit key
|
||||
var metadata = declared.Metadata.ToList();
|
||||
metadata.Add(new KeyValuePair<string, string?>("installed.missing", "true"));
|
||||
if (_options.EmitDependencyEdges && declared.Edges.Count > 0)
|
||||
{
|
||||
AddEdgeMetadata(metadata, declared.Edges, "edge");
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: declared.ComponentKey,
|
||||
purl: null,
|
||||
name: declared.Name,
|
||||
version: declared.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: declared.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lookupKey = $"{declared.NormalizedId}::{declared.Version}";
|
||||
if (matchedDeclared.Contains(lookupKey))
|
||||
{
|
||||
// Already matched with an installed package
|
||||
continue;
|
||||
}
|
||||
|
||||
// Declared package not in installed set - emit as declared-only
|
||||
{
|
||||
var metadata = declared.Metadata.ToList();
|
||||
metadata.Add(new KeyValuePair<string, string?>("installed.missing", "true"));
|
||||
if (_options.EmitDependencyEdges && declared.Edges.Count > 0)
|
||||
{
|
||||
AddEdgeMetadata(metadata, declared.Edges, "edge");
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: declared.Purl!,
|
||||
name: declared.Name,
|
||||
version: declared.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: declared.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitDeclaredOnlyPackages(
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<DotNetDeclaredPackage> declaredPackages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var package in declaredPackages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Build metadata with optional edges
|
||||
var metadata = package.Metadata.ToList();
|
||||
if (_options.EmitDependencyEdges && package.Edges.Count > 0)
|
||||
{
|
||||
AddEdgeMetadata(metadata, package.Edges, "edge");
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
if (package.Purl is not null)
|
||||
{
|
||||
// Resolved version - use PURL
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unresolved version - use explicit key
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: package.ComponentKey,
|
||||
purl: null,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddEdgeMetadata(
|
||||
List<KeyValuePair<string, string?>> metadata,
|
||||
IReadOnlyList<DotNetDependencyEdge> edges,
|
||||
string prefix)
|
||||
{
|
||||
if (edges.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 0; index < edges.Count; index++)
|
||||
{
|
||||
var edge = edges[index];
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].target", edge.Target));
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].reason", edge.Reason));
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].confidence", edge.Confidence));
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].source", edge.Source));
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitBundlingOnlySignals(
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<BundlingSignal> bundlingSignals,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bundlingSignals.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var signal in bundlingSignals)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Emit a synthetic bundle marker component
|
||||
var metadata = new List<KeyValuePair<string, string?>>(signal.ToMetadata())
|
||||
{
|
||||
new("synthetic", "true"),
|
||||
new("provenance", "bundle-detection")
|
||||
};
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
var componentKey = $"bundle:dotnet/{signal.FilePath.Replace('/', '-').Replace('\\', '-')}";
|
||||
var appName = Path.GetFileNameWithoutExtension(signal.FilePath);
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: componentKey,
|
||||
purl: null,
|
||||
name: $"[Bundle] {appName}",
|
||||
version: null,
|
||||
type: "bundle",
|
||||
metadata: metadata,
|
||||
evidence: [new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"bundle-detection",
|
||||
signal.FilePath,
|
||||
signal.Kind.ToString(),
|
||||
null)],
|
||||
usedByEntrypoint: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collects bundling signals from candidate files adjacent to deps.json/runtimeconfig.json.
|
||||
/// Applies Decision D3 bounded candidate selection rules.
|
||||
/// </summary>
|
||||
internal sealed class DotNetBundlingSignalCollector
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum file size to scan (500 MB).
|
||||
/// </summary>
|
||||
private const long MaxFileSizeBytes = 500 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of indicators to include in metadata.
|
||||
/// </summary>
|
||||
private const int MaxIndicators = 5;
|
||||
|
||||
private static readonly string[] ExecutableExtensions = [".exe", ".dll", ""];
|
||||
|
||||
private readonly LanguageAnalyzerContext _context;
|
||||
|
||||
public DotNetBundlingSignalCollector(LanguageAnalyzerContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects bundling signals from candidate files.
|
||||
/// </summary>
|
||||
public IReadOnlyList<BundlingSignal> Collect(CancellationToken cancellationToken)
|
||||
{
|
||||
var signals = new List<BundlingSignal>();
|
||||
var processedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Find all deps.json and runtimeconfig.json files
|
||||
var depsFiles = FindDepsFiles();
|
||||
var runtimeConfigFiles = FindRuntimeConfigFiles();
|
||||
|
||||
// Combine unique directories
|
||||
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var depsFile in depsFiles)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(depsFile);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
directories.Add(dir);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var configFile in runtimeConfigFiles)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(configFile);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
directories.Add(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each directory
|
||||
foreach (var directory in directories.OrderBy(d => d, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var candidates = GetCandidateFiles(directory, depsFiles.Concat(runtimeConfigFiles));
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (processedPaths.Contains(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
processedPaths.Add(candidate);
|
||||
|
||||
var signal = AnalyzeCandidate(candidate, cancellationToken);
|
||||
if (signal is not null)
|
||||
{
|
||||
signals.Add(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signals
|
||||
.OrderBy(s => s.FilePath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string[] FindDepsFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(_context.RootPath, "*.deps.json", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
})
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private string[] FindRuntimeConfigFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(_context.RootPath, "*.runtimeconfig.json", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
})
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetCandidateFiles(string directory, IEnumerable<string> manifestFiles)
|
||||
{
|
||||
// Extract app names from manifest files in this directory
|
||||
var appNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var manifestFile in manifestFiles)
|
||||
{
|
||||
var manifestDir = Path.GetDirectoryName(manifestFile);
|
||||
if (!string.Equals(manifestDir, directory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(manifestFile);
|
||||
|
||||
// Extract app name from "AppName.deps.json" or "AppName.runtimeconfig.json"
|
||||
string? appName = null;
|
||||
if (fileName.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
appName = fileName[..^".deps.json".Length];
|
||||
}
|
||||
else if (fileName.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
appName = fileName[..^".runtimeconfig.json".Length];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(appName))
|
||||
{
|
||||
appNames.Add(appName);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate candidate file paths
|
||||
foreach (var appName in appNames.OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
foreach (var ext in ExecutableExtensions)
|
||||
{
|
||||
var candidatePath = Path.Combine(directory, appName + ext);
|
||||
if (File.Exists(candidatePath))
|
||||
{
|
||||
yield return candidatePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BundlingSignal? AnalyzeCandidate(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = _context.GetRelativePath(filePath).Replace('\\', '/');
|
||||
|
||||
// Check file size
|
||||
if (fileInfo.Length > MaxFileSizeBytes)
|
||||
{
|
||||
return new BundlingSignal(
|
||||
FilePath: relativePath,
|
||||
Kind: BundlingKind.Unknown,
|
||||
IsSkipped: true,
|
||||
SkipReason: "size-exceeded",
|
||||
Indicators: [],
|
||||
SizeBytes: fileInfo.Length,
|
||||
EstimatedBundledAssemblies: 0);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Try single-file detection first
|
||||
var singleFileResult = SingleFileAppDetector.Analyze(filePath);
|
||||
if (singleFileResult.IsSingleFile)
|
||||
{
|
||||
return new BundlingSignal(
|
||||
FilePath: relativePath,
|
||||
Kind: BundlingKind.SingleFile,
|
||||
IsSkipped: false,
|
||||
SkipReason: null,
|
||||
Indicators: singleFileResult.Indicators.Take(MaxIndicators).ToImmutableArray(),
|
||||
SizeBytes: singleFileResult.FileSize,
|
||||
EstimatedBundledAssemblies: singleFileResult.EstimatedBundledAssemblies);
|
||||
}
|
||||
|
||||
// Try ILMerge detection
|
||||
var ilMergeResult = ILMergedAssemblyDetector.Analyze(filePath);
|
||||
if (ilMergeResult.IsMerged)
|
||||
{
|
||||
var kind = ilMergeResult.Tool switch
|
||||
{
|
||||
BundlingTool.ILMerge => BundlingKind.ILMerge,
|
||||
BundlingTool.ILRepack => BundlingKind.ILRepack,
|
||||
BundlingTool.CosturaFody => BundlingKind.CosturaFody,
|
||||
_ => BundlingKind.Unknown
|
||||
};
|
||||
|
||||
return new BundlingSignal(
|
||||
FilePath: relativePath,
|
||||
Kind: kind,
|
||||
IsSkipped: false,
|
||||
SkipReason: null,
|
||||
Indicators: ilMergeResult.Indicators.Take(MaxIndicators).ToImmutableArray(),
|
||||
SizeBytes: fileInfo.Length,
|
||||
EstimatedBundledAssemblies: ilMergeResult.EmbeddedAssemblies.Length);
|
||||
}
|
||||
|
||||
// No bundling detected
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected bundling signal.
|
||||
/// </summary>
|
||||
internal sealed record BundlingSignal(
|
||||
string FilePath,
|
||||
BundlingKind Kind,
|
||||
bool IsSkipped,
|
||||
string? SkipReason,
|
||||
ImmutableArray<string> Indicators,
|
||||
long SizeBytes,
|
||||
int EstimatedBundledAssemblies)
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts to metadata key-value pairs.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> ToMetadata()
|
||||
{
|
||||
yield return new("bundle.detected", "true");
|
||||
yield return new("bundle.filePath", FilePath);
|
||||
yield return new("bundle.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new("bundle.sizeBytes", SizeBytes.ToString());
|
||||
|
||||
if (IsSkipped)
|
||||
{
|
||||
yield return new("bundle.skipped", "true");
|
||||
if (!string.IsNullOrEmpty(SkipReason))
|
||||
{
|
||||
yield return new("bundle.skipReason", SkipReason);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (EstimatedBundledAssemblies > 0)
|
||||
{
|
||||
yield return new("bundle.estimatedAssemblies", EstimatedBundledAssemblies.ToString());
|
||||
}
|
||||
|
||||
for (var i = 0; i < Indicators.Length; i++)
|
||||
{
|
||||
yield return new($"bundle.indicator[{i}]", Indicators[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of bundling detected.
|
||||
/// </summary>
|
||||
internal enum BundlingKind
|
||||
{
|
||||
Unknown,
|
||||
SingleFile,
|
||||
ILMerge,
|
||||
ILRepack,
|
||||
CosturaFody
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.Metadata.Ecma335;
|
||||
using System.Reflection.PortableExecutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Callgraph;
|
||||
|
||||
/// <summary>
|
||||
/// Builds .NET reachability graphs from assembly metadata.
|
||||
/// Extracts methods, call edges, synthetic roots, and emits unknowns.
|
||||
/// </summary>
|
||||
internal sealed class DotNetCallgraphBuilder
|
||||
{
|
||||
private readonly Dictionary<string, DotNetMethodNode> _methods = new();
|
||||
private readonly List<DotNetCallEdge> _edges = new();
|
||||
private readonly List<DotNetSyntheticRoot> _roots = new();
|
||||
private readonly List<DotNetUnknown> _unknowns = new();
|
||||
private readonly Dictionary<string, string> _typeToAssemblyPath = new();
|
||||
private readonly Dictionary<string, string?> _assemblyToPurl = new();
|
||||
private readonly string _contextDigest;
|
||||
private int _assemblyCount;
|
||||
private int _typeCount;
|
||||
|
||||
public DotNetCallgraphBuilder(string contextDigest)
|
||||
{
|
||||
_contextDigest = contextDigest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an assembly to the graph.
|
||||
/// </summary>
|
||||
public void AddAssembly(string assemblyPath, string? purl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(assemblyPath);
|
||||
using var peReader = new PEReader(stream);
|
||||
|
||||
if (!peReader.HasMetadata)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = peReader.GetMetadataReader();
|
||||
var assemblyName = GetAssemblyName(metadata);
|
||||
|
||||
_assemblyCount++;
|
||||
_assemblyToPurl[assemblyName] = purl;
|
||||
|
||||
// Add types and methods
|
||||
foreach (var typeDefHandle in metadata.TypeDefinitions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var typeDef = metadata.GetTypeDefinition(typeDefHandle);
|
||||
AddType(metadata, typeDef, assemblyName, assemblyPath, purl, cancellationToken);
|
||||
}
|
||||
|
||||
// Extract call edges
|
||||
foreach (var typeDefHandle in metadata.TypeDefinitions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var typeDef = metadata.GetTypeDefinition(typeDefHandle);
|
||||
ExtractCallEdgesFromType(metadata, typeDef, assemblyName, assemblyPath, peReader);
|
||||
}
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
var unknownId = DotNetGraphIdentifiers.ComputeUnknownId(
|
||||
assemblyPath,
|
||||
DotNetUnknownType.UnresolvedAssembly,
|
||||
null,
|
||||
null);
|
||||
_unknowns.Add(new DotNetUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: DotNetUnknownType.UnresolvedAssembly,
|
||||
SourceId: assemblyPath,
|
||||
AssemblyName: Path.GetFileName(assemblyPath),
|
||||
TypeName: null,
|
||||
MethodName: null,
|
||||
Reason: "Assembly could not be parsed (invalid format)",
|
||||
AssemblyPath: assemblyPath));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final reachability graph.
|
||||
/// </summary>
|
||||
public DotNetReachabilityGraph Build()
|
||||
{
|
||||
var methods = _methods.Values
|
||||
.OrderBy(m => m.AssemblyName)
|
||||
.ThenBy(m => m.TypeName)
|
||||
.ThenBy(m => m.MethodName)
|
||||
.ThenBy(m => m.Signature)
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = _edges
|
||||
.OrderBy(e => e.CallerId)
|
||||
.ThenBy(e => e.ILOffset)
|
||||
.ToImmutableArray();
|
||||
|
||||
var roots = _roots
|
||||
.OrderBy(r => (int)r.Phase)
|
||||
.ThenBy(r => r.Order)
|
||||
.ThenBy(r => r.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var unknowns = _unknowns
|
||||
.OrderBy(u => u.AssemblyPath)
|
||||
.ThenBy(u => u.SourceId)
|
||||
.ToImmutableArray();
|
||||
|
||||
var contentHash = DotNetGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
|
||||
|
||||
var metadata = new DotNetGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratorVersion: DotNetGraphIdentifiers.GetGeneratorVersion(),
|
||||
ContextDigest: _contextDigest,
|
||||
AssemblyCount: _assemblyCount,
|
||||
TypeCount: _typeCount,
|
||||
MethodCount: methods.Length,
|
||||
EdgeCount: edges.Length,
|
||||
UnknownCount: unknowns.Length,
|
||||
SyntheticRootCount: roots.Length);
|
||||
|
||||
return new DotNetReachabilityGraph(
|
||||
_contextDigest,
|
||||
methods,
|
||||
edges,
|
||||
roots,
|
||||
unknowns,
|
||||
metadata,
|
||||
contentHash);
|
||||
}
|
||||
|
||||
private void AddType(
|
||||
MetadataReader metadata,
|
||||
TypeDefinition typeDef,
|
||||
string assemblyName,
|
||||
string assemblyPath,
|
||||
string? purl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var typeName = GetFullTypeName(metadata, typeDef);
|
||||
if (string.IsNullOrEmpty(typeName) || typeName.StartsWith("<"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_typeCount++;
|
||||
_typeToAssemblyPath[typeName] = assemblyPath;
|
||||
|
||||
var rootOrder = 0;
|
||||
|
||||
foreach (var methodDefHandle in typeDef.GetMethods())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var methodDef = metadata.GetMethodDefinition(methodDefHandle);
|
||||
var methodName = metadata.GetString(methodDef.Name);
|
||||
|
||||
if (string.IsNullOrEmpty(methodName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var signature = GetMethodSignature(metadata, methodDef);
|
||||
var methodId = DotNetGraphIdentifiers.ComputeMethodId(assemblyName, typeName, methodName, signature);
|
||||
var methodDigest = DotNetGraphIdentifiers.ComputeMethodDigest(assemblyName, typeName, methodName, signature);
|
||||
|
||||
var isStatic = (methodDef.Attributes & MethodAttributes.Static) != 0;
|
||||
var isPublic = (methodDef.Attributes & MethodAttributes.Public) != 0;
|
||||
var isVirtual = (methodDef.Attributes & MethodAttributes.Virtual) != 0;
|
||||
var isGeneric = methodDef.GetGenericParameters().Count > 0;
|
||||
|
||||
var node = new DotNetMethodNode(
|
||||
MethodId: methodId,
|
||||
AssemblyName: assemblyName,
|
||||
TypeName: typeName,
|
||||
MethodName: methodName,
|
||||
Signature: signature,
|
||||
Purl: purl,
|
||||
AssemblyPath: assemblyPath,
|
||||
MetadataToken: MetadataTokens.GetToken(methodDefHandle),
|
||||
MethodDigest: methodDigest,
|
||||
IsStatic: isStatic,
|
||||
IsPublic: isPublic,
|
||||
IsVirtual: isVirtual,
|
||||
IsGeneric: isGeneric);
|
||||
|
||||
_methods.TryAdd(methodId, node);
|
||||
|
||||
// Find synthetic roots
|
||||
AddSyntheticRootsForMethod(methodDef, methodName, typeName, methodId, assemblyPath, metadata, ref rootOrder);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSyntheticRootsForMethod(
|
||||
MethodDefinition methodDef,
|
||||
string methodName,
|
||||
string typeName,
|
||||
string methodId,
|
||||
string assemblyPath,
|
||||
MetadataReader metadata,
|
||||
ref int rootOrder)
|
||||
{
|
||||
var isStatic = (methodDef.Attributes & MethodAttributes.Static) != 0;
|
||||
var isPublic = (methodDef.Attributes & MethodAttributes.Public) != 0;
|
||||
|
||||
// Main entry point
|
||||
if (methodName == "Main" && isStatic)
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.AppStart, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.Main,
|
||||
Source: "Main",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.AppStart,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Static constructor
|
||||
if (methodName == ".cctor")
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.ModuleInit, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.StaticConstructor,
|
||||
Source: "cctor",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.ModuleInit,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Check for ModuleInitializer attribute
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "System.Runtime.CompilerServices.ModuleInitializerAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.ModuleInit, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.ModuleInitializer,
|
||||
Source: "ModuleInitializer",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.ModuleInit,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// ASP.NET Controller actions
|
||||
if (typeName.EndsWith("Controller") && isPublic && !isStatic &&
|
||||
!methodName.StartsWith("get_") && !methodName.StartsWith("set_") &&
|
||||
methodName != ".ctor")
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.ControllerAction,
|
||||
Source: "ControllerAction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Test methods (xUnit, NUnit, MSTest)
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.FactAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.TheoryAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "NUnit.Framework.TestAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.TestMethod,
|
||||
Source: "TestMethod",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Azure Functions
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.Azure.WebJobs.FunctionNameAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.Azure.Functions.Worker.FunctionAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.AzureFunction,
|
||||
Source: "AzureFunction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// AWS Lambda
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Amazon.Lambda.Core.LambdaSerializerAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.LambdaHandler,
|
||||
Source: "LambdaHandler",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractCallEdgesFromType(
|
||||
MetadataReader metadata,
|
||||
TypeDefinition typeDef,
|
||||
string assemblyName,
|
||||
string assemblyPath,
|
||||
PEReader peReader)
|
||||
{
|
||||
var typeName = GetFullTypeName(metadata, typeDef);
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var methodDefHandle in typeDef.GetMethods())
|
||||
{
|
||||
var methodDef = metadata.GetMethodDefinition(methodDefHandle);
|
||||
var methodName = metadata.GetString(methodDef.Name);
|
||||
var signature = GetMethodSignature(metadata, methodDef);
|
||||
var callerId = DotNetGraphIdentifiers.ComputeMethodId(assemblyName, typeName, methodName, signature);
|
||||
|
||||
// Get method body
|
||||
var rva = methodDef.RelativeVirtualAddress;
|
||||
if (rva == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var methodBody = peReader.GetMethodBody(rva);
|
||||
ExtractCallEdgesFromMethodBody(metadata, methodBody, callerId, assemblyName, assemblyPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Method body could not be read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractCallEdgesFromMethodBody(
|
||||
MetadataReader metadata,
|
||||
MethodBodyBlock methodBody,
|
||||
string callerId,
|
||||
string assemblyName,
|
||||
string assemblyPath)
|
||||
{
|
||||
var ilBytes = methodBody.GetILBytes();
|
||||
if (ilBytes is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
while (offset < ilBytes.Length)
|
||||
{
|
||||
var ilOffset = offset;
|
||||
int opcode = ilBytes[offset++];
|
||||
|
||||
// Handle two-byte opcodes (0xFE prefix)
|
||||
if (opcode == 0xFE && offset < ilBytes.Length)
|
||||
{
|
||||
opcode = 0xFE00 | ilBytes[offset++];
|
||||
}
|
||||
|
||||
switch (opcode)
|
||||
{
|
||||
case 0x28: // call
|
||||
case 0x6F: // callvirt
|
||||
case 0x73: // newobj
|
||||
{
|
||||
if (offset + 4 > ilBytes.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var token = BitConverter.ToInt32(ilBytes, offset);
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode switch
|
||||
{
|
||||
0x28 => DotNetEdgeType.Call,
|
||||
0x6F => DotNetEdgeType.CallVirt,
|
||||
0x73 => DotNetEdgeType.NewObj,
|
||||
_ => DotNetEdgeType.Call,
|
||||
};
|
||||
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0xFE06: // ldftn (0xFE 0x06)
|
||||
case 0xFE07: // ldvirtftn (0xFE 0x07)
|
||||
{
|
||||
if (offset + 4 > ilBytes.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var token = BitConverter.ToInt32(ilBytes, offset);
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode == 0xFE06 ? DotNetEdgeType.LdFtn : DotNetEdgeType.LdVirtFtn;
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0x29: // calli
|
||||
{
|
||||
if (offset + 4 > ilBytes.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
offset += 4; // Skip signature token
|
||||
|
||||
// calli target is unknown at static analysis time
|
||||
var targetId = $"indirect:{ilOffset}";
|
||||
var edgeId = DotNetGraphIdentifiers.ComputeEdgeId(callerId, targetId, ilOffset);
|
||||
|
||||
_edges.Add(new DotNetCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
CalleeId: targetId,
|
||||
CalleePurl: null,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: DotNetEdgeType.CallI,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: false,
|
||||
Confidence: 0.2));
|
||||
|
||||
var unknownId = DotNetGraphIdentifiers.ComputeUnknownId(
|
||||
edgeId,
|
||||
DotNetUnknownType.DynamicTarget,
|
||||
null,
|
||||
null);
|
||||
_unknowns.Add(new DotNetUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: DotNetUnknownType.DynamicTarget,
|
||||
SourceId: edgeId,
|
||||
AssemblyName: assemblyName,
|
||||
TypeName: null,
|
||||
MethodName: null,
|
||||
Reason: "Indirect call target requires runtime analysis",
|
||||
AssemblyPath: assemblyPath));
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
offset += GetILInstructionSize(opcode) - (opcode > 0xFF ? 2 : 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCallEdge(
|
||||
MetadataReader metadata,
|
||||
string callerId,
|
||||
int token,
|
||||
int ilOffset,
|
||||
DotNetEdgeType edgeType,
|
||||
string assemblyName,
|
||||
string assemblyPath)
|
||||
{
|
||||
var handle = MetadataTokens.EntityHandle(token);
|
||||
|
||||
string? targetAssembly = null;
|
||||
string? targetType = null;
|
||||
string? targetMethod = null;
|
||||
string? targetSignature = null;
|
||||
|
||||
switch (handle.Kind)
|
||||
{
|
||||
case HandleKind.MethodDefinition:
|
||||
{
|
||||
var methodDef = metadata.GetMethodDefinition((MethodDefinitionHandle)handle);
|
||||
var typeDef = metadata.GetTypeDefinition(methodDef.GetDeclaringType());
|
||||
targetAssembly = assemblyName;
|
||||
targetType = GetFullTypeName(metadata, typeDef);
|
||||
targetMethod = metadata.GetString(methodDef.Name);
|
||||
targetSignature = GetMethodSignature(metadata, methodDef);
|
||||
break;
|
||||
}
|
||||
case HandleKind.MemberReference:
|
||||
{
|
||||
var memberRef = metadata.GetMemberReference((MemberReferenceHandle)handle);
|
||||
targetMethod = metadata.GetString(memberRef.Name);
|
||||
targetSignature = GetMemberRefSignature(metadata, memberRef);
|
||||
|
||||
switch (memberRef.Parent.Kind)
|
||||
{
|
||||
case HandleKind.TypeReference:
|
||||
var typeRef = metadata.GetTypeReference((TypeReferenceHandle)memberRef.Parent);
|
||||
targetType = GetTypeRefName(metadata, typeRef);
|
||||
targetAssembly = GetTypeRefAssembly(metadata, typeRef);
|
||||
break;
|
||||
case HandleKind.TypeDefinition:
|
||||
var typeDef = metadata.GetTypeDefinition((TypeDefinitionHandle)memberRef.Parent);
|
||||
targetType = GetFullTypeName(metadata, typeDef);
|
||||
targetAssembly = assemblyName;
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case HandleKind.MethodSpecification:
|
||||
{
|
||||
var methodSpec = metadata.GetMethodSpecification((MethodSpecificationHandle)handle);
|
||||
// Recursively resolve the generic method
|
||||
AddCallEdge(metadata, callerId, MetadataTokens.GetToken(methodSpec.Method), ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetType is null || targetMethod is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var calleeId = DotNetGraphIdentifiers.ComputeMethodId(
|
||||
targetAssembly ?? "unknown",
|
||||
targetType,
|
||||
targetMethod,
|
||||
targetSignature ?? "()");
|
||||
|
||||
var isResolved = _methods.ContainsKey(calleeId) ||
|
||||
_typeToAssemblyPath.ContainsKey(targetType);
|
||||
var calleePurl = isResolved ? GetPurlForAssembly(targetAssembly) : null;
|
||||
|
||||
var edgeId = DotNetGraphIdentifiers.ComputeEdgeId(callerId, calleeId, ilOffset);
|
||||
|
||||
_edges.Add(new DotNetCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
CalleeId: calleeId,
|
||||
CalleePurl: calleePurl,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: edgeType,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.7));
|
||||
|
||||
if (!isResolved && !string.IsNullOrEmpty(targetAssembly))
|
||||
{
|
||||
var unknownId = DotNetGraphIdentifiers.ComputeUnknownId(
|
||||
edgeId,
|
||||
DotNetUnknownType.UnresolvedMethod,
|
||||
targetType,
|
||||
targetMethod);
|
||||
_unknowns.Add(new DotNetUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: DotNetUnknownType.UnresolvedMethod,
|
||||
SourceId: edgeId,
|
||||
AssemblyName: targetAssembly,
|
||||
TypeName: targetType,
|
||||
MethodName: targetMethod,
|
||||
Reason: "Method not found in analyzed assemblies",
|
||||
AssemblyPath: assemblyPath));
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetPurlForAssembly(string? assemblyName)
|
||||
{
|
||||
if (assemblyName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _assemblyToPurl.TryGetValue(assemblyName, out var purl) ? purl : null;
|
||||
}
|
||||
|
||||
private static string GetAssemblyName(MetadataReader metadata)
|
||||
{
|
||||
if (metadata.IsAssembly)
|
||||
{
|
||||
var assemblyDef = metadata.GetAssemblyDefinition();
|
||||
return metadata.GetString(assemblyDef.Name);
|
||||
}
|
||||
|
||||
var moduleDef = metadata.GetModuleDefinition();
|
||||
return metadata.GetString(moduleDef.Name);
|
||||
}
|
||||
|
||||
private static string GetFullTypeName(MetadataReader metadata, TypeDefinition typeDef)
|
||||
{
|
||||
var name = metadata.GetString(typeDef.Name);
|
||||
var ns = metadata.GetString(typeDef.Namespace);
|
||||
|
||||
if (!typeDef.GetDeclaringType().IsNil)
|
||||
{
|
||||
var declaringType = metadata.GetTypeDefinition(typeDef.GetDeclaringType());
|
||||
var declaringName = GetFullTypeName(metadata, declaringType);
|
||||
return $"{declaringName}+{name}";
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}";
|
||||
}
|
||||
|
||||
private static string GetTypeRefName(MetadataReader metadata, TypeReference typeRef)
|
||||
{
|
||||
var name = metadata.GetString(typeRef.Name);
|
||||
var ns = metadata.GetString(typeRef.Namespace);
|
||||
return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}";
|
||||
}
|
||||
|
||||
private static string? GetTypeRefAssembly(MetadataReader metadata, TypeReference typeRef)
|
||||
{
|
||||
switch (typeRef.ResolutionScope.Kind)
|
||||
{
|
||||
case HandleKind.AssemblyReference:
|
||||
var asmRef = metadata.GetAssemblyReference((AssemblyReferenceHandle)typeRef.ResolutionScope);
|
||||
return metadata.GetString(asmRef.Name);
|
||||
case HandleKind.ModuleReference:
|
||||
var modRef = metadata.GetModuleReference((ModuleReferenceHandle)typeRef.ResolutionScope);
|
||||
return metadata.GetString(modRef.Name);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMethodSignature(MetadataReader metadata, MethodDefinition methodDef)
|
||||
{
|
||||
var sig = methodDef.Signature;
|
||||
var sigReader = metadata.GetBlobReader(sig);
|
||||
|
||||
// Simplified signature parsing
|
||||
var header = sigReader.ReadByte();
|
||||
var paramCount = sigReader.ReadCompressedInteger();
|
||||
|
||||
return $"({paramCount} params)";
|
||||
}
|
||||
|
||||
private static string GetMemberRefSignature(MetadataReader metadata, MemberReference memberRef)
|
||||
{
|
||||
var sig = memberRef.Signature;
|
||||
var sigReader = metadata.GetBlobReader(sig);
|
||||
|
||||
var header = sigReader.ReadByte();
|
||||
if ((header & 0x20) != 0) // HASTHIS
|
||||
{
|
||||
header = sigReader.ReadByte();
|
||||
}
|
||||
|
||||
var paramCount = sigReader.ReadCompressedInteger();
|
||||
return $"({paramCount} params)";
|
||||
}
|
||||
|
||||
private static bool HasAttribute(MetadataReader metadata, CustomAttributeHandleCollection attributes, string attributeTypeName)
|
||||
{
|
||||
foreach (var attrHandle in attributes)
|
||||
{
|
||||
var attr = metadata.GetCustomAttribute(attrHandle);
|
||||
var ctorHandle = attr.Constructor;
|
||||
|
||||
string? typeName = null;
|
||||
switch (ctorHandle.Kind)
|
||||
{
|
||||
case HandleKind.MemberReference:
|
||||
var memberRef = metadata.GetMemberReference((MemberReferenceHandle)ctorHandle);
|
||||
if (memberRef.Parent.Kind == HandleKind.TypeReference)
|
||||
{
|
||||
var typeRef = metadata.GetTypeReference((TypeReferenceHandle)memberRef.Parent);
|
||||
typeName = GetTypeRefName(metadata, typeRef);
|
||||
}
|
||||
|
||||
break;
|
||||
case HandleKind.MethodDefinition:
|
||||
var methodDef = metadata.GetMethodDefinition((MethodDefinitionHandle)ctorHandle);
|
||||
var declaringType = metadata.GetTypeDefinition(methodDef.GetDeclaringType());
|
||||
typeName = GetFullTypeName(metadata, declaringType);
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeName is not null && attributeTypeName.Contains(typeName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int GetILInstructionSize(int opcode)
|
||||
{
|
||||
// Simplified IL instruction size lookup
|
||||
return opcode switch
|
||||
{
|
||||
// No operand (1 byte total)
|
||||
0x00 => 1, // nop
|
||||
0x01 => 1, // break
|
||||
>= 0x02 and <= 0x0E => 1, // ldarg.0-3, ldloc.0-3, stloc.0-3
|
||||
0x14 => 1, // ldnull
|
||||
>= 0x15 and <= 0x1E => 1, // ldc.i4.m1 through ldc.i4.8
|
||||
0x25 => 1, // dup
|
||||
0x26 => 1, // pop
|
||||
0x2A => 1, // ret
|
||||
>= 0x46 and <= 0x6E => 1, // ldind.*, stind.*, arithmetic, conversions
|
||||
>= 0x9A and <= 0x9C => 1, // throw, ldlen, etc.
|
||||
|
||||
// 1-byte operand (2 bytes total)
|
||||
0x0F => 2, // ldarg.s
|
||||
0x10 => 2, // ldarga.s
|
||||
0x11 => 2, // starg.s
|
||||
0x12 => 2, // ldloc.s
|
||||
0x13 => 2, // ldloca.s
|
||||
0x1F => 2, // ldc.i4.s
|
||||
>= 0x2B and <= 0x37 => 2, // br.s, brfalse.s, brtrue.s, etc.
|
||||
0xDE => 2, // leave.s
|
||||
|
||||
// 4-byte operand (5 bytes total)
|
||||
0x20 => 5, // ldc.i4
|
||||
0x21 => 9, // ldc.i8 (8-byte operand)
|
||||
0x22 => 5, // ldc.r4
|
||||
0x23 => 9, // ldc.r8 (8-byte operand)
|
||||
0x27 => 5, // jmp
|
||||
0x28 => 5, // call
|
||||
0x29 => 5, // calli
|
||||
>= 0x38 and <= 0x44 => 5, // br, brfalse, brtrue, beq, etc.
|
||||
0x45 => 5, // switch (base - actual size varies)
|
||||
0x6F => 5, // callvirt
|
||||
0x70 => 5, // cpobj
|
||||
0x71 => 5, // ldobj
|
||||
0x72 => 5, // ldstr
|
||||
0x73 => 5, // newobj
|
||||
0x74 => 5, // castclass
|
||||
0x75 => 5, // isinst
|
||||
0x79 => 5, // unbox
|
||||
0x7B => 5, // ldfld
|
||||
0x7C => 5, // ldflda
|
||||
0x7D => 5, // stfld
|
||||
0x7E => 5, // ldsfld
|
||||
0x7F => 5, // ldsflda
|
||||
0x80 => 5, // stsfld
|
||||
0x81 => 5, // stobj
|
||||
0x8C => 5, // box
|
||||
0x8D => 5, // newarr
|
||||
0x8F => 5, // ldelema
|
||||
0xA3 => 5, // ldelem
|
||||
0xA4 => 5, // stelem
|
||||
0xA5 => 5, // unbox.any
|
||||
0xC2 => 5, // refanyval
|
||||
0xC6 => 5, // mkrefany
|
||||
0xD0 => 5, // ldtoken
|
||||
0xDD => 5, // leave
|
||||
|
||||
// Two-byte opcodes (0xFE prefix) - sizes include the prefix byte
|
||||
0xFE00 => 2, // arglist
|
||||
0xFE01 => 2, // ceq
|
||||
0xFE02 => 2, // cgt
|
||||
0xFE03 => 2, // cgt.un
|
||||
0xFE04 => 2, // clt
|
||||
0xFE05 => 2, // clt.un
|
||||
0xFE06 => 6, // ldftn (2 + 4)
|
||||
0xFE07 => 6, // ldvirtftn (2 + 4)
|
||||
0xFE09 => 4, // ldarg (2 + 2)
|
||||
0xFE0A => 4, // ldarga (2 + 2)
|
||||
0xFE0B => 4, // starg (2 + 2)
|
||||
0xFE0C => 4, // ldloc (2 + 2)
|
||||
0xFE0D => 4, // ldloca (2 + 2)
|
||||
0xFE0E => 4, // stloc (2 + 2)
|
||||
0xFE0F => 2, // localloc
|
||||
0xFE11 => 2, // endfilter
|
||||
0xFE12 => 3, // unaligned. (2 + 1)
|
||||
0xFE13 => 2, // volatile.
|
||||
0xFE14 => 2, // tail.
|
||||
0xFE15 => 6, // initobj (2 + 4)
|
||||
0xFE16 => 6, // constrained. (2 + 4)
|
||||
0xFE17 => 2, // cpblk
|
||||
0xFE18 => 2, // initblk
|
||||
0xFE1A => 3, // no. (2 + 1)
|
||||
0xFE1C => 6, // sizeof (2 + 4)
|
||||
0xFE1D => 2, // refanytype
|
||||
0xFE1E => 2, // readonly.
|
||||
|
||||
_ => 1, // default for unrecognized
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Callgraph;
|
||||
|
||||
/// <summary>
|
||||
/// .NET reachability graph containing methods, call edges, and metadata.
|
||||
/// </summary>
|
||||
public sealed record DotNetReachabilityGraph(
|
||||
string ContextDigest,
|
||||
ImmutableArray<DotNetMethodNode> Methods,
|
||||
ImmutableArray<DotNetCallEdge> Edges,
|
||||
ImmutableArray<DotNetSyntheticRoot> SyntheticRoots,
|
||||
ImmutableArray<DotNetUnknown> Unknowns,
|
||||
DotNetGraphMetadata Metadata,
|
||||
string ContentHash);
|
||||
|
||||
/// <summary>
|
||||
/// A method node in the .NET call graph.
|
||||
/// </summary>
|
||||
/// <param name="MethodId">Deterministic method identifier (sha256 of assembly+type+name+signature).</param>
|
||||
/// <param name="AssemblyName">Name of the containing assembly.</param>
|
||||
/// <param name="TypeName">Fully qualified type name.</param>
|
||||
/// <param name="MethodName">Method name.</param>
|
||||
/// <param name="Signature">Method signature (parameters and return type).</param>
|
||||
/// <param name="Purl">Package URL if resolvable (e.g., pkg:nuget/Newtonsoft.Json@13.0.1).</param>
|
||||
/// <param name="AssemblyPath">Path to the containing assembly.</param>
|
||||
/// <param name="MetadataToken">IL metadata token.</param>
|
||||
/// <param name="MethodDigest">SHA-256 of (assembly + type + name + signature).</param>
|
||||
/// <param name="IsStatic">Whether the method is static.</param>
|
||||
/// <param name="IsPublic">Whether the method is public.</param>
|
||||
/// <param name="IsVirtual">Whether the method is virtual.</param>
|
||||
/// <param name="IsGeneric">Whether the method has generic parameters.</param>
|
||||
public sealed record DotNetMethodNode(
|
||||
string MethodId,
|
||||
string AssemblyName,
|
||||
string TypeName,
|
||||
string MethodName,
|
||||
string Signature,
|
||||
string? Purl,
|
||||
string AssemblyPath,
|
||||
int MetadataToken,
|
||||
string MethodDigest,
|
||||
bool IsStatic,
|
||||
bool IsPublic,
|
||||
bool IsVirtual,
|
||||
bool IsGeneric);
|
||||
|
||||
/// <summary>
|
||||
/// A call edge in the .NET call graph.
|
||||
/// </summary>
|
||||
/// <param name="EdgeId">Deterministic edge identifier.</param>
|
||||
/// <param name="CallerId">MethodId of the calling method.</param>
|
||||
/// <param name="CalleeId">MethodId of the called method (or Unknown placeholder).</param>
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeMethodDigest">Method digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (call instruction type).</param>
|
||||
/// <param name="ILOffset">IL offset where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
public sealed record DotNetCallEdge(
|
||||
string EdgeId,
|
||||
string CallerId,
|
||||
string CalleeId,
|
||||
string? CalleePurl,
|
||||
string? CalleeMethodDigest,
|
||||
DotNetEdgeType EdgeType,
|
||||
int ILOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Type of .NET call edge.
|
||||
/// </summary>
|
||||
public enum DotNetEdgeType
|
||||
{
|
||||
/// <summary>call - direct method call.</summary>
|
||||
Call,
|
||||
|
||||
/// <summary>callvirt - virtual method call.</summary>
|
||||
CallVirt,
|
||||
|
||||
/// <summary>newobj - constructor call.</summary>
|
||||
NewObj,
|
||||
|
||||
/// <summary>ldftn - load function pointer (delegate).</summary>
|
||||
LdFtn,
|
||||
|
||||
/// <summary>ldvirtftn - load virtual function pointer.</summary>
|
||||
LdVirtFtn,
|
||||
|
||||
/// <summary>calli - indirect call through function pointer.</summary>
|
||||
CallI,
|
||||
|
||||
/// <summary>P/Invoke call to native code.</summary>
|
||||
PInvoke,
|
||||
|
||||
/// <summary>Reflection-based invocation.</summary>
|
||||
Reflection,
|
||||
|
||||
/// <summary>Dynamic invocation (DLR).</summary>
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the .NET call graph.
|
||||
/// </summary>
|
||||
/// <param name="RootId">Deterministic root identifier.</param>
|
||||
/// <param name="TargetId">MethodId of the target method.</param>
|
||||
/// <param name="RootType">Type of synthetic root.</param>
|
||||
/// <param name="Source">Source of the root (e.g., Main, ModuleInit, AspNetController).</param>
|
||||
/// <param name="AssemblyPath">Path to the containing assembly.</param>
|
||||
/// <param name="Phase">Execution phase.</param>
|
||||
/// <param name="Order">Order within the phase.</param>
|
||||
/// <param name="IsResolved">Whether the target was successfully resolved.</param>
|
||||
public sealed record DotNetSyntheticRoot(
|
||||
string RootId,
|
||||
string TargetId,
|
||||
DotNetRootType RootType,
|
||||
string Source,
|
||||
string AssemblyPath,
|
||||
DotNetRootPhase Phase,
|
||||
int Order,
|
||||
bool IsResolved = true);
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase for .NET synthetic roots.
|
||||
/// </summary>
|
||||
public enum DotNetRootPhase
|
||||
{
|
||||
/// <summary>Module initialization - module initializers, static constructors.</summary>
|
||||
ModuleInit = 0,
|
||||
|
||||
/// <summary>Application startup - Main, Startup.Configure.</summary>
|
||||
AppStart = 1,
|
||||
|
||||
/// <summary>Runtime execution - controllers, handlers, tests.</summary>
|
||||
Runtime = 2,
|
||||
|
||||
/// <summary>Shutdown - finalizers, dispose.</summary>
|
||||
Shutdown = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of .NET synthetic root.
|
||||
/// </summary>
|
||||
public enum DotNetRootType
|
||||
{
|
||||
/// <summary>Main entry point.</summary>
|
||||
Main,
|
||||
|
||||
/// <summary>Module initializer ([ModuleInitializer]).</summary>
|
||||
ModuleInitializer,
|
||||
|
||||
/// <summary>Static constructor (.cctor).</summary>
|
||||
StaticConstructor,
|
||||
|
||||
/// <summary>ASP.NET Controller action.</summary>
|
||||
ControllerAction,
|
||||
|
||||
/// <summary>ASP.NET Minimal API endpoint.</summary>
|
||||
MinimalApiEndpoint,
|
||||
|
||||
/// <summary>gRPC service method.</summary>
|
||||
GrpcMethod,
|
||||
|
||||
/// <summary>Azure Function entry.</summary>
|
||||
AzureFunction,
|
||||
|
||||
/// <summary>AWS Lambda handler.</summary>
|
||||
LambdaHandler,
|
||||
|
||||
/// <summary>xUnit/NUnit/MSTest method.</summary>
|
||||
TestMethod,
|
||||
|
||||
/// <summary>Background service worker.</summary>
|
||||
BackgroundWorker,
|
||||
|
||||
/// <summary>Event handler (UI, etc.).</summary>
|
||||
EventHandler,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unknown/unresolved reference in the .NET call graph.
|
||||
/// </summary>
|
||||
public sealed record DotNetUnknown(
|
||||
string UnknownId,
|
||||
DotNetUnknownType UnknownType,
|
||||
string SourceId,
|
||||
string? AssemblyName,
|
||||
string? TypeName,
|
||||
string? MethodName,
|
||||
string Reason,
|
||||
string AssemblyPath);
|
||||
|
||||
/// <summary>
|
||||
/// Type of unknown reference in .NET.
|
||||
/// </summary>
|
||||
public enum DotNetUnknownType
|
||||
{
|
||||
/// <summary>Assembly could not be resolved.</summary>
|
||||
UnresolvedAssembly,
|
||||
|
||||
/// <summary>Type could not be resolved.</summary>
|
||||
UnresolvedType,
|
||||
|
||||
/// <summary>Method could not be resolved.</summary>
|
||||
UnresolvedMethod,
|
||||
|
||||
/// <summary>P/Invoke target is unknown.</summary>
|
||||
PInvokeTarget,
|
||||
|
||||
/// <summary>Reflection target is unknown.</summary>
|
||||
ReflectionTarget,
|
||||
|
||||
/// <summary>Dynamic invoke target is unknown.</summary>
|
||||
DynamicTarget,
|
||||
|
||||
/// <summary>Generic instantiation could not be resolved.</summary>
|
||||
UnresolvedGeneric,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for the .NET reachability graph.
|
||||
/// </summary>
|
||||
public sealed record DotNetGraphMetadata(
|
||||
DateTimeOffset GeneratedAt,
|
||||
string GeneratorVersion,
|
||||
string ContextDigest,
|
||||
int AssemblyCount,
|
||||
int TypeCount,
|
||||
int MethodCount,
|
||||
int EdgeCount,
|
||||
int UnknownCount,
|
||||
int SyntheticRootCount);
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for creating deterministic .NET graph identifiers.
|
||||
/// </summary>
|
||||
internal static class DotNetGraphIdentifiers
|
||||
{
|
||||
private const string GeneratorVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic method ID.
|
||||
/// </summary>
|
||||
public static string ComputeMethodId(string assemblyName, string typeName, string methodName, string signature)
|
||||
{
|
||||
var input = $"{assemblyName}:{typeName}:{methodName}:{signature}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"dnmethod:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic method digest.
|
||||
/// </summary>
|
||||
public static string ComputeMethodDigest(string assemblyName, string typeName, string methodName, string signature)
|
||||
{
|
||||
var input = $"{assemblyName}:{typeName}:{methodName}:{signature}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic edge ID.
|
||||
/// </summary>
|
||||
public static string ComputeEdgeId(string callerId, string calleeId, int ilOffset)
|
||||
{
|
||||
var input = $"{callerId}:{calleeId}:{ilOffset}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"dnedge:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic root ID.
|
||||
/// </summary>
|
||||
public static string ComputeRootId(DotNetRootPhase phase, int order, string targetId)
|
||||
{
|
||||
var phaseName = phase.ToString().ToLowerInvariant();
|
||||
return $"dnroot:{phaseName}:{order}:{targetId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic unknown ID.
|
||||
/// </summary>
|
||||
public static string ComputeUnknownId(string sourceId, DotNetUnknownType unknownType, string? typeName, string? methodName)
|
||||
{
|
||||
var input = $"{sourceId}:{unknownType}:{typeName ?? ""}:{methodName ?? ""}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"dnunk:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes content hash for the entire graph.
|
||||
/// </summary>
|
||||
public static string ComputeGraphHash(
|
||||
ImmutableArray<DotNetMethodNode> methods,
|
||||
ImmutableArray<DotNetCallEdge> edges,
|
||||
ImmutableArray<DotNetSyntheticRoot> roots)
|
||||
{
|
||||
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
foreach (var m in methods.OrderBy(m => m.MethodId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodId));
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodDigest));
|
||||
}
|
||||
|
||||
foreach (var e in edges.OrderBy(e => e.EdgeId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(e.EdgeId));
|
||||
}
|
||||
|
||||
foreach (var r in roots.OrderBy(r => r.RootId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(r.RootId));
|
||||
}
|
||||
|
||||
return Convert.ToHexString(sha.GetCurrentHash()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current generator version.
|
||||
/// </summary>
|
||||
public static string GetGeneratorVersion() => GeneratorVersion;
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Discovery;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Inheritance;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.LockFiles;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Parsing;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collects declared dependencies from build files when no deps.json exists.
|
||||
/// Follows precedence order: packages.lock.json > csproj+CPM > packages.config.
|
||||
/// </summary>
|
||||
internal sealed class DotNetDeclaredDependencyCollector
|
||||
{
|
||||
private readonly LanguageAnalyzerContext _context;
|
||||
private readonly DotNetAnalyzerOptions _options;
|
||||
private readonly DotNetBuildFileDiscovery _discovery;
|
||||
|
||||
public DotNetDeclaredDependencyCollector(LanguageAnalyzerContext context, DotNetAnalyzerOptions options)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_discovery = new DotNetBuildFileDiscovery();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects declared dependencies from build files.
|
||||
/// </summary>
|
||||
public async ValueTask<IReadOnlyList<DotNetDeclaredPackage>> CollectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var discoveryResult = _discovery.Discover(_context.RootPath);
|
||||
if (!discoveryResult.HasFiles && discoveryResult.LockFiles.Length == 0 && discoveryResult.LegacyPackagesConfigs.Length == 0)
|
||||
{
|
||||
return Array.Empty<DotNetDeclaredPackage>();
|
||||
}
|
||||
|
||||
var aggregator = new DeclaredPackageAggregator();
|
||||
|
||||
// 1. Collect from packages.lock.json files (highest precedence for version resolution)
|
||||
foreach (var lockFile in discoveryResult.LockFiles.Where(f => f.FileType == DotNetFileType.PackagesLockJson))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await CollectFromLockFileAsync(lockFile, aggregator, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 2. Collect from project files with CPM resolution
|
||||
var cpmLookup = await BuildCpmLookupAsync(discoveryResult, cancellationToken).ConfigureAwait(false);
|
||||
var propsLookup = await BuildPropsLookupAsync(discoveryResult, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var projectFile in discoveryResult.ProjectFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await CollectFromProjectFileAsync(projectFile, cpmLookup, propsLookup, aggregator, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 3. Collect from legacy packages.config (lowest precedence)
|
||||
foreach (var packagesConfig in discoveryResult.LegacyPackagesConfigs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await CollectFromPackagesConfigAsync(packagesConfig, aggregator, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return aggregator.Build();
|
||||
}
|
||||
|
||||
private async ValueTask CollectFromLockFileAsync(
|
||||
DiscoveredFile lockFile,
|
||||
DeclaredPackageAggregator aggregator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await PackagesLockJsonParser.ParseAsync(lockFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Dependencies.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var dependency in result.Dependencies)
|
||||
{
|
||||
var declaration = new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = dependency.PackageId,
|
||||
Version = dependency.ResolvedVersion,
|
||||
TargetFrameworks = !string.IsNullOrEmpty(dependency.TargetFramework)
|
||||
? [dependency.TargetFramework]
|
||||
: [],
|
||||
IsDevelopmentDependency = false,
|
||||
Source = dependency.IsDirect ? "packages.lock.json (Direct)" : "packages.lock.json (Transitive)",
|
||||
Locator = lockFile.RelativePath,
|
||||
VersionSource = DotNetVersionSource.LockFile
|
||||
};
|
||||
|
||||
// Collect edges from lock file dependencies (format: "packageName:version")
|
||||
var edges = new List<DotNetDependencyEdge>();
|
||||
foreach (var dep in dependency.Dependencies)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dep))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse "packageName:version" format
|
||||
var colonIndex = dep.IndexOf(':');
|
||||
var targetId = colonIndex > 0 ? dep.Substring(0, colonIndex).Trim().ToLowerInvariant() : dep.Trim().ToLowerInvariant();
|
||||
|
||||
edges.Add(new DotNetDependencyEdge(
|
||||
Target: targetId,
|
||||
Reason: "declared-dependency",
|
||||
Confidence: "high",
|
||||
Source: "packages.lock.json"));
|
||||
}
|
||||
|
||||
aggregator.Add(declaration, lockFile.RelativePath, edges);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask CollectFromProjectFileAsync(
|
||||
DiscoveredFile projectFile,
|
||||
ImmutableDictionary<string, string> cpmLookup,
|
||||
ImmutableDictionary<string, string> propsLookup,
|
||||
DeclaredPackageAggregator aggregator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projectMetadata = await MsBuildProjectParser.ParseAsync(projectFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (projectMetadata.PackageReferences.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var packageRef in projectMetadata.PackageReferences)
|
||||
{
|
||||
var resolvedVersion = ResolveVersion(packageRef, cpmLookup, propsLookup, projectMetadata);
|
||||
var versionSource = DetermineVersionSource(packageRef, resolvedVersion, projectMetadata.ManagePackageVersionsCentrally);
|
||||
|
||||
var declaration = new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = packageRef.PackageId,
|
||||
Version = resolvedVersion,
|
||||
TargetFrameworks = projectMetadata.TargetFrameworks,
|
||||
IsDevelopmentDependency = packageRef.IsDevelopmentDependency,
|
||||
IncludeAssets = packageRef.IncludeAssets,
|
||||
ExcludeAssets = packageRef.ExcludeAssets,
|
||||
PrivateAssets = packageRef.PrivateAssets,
|
||||
Condition = packageRef.Condition,
|
||||
Source = "csproj",
|
||||
Locator = projectFile.RelativePath,
|
||||
VersionSource = versionSource,
|
||||
VersionProperty = ExtractPropertyName(packageRef.Version)
|
||||
};
|
||||
|
||||
aggregator.Add(declaration, projectFile.RelativePath, edges: null);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask CollectFromPackagesConfigAsync(
|
||||
DiscoveredFile packagesConfig,
|
||||
DeclaredPackageAggregator aggregator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await PackagesConfigParser.ParseAsync(packagesConfig.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Packages.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var package in result.Packages)
|
||||
{
|
||||
var declaration = package with
|
||||
{
|
||||
Locator = packagesConfig.RelativePath
|
||||
};
|
||||
|
||||
aggregator.Add(declaration, packagesConfig.RelativePath, edges: null);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<ImmutableDictionary<string, string>> BuildCpmLookupAsync(
|
||||
DiscoveryResult discovery,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var cpmFile in discovery.DirectoryPackagesPropsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await CentralPackageManagementParser.ParseAsync(cpmFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pv in result.PackageVersions)
|
||||
{
|
||||
if (!builder.ContainsKey(pv.PackageId) && !string.IsNullOrEmpty(pv.Version))
|
||||
{
|
||||
builder[pv.PackageId] = pv.Version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private async ValueTask<ImmutableDictionary<string, string>> BuildPropsLookupAsync(
|
||||
DiscoveryResult discovery,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var propsFile in discovery.DirectoryBuildPropsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await DirectoryBuildPropsParser.ParseAsync(propsFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var kvp in result.Properties)
|
||||
{
|
||||
if (!builder.ContainsKey(kvp.Key))
|
||||
{
|
||||
builder[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? ResolveVersion(
|
||||
DotNetDependencyDeclaration packageRef,
|
||||
ImmutableDictionary<string, string> cpmLookup,
|
||||
ImmutableDictionary<string, string> propsLookup,
|
||||
DotNetProjectMetadata projectMetadata)
|
||||
{
|
||||
// If version is explicitly set and resolved, use it
|
||||
if (!string.IsNullOrEmpty(packageRef.Version) && packageRef.IsVersionResolved)
|
||||
{
|
||||
return packageRef.Version;
|
||||
}
|
||||
|
||||
// If version is a property reference, try to resolve it
|
||||
if (!string.IsNullOrEmpty(packageRef.Version) && packageRef.Version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
var resolved = ResolvePropertyValue(packageRef.Version, propsLookup, projectMetadata.Properties);
|
||||
if (!string.IsNullOrEmpty(resolved) && !resolved.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Return the unresolved value for identity purposes
|
||||
return packageRef.Version;
|
||||
}
|
||||
|
||||
// If version is empty and CPM is enabled, look up in CPM
|
||||
if (string.IsNullOrEmpty(packageRef.Version) && projectMetadata.ManagePackageVersionsCentrally)
|
||||
{
|
||||
if (cpmLookup.TryGetValue(packageRef.PackageId, out var cpmVersion))
|
||||
{
|
||||
return cpmVersion;
|
||||
}
|
||||
|
||||
// CPM enabled but version not found - return null to trigger unresolved handling
|
||||
return null;
|
||||
}
|
||||
|
||||
return packageRef.Version;
|
||||
}
|
||||
|
||||
private static string? ResolvePropertyValue(
|
||||
string value,
|
||||
ImmutableDictionary<string, string> propsLookup,
|
||||
ImmutableDictionary<string, string> projectProperties)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var result = value;
|
||||
var maxIterations = 10; // Prevent infinite loops
|
||||
|
||||
for (var i = 0; i < maxIterations && result.Contains("$(", StringComparison.Ordinal); i++)
|
||||
{
|
||||
var startIdx = result.IndexOf("$(", StringComparison.Ordinal);
|
||||
var endIdx = result.IndexOf(')', startIdx);
|
||||
if (endIdx < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var propertyName = result.Substring(startIdx + 2, endIdx - startIdx - 2);
|
||||
string? propertyValue = null;
|
||||
|
||||
// Try project properties first, then props files
|
||||
if (projectProperties.TryGetValue(propertyName, out var projValue))
|
||||
{
|
||||
propertyValue = projValue;
|
||||
}
|
||||
else if (propsLookup.TryGetValue(propertyName, out var propsValue))
|
||||
{
|
||||
propertyValue = propsValue;
|
||||
}
|
||||
|
||||
if (propertyValue is not null)
|
||||
{
|
||||
result = result.Substring(0, startIdx) + propertyValue + result.Substring(endIdx + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Property not found, stop resolution
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DotNetVersionSource DetermineVersionSource(
|
||||
DotNetDependencyDeclaration packageRef,
|
||||
string? resolvedVersion,
|
||||
bool cpmEnabled)
|
||||
{
|
||||
if (resolvedVersion is null)
|
||||
{
|
||||
return DotNetVersionSource.Unresolved;
|
||||
}
|
||||
|
||||
if (resolvedVersion.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return DotNetVersionSource.Unresolved;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(packageRef.Version) && cpmEnabled)
|
||||
{
|
||||
return DotNetVersionSource.CentralPackageManagement;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(packageRef.Version) && packageRef.Version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return DotNetVersionSource.Property;
|
||||
}
|
||||
|
||||
return DotNetVersionSource.Direct;
|
||||
}
|
||||
|
||||
private static string? ExtractPropertyName(string? version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startIdx = version.IndexOf("$(", StringComparison.Ordinal);
|
||||
if (startIdx < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endIdx = version.IndexOf(')', startIdx);
|
||||
if (endIdx < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return version.Substring(startIdx + 2, endIdx - startIdx - 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates declared packages with deduplication.
|
||||
/// </summary>
|
||||
internal sealed class DeclaredPackageAggregator
|
||||
{
|
||||
private readonly Dictionary<string, DotNetDeclaredPackageBuilder> _packages = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Add(DotNetDependencyDeclaration declaration, string sourceLocator, IReadOnlyList<DotNetDependencyEdge>? edges = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(declaration.PackageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedId = declaration.PackageId.Trim().ToLowerInvariant();
|
||||
var version = declaration.Version?.Trim() ?? string.Empty;
|
||||
var key = BuildKey(normalizedId, version, declaration.VersionSource);
|
||||
|
||||
if (!_packages.TryGetValue(key, out var builder))
|
||||
{
|
||||
builder = new DotNetDeclaredPackageBuilder(declaration.PackageId, normalizedId, version, declaration.VersionSource);
|
||||
_packages[key] = builder;
|
||||
}
|
||||
|
||||
builder.AddDeclaration(declaration, sourceLocator, edges);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DotNetDeclaredPackage> Build()
|
||||
{
|
||||
if (_packages.Count == 0)
|
||||
{
|
||||
return Array.Empty<DotNetDeclaredPackage>();
|
||||
}
|
||||
|
||||
var result = new List<DotNetDeclaredPackage>(_packages.Count);
|
||||
foreach (var builder in _packages.Values)
|
||||
{
|
||||
result.Add(builder.Build());
|
||||
}
|
||||
|
||||
result.Sort(static (a, b) => string.CompareOrdinal(a.ComponentKey, b.ComponentKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string BuildKey(string normalizedId, string version, DotNetVersionSource versionSource)
|
||||
{
|
||||
// For resolved versions, key by id+version
|
||||
// For unresolved versions, include source info in key to avoid collisions
|
||||
if (versionSource == DotNetVersionSource.Unresolved || string.IsNullOrEmpty(version) || version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return $"unresolved::{normalizedId}::{version}";
|
||||
}
|
||||
|
||||
return $"{normalizedId}::{version}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for declared packages.
|
||||
/// </summary>
|
||||
internal sealed class DotNetDeclaredPackageBuilder
|
||||
{
|
||||
private readonly string _originalId;
|
||||
private readonly string _normalizedId;
|
||||
private readonly string _version;
|
||||
private readonly DotNetVersionSource _versionSource;
|
||||
|
||||
private readonly SortedSet<string> _sources = new(StringComparer.Ordinal);
|
||||
private readonly SortedSet<string> _locators = new(StringComparer.Ordinal);
|
||||
private readonly SortedSet<string> _targetFrameworks = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer());
|
||||
private readonly Dictionary<string, DotNetDependencyEdge> _edges = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _isDevelopmentDependency;
|
||||
private string? _unresolvedReason;
|
||||
|
||||
public DotNetDeclaredPackageBuilder(string originalId, string normalizedId, string version, DotNetVersionSource versionSource)
|
||||
{
|
||||
_originalId = originalId;
|
||||
_normalizedId = normalizedId;
|
||||
_version = version;
|
||||
_versionSource = versionSource;
|
||||
}
|
||||
|
||||
public void AddDeclaration(DotNetDependencyDeclaration declaration, string sourceLocator, IReadOnlyList<DotNetDependencyEdge>? edges = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(declaration.Source))
|
||||
{
|
||||
_sources.Add(declaration.Source);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(sourceLocator))
|
||||
{
|
||||
_locators.Add(sourceLocator);
|
||||
}
|
||||
|
||||
foreach (var tfm in declaration.TargetFrameworks)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tfm))
|
||||
{
|
||||
_targetFrameworks.Add(tfm);
|
||||
}
|
||||
}
|
||||
|
||||
if (declaration.IsDevelopmentDependency)
|
||||
{
|
||||
_isDevelopmentDependency = true;
|
||||
}
|
||||
|
||||
// Determine unresolved reason
|
||||
if (_versionSource == DotNetVersionSource.Unresolved && _unresolvedReason is null)
|
||||
{
|
||||
_unresolvedReason = DetermineUnresolvedReason(declaration);
|
||||
}
|
||||
|
||||
// Add evidence
|
||||
if (!string.IsNullOrEmpty(sourceLocator))
|
||||
{
|
||||
_evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
declaration.Source ?? "declared",
|
||||
sourceLocator,
|
||||
declaration.Coordinate,
|
||||
Sha256: null));
|
||||
}
|
||||
|
||||
// Add edges (deduped by target)
|
||||
if (edges is not null)
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!_edges.ContainsKey(edge.Target))
|
||||
{
|
||||
_edges[edge.Target] = edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DotNetDeclaredPackage Build()
|
||||
{
|
||||
var metadata = BuildMetadata();
|
||||
var evidence = _evidence
|
||||
.OrderBy(static e => e.Source, StringComparer.Ordinal)
|
||||
.ThenBy(static e => e.Locator, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
// Build ordered edges list
|
||||
var edges = _edges.Values
|
||||
.OrderBy(static e => e.Target, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new DotNetDeclaredPackage(
|
||||
name: _originalId,
|
||||
normalizedId: _normalizedId,
|
||||
version: _version,
|
||||
versionSource: _versionSource,
|
||||
isVersionResolved: _versionSource != DotNetVersionSource.Unresolved &&
|
||||
!string.IsNullOrEmpty(_version) &&
|
||||
!_version.Contains("$(", StringComparison.Ordinal),
|
||||
unresolvedReason: _unresolvedReason,
|
||||
isDevelopmentDependency: _isDevelopmentDependency,
|
||||
metadata: metadata,
|
||||
evidence: evidence,
|
||||
edges: edges);
|
||||
}
|
||||
|
||||
private IReadOnlyList<KeyValuePair<string, string?>> BuildMetadata()
|
||||
{
|
||||
var metadata = new List<KeyValuePair<string, string?>>(32)
|
||||
{
|
||||
new("package.id", _originalId),
|
||||
new("package.id.normalized", _normalizedId),
|
||||
new("package.version", _version),
|
||||
new("declaredOnly", "true"),
|
||||
new("declared.versionSource", _versionSource.ToString().ToLowerInvariant())
|
||||
};
|
||||
|
||||
if (!IsVersionResolved())
|
||||
{
|
||||
metadata.Add(new("declared.versionResolved", "false"));
|
||||
if (!string.IsNullOrEmpty(_unresolvedReason))
|
||||
{
|
||||
metadata.Add(new("declared.unresolvedReason", _unresolvedReason));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_version))
|
||||
{
|
||||
metadata.Add(new("declared.rawVersion", _version));
|
||||
}
|
||||
}
|
||||
|
||||
if (_isDevelopmentDependency)
|
||||
{
|
||||
metadata.Add(new("declared.isDevelopmentDependency", "true"));
|
||||
}
|
||||
|
||||
// Add sources
|
||||
var sourceIndex = 0;
|
||||
foreach (var source in _sources)
|
||||
{
|
||||
metadata.Add(new($"declared.source[{sourceIndex++}]", source));
|
||||
}
|
||||
|
||||
// Add locators
|
||||
var locatorIndex = 0;
|
||||
foreach (var locator in _locators)
|
||||
{
|
||||
metadata.Add(new($"declared.locator[{locatorIndex++}]", locator));
|
||||
}
|
||||
|
||||
// Add target frameworks
|
||||
var tfmIndex = 0;
|
||||
foreach (var tfm in _targetFrameworks)
|
||||
{
|
||||
metadata.Add(new($"declared.tfm[{tfmIndex++}]", tfm));
|
||||
}
|
||||
|
||||
metadata.Add(new("provenance", "declared"));
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private bool IsVersionResolved()
|
||||
=> _versionSource != DotNetVersionSource.Unresolved &&
|
||||
!string.IsNullOrEmpty(_version) &&
|
||||
!_version.Contains("$(", StringComparison.Ordinal);
|
||||
|
||||
private static string? DetermineUnresolvedReason(DotNetDependencyDeclaration declaration)
|
||||
{
|
||||
if (string.IsNullOrEmpty(declaration.Version))
|
||||
{
|
||||
if (declaration.VersionSource == DotNetVersionSource.CentralPackageManagement ||
|
||||
declaration.Source?.Contains("csproj", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return "cpm-missing";
|
||||
}
|
||||
|
||||
return "version-omitted";
|
||||
}
|
||||
|
||||
if (declaration.Version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return "property-unresolved";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence>
|
||||
{
|
||||
public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return x.Kind == y.Kind &&
|
||||
string.Equals(x.Source, y.Source, StringComparison.Ordinal) &&
|
||||
string.Equals(x.Locator, y.Locator, StringComparison.Ordinal) &&
|
||||
string.Equals(x.Value, y.Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(LanguageComponentEvidence obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Kind);
|
||||
hash.Add(obj.Source, StringComparer.Ordinal);
|
||||
hash.Add(obj.Locator, StringComparer.Ordinal);
|
||||
hash.Add(obj.Value, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a declared-only .NET package (not from deps.json).
|
||||
/// </summary>
|
||||
internal sealed class DotNetDeclaredPackage
|
||||
{
|
||||
public DotNetDeclaredPackage(
|
||||
string name,
|
||||
string normalizedId,
|
||||
string version,
|
||||
DotNetVersionSource versionSource,
|
||||
bool isVersionResolved,
|
||||
string? unresolvedReason,
|
||||
bool isDevelopmentDependency,
|
||||
IReadOnlyList<KeyValuePair<string, string?>> metadata,
|
||||
IReadOnlyCollection<LanguageComponentEvidence> evidence,
|
||||
IReadOnlyList<DotNetDependencyEdge>? edges = null)
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(name) ? normalizedId : name.Trim();
|
||||
NormalizedId = normalizedId;
|
||||
Version = version ?? string.Empty;
|
||||
VersionSource = versionSource;
|
||||
IsVersionResolved = isVersionResolved;
|
||||
UnresolvedReason = unresolvedReason;
|
||||
IsDevelopmentDependency = isDevelopmentDependency;
|
||||
Metadata = metadata ?? Array.Empty<KeyValuePair<string, string?>>();
|
||||
Evidence = evidence ?? Array.Empty<LanguageComponentEvidence>();
|
||||
Edges = edges ?? Array.Empty<DotNetDependencyEdge>();
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string NormalizedId { get; }
|
||||
public string Version { get; }
|
||||
public DotNetVersionSource VersionSource { get; }
|
||||
public bool IsVersionResolved { get; }
|
||||
public string? UnresolvedReason { get; }
|
||||
public bool IsDevelopmentDependency { get; }
|
||||
public IReadOnlyList<KeyValuePair<string, string?>> Metadata { get; }
|
||||
public IReadOnlyCollection<LanguageComponentEvidence> Evidence { get; }
|
||||
public IReadOnlyList<DotNetDependencyEdge> Edges { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PURL if version is resolved, otherwise null.
|
||||
/// </summary>
|
||||
public string? Purl => IsVersionResolved && !string.IsNullOrEmpty(Version)
|
||||
? $"pkg:nuget/{NormalizedId}@{Version}"
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the component key (PURL-based if resolved, explicit key if unresolved).
|
||||
/// </summary>
|
||||
public string ComponentKey
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Purl is not null)
|
||||
{
|
||||
return $"purl::{Purl}";
|
||||
}
|
||||
|
||||
// Explicit key for unresolved versions: declared:nuget/<id>/<hash>
|
||||
var keyMaterial = $"{VersionSource}|{string.Join(",", Metadata.Where(m => m.Key.StartsWith("declared.locator", StringComparison.Ordinal)).Select(m => m.Value))}|{Version}";
|
||||
var hash = ComputeShortHash(keyMaterial);
|
||||
return $"declared:nuget/{NormalizedId}/{hash}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeShortHash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hashBytes = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hashBytes).Substring(0, 8).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,8 @@ internal static class DotNetDependencyCollector
|
||||
.OrderBy(static path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (depsFiles.Length == 0)
|
||||
{
|
||||
return Array.Empty<DotNetPackage>();
|
||||
}
|
||||
// When no deps.json files exist, fallback to declared-only collection
|
||||
// is handled by DotNetDeclaredDependencyCollector called from the analyzer
|
||||
|
||||
var aggregator = new DotNetPackageAggregator(context, options, entrypoints, runtimeEdges);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user