Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,29 @@
# StellaOps.Scanner.Analyzers.Lang.DotNet — Agent Charter
## Role
Create the .NET analyzer plug-in that inspects `*.deps.json`, `runtimeconfig.json`, assemblies, and RID-specific assets to deliver accurate NuGet components with signing metadata.
## Scope
- Parse dependency graphs from `*.deps.json` and merge with `runtimeconfig.json` and bundle manifests.
- Capture assembly metadata (strong name, file version, Authenticode) and correlate with packages.
- Handle RID-specific asset selection, self-contained apps, and crossgen/native dependency hints.
- Package plug-in manifest, determinism fixtures, benchmarks, and Offline Kit documentation.
## Out of Scope
- Policy evaluation or Signer integration (handled elsewhere).
- Native dependency resolution outside RID mapping.
- Windows-specific MSI/SxS analyzers (covered by native analyzer roadmap).
## Expectations
- Performance target: multi-target app fixture <1.2s, memory <250MB.
- Deterministic RID collapsing to reduce component duplication by 40% vs naive approach.
- Offline-first; support air-gapped strong-name/Authenticode validation using cached root store.
- Rich telemetry (components per RID, strong-name validations) conforming to Scanner metrics.
## Dependencies
- Shared language analyzer infrastructure; Worker dispatcher; optional security key store for signature verification.
## Testing & Artifacts
- Fixtures for framework-dependent and self-contained apps (linux-musl, win-x64).
- Golden outputs capturing signature metadata and RID grouping.
- Benchmark comparing analyzer fidelity vs market competitors.

View File

@@ -0,0 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.DotNet";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new DotNetLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "dotnet";
public string DisplayName => ".NET Analyzer (preview)";
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)
{
return;
}
foreach (var package in packages)
{
cancellationToken.ThrowIfCancellationRequested();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "nuget",
metadata: package.Metadata,
evidence: package.Evidence,
usedByEntrypoint: package.UsedByEntrypoint);
}
}
}

View File

@@ -0,0 +1,7 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public interface IDotNetAuthenticodeInspector
{
DotNetAuthenticodeMetadata? TryInspect(string assemblyPath, CancellationToken cancellationToken);
}
public sealed record DotNetAuthenticodeMetadata(
string? Subject,
string? Issuer,
DateTimeOffset? NotBefore,
DateTimeOffset? NotAfter,
string? Thumbprint,
string? SerialNumber);

View File

