feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies.
- Documented roles and guidelines in AGENTS.md for Scheduler module.
- Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs.
- Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics.
- Developed API endpoints for managing resolver jobs and retrieving metrics.
- Defined models for resolver job requests and responses.
- Integrated dependency injection for resolver job services.
- Implemented ImpactIndexSnapshot for persisting impact index data.
- Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring.
- Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService.
- Created dotnet-filter.sh script to handle command-line arguments for dotnet.
- Established nuget-prime project for managing package downloads.
This commit is contained in:
master
2025-11-18 07:52:15 +02:00
parent e69b57d467
commit 8355e2ff75
299 changed files with 13293 additions and 2444 deletions

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
internal static class DenoPolicySignalEmitter
{
public static IReadOnlyDictionary<string, string> FromTrace(string observationHash, DenoRuntimeTraceMetadata metadata)
{
ArgumentException.ThrowIfNullOrWhiteSpace(observationHash);
ArgumentNullException.ThrowIfNull(metadata);
var signals = new Dictionary<string, string>(StringComparer.Ordinal)
{
["surface.lang.deno.runtime.hash"] = observationHash,
["surface.lang.deno.permissions"] = string.Join(',', metadata.UniquePermissions),
["surface.lang.deno.remote_origins"] = string.Join(',', metadata.RemoteOrigins),
["surface.lang.deno.npm_modules"] = metadata.NpmResolutions.ToString(CultureInfo.InvariantCulture),
["surface.lang.deno.wasm_modules"] = metadata.WasmLoads.ToString(CultureInfo.InvariantCulture),
["surface.lang.deno.dynamic_imports"] = metadata.DynamicImports.ToString(CultureInfo.InvariantCulture),
["surface.lang.deno.module_loads"] = metadata.ModuleLoads.ToString(CultureInfo.InvariantCulture),
["surface.lang.deno.permission_uses"] = metadata.PermissionUses.ToString(CultureInfo.InvariantCulture),
};
return signals;
}
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
internal abstract record DenoRuntimeEvent(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("ts")] DateTimeOffset Timestamp);
internal sealed record DenoModuleLoadEvent(
DateTimeOffset Ts,
DenoModuleIdentity Module,
string Reason,
IReadOnlyList<string> Permissions,
string? Origin) : DenoRuntimeEvent("deno.module.load", Ts);
internal sealed record DenoPermissionUseEvent(
DateTimeOffset Ts,
string Permission,
DenoModuleIdentity Module,
string Details) : DenoRuntimeEvent("deno.permission.use", Ts);
internal sealed record DenoNpmResolutionEvent(
DateTimeOffset Ts,
string Specifier,
string Package,
string Version,
string Resolved,
bool Exists) : DenoRuntimeEvent("deno.npm.resolution", Ts);
internal sealed record DenoWasmLoadEvent(
DateTimeOffset Ts,
DenoModuleIdentity Module,
string Importer,
string Reason) : DenoRuntimeEvent("deno.wasm.load", Ts);
internal sealed record DenoModuleIdentity(
[property: JsonPropertyName("normalized")] string Normalized,
[property: JsonPropertyName("path_sha256")] string PathSha256);

View File

@@ -0,0 +1,36 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
internal static class DenoRuntimePathHasher
{
public static DenoModuleIdentity Create(string rootPath, string absolutePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
ArgumentException.ThrowIfNullOrWhiteSpace(absolutePath);
var normalized = NormalizeRelative(rootPath, absolutePath);
var sha = ComputeSha256(normalized);
return new DenoModuleIdentity(normalized, sha);
}
private static string NormalizeRelative(string rootPath, string absolutePath)
{
var relative = Path.GetRelativePath(rootPath, absolutePath);
if (string.IsNullOrWhiteSpace(relative) || relative == ".")
{
return ".";
}
return relative.Replace('\\', '/');
}
private static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,91 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
/// <summary>
/// Collects runtime events from the Deno harness and emits deterministic NDJSON payloads.
/// </summary>
internal sealed class DenoRuntimeTraceRecorder
{
private readonly List<DenoRuntimeEvent> _events = new();
private readonly string _rootPath;
public DenoRuntimeTraceRecorder(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_rootPath = Path.GetFullPath(rootPath);
}
public void AddModuleLoad(string absoluteModulePath, string reason, IEnumerable<string> permissions, string? origin = null, DateTimeOffset? timestamp = null)
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
var evt = new DenoModuleLoadEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Module: identity,
Reason: reason ?? string.Empty,
Permissions: NormalizePermissions(permissions),
Origin: string.IsNullOrWhiteSpace(origin) ? null : origin);
_events.Add(evt);
}
public void AddPermissionUse(string absoluteModulePath, string permission, string details, DateTimeOffset? timestamp = null)
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
var evt = new DenoPermissionUseEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Permission: permission ?? string.Empty,
Module: identity,
Details: details ?? string.Empty);
_events.Add(evt);
}
public void AddNpmResolution(string specifier, string package, string version, string resolved, bool exists, DateTimeOffset? timestamp = null)
{
_events.Add(new DenoNpmResolutionEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Specifier: specifier ?? string.Empty,
Package: package ?? string.Empty,
Version: version ?? string.Empty,
Resolved: resolved ?? string.Empty,
Exists: exists));
}
public void AddWasmLoad(string absoluteModulePath, string importerRelativePath, string reason, DateTimeOffset? timestamp = null)
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
_events.Add(new DenoWasmLoadEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Module: identity,
Importer: importerRelativePath ?? string.Empty,
Reason: reason ?? string.Empty));
}
public DenoRuntimeTraceSnapshot Build()
{
var (content, hash, metadata) = DenoRuntimeTraceSerializer.Serialize(_events);
return new DenoRuntimeTraceSnapshot(content, hash, metadata);
}
private static IReadOnlyList<string> NormalizePermissions(IEnumerable<string> permissions)
{
if (permissions is null)
{
return Array.Empty<string>();
}
return permissions
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
}
}
internal sealed record DenoRuntimeTraceSnapshot(
byte[] Content,
string Sha256,
DenoRuntimeTraceMetadata Metadata)
{
public ImmutableArray<byte> ContentImmutable => Content.ToImmutableArray();
}

