up
Some checks failed
Docs CI / lint-and-preview (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
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (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

This commit is contained in:
StellaOps Bot
2025-12-14 15:50:38 +02:00
parent f1a39c4ce3
commit 233873f620
249 changed files with 29746 additions and 154 deletions

View File

@@ -1,9 +1,15 @@
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using System.Text.Json;
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
/// <summary>
/// Resolves publish artifacts (deps/runtimeconfig) into deterministic entrypoint identities.
/// Per SCANNER-ANALYZERS-LANG-11-001: maps project/publish artifacts to normalized entrypoint records
/// with assembly name, MVID, TFM, RID, host kind, publish mode, ALC hints, and probing paths.
/// </summary>
public static class DotNetEntrypointResolver
{
@@ -46,6 +52,7 @@ public static class DotNetEntrypointResolver
}
var name = GetEntrypointName(depsPath);
var directory = Path.GetDirectoryName(depsPath) ?? ".";
DotNetRuntimeConfig? runtimeConfig = null;
var runtimeConfigPath = GetRuntimeConfigPath(depsPath, name);
@@ -61,16 +68,51 @@ public static class DotNetEntrypointResolver
var rids = CollectRuntimeIdentifiers(depsFile, runtimeConfig);
var publishKind = DeterminePublishKind(depsFile);
var id = BuildDeterministicId(name, tfms, rids, publishKind);
// Resolve assembly and apphost paths
var (assemblyPath, apphostPath) = ResolveEntrypointPaths(directory, name);
// Extract MVID from PE header (11-001 requirement)
var mvid = ExtractMvid(assemblyPath);
// Compute SHA-256 hash over assembly bytes (11-001 requirement)
var (hash, fileSize) = ComputeHashAndSize(assemblyPath);
// Determine host kind: apphost, framework-dependent, self-contained (11-001 requirement)
var hostKind = DetermineHostKind(apphostPath, publishKind);
// Determine publish mode: single-file, trimmed, normal (11-001 requirement)
var publishMode = DeterminePublishMode(apphostPath, depsFile, directory);
// Collect ALC hints from runtimeconfig.dev.json (11-001 requirement)
var alcHints = CollectAlcHints(directory, name);
// Collect probing paths from runtimeconfig files (11-001 requirement)
var probingPaths = CollectProbingPaths(directory, name);
// Collect native dependencies for apphost bundles (11-001 requirement)
var nativeDeps = CollectNativeDependencies(apphostPath, publishMode);
var id = BuildDeterministicId(name, tfms, rids, publishKind, mvid);
results.Add(new DotNetEntrypoint(
Id: id,
Name: name,
AssemblyName: Path.GetFileName(assemblyPath ?? $"{name}.dll"),
Mvid: mvid,
TargetFrameworks: tfms,
RuntimeIdentifiers: rids,
HostKind: hostKind,
PublishKind: publishKind,
PublishMode: publishMode,
AlcHints: alcHints,
ProbingPaths: probingPaths,
NativeDependencies: nativeDeps,
Hash: hash,
FileSizeBytes: fileSize,
RelativeDepsPath: relativeDepsPath,
RelativeRuntimeConfigPath: relativeRuntimeConfig,
PublishKind: publishKind));
RelativeAssemblyPath: assemblyPath is not null ? NormalizeRelative(context.GetRelativePath(assemblyPath)) : null,
RelativeApphostPath: apphostPath is not null ? NormalizeRelative(context.GetRelativePath(apphostPath)) : null));
}
catch (IOException)
{
@@ -89,6 +131,292 @@ public static class DotNetEntrypointResolver
return ValueTask.FromResult<IReadOnlyList<DotNetEntrypoint>>(results);
}
private static (string? assemblyPath, string? apphostPath) ResolveEntrypointPaths(string directory, string name)
{
string? assemblyPath = null;
string? apphostPath = null;
// Look for main assembly (.dll)
var dllPath = Path.Combine(directory, $"{name}.dll");
if (File.Exists(dllPath))
{
assemblyPath = dllPath;
}
// Look for apphost executable (.exe on Windows, no extension on Unix)
var exePath = Path.Combine(directory, $"{name}.exe");
if (File.Exists(exePath))
{
apphostPath = exePath;
}
else
{
// Check for Unix-style executable (no extension)
var unixExePath = Path.Combine(directory, name);
if (File.Exists(unixExePath) && !unixExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
apphostPath = unixExePath;
}
}
return (assemblyPath, apphostPath);
}
private static Guid? ExtractMvid(string? assemblyPath)
{
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath))
{
return null;
}
try
{
using var stream = File.OpenRead(assemblyPath);
using var peReader = new PEReader(stream);
if (!peReader.HasMetadata)
{
return null;
}
var metadataReader = peReader.GetMetadataReader();
var moduleDefinition = metadataReader.GetModuleDefinition();
return metadataReader.GetGuid(moduleDefinition.Mvid);
}
catch (BadImageFormatException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
}
private static (string? hash, long fileSize) ComputeHashAndSize(string? assemblyPath)
{
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath))
{
return (null, 0);
}
try
{
using var stream = File.OpenRead(assemblyPath);
var fileSize = stream.Length;
var hashBytes = SHA256.HashData(stream);
var hash = $"sha256:{Convert.ToHexStringLower(hashBytes)}";
return (hash, fileSize);
}
catch (IOException)
{
return (null, 0);
}
}
private static DotNetHostKind DetermineHostKind(string? apphostPath, DotNetPublishKind publishKind)
{
if (!string.IsNullOrEmpty(apphostPath) && File.Exists(apphostPath))
{
return DotNetHostKind.Apphost;
}
return publishKind switch
{
DotNetPublishKind.SelfContained => DotNetHostKind.SelfContained,
DotNetPublishKind.FrameworkDependent => DotNetHostKind.FrameworkDependent,
_ => DotNetHostKind.Unknown
};
}
private static DotNetPublishMode DeterminePublishMode(string? apphostPath, DotNetDepsFile depsFile, string directory)
{
// Check for single-file bundle
if (!string.IsNullOrEmpty(apphostPath) && File.Exists(apphostPath))
{
var singleFileResult = SingleFileAppDetector.Analyze(apphostPath);
if (singleFileResult.IsSingleFile)
{
return DotNetPublishMode.SingleFile;
}
}
// Check for trimmed publish (look for trim markers or reduced dependency count)
var trimmedMarkerPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(apphostPath ?? "app")}.staticwebassets.runtime.json");
if (File.Exists(trimmedMarkerPath))
{
return DotNetPublishMode.Trimmed;
}
// Check deps.json for trimmed indicators
foreach (var library in depsFile.Libraries.Values)
{
if (library.Id.Contains("ILLink", StringComparison.OrdinalIgnoreCase) ||
library.Id.Contains("Trimmer", StringComparison.OrdinalIgnoreCase))
{
return DotNetPublishMode.Trimmed;
}
}
return DotNetPublishMode.Normal;
}
private static IReadOnlyCollection<string> CollectAlcHints(string directory, string name)
{
var hints = new SortedSet<string>(StringComparer.Ordinal);
// Check runtimeconfig.dev.json for ALC hints
var devConfigPath = Path.Combine(directory, $"{name}.runtimeconfig.dev.json");
if (File.Exists(devConfigPath))
{
try
{
var json = File.ReadAllText(devConfigPath);
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
if (doc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptions))
{
// Look for additionalProbingPaths which indicate ALC usage
if (runtimeOptions.TryGetProperty("additionalProbingPaths", out var probingPaths) &&
probingPaths.ValueKind == JsonValueKind.Array)
{
foreach (var path in probingPaths.EnumerateArray())
{
if (path.ValueKind == JsonValueKind.String)
{
var pathValue = path.GetString();
if (!string.IsNullOrWhiteSpace(pathValue))
{
// Extract ALC hint from path pattern
if (pathValue.Contains(".nuget", StringComparison.OrdinalIgnoreCase))
{
hints.Add("NuGetAssemblyLoadContext");
}
else if (pathValue.Contains("sdk", StringComparison.OrdinalIgnoreCase))
{
hints.Add("SdkAssemblyLoadContext");
}
}
}
}
}
}
}
catch (JsonException)
{
// Ignore malformed dev config
}
catch (IOException)
{
// Ignore read errors
}
}
// Add default ALC hint
if (hints.Count == 0)
{
hints.Add("Default");
}
return hints;
}
private static IReadOnlyCollection<string> CollectProbingPaths(string directory, string name)
{
var paths = new SortedSet<string>(StringComparer.Ordinal);
// Check runtimeconfig.dev.json for probing paths
var devConfigPath = Path.Combine(directory, $"{name}.runtimeconfig.dev.json");
if (File.Exists(devConfigPath))
{
try
{
var json = File.ReadAllText(devConfigPath);
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
if (doc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptions) &&
runtimeOptions.TryGetProperty("additionalProbingPaths", out var probingPaths) &&
probingPaths.ValueKind == JsonValueKind.Array)
{
foreach (var path in probingPaths.EnumerateArray())
{
if (path.ValueKind == JsonValueKind.String)
{
var pathValue = path.GetString();
if (!string.IsNullOrWhiteSpace(pathValue))
{
// Normalize and add the probing path
paths.Add(NormalizeRelative(pathValue));
}
}
}
}
}
catch (JsonException)
{
// Ignore malformed dev config
}
catch (IOException)
{
// Ignore read errors
}
}
return paths;
}
private static IReadOnlyCollection<string> CollectNativeDependencies(string? apphostPath, DotNetPublishMode publishMode)
{
var nativeDeps = new SortedSet<string>(StringComparer.Ordinal);
if (publishMode != DotNetPublishMode.SingleFile || string.IsNullOrEmpty(apphostPath))
{
return nativeDeps;
}
// For single-file apps, try to extract bundled native library names
// This is a simplified detection - full extraction would require parsing the bundle manifest
var directory = Path.GetDirectoryName(apphostPath);
if (string.IsNullOrEmpty(directory))
{
return nativeDeps;
}
// Look for extracted native libraries (some single-file apps extract natives at runtime)
var nativePatterns = new[] { "*.so", "*.dylib", "*.dll" };
foreach (var pattern in nativePatterns)
{
try
{
foreach (var nativePath in Directory.EnumerateFiles(directory, pattern))
{
var fileName = Path.GetFileName(nativePath);
// Filter out managed assemblies
if (!fileName.Equals(Path.GetFileName(apphostPath), StringComparison.OrdinalIgnoreCase) &&
!fileName.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase) &&
!fileName.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase))
{
nativeDeps.Add(fileName);
}
}
}
catch (IOException)
{
// Ignore enumeration errors
}
}
return nativeDeps;
}
private static string GetEntrypointName(string depsPath)
{
// Strip .json then any trailing .deps suffix to yield a logical entrypoint name.
@@ -273,12 +601,14 @@ public static class DotNetEntrypointResolver
string name,
IReadOnlyCollection<string> tfms,
IReadOnlyCollection<string> rids,
DotNetPublishKind publishKind)
DotNetPublishKind publishKind,
Guid? mvid)
{
var tfmPart = tfms.Count == 0 ? "unknown" : string.Join('+', tfms.OrderBy(t => t, StringComparer.OrdinalIgnoreCase));
var ridPart = rids.Count == 0 ? "none" : string.Join('+', rids.OrderBy(r => r, StringComparer.OrdinalIgnoreCase));
var publishPart = publishKind.ToString().ToLowerInvariant();
return $"{name}:{tfmPart}:{ridPart}:{publishPart}";
var mvidPart = mvid?.ToString("N") ?? "no-mvid";
return $"{name}:{tfmPart}:{ridPart}:{publishPart}:{mvidPart}";
}
private static string NormalizeRelative(string path)
@@ -293,18 +623,84 @@ public static class DotNetEntrypointResolver
}
}
/// <summary>
/// Represents a resolved .NET entrypoint with deterministic identity per SCANNER-ANALYZERS-LANG-11-001.
/// </summary>
public sealed record DotNetEntrypoint(
/// <summary>Deterministic identifier: name:tfms:rids:publishKind:mvid</summary>
string Id,
/// <summary>Logical entrypoint name derived from deps.json</summary>
string Name,
/// <summary>Assembly file name (e.g., "MyApp.dll")</summary>
string AssemblyName,
/// <summary>Module Version ID from PE metadata (deterministic per build)</summary>
Guid? Mvid,
/// <summary>Target frameworks (normalized, e.g., "net8.0")</summary>
IReadOnlyCollection<string> TargetFrameworks,
/// <summary>Runtime identifiers (e.g., "linux-x64", "win-x64")</summary>
IReadOnlyCollection<string> RuntimeIdentifiers,
/// <summary>Host kind: apphost, framework-dependent, self-contained</summary>
DotNetHostKind HostKind,
/// <summary>Publish kind from deps.json analysis</summary>
DotNetPublishKind PublishKind,
/// <summary>Publish mode: normal, single-file, trimmed</summary>
DotNetPublishMode PublishMode,
/// <summary>AssemblyLoadContext hints from runtimeconfig.dev.json</summary>
IReadOnlyCollection<string> AlcHints,
/// <summary>Additional probing paths from runtimeconfig.dev.json</summary>
IReadOnlyCollection<string> ProbingPaths,
/// <summary>Native dependencies for single-file bundles</summary>
IReadOnlyCollection<string> NativeDependencies,
/// <summary>SHA-256 hash of assembly bytes (sha256:hex)</summary>
string? Hash,
/// <summary>Assembly file size in bytes</summary>
long FileSizeBytes,
/// <summary>Relative path to deps.json</summary>
string RelativeDepsPath,
/// <summary>Relative path to runtimeconfig.json</summary>
string? RelativeRuntimeConfigPath,
DotNetPublishKind PublishKind);
/// <summary>Relative path to main assembly (.dll)</summary>
string? RelativeAssemblyPath,
/// <summary>Relative path to apphost executable</summary>
string? RelativeApphostPath);
/// <summary>
/// .NET host kind classification per SCANNER-ANALYZERS-LANG-11-001.
/// </summary>
public enum DotNetHostKind
{
/// <summary>Host kind could not be determined</summary>
Unknown = 0,
/// <summary>Application uses apphost executable</summary>
Apphost = 1,
/// <summary>Framework-dependent deployment (requires shared runtime)</summary>
FrameworkDependent = 2,
/// <summary>Self-contained deployment (includes runtime)</summary>
SelfContained = 3
}
/// <summary>
/// .NET publish kind from deps.json analysis.
/// </summary>
public enum DotNetPublishKind
{
/// <summary>Publish kind could not be determined</summary>
Unknown = 0,
/// <summary>Framework-dependent (relies on shared .NET runtime)</summary>
FrameworkDependent = 1,
/// <summary>Self-contained (includes .NET runtime)</summary>
SelfContained = 2
}
/// <summary>
/// .NET publish mode per SCANNER-ANALYZERS-LANG-11-001.
/// </summary>
public enum DotNetPublishMode
{
/// <summary>Normal publish (separate files)</summary>
Normal = 0,
/// <summary>Single-file publish (assemblies bundled into executable)</summary>
SingleFile = 1,
/// <summary>Trimmed publish (unused code removed)</summary>
Trimmed = 2
}