@@ -0,0 +1,518 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetDepsFile
{
private DotNetDepsFile(string relativePath, IReadOnlyDictionary<string, DotNetLibrary> libraries)
{
RelativePath = relativePath;
Libraries = libraries;
}
public string RelativePath { get; }
public IReadOnlyDictionary<string, DotNetLibrary> Libraries { get; }
public static DotNetDepsFile? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (root.ValueKind is not JsonValueKind.Object)
{
return null;
}
var libraries = ParseLibraries(root, cancellationToken);
if (libraries.Count == 0)
{
return null;
}
PopulateTargets(root, libraries, cancellationToken);
return new DotNetDepsFile(relativePath, libraries);
}
private static Dictionary<string, DotNetLibrary> ParseLibraries(JsonElement root, CancellationToken cancellationToken)
{
var result = new Dictionary<string, DotNetLibrary>(StringComparer.Ordinal);
if (!root.TryGetProperty("libraries", out var librariesElement) || librariesElement.ValueKind is not JsonValueKind.Object)
{
return result;
}
foreach (var property in librariesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (DotNetLibrary.TryCreate(property.Name, property.Value, out var library))
{
result[property.Name] = library;
}
}
return result;
}
private static void PopulateTargets(JsonElement root, IDictionary<string, DotNetLibrary> libraries, CancellationToken cancellationToken)
{
if (!root.TryGetProperty("targets", out var targetsElement) || targetsElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var targetProperty in targetsElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
var (tfm, rid) = ParseTargetKey(targetProperty.Name);
if (targetProperty.Value.ValueKind is not JsonValueKind.Object)
{
continue;
}
foreach (var libraryProperty in targetProperty.Value.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (!libraries.TryGetValue(libraryProperty.Name, out var library))
{
continue;
}
if (!string.IsNullOrEmpty(tfm))
{
library.AddTargetFramework(tfm);
}
if (!string.IsNullOrEmpty(rid))
{
library.AddRuntimeIdentifier(rid);
}
library.MergeTargetMetadata(libraryProperty.Value, tfm, rid);
}
}
}
private static (string tfm, string? rid) ParseTargetKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return (string.Empty, null);
}
var separatorIndex = value.IndexOf('/');
if (separatorIndex < 0)
{
return (value.Trim(), null);
}
var tfm = value[..separatorIndex].Trim();
var rid = value[(separatorIndex + 1)..].Trim();
return (tfm, string.IsNullOrEmpty(rid) ? null : rid);
}
}
internal sealed class DotNetLibrary
{
private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal);
private readonly List<DotNetLibraryAsset> _runtimeAssets = new();
private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal);
private DotNetLibrary(
string key,
string id,
string version,
string type,
bool? serviceable,
string? sha512,
string? path,
string? hashPath)
{
Key = key;
Id = id;
Version = version;
Type = type;
Serviceable = serviceable;
Sha512 = NormalizeValue(sha512);
PackagePath = NormalizePath(path);
HashPath = NormalizePath(hashPath);
}
public string Key { get; }
public string Id { get; }
public string Version { get; }
public string Type { get; }
public bool? Serviceable { get; }
public string? Sha512 { get; }
public string? PackagePath { get; }
public string? HashPath { get; }
public bool IsPackage => string.Equals(Type, "package", StringComparison.OrdinalIgnoreCase);
public IReadOnlyCollection<string> Dependencies => _dependencies;
public IReadOnlyCollection<string> TargetFrameworks => _targetFrameworks;
public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers;
public IReadOnlyCollection<DotNetLibraryAsset> RuntimeAssets => _runtimeAssets;
public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library)
{
library = null;
if (!TrySplitNameAndVersion(key, out var id, out var version))
{
return false;
}
var type = element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String
? typeElement.GetString() ?? string.Empty
: string.Empty;
bool? serviceable = null;
if (element.TryGetProperty("serviceable", out var serviceableElement))
{
if (serviceableElement.ValueKind is JsonValueKind.True)
{
serviceable = true;
}
else if (serviceableElement.ValueKind is JsonValueKind.False)
{
serviceable = false;
}
}
var sha512 = element.TryGetProperty("sha512", out var sha512Element) && sha512Element.ValueKind == JsonValueKind.String
? sha512Element.GetString()
: null;
var path = element.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String
? pathElement.GetString()
: null;
var hashPath = element.TryGetProperty("hashPath", out var hashElement) && hashElement.ValueKind == JsonValueKind.String
? hashElement.GetString()
: null;
library = new DotNetLibrary(key, id, version, type, serviceable, sha512, path, hashPath);
library.MergeLibraryMetadata(element);
return true;
}
public void AddTargetFramework(string tfm)
{
if (!string.IsNullOrWhiteSpace(tfm))
{
_targetFrameworks.Add(tfm);
}
}
public void AddRuntimeIdentifier(string rid)
{
if (!string.IsNullOrWhiteSpace(rid))
{
_runtimeIdentifiers.Add(rid);
}
}
public void MergeTargetMetadata(JsonElement element, string? tfm, string? rid)
{
if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object)
{
foreach (var dependencyProperty in dependenciesElement.EnumerateObject())
{
AddDependency(dependencyProperty.Name);
}
}
MergeRuntimeAssets(element, tfm, rid);
}
public void MergeLibraryMetadata(JsonElement element)
{
if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object)
{
foreach (var dependencyProperty in dependenciesElement.EnumerateObject())
{
AddDependency(dependencyProperty.Name);
}
}
MergeRuntimeAssets(element, tfm: null, rid: null);
}
private void MergeRuntimeAssets(JsonElement element, string? tfm, string? rid)
{
AddRuntimeAssetsFromRuntime(element, tfm, rid);
AddRuntimeAssetsFromRuntimeTargets(element, tfm, rid);
}
private void AddRuntimeAssetsFromRuntime(JsonElement element, string? tfm, string? rid)
{
if (!element.TryGetProperty("runtime", out var runtimeElement) || runtimeElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var assetProperty in runtimeElement.EnumerateObject())
{
if (DotNetLibraryAsset.TryCreateFromRuntime(assetProperty.Name, assetProperty.Value, tfm, rid, out var asset))
{
_runtimeAssets.Add(asset);
}
}
}
private void AddRuntimeAssetsFromRuntimeTargets(JsonElement element, string? tfm, string? rid)
{
if (!element.TryGetProperty("runtimeTargets", out var runtimeTargetsElement) || runtimeTargetsElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var assetProperty in runtimeTargetsElement.EnumerateObject())
{
if (DotNetLibraryAsset.TryCreateFromRuntimeTarget(assetProperty.Name, assetProperty.Value, tfm, rid, out var asset))
{
_runtimeAssets.Add(asset);
}
}
}
private void AddDependency(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
var dependencyId = name;
if (TrySplitNameAndVersion(name, out var parsedName, out _))
{
dependencyId = parsedName;
}
if (!string.IsNullOrWhiteSpace(dependencyId))
{
_dependencies.Add(dependencyId);
}
}
private static bool TrySplitNameAndVersion(string key, out string name, out string version)
{
name = string.Empty;
version = string.Empty;
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
var separatorIndex = key.LastIndexOf('/');
if (separatorIndex <= 0 || separatorIndex >= key.Length - 1)
{
return false;
}
name = key[..separatorIndex].Trim();
version = key[(separatorIndex + 1)..].Trim();
return name.Length > 0 && version.Length > 0;
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return path.Replace('\\', '/');
}
private static string? NormalizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}
internal enum DotNetLibraryAssetKind
{
Runtime,
Native
}
internal sealed record DotNetLibraryAsset(
string RelativePath,
string? TargetFramework,
string? RuntimeIdentifier,
string? AssemblyVersion,
string? FileVersion,
DotNetLibraryAssetKind Kind)
{
public static bool TryCreateFromRuntime(string name, JsonElement element, string? tfm, string? rid, [NotNullWhen(true)] out DotNetLibraryAsset? asset)
{
asset = null;
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var normalizedPath = NormalizePath(name);
if (string.IsNullOrEmpty(normalizedPath))
{
return false;
}
if (!IsManagedAssembly(normalizedPath))
{
return false;
}
string? assemblyVersion = null;
string? fileVersion = null;
if (element.ValueKind == JsonValueKind.Object)
{
if (element.TryGetProperty("assemblyVersion", out var assemblyVersionElement) && assemblyVersionElement.ValueKind == JsonValueKind.String)
{
assemblyVersion = NormalizeValue(assemblyVersionElement.GetString());
}
if (element.TryGetProperty("fileVersion", out var fileVersionElement) && fileVersionElement.ValueKind == JsonValueKind.String)
{
fileVersion = NormalizeValue(fileVersionElement.GetString());
}
}
asset = new DotNetLibraryAsset(
RelativePath: normalizedPath,
TargetFramework: NormalizeValue(tfm),
RuntimeIdentifier: NormalizeValue(rid),
AssemblyVersion: assemblyVersion,
FileVersion: fileVersion,
Kind: DotNetLibraryAssetKind.Runtime);
return true;
}
public static bool TryCreateFromRuntimeTarget(string name, JsonElement element, string? tfm, string? rid, [NotNullWhen(true)] out DotNetLibraryAsset? asset)
{
asset = null;
if (string.IsNullOrWhiteSpace(name) || element.ValueKind is not JsonValueKind.Object)
{
return false;
}
var assetType = element.TryGetProperty("assetType", out var assetTypeElement) && assetTypeElement.ValueKind == JsonValueKind.String
? NormalizeValue(assetTypeElement.GetString())
: null;
var normalizedPath = NormalizePath(name);
if (string.IsNullOrEmpty(normalizedPath))
{
return false;
}
DotNetLibraryAssetKind kind;
if (assetType is null || string.Equals(assetType, "runtime", StringComparison.OrdinalIgnoreCase))
{
if (!IsManagedAssembly(normalizedPath))
{
return false;
}
kind = DotNetLibraryAssetKind.Runtime;
}
else if (string.Equals(assetType, "native", StringComparison.OrdinalIgnoreCase))
{
kind = DotNetLibraryAssetKind.Native;
}
else
{
return false;
}
string? assemblyVersion = null;
string? fileVersion = null;
if (kind == DotNetLibraryAssetKind.Runtime &&
element.TryGetProperty("assemblyVersion", out var assemblyVersionElement) &&
assemblyVersionElement.ValueKind == JsonValueKind.String)
{
assemblyVersion = NormalizeValue(assemblyVersionElement.GetString());
}
if (kind == DotNetLibraryAssetKind.Runtime &&
element.TryGetProperty("fileVersion", out var fileVersionElement) &&
fileVersionElement.ValueKind == JsonValueKind.String)
{
fileVersion = NormalizeValue(fileVersionElement.GetString());
}
string? runtimeIdentifier = rid;
if (element.TryGetProperty("rid", out var ridElement) && ridElement.ValueKind == JsonValueKind.String)
{
runtimeIdentifier = NormalizeValue(ridElement.GetString());
}
asset = new DotNetLibraryAsset(
RelativePath: normalizedPath,
TargetFramework: NormalizeValue(tfm),
RuntimeIdentifier: NormalizeValue(runtimeIdentifier),
AssemblyVersion: assemblyVersion,
FileVersion: fileVersion,
Kind: kind);
return true;
}
private static string NormalizePath(string value)
{
var normalized = NormalizeValue(value);
if (string.IsNullOrEmpty(normalized))
{
return string.Empty;
}
return normalized.Replace('\\', '/');
}
private static bool IsManagedAssembly(string path)
=> path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase);
private static string? NormalizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}