View File

@@ -0,0 +1,175 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
internal static class DenoRuntimeTraceSerializer
{
private static readonly JsonWriterOptions WriterOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false
};
public static (byte[] Content, string Sha256, DenoRuntimeTraceMetadata Metadata) Serialize(
IEnumerable<DenoRuntimeEvent> events)
{
ArgumentNullException.ThrowIfNull(events);
var ordered = events
.OrderBy(e => e.Timestamp)
.ThenBy(e => e.Type, StringComparer.Ordinal)
.ToArray();
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream, WriterOptions))
{
foreach (var evt in ordered)
{
WriteEvent(writer, evt);
writer.Flush();
stream.WriteByte((byte)'\n');
}
}
var bytes = stream.ToArray();
var hash = ComputeSha256(bytes);
var metadata = ComputeMetadata(ordered);
return (bytes, hash, metadata);
}
private static void WriteEvent(Utf8JsonWriter writer, DenoRuntimeEvent evt)
{
writer.WriteStartObject();
writer.WriteString("type", evt.Type);
writer.WriteString("ts", evt.Timestamp.ToUniversalTime());
switch (evt)
{
case DenoModuleLoadEvent e:
WriteModule(writer, e.Module);
writer.WriteString("reason", e.Reason);
writer.WriteStartArray("permissions");
foreach (var p in e.Permissions.OrderBy(p => p, StringComparer.Ordinal))
{
writer.WriteStringValue(p);
}
writer.WriteEndArray();
if (!string.IsNullOrWhiteSpace(e.Origin))
{
writer.WriteString("origin", e.Origin);
}
break;
case DenoPermissionUseEvent e:
writer.WriteString("permission", e.Permission);
WriteModule(writer, e.Module);
if (!string.IsNullOrWhiteSpace(e.Details))
{
writer.WriteString("details", e.Details);
}
break;
case DenoNpmResolutionEvent e:
writer.WriteString("specifier", e.Specifier);
writer.WriteString("package", e.Package);
writer.WriteString("version", e.Version);
writer.WriteString("resolved", e.Resolved);
writer.WriteBoolean("exists", e.Exists);
break;
case DenoWasmLoadEvent e:
WriteModule(writer, e.Module);
writer.WriteString("importer", e.Importer);
writer.WriteString("reason", e.Reason);
break;
default:
throw new InvalidOperationException($"Unsupported runtime event type '{evt.GetType().Name}'.");
}
writer.WriteEndObject();
}
private static void WriteModule(Utf8JsonWriter writer, DenoModuleIdentity module)
{
writer.WritePropertyName("module");
writer.WriteStartObject();
writer.WriteString("normalized", module.Normalized);
writer.WriteString("path_sha256", module.PathSha256);
writer.WriteEndObject();
}
private static string ComputeSha256(byte[] content)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(content ?? Array.Empty<byte>());
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static DenoRuntimeTraceMetadata ComputeMetadata(IReadOnlyCollection<DenoRuntimeEvent> events)
{
var moduleLoads = 0;
var permissionUses = 0;
var origins = new HashSet<string>(StringComparer.Ordinal);
var permissions = new HashSet<string>(StringComparer.Ordinal);
var npmResolutions = 0;
var wasmLoads = 0;
var dynamicImports = 0;
foreach (var evt in events)
{
switch (evt)
{
case DenoModuleLoadEvent e:
moduleLoads++;
if (!string.IsNullOrWhiteSpace(e.Origin))
{
origins.Add(e.Origin!);
}
if (string.Equals(e.Reason, "dynamic-import", StringComparison.Ordinal))
{
dynamicImports++;
}
foreach (var p in e.Permissions ?? Array.Empty<string>())
{
if (!string.IsNullOrWhiteSpace(p))
{
permissions.Add(p.Trim().ToLowerInvariant());
}
}
break;
case DenoPermissionUseEvent:
permissionUses++;
break;
case DenoNpmResolutionEvent:
npmResolutions++;
break;
case DenoWasmLoadEvent:
wasmLoads++;
break;
}
}
return new DenoRuntimeTraceMetadata(
EventCount: events.Count,
ModuleLoads: moduleLoads,
PermissionUses: permissionUses,
RemoteOrigins: origins.OrderBy(o => o, StringComparer.Ordinal).ToArray(),
UniquePermissions: permissions.OrderBy(p => p, StringComparer.Ordinal).ToArray(),
NpmResolutions: npmResolutions,
WasmLoads: wasmLoads,
DynamicImports: dynamicImports);
}
}
internal sealed record DenoRuntimeTraceMetadata(
int EventCount,
int ModuleLoads,
int PermissionUses,
IReadOnlyList<string> RemoteOrigins,
IReadOnlyList<string> UniquePermissions,
int NpmResolutions,
int WasmLoads,
int DynamicImports);

View File