View File

@@ -204,6 +204,59 @@ public sealed class RuntimeEventRepository : RepositoryBase<ScannerDataSource>
cancellationToken);
}
public Task<RuntimeEventDocument?> GetByEventIdAsync(string eventId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
var sql = $"""
SELECT id, event_id, schema_version, tenant, node, kind, "when", received_at, expires_at,
platform, namespace, pod, container, container_id, image_ref, image_digest,
engine, engine_version, baseline_digest, image_signed, sbom_referrer, build_id, payload
FROM {Table}
WHERE event_id = @event_id
""";
return QuerySingleOrDefaultAsync(
Tenant,
sql,
cmd => AddParameter(cmd, "event_id", eventId),
MapRuntimeEvent,
cancellationToken);
}
public async Task<IReadOnlyList<RuntimeEventDocument>> GetByImageDigestAsync(
string imageDigest,
int limit,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
if (limit <= 0)
{
limit = 100;
}
var sql = $"""
SELECT id, event_id, schema_version, tenant, node, kind, "when", received_at, expires_at,
platform, namespace, pod, container, container_id, image_ref, image_digest,
engine, engine_version, baseline_digest, image_signed, sbom_referrer, build_id, payload
FROM {Table}
WHERE image_digest = @image_digest
ORDER BY received_at DESC
LIMIT @limit
""";
return await QueryAsync(
Tenant,
sql,
cmd =>
{
AddParameter(cmd, "image_digest", imageDigest.Trim().ToLowerInvariant());
AddParameter(cmd, "limit", limit);
},
MapRuntimeEvent,
cancellationToken).ConfigureAwait(false);
}
private static RuntimeEventDocument MapRuntimeEvent(NpgsqlDataReader reader)
{
var payloadOrdinal = reader.GetOrdinal("payload");