View File

@@ -0,0 +1,332 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security;
using System.Xml;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal static class DotNetFileMetadataCache
{
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<string>> Sha256Cache = new();
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<AssemblyName>> AssemblyCache = new();
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<FileVersionInfo>> VersionCache = new();
public static bool TryGetSha256(string path, out string? sha256)
=> TryGet(path, Sha256Cache, ComputeSha256, out sha256);
public static bool TryGetAssemblyName(string path, out AssemblyName? assemblyName)
=> TryGet(path, AssemblyCache, TryReadAssemblyName, out assemblyName);
public static bool TryGetFileVersionInfo(string path, out FileVersionInfo? versionInfo)
=> TryGet(path, VersionCache, TryReadFileVersionInfo, out versionInfo);
private static bool TryGet<T>(string path, ConcurrentDictionary<DotNetFileCacheKey, Optional<T>> cache, Func<string, T?> resolver, out T? value)
where T : class
{
value = null;
DotNetFileCacheKey key;
try
{
var info = new FileInfo(path);
if (!info.Exists)
{
return false;
}
key = new DotNetFileCacheKey(info.FullName, info.Length, info.LastWriteTimeUtc.Ticks);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
var optional = cache.GetOrAdd(key, static (cacheKey, state) => CreateOptional(cacheKey.Path, state.resolver), (resolver, path));
if (!optional.HasValue)
{
return false;
}
value = optional.Value;
return value is not null;
}
private static Optional<T> CreateOptional<T>(string path, Func<string, T?> resolver) where T : class
{
try
{
var value = resolver(path);
return Optional<T>.From(value);
}
catch (FileNotFoundException)
{
return Optional<T>.None;
}
catch (FileLoadException)
{
return Optional<T>.None;
}
catch (BadImageFormatException)
{
return Optional<T>.None;
}
catch (UnauthorizedAccessException)
{
return Optional<T>.None;
}
catch (SecurityException)
{
return Optional<T>.None;
}
catch (IOException)
{
return Optional<T>.None;
}
}
private static string? ComputeSha256(string path)
{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static AssemblyName? TryReadAssemblyName(string path)
{
try
{
return AssemblyName.GetAssemblyName(path);
}
catch (FileNotFoundException)
{
return null;
}
catch (FileLoadException)
{
return null;
}
catch (BadImageFormatException)
{
return null;
}
catch (IOException)
{
return null;
}
}
private static FileVersionInfo? TryReadFileVersionInfo(string path)
{
try
{
return FileVersionInfo.GetVersionInfo(path);
}
catch (FileNotFoundException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
}
internal static class DotNetLicenseCache
{
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<DotNetLicenseInfo>> Licenses = new();
public static bool TryGetLicenseInfo(string nuspecPath, out DotNetLicenseInfo? info)
{
info = null;
DotNetFileCacheKey key;
try
{
var fileInfo = new FileInfo(nuspecPath);
if (!fileInfo.Exists)
{
return false;
}
key = new DotNetFileCacheKey(fileInfo.FullName, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
var optional = Licenses.GetOrAdd(key, static (cacheKey, path) => CreateOptional(path), nuspecPath);
if (!optional.HasValue)
{
return false;
}
info = optional.Value;
return info is not null;
}
private static Optional<DotNetLicenseInfo> CreateOptional(string nuspecPath)
{
try
{
var info = Parse(nuspecPath);
return Optional<DotNetLicenseInfo>.From(info);
}
catch (IOException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (UnauthorizedAccessException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (SecurityException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (XmlException)
{
return Optional<DotNetLicenseInfo>.None;
}
}
private static DotNetLicenseInfo? Parse(string path)
{
using var stream = File.OpenRead(path);
using var reader = XmlReader.Create(stream, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore,
IgnoreComments = true,
IgnoreWhitespace = true,
});
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var urls = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
if (string.Equals(reader.LocalName, "license", StringComparison.OrdinalIgnoreCase))
{
var type = reader.GetAttribute("type");
var value = reader.ReadElementContentAsString()?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (string.Equals(type, "expression", StringComparison.OrdinalIgnoreCase))
{
expressions.Add(value);
}
else if (string.Equals(type, "file", StringComparison.OrdinalIgnoreCase))
{
files.Add(NormalizeLicensePath(value));
}
else
{
expressions.Add(value);
}
}
else if (string.Equals(reader.LocalName, "licenseUrl", StringComparison.OrdinalIgnoreCase))
{
var value = reader.ReadElementContentAsString()?.Trim();
if (!string.IsNullOrWhiteSpace(value))
{
urls.Add(value);
}
}
}
if (expressions.Count == 0 && files.Count == 0 && urls.Count == 0)
{
return null;
}
return new DotNetLicenseInfo(
expressions.ToArray(),
files.ToArray(),
urls.ToArray());
}
private static string NormalizeLicensePath(string value)
=> value.Replace('\\', '/').Trim();
}
internal sealed record DotNetLicenseInfo(
IReadOnlyList<string> Expressions,
IReadOnlyList<string> Files,
IReadOnlyList<string> Urls);
internal readonly record struct DotNetFileCacheKey(string Path, long Length, long LastWriteTicks)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
: Path;
public bool Equals(DotNetFileCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
}
internal readonly struct Optional<T> where T : class
{
private Optional(bool hasValue, T? value)
{
HasValue = hasValue;
Value = value;
}
public bool HasValue { get; }
public T? Value { get; }
public static Optional<T> From(T? value)
=> value is null ? None : new Optional<T>(true, value);
public static Optional<T> None => default;
}

View File

@@ -0,0 +1,158 @@
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetRuntimeConfig
{
private DotNetRuntimeConfig(
string relativePath,
IReadOnlyCollection<string> tfms,
IReadOnlyCollection<string> frameworks,
IReadOnlyCollection<RuntimeGraphEntry> runtimeGraph)
{
RelativePath = relativePath;
Tfms = tfms;
Frameworks = frameworks;
RuntimeGraph = runtimeGraph;
}
public string RelativePath { get; }
public IReadOnlyCollection<string> Tfms { get; }
public IReadOnlyCollection<string> Frameworks { get; }
public IReadOnlyCollection<RuntimeGraphEntry> RuntimeGraph { get; }
public static DotNetRuntimeConfig? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (!root.TryGetProperty("runtimeOptions", out var runtimeOptions) || runtimeOptions.ValueKind is not JsonValueKind.Object)
{
return null;
}
var tfms = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var frameworks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var runtimeGraph = new List<RuntimeGraphEntry>();
if (runtimeOptions.TryGetProperty("tfm", out var tfmElement) && tfmElement.ValueKind == JsonValueKind.String)
{
AddIfPresent(tfms, tfmElement.GetString());
}
if (runtimeOptions.TryGetProperty("framework", out var frameworkElement) && frameworkElement.ValueKind == JsonValueKind.Object)
{
var frameworkId = FormatFramework(frameworkElement);
AddIfPresent(frameworks, frameworkId);
}
if (runtimeOptions.TryGetProperty("frameworks", out var frameworksElement) && frameworksElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in frameworksElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("includedFrameworks", out var includedElement) && includedElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in includedElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("runtimeGraph", out var runtimeGraphElement) &&
runtimeGraphElement.ValueKind == JsonValueKind.Object &&
runtimeGraphElement.TryGetProperty("runtimes", out var runtimesElement) &&
runtimesElement.ValueKind == JsonValueKind.Object)
{
foreach (var ridProperty in runtimesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(ridProperty.Name))
{
continue;
}
var fallbacks = new List<string>();
if (ridProperty.Value.ValueKind == JsonValueKind.Object &&
ridProperty.Value.TryGetProperty("fallbacks", out var fallbacksElement) &&
fallbacksElement.ValueKind == JsonValueKind.Array)
{
foreach (var fallback in fallbacksElement.EnumerateArray())
{
if (fallback.ValueKind == JsonValueKind.String)
{
var fallbackValue = fallback.GetString();
if (!string.IsNullOrWhiteSpace(fallbackValue))
{
fallbacks.Add(fallbackValue.Trim());
}
}
}
}
runtimeGraph.Add(new RuntimeGraphEntry(ridProperty.Name.Trim(), fallbacks));
}
}
return new DotNetRuntimeConfig(
relativePath,
tfms.ToArray(),
frameworks.ToArray(),
runtimeGraph);
}
private static void AddIfPresent(ISet<string> set, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
set.Add(value.Trim());
}
}
private static string? FormatFramework(JsonElement element)
{
if (element.ValueKind is not JsonValueKind.Object)
{
return null;
}
var name = element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String
? nameElement.GetString()
: null;
var version = element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String
? versionElement.GetString()
: null;
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
if (string.IsNullOrWhiteSpace(version))
{
return name.Trim();
}
return $"{name.Trim()}@{version.Trim()}";
}
internal sealed record RuntimeGraphEntry(string Rid, IReadOnlyList<string> Fallbacks);
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
<None Include="**\\*" Exclude="**\\*.cs;**\\*.json;bin\\**;obj\\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
# .NET Analyzer Task Flow
| Seq | ID | Status | Depends on | Description | Exit Criteria |
|-----|----|--------|------------|-------------|---------------|
| 1 | SCANNER-ANALYZERS-LANG-10-305A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components. | RID graph deterministic; fixtures confirm consistent component ordering; fallback to `bin:{sha256}` documented. |
| 2 | SCANNER-ANALYZERS-LANG-10-305B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-305A | Extract assembly metadata (strong name, file/product info) and optional Authenticode details when offline cert bundle provided. | Signing metadata captured for signed assemblies; offline trust store documented; hash validations deterministic. |
| 3 | SCANNER-ANALYZERS-LANG-10-305C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-305B | Handle self-contained apps and native assets; merge with EntryTrace usage hints. | Self-contained fixtures map to components with RID flags; usage hints propagate; tests cover linux/win variants. |
| 4 | SCANNER-ANALYZERS-LANG-10-307D | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-305C | Integrate shared helpers (license mapping, quiet provenance) and concurrency-safe caches. | Shared helpers reused; concurrency tests for parallel layer scans pass; no redundant allocations. |
| 5 | SCANNER-ANALYZERS-LANG-10-308D | DONE (2025-10-23) | SCANNER-ANALYZERS-LANG-10-307D | Determinism fixtures + benchmark harness; compare to competitor scanners for accuracy/perf. | Fixtures in `Fixtures/lang/dotnet/`; determinism CI guard; benchmark demonstrates lower duplication + faster runtime. |
| 6 | SCANNER-ANALYZERS-LANG-10-309D | DONE (2025-10-23) | SCANNER-ANALYZERS-LANG-10-308D | Package plug-in (manifest, DI registration) and update Offline Kit instructions. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. |
## .NET Entry-Point & Dependency Resolver (Sprint 11)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-LANG-11-001 | TODO | StellaOps.Scanner EPDR Guild, Language Analyzer Guild | - | Build entrypoint resolver that maps project/publish artifacts to entrypoint identities (assembly name, MVID, TFM, RID) and environment profiles (publish mode, host kind, probing paths). Output normalized `entrypoints[]` records with deterministic IDs. | Entrypoint records produced for fixtures (framework-dependent, self-contained, single-file, multi-TFM/RID); determinism check passes; docs updated. |
| SCANNER-ANALYZERS-LANG-11-002 | TODO | StellaOps.Scanner EPDR Guild | SCANNER-ANALYZERS-LANG-11-001 | Implement static analyzer (IL + reflection heuristics) capturing AssemblyRef, ModuleRef/PInvoke, DynamicDependency, reflection literals, DI patterns, and custom AssemblyLoadContext probing hints. Emit dependency edges with reason codes and confidence. | Static analysis coverage demonstrated on fixtures; edges carry reason codes (`il-assemblyref`, `il-moduleref`, `reflection-literal`, `alc-probing`); tests cover trimmed/single-file cases. |
| SCANNER-ANALYZERS-LANG-11-003 | TODO | StellaOps.Scanner EPDR Guild, Signals Guild | SCANNER-ANALYZERS-LANG-11-002 | Ingest optional runtime evidence (AssemblyLoad, Resolving, P/Invoke) via event listener harness; merge runtime edges with static/declared ones and attach reason codes/confidence. | Runtime listener service pluggable; fixtures record runtime edges; merged output shows combined reason set with confidence per edge. |
| SCANNER-ANALYZERS-LANG-11-004 | TODO | StellaOps.Scanner EPDR Guild, SBOM Service Guild | SCANNER-ANALYZERS-LANG-11-002 | Produce normalized observation export to Scanner writer: entrypoints + dependency edges + environment profiles (AOC compliant). Wire to SBOM service entrypoint tagging. | Analyzer writes observation records consumed by SBOM service tests; AOC compliance docs updated; determinism checked. |
| SCANNER-ANALYZERS-LANG-11-005 | TODO | StellaOps.Scanner EPDR Guild, QA Guild | SCANNER-ANALYZERS-LANG-11-004 | Add comprehensive fixtures/benchmarks covering framework-dependent, self-contained, single-file, trimmed, NativeAOT, multi-RID scenarios; include explain traces and perf benchmarks vs previous analyzer. | Fixtures stored under `fixtures/lang/dotnet/epdr`; determinism + perf thresholds validated; benchmark results documented. |

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.dotnet",
"displayName": "StellaOps .NET Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.DotNet.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.DotNet.DotNetAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"dotnet",
"nuget"
],
"metadata": {
"org.stellaops.analyzer.language": "dotnet",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}