@@ -66,27 +66,30 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
}
}
private async ValueTask ProcessArchiveAsync(
JavaArchive archive,
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
JavaLockData lockData,
HashSet<string> matchedLocks,
bool hasLockEntries,
CancellationToken cancellationToken)
{
ManifestMetadata? manifestMetadata = null;
if (archive.TryGetEntry("META-INF/MANIFEST.MF", out var manifestEntry))
{
manifestMetadata = await ParseManifestAsync(archive, manifestEntry, cancellationToken).ConfigureAwait(false);
}
foreach (var entry in archive.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
if (IsManifestEntry(entry.EffectivePath))
{
private async ValueTask ProcessArchiveAsync(
JavaArchive archive,
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
JavaLockData lockData,
HashSet<string> matchedLocks,
bool hasLockEntries,
CancellationToken cancellationToken)
{
ManifestMetadata? manifestMetadata = null;
if (archive.TryGetEntry("META-INF/MANIFEST.MF", out var manifestEntry))
{
manifestMetadata = await ParseManifestAsync(archive, manifestEntry, cancellationToken).ConfigureAwait(false);
}
var frameworkConfig = ScanFrameworkConfigs(archive, cancellationToken);
var jniHints = ScanJniHints(archive, cancellationToken);
foreach (var entry in archive.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
if (IsManifestEntry(entry.EffectivePath))
{
continue;
}
@@ -103,31 +106,44 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
var metadata = CreateInstalledMetadata(artifact, archive, manifestMetadata);
if (lockData.TryGet(artifact.GroupId, artifact.ArtifactId, artifact.Version, out var lockEntry))
{
matchedLocks.Add(lockEntry!.Key);
AppendLockMetadata(metadata, lockEntry);
}
else if (hasLockEntries)
{
AddMetadata(metadata, "lockMissing", "true");
}
var evidence = new List<LanguageComponentEvidence>
{
new(LanguageEvidenceKind.File, "pom.properties", BuildLocator(archive, entry.OriginalPath), null, artifact.PomSha256),
};
if (manifestMetadata is not null)
{
evidence.Add(manifestMetadata.CreateEvidence(archive));
}
var usedByEntrypoint = context.UsageHints.IsPathUsed(archive.AbsolutePath);
writer.AddFromPurl(
analyzerId: Id,
purl: artifact.Purl,
if (lockData.TryGet(artifact.GroupId, artifact.ArtifactId, artifact.Version, out var lockEntry))
{
matchedLocks.Add(lockEntry!.Key);
AppendLockMetadata(metadata, lockEntry);
}
else if (hasLockEntries)
{
AddMetadata(metadata, "lockMissing", "true");
}
foreach (var hint in frameworkConfig.Metadata)
{
AddMetadata(metadata, hint.Key, hint.Value);
}
foreach (var hint in jniHints.Metadata)
{
AddMetadata(metadata, hint.Key, hint.Value);
}
var evidence = new List<LanguageComponentEvidence>
{
new(LanguageEvidenceKind.File, "pom.properties", BuildLocator(archive, entry.OriginalPath), null, artifact.PomSha256),
};
if (manifestMetadata is not null)
{
evidence.Add(manifestMetadata.CreateEvidence(archive));
}
evidence.AddRange(frameworkConfig.Evidence);
evidence.AddRange(jniHints.Evidence);
var usedByEntrypoint = context.UsageHints.IsPathUsed(archive.AbsolutePath);
writer.AddFromPurl(
analyzerId: Id,
purl: artifact.Purl,
name: artifact.ArtifactId,
version: artifact.Version,
type: "maven",
@@ -150,24 +166,322 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
return string.Concat(relativeArchive, "!", normalizedEntry);
}
private static string NormalizeEntry(string entryPath)
=> entryPath.Replace('\\', '/');
private static string NormalizeArchivePath(string relativePath)
{
if (string.IsNullOrEmpty(relativePath) || string.Equals(relativePath, ".", StringComparison.Ordinal))
{
return ".";
}
return relativePath.Replace('\\', '/');
}
private static bool IsPomPropertiesEntry(string entryName)
=> entryName.StartsWith("META-INF/maven/", StringComparison.OrdinalIgnoreCase)
&& entryName.EndsWith("/pom.properties", StringComparison.OrdinalIgnoreCase);
private static bool IsManifestEntry(string entryName)
private static string NormalizeEntry(string entryPath)
=> entryPath.Replace('\\', '/');
private static string NormalizeArchivePath(string relativePath)
{
if (string.IsNullOrEmpty(relativePath) || string.Equals(relativePath, ".", StringComparison.Ordinal))
{
return ".";
}
return relativePath.Replace('\\', '/');
}
private static FrameworkConfigSummary ScanFrameworkConfigs(JavaArchive archive, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(archive);
var metadata = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
var evidence = new List<LanguageComponentEvidence>();
foreach (var entry in archive.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
var path = entry.EffectivePath;
if (IsSpringFactories(path))
{
AddConfigHint(metadata, evidence, "config.spring.factories", archive, entry);
}
else if (IsSpringImports(path))
{
AddConfigHint(metadata, evidence, "config.spring.imports", archive, entry);
}
else if (IsSpringApplicationConfig(path))
{
AddConfigHint(metadata, evidence, "config.spring.properties", archive, entry);
}
else if (IsSpringBootstrapConfig(path))
{
AddConfigHint(metadata, evidence, "config.spring.bootstrap", archive, entry);
}
if (IsWebXml(path))
{
AddConfigHint(metadata, evidence, "config.web.xml", archive, entry);
}
if (IsWebFragment(path))
{
AddConfigHint(metadata, evidence, "config.web.fragment", archive, entry);
}
if (IsJpaConfig(path))
{
AddConfigHint(metadata, evidence, "config.jpa", archive, entry);
}
if (IsCdiConfig(path))
{
AddConfigHint(metadata, evidence, "config.cdi", archive, entry);
}
if (IsJaxbConfig(path))
{
AddConfigHint(metadata, evidence, "config.jaxb", archive, entry);
}
if (IsJaxRsConfig(path))
{
AddConfigHint(metadata, evidence, "config.jaxrs", archive, entry);
}
if (IsLoggingConfig(path))
{
AddConfigHint(metadata, evidence, "config.logging", archive, entry);
}
if (IsGraalConfig(path))
{
AddConfigHint(metadata, evidence, "config.graal", archive, entry);
}
}
var flattened = metadata.ToDictionary(
static pair => pair.Key,
static pair => string.Join(",", pair.Value),
StringComparer.Ordinal);
return new FrameworkConfigSummary(flattened, evidence);
}
private static JniHintSummary ScanJniHints(JavaArchive archive, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(archive);
var metadata = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
var evidence = new List<LanguageComponentEvidence>();
foreach (var entry in archive.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
var path = entry.EffectivePath;
var locator = BuildLocator(archive, entry.OriginalPath);
if (IsNativeLibrary(path))
{
AddHint(metadata, evidence, "jni.nativeLibs", Path.GetFileName(path), locator, "jni-native");
}
if (IsGraalJniConfig(path))
{
AddHint(metadata, evidence, "jni.graalConfig", locator, locator, "jni-graal");
}
if (IsClassFile(path) && entry.Length is > 0 and < 1_000_000)
{
TryScanClassForLoadCalls(archive, entry, locator, metadata, evidence, cancellationToken);
}
}
var flattened = metadata.ToDictionary(
static pair => pair.Key,
static pair => string.Join(",", pair.Value),
StringComparer.Ordinal);
return new JniHintSummary(flattened, evidence);
}
private static void TryScanClassForLoadCalls(
JavaArchive archive,
JavaArchiveEntry entry,
string locator,
IDictionary<string, SortedSet<string>> metadata,
ICollection<LanguageComponentEvidence> evidence,
CancellationToken cancellationToken)
{
try
{
using var stream = archive.OpenEntry(entry);
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
var bytes = buffer.ToArray();
if (ContainsAscii(bytes, "System.loadLibrary"))
{
AddHint(metadata, evidence, "jni.loadCalls", locator, locator, "jni-load");
}
else if (ContainsAscii(bytes, "System.load"))
{
AddHint(metadata, evidence, "jni.loadCalls", locator, locator, "jni-load");
}
}
catch
{
// best effort; skip unreadable class entries
}
}
private static bool ContainsAscii(byte[] buffer, string ascii)
{
if (buffer.Length == 0 || string.IsNullOrEmpty(ascii))
{
return false;
}
var needle = Encoding.ASCII.GetBytes(ascii);
return SpanSearch(buffer, needle) >= 0;
}
private static int SpanSearch(byte[] haystack, byte[] needle)
{
if (needle.Length == 0 || haystack.Length < needle.Length)
{
return -1;
}
var lastStart = haystack.Length - needle.Length;
for (var i = 0; i <= lastStart; i++)
{
var matched = true;
for (var j = 0; j < needle.Length; j++)
{
if (haystack[i + j] != needle[j])
{
matched = false;
break;
}
}
if (matched)
{
return i;
}
}
return -1;
}
private static void AddHint(
IDictionary<string, SortedSet<string>> metadata,
ICollection<LanguageComponentEvidence> evidence,
string key,
string value,
string locator,
string evidenceSource)
{
if (!metadata.TryGetValue(key, out var items))
{
items = new SortedSet<string>(StringComparer.Ordinal);
metadata[key] = items;
}
items.Add(value);
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
evidenceSource,
locator,
value: null,
sha256: null));
}
private static void AddConfigHint(
IDictionary<string, SortedSet<string>> metadata,
ICollection<LanguageComponentEvidence> evidence,
string key,
JavaArchive archive,
JavaArchiveEntry entry)
{
if (!metadata.TryGetValue(key, out var locators))
{
locators = new SortedSet<string>(StringComparer.Ordinal);
metadata[key] = locators;
}
var locator = BuildLocator(archive, entry.OriginalPath);
locators.Add(locator);
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"framework-config",
locator,
value: null,
sha256: null));
}
private static bool IsSpringFactories(string path)
=> string.Equals(path, "META-INF/spring.factories", StringComparison.OrdinalIgnoreCase);
private static bool IsSpringImports(string path)
=> path.StartsWith("META-INF/spring/", StringComparison.OrdinalIgnoreCase)
&& path.EndsWith(".imports", StringComparison.OrdinalIgnoreCase);
private static bool IsSpringApplicationConfig(string path)
=> path.EndsWith("application.properties", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("application.yml", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("application.yaml", StringComparison.OrdinalIgnoreCase);
private static bool IsSpringBootstrapConfig(string path)
=> path.EndsWith("bootstrap.properties", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("bootstrap.yml", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("bootstrap.yaml", StringComparison.OrdinalIgnoreCase);
private static bool IsWebXml(string path)
=> path.EndsWith("WEB-INF/web.xml", StringComparison.OrdinalIgnoreCase);
private static bool IsWebFragment(string path)
=> path.EndsWith("META-INF/web-fragment.xml", StringComparison.OrdinalIgnoreCase);
private static bool IsJpaConfig(string path)
=> path.EndsWith("META-INF/persistence.xml", StringComparison.OrdinalIgnoreCase);
private static bool IsCdiConfig(string path)
=> path.EndsWith("META-INF/beans.xml", StringComparison.OrdinalIgnoreCase);
private static bool IsJaxbConfig(string path)
=> path.EndsWith("META-INF/jaxb.index", StringComparison.OrdinalIgnoreCase);
private static bool IsJaxRsConfig(string path)
=> path.StartsWith("META-INF/services/", StringComparison.OrdinalIgnoreCase)
&& path.Contains("ws.rs", StringComparison.OrdinalIgnoreCase);
private static bool IsLoggingConfig(string path)
=> path.EndsWith("log4j2.xml", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("logback.xml", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("logging.properties", StringComparison.OrdinalIgnoreCase);
private static bool IsGraalConfig(string path)
=> path.StartsWith("META-INF/native-image/", StringComparison.OrdinalIgnoreCase)
&& (path.EndsWith("reflect-config.json", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("resource-config.json", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("proxy-config.json", StringComparison.OrdinalIgnoreCase));
private static bool IsGraalJniConfig(string path)
=> path.StartsWith("META-INF/native-image/", StringComparison.OrdinalIgnoreCase)
&& path.EndsWith("jni-config.json", StringComparison.OrdinalIgnoreCase);
private static bool IsNativeLibrary(string path)
{
var extension = Path.GetExtension(path);
return extension.Equals(".so", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".dll", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".dylib", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".jnilib", StringComparison.OrdinalIgnoreCase);
}
private static bool IsClassFile(string path)
=> path.EndsWith(".class", StringComparison.OrdinalIgnoreCase);
private static bool IsPomPropertiesEntry(string entryName)
=> entryName.StartsWith("META-INF/maven/", StringComparison.OrdinalIgnoreCase)
&& entryName.EndsWith("/pom.properties", StringComparison.OrdinalIgnoreCase);
private static bool IsManifestEntry(string entryName)
=> string.Equals(entryName, "META-INF/MANIFEST.MF", StringComparison.OrdinalIgnoreCase);
private static void AppendLockMetadata(ICollection<KeyValuePair<string, string?>> metadata, JavaLockEntry entry)
@@ -283,9 +597,16 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
else if (key.Equals("Implementation-Vendor", StringComparison.OrdinalIgnoreCase))
{
vendor ??= value;
}
}
}
}
internal sealed record FrameworkConfigSummary(
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyCollection<LanguageComponentEvidence> Evidence);
internal sealed record JniHintSummary(
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyCollection<LanguageComponentEvidence> Evidence);
if (title is null && version is null && vendor is null)
{
return null;

View File

@@ -1,9 +1,13 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;
global using System.Collections.Generic;
global using System.IO;
global using System.IO.Compression;
global using System.Linq;
global using System.Formats.Tar;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -9,15 +9,17 @@ internal sealed class NodePackage
string packageJsonLocator,
bool? isPrivate,
NodeLockEntry? lockEntry,
bool isWorkspaceMember,
string? workspaceRoot,
IReadOnlyList<string> workspaceTargets,
string? workspaceLink,
bool isWorkspaceMember,
string? workspaceRoot,
IReadOnlyList<string> workspaceTargets,
string? workspaceLink,
IReadOnlyList<NodeLifecycleScript> lifecycleScripts,
IReadOnlyList<NodeVersionTarget> nodeVersions,
bool usedByEntrypoint,
bool declaredOnly = false,
string? lockSource = null,
string? lockLocator = null)
string? lockLocator = null,
string? packageSha256 = null)
{
Name = name;
Version = version;
@@ -26,14 +28,16 @@ internal sealed class NodePackage
IsPrivate = isPrivate;
LockEntry = lockEntry;
IsWorkspaceMember = isWorkspaceMember;
WorkspaceRoot = workspaceRoot;
WorkspaceTargets = workspaceTargets;
WorkspaceRoot = workspaceRoot;
WorkspaceTargets = workspaceTargets;
WorkspaceLink = workspaceLink;
LifecycleScripts = lifecycleScripts ?? Array.Empty<NodeLifecycleScript>();
NodeVersions = nodeVersions ?? Array.Empty<NodeVersionTarget>();
IsUsedByEntrypoint = usedByEntrypoint;
DeclaredOnly = declaredOnly;
LockSource = lockSource;
LockLocator = lockLocator;
PackageSha256 = packageSha256;
}
public string Name { get; }
@@ -58,6 +62,8 @@ internal sealed class NodePackage
public IReadOnlyList<NodeLifecycleScript> LifecycleScripts { get; }
public IReadOnlyList<NodeVersionTarget> NodeVersions { get; }
public bool HasInstallScripts => LifecycleScripts.Count > 0;
public bool IsUsedByEntrypoint { get; }
@@ -67,6 +73,8 @@ internal sealed class NodePackage
public string? LockSource { get; }
public string? LockLocator { get; }
public string? PackageSha256 { get; }
public string RelativePathNormalized => string.IsNullOrEmpty(RelativePath) ? string.Empty : RelativePath.Replace(Path.DirectorySeparatorChar, '/');
@@ -80,13 +88,23 @@ internal sealed class NodePackage
{
CreateRootEvidence()
};
foreach (var script in LifecycleScripts)
{
var locator = string.IsNullOrEmpty(PackageJsonLocator)
? $"package.json#scripts.{script.Name}"
: $"{PackageJsonLocator}#scripts.{script.Name}";
foreach (var version in NodeVersions.OrderBy(static v => v.Kind, StringComparer.Ordinal))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
$"node-version:{version.Kind}",
version.Locator,
version.Version,
version.Sha256));
}
foreach (var script in LifecycleScripts)
{
var locator = string.IsNullOrEmpty(PackageJsonLocator)
? $"package.json#scripts.{script.Name}"
: $"{PackageJsonLocator}#scripts.{script.Name}";
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"package.json:scripts",
@@ -95,7 +113,9 @@ internal sealed class NodePackage
script.Sha256));
}
return evidence;
return evidence
.OrderBy(static e => e.ComparisonKey, StringComparer.Ordinal)
.ToArray();
}
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata()
@@ -137,16 +157,36 @@ internal sealed class NodePackage
entries.Add(new KeyValuePair<string, string?>("workspaceLink", WorkspaceLink));
}
if (WorkspaceTargets.Count > 0)
{
entries.Add(new KeyValuePair<string, string?>("workspaceTargets", string.Join(';', WorkspaceTargets)));
}
if (HasInstallScripts)
{
entries.Add(new KeyValuePair<string, string?>("installScripts", "true"));
var lifecycleNames = LifecycleScripts
.Select(static script => script.Name)
if (WorkspaceTargets.Count > 0)
{
entries.Add(new KeyValuePair<string, string?>("workspaceTargets", string.Join(';', WorkspaceTargets)));
}
if (NodeVersions.Count > 0)
{
var distinctVersions = NodeVersions
.Select(static v => v.Version)
.Where(static v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.Ordinal)
.OrderBy(static v => v, StringComparer.Ordinal)
.ToArray();
if (distinctVersions.Length > 0)
{
entries.Add(new KeyValuePair<string, string?>("nodeVersion", string.Join(';', distinctVersions)));
}
foreach (var versionTarget in NodeVersions.OrderBy(static v => v.Kind, StringComparer.Ordinal))
{
entries.Add(new KeyValuePair<string, string?>($"nodeVersionSource.{versionTarget.Kind}", versionTarget.Version));
}
}
if (HasInstallScripts)
{
entries.Add(new KeyValuePair<string, string?>("installScripts", "true"));
var lifecycleNames = LifecycleScripts
.Select(static script => script.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -216,6 +256,6 @@ internal sealed class NodePackage
var kind = DeclaredOnly ? LanguageEvidenceKind.Metadata : LanguageEvidenceKind.File;
return new LanguageComponentEvidence(kind, evidenceSource, locator, Value: null, Sha256: null);
return new LanguageComponentEvidence(kind, evidenceSource, locator, Value: null, Sha256: PackageSha256);
}
}

View File

@@ -48,14 +48,16 @@ internal static class NodePackageCollector
}
}
var nodeModules = Path.Combine(context.RootPath, "node_modules");
TraverseDirectory(context, nodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
foreach (var pendingRoot in pendingNodeModuleRoots.OrderBy(static path => path, StringComparer.Ordinal))
{
TraverseDirectory(context, pendingRoot, lockData, workspaceIndex, packages, visited, cancellationToken);
}
var nodeModules = Path.Combine(context.RootPath, "node_modules");
TraverseDirectory(context, nodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
foreach (var pendingRoot in pendingNodeModuleRoots.OrderBy(static path => path, StringComparer.Ordinal))
{
TraverseDirectory(context, pendingRoot, lockData, workspaceIndex, packages, visited, cancellationToken);
}
TraverseTarballs(context, lockData, workspaceIndex, packages, visited, cancellationToken);
AppendDeclaredPackages(packages, lockData);
return packages;
@@ -181,6 +183,108 @@ internal static class NodePackageCollector
TraverseDirectory(context, nestedNodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
}
private static void TraverseTarballs(
LanguageAnalyzerContext context,
NodeLockData lockData,
NodeWorkspaceIndex workspaceIndex,
List<NodePackage> packages,
HashSet<string> visited,
CancellationToken cancellationToken)
{
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint | FileAttributes.Device
};
foreach (var tgzPath in Directory.EnumerateFiles(context.RootPath, "*.tgz", enumerationOptions))
{
cancellationToken.ThrowIfCancellationRequested();
TryProcessTarball(context, tgzPath, packages, visited, cancellationToken);
}
}
private static void TryProcessTarball(
LanguageAnalyzerContext context,
string tgzPath,
List<NodePackage> packages,
HashSet<string> visited,
CancellationToken cancellationToken)
{
try
{
using var fileStream = File.OpenRead(tgzPath);
using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress, leaveOpen: false);
using var tarReader = new TarReader(gzipStream);
TarEntry? entry;
while ((entry = tarReader.GetNextEntry()) is not null)
{
cancellationToken.ThrowIfCancellationRequested();
if (entry.EntryType != TarEntryType.RegularFile)
{
continue;
}
var normalizedName = entry.Name.Replace('\\', '/');
if (!normalizedName.EndsWith("package.json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
using var buffer = new MemoryStream();
entry.DataStream?.CopyTo(buffer);
buffer.Position = 0;
var sha256 = SHA256.HashData(buffer.ToArray());
var sha256Hex = Convert.ToHexString(sha256).ToLowerInvariant();
buffer.Position = 0;
using var document = JsonDocument.Parse(buffer);
var root = document.RootElement;
var relativeDirectory = NormalizeRelativeDirectoryTar(context, tgzPath);
var locator = BuildTarLocator(context, tgzPath, normalizedName);
var usedByEntrypoint = context.UsageHints.IsPathUsed(tgzPath);
var package = TryCreatePackageFromJson(
context,
root,
relativeDirectory,
locator,
usedByEntrypoint,
cancellationToken,
packageSha256: sha256Hex);
if (package is null)
{
continue;
}
if (visited.Add($"tar::{locator}"))
{
packages.Add(package);
}
break;
}
}
catch (IOException)
{
// ignore unreadable tarballs
}
catch (InvalidDataException)
{
// ignore invalid gzip/tar payloads
}
catch (JsonException)
{
// ignore malformed package definitions in tarballs
}
}
private static void AppendDeclaredPackages(List<NodePackage> packages, NodeLockData lockData)
{
if (lockData.DeclaredPackages.Count == 0)
@@ -223,6 +327,7 @@ internal static class NodePackageCollector
workspaceTargets: Array.Empty<string>(),
workspaceLink: null,
lifecycleScripts: Array.Empty<NodeLifecycleScript>(),
nodeVersions: Array.Empty<NodeVersionTarget>(),
usedByEntrypoint: false,
declaredOnly: true,
lockSource: entry.Source,
@@ -257,87 +362,113 @@ internal static class NodePackageCollector
return $"{entry.Source}:{entry.Locator}";
}
private static NodePackage? TryCreatePackage(
LanguageAnalyzerContext context,
string packageJsonPath,
string relativeDirectory,
NodeLockData lockData,
NodeWorkspaceIndex workspaceIndex,
CancellationToken cancellationToken)
{
try
{
using var stream = File.OpenRead(packageJsonPath);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
if (!root.TryGetProperty("name", out var nameElement))
{
return null;
}
var name = nameElement.GetString();
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
if (!root.TryGetProperty("version", out var versionElement))
{
return null;
}
var version = versionElement.GetString();
if (string.IsNullOrWhiteSpace(version))
{
return null;
}
bool? isPrivate = null;
if (root.TryGetProperty("private", out var privateElement) && privateElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
isPrivate = privateElement.GetBoolean();
}
var lockEntry = lockData.TryGet(relativeDirectory, name, out var entry) ? entry : null;
var locator = BuildLocator(relativeDirectory);
var usedByEntrypoint = context.UsageHints.IsPathUsed(packageJsonPath);
var lockLocator = BuildLockLocator(lockEntry);
var lockSource = lockEntry?.Source;
var isWorkspaceMember = workspaceIndex.TryGetMember(relativeDirectory, out var workspaceRoot);
var workspaceTargets = ExtractWorkspaceTargets(relativeDirectory, root, workspaceIndex);
var workspaceLink = !isWorkspaceMember && workspaceIndex.TryGetWorkspacePathByName(name, out var workspacePathByName)
? NormalizeRelativeDirectory(context, Path.Combine(context.RootPath, relativeDirectory))
: null;
var lifecycleScripts = ExtractLifecycleScripts(root);
return new NodePackage(
name: name.Trim(),
version: version.Trim(),
relativePath: relativeDirectory,
packageJsonLocator: locator,
isPrivate: isPrivate,
lockEntry: lockEntry,
isWorkspaceMember: isWorkspaceMember,
workspaceRoot: workspaceRoot,
workspaceTargets: workspaceTargets,
workspaceLink: workspaceLink,
lifecycleScripts: lifecycleScripts,
usedByEntrypoint: usedByEntrypoint,
declaredOnly: false,
lockSource: lockSource,
lockLocator: lockLocator);
}
catch (IOException)
{
return null;
}
private static NodePackage? TryCreatePackage(
LanguageAnalyzerContext context,
string packageJsonPath,
string relativeDirectory,
NodeLockData lockData,
NodeWorkspaceIndex workspaceIndex,
CancellationToken cancellationToken)
{
try
{
using var stream = File.OpenRead(packageJsonPath);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
return TryCreatePackageFromJson(
context,
root,
relativeDirectory,
BuildLocator(relativeDirectory),
context.UsageHints.IsPathUsed(packageJsonPath),
cancellationToken,
lockData,
workspaceIndex,
packageJsonPath);
}
catch (IOException)
{
return null;
}
catch (JsonException)
{
return null;
}
}
}
private static NodePackage? TryCreatePackageFromJson(
LanguageAnalyzerContext context,
JsonElement root,
string relativeDirectory,
string packageJsonLocator,
bool usedByEntrypoint,
CancellationToken cancellationToken,
NodeLockData? lockData = null,
NodeWorkspaceIndex? workspaceIndex = null,
string? packageJsonPath = null,
string? packageSha256 = null)
{
if (!root.TryGetProperty("name", out var nameElement))
{
return null;
}
var name = nameElement.GetString();
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
if (!root.TryGetProperty("version", out var versionElement))
{
return null;
}
var version = versionElement.GetString();
if (string.IsNullOrWhiteSpace(version))
{
return null;
}
bool? isPrivate = null;
if (root.TryGetProperty("private", out var privateElement) && privateElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
isPrivate = privateElement.GetBoolean();
}
var lockEntry = lockData?.TryGet(relativeDirectory, name, out var entry) == true ? entry : null;
var lockLocator = BuildLockLocator(lockEntry);
var lockSource = lockEntry?.Source;
var isWorkspaceMember = workspaceIndex?.TryGetMember(relativeDirectory, out var workspaceRoot) == true;
var workspaceRootValue = isWorkspaceMember && workspaceIndex is not null ? workspaceRoot : null;
var workspaceTargets = workspaceIndex is null ? Array.Empty<string>() : ExtractWorkspaceTargets(relativeDirectory, root, workspaceIndex);
var workspaceLink = workspaceIndex is not null && !isWorkspaceMember && workspaceIndex.TryGetWorkspacePathByName(name, out var workspacePathByName)
? NormalizeRelativeDirectory(context, Path.Combine(context.RootPath, relativeDirectory))
: null;
var lifecycleScripts = ExtractLifecycleScripts(root);
var nodeVersions = NodeVersionDetector.Detect(context, relativeDirectory, cancellationToken);
return new NodePackage(
name: name.Trim(),
version: version.Trim(),
relativePath: relativeDirectory,
packageJsonLocator: packageJsonLocator,
isPrivate: isPrivate,
lockEntry: lockEntry,
isWorkspaceMember: isWorkspaceMember,
workspaceRoot: workspaceRootValue,
workspaceTargets: workspaceTargets,
workspaceLink: workspaceLink,
lifecycleScripts: lifecycleScripts,
nodeVersions: nodeVersions,
usedByEntrypoint: usedByEntrypoint,
declaredOnly: false,
lockSource: lockSource,
lockLocator: lockLocator,
packageSha256: packageSha256);
}
private static string NormalizeRelativeDirectory(LanguageAnalyzerContext context, string directory)
{
@@ -350,15 +481,37 @@ internal static class NodePackageCollector
return relative.Replace(Path.DirectorySeparatorChar, '/');
}
private static string BuildLocator(string relativeDirectory)
{
if (string.IsNullOrEmpty(relativeDirectory))
{
return "package.json";
}
return relativeDirectory + "/package.json";
}
private static string BuildLocator(string relativeDirectory)
{
if (string.IsNullOrEmpty(relativeDirectory))
{
return "package.json";
}
return relativeDirectory + "/package.json";
}
private static string BuildTarLocator(LanguageAnalyzerContext context, string tgzPath, string entryName)
{
var relative = context.GetRelativePath(tgzPath);
var normalizedArchive = string.IsNullOrWhiteSpace(relative) || relative == "."
? Path.GetFileName(tgzPath)
: relative.Replace(Path.DirectorySeparatorChar, '/');
var normalizedEntry = entryName.Replace('\\', '/');
return $"{normalizedArchive}!{normalizedEntry}";
}
private static string NormalizeRelativeDirectoryTar(LanguageAnalyzerContext context, string tgzPath)
{
var relative = context.GetRelativePath(Path.GetDirectoryName(tgzPath)!);
if (string.IsNullOrEmpty(relative) || relative == ".")
{
return "tgz";
}
return relative.Replace(Path.DirectorySeparatorChar, '/');
}
private static bool ShouldSkipDirectory(string name)
{

View File

@@ -0,0 +1,145 @@
using System.Globalization;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal static class NodeVersionDetector
{
private static readonly string[] VersionFiles = { ".nvmrc", ".node-version" };
public static IReadOnlyList<NodeVersionTarget> Detect(LanguageAnalyzerContext context, string relativeDirectory, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var targets = new List<NodeVersionTarget>();
var baseDirectory = ResolveAbsolutePath(context.RootPath, relativeDirectory);
foreach (var versionFile in VersionFiles)
{
var path = Path.Combine(baseDirectory, versionFile);
if (!File.Exists(path))
{
continue;
}
var version = ReadFirstNonEmptyLine(path, cancellationToken);
if (string.IsNullOrWhiteSpace(version))
{
continue;
}
targets.Add(CreateTarget(context, relativeDirectory, path, version.Trim(), GetSha256(path), versionFile.TrimStart('.')));
}
var dockerfilePath = Path.Combine(baseDirectory, "Dockerfile");
if (File.Exists(dockerfilePath))
{
var dockerVersion = ExtractNodeTagFromDockerfile(dockerfilePath, cancellationToken);
if (!string.IsNullOrWhiteSpace(dockerVersion))
{
targets.Add(CreateTarget(context, relativeDirectory, dockerfilePath, dockerVersion!, GetSha256(dockerfilePath), "dockerfile"));
}
}
return targets
.OrderBy(static t => t.Kind, StringComparer.Ordinal)
.ThenBy(static t => t.Version, StringComparer.Ordinal)
.ThenBy(static t => t.Locator, StringComparer.Ordinal)
.ToArray();
}
private static NodeVersionTarget CreateTarget(LanguageAnalyzerContext context, string relativeDirectory, string absolutePath, string version, string sha256, string kind)
{
var locator = BuildLocator(context, absolutePath);
return new NodeVersionTarget(kind, version, locator, sha256);
}
private static string BuildLocator(LanguageAnalyzerContext context, string absolutePath)
{
var relative = context.GetRelativePath(absolutePath);
if (string.IsNullOrWhiteSpace(relative) || relative == ".")
{
return Path.GetFileName(absolutePath);
}
return relative.Replace(Path.DirectorySeparatorChar, '/');
}
private static string ResolveAbsolutePath(string rootPath, string relativeDirectory)
{
if (string.IsNullOrWhiteSpace(relativeDirectory))
{
return rootPath;
}
return Path.Combine(rootPath, relativeDirectory.Replace('/', Path.DirectorySeparatorChar));
}
private static string GetSha256(string path)
{
var bytes = File.ReadAllBytes(path);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string? ReadFirstNonEmptyLine(string path, CancellationToken cancellationToken)
{
using var stream = File.OpenText(path);
while (!stream.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = stream.ReadLine();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
return line.Trim();
}
return null;
}
private static string? ExtractNodeTagFromDockerfile(string path, CancellationToken cancellationToken)
{
using var reader = File.OpenText(path);
var linesChecked = 0;
while (!reader.EndOfStream && linesChecked < 200)
{
cancellationToken.ThrowIfCancellationRequested();
var line = reader.ReadLine();
if (line is null)
{
break;
}
linesChecked++;
var trimmed = line.Trim();
if (!trimmed.StartsWith("FROM", true, CultureInfo.InvariantCulture))
{
continue;
}
var tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var tag = tokens
.Skip(1)
.FirstOrDefault(static token => token.StartsWith("node:", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(tag))
{
continue;
}
var versionPart = tag["node:".Length..];
var atIndex = versionPart.IndexOf('@');
if (atIndex > 0)
{
versionPart = versionPart[..atIndex];
}
return versionPart;
}
return null;
}
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal sealed record NodeVersionTarget(
string Kind,
string Version,
string Locator,
string? Sha256);