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

This commit is contained in:
StellaOps Bot
2025-12-13 18:08:55 +02:00
parent 6e45066e37
commit f1a39c4ce3
234 changed files with 24038 additions and 6910 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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