nuget reorganization
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
|
||||
#pragma warning disable CA2022 // Stream.Read validation handled via ReadExactly/ReadAtLeast
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native;
|
||||
@@ -13,17 +11,18 @@ public static class NativeFormatDetector
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
Span<byte> header = stackalloc byte[512];
|
||||
var read = stream.ReadAtLeast(header, 4, throwOnEndOfStream: false);
|
||||
if (read < 4)
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
var data = buffer.ToArray();
|
||||
var span = data.AsSpan();
|
||||
|
||||
if (span.Length < 4)
|
||||
{
|
||||
identity = new NativeBinaryIdentity(NativeFormat.Unknown, null, null, null, null, null, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
var span = header[..read];
|
||||
|
||||
if (IsElf(span, stream, out identity))
|
||||
if (IsElf(span, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -33,7 +32,7 @@ public static class NativeFormatDetector
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsMachO(span, stream, out identity))
|
||||
if (IsMachO(span, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -42,7 +41,7 @@ public static class NativeFormatDetector
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsElf(ReadOnlySpan<byte> span, Stream stream, out NativeBinaryIdentity identity)
|
||||
private static bool IsElf(ReadOnlySpan<byte> span, out NativeBinaryIdentity identity)
|
||||
{
|
||||
identity = default!;
|
||||
if (span.Length < 20)
|
||||
@@ -92,15 +91,12 @@ public static class NativeFormatDetector
|
||||
for (var i = 0; i < phnum; i++)
|
||||
{
|
||||
var entryOffset = (long)(phoff + (ulong)(i * phentsize));
|
||||
if (entryOffset + phentsize > stream.Length)
|
||||
if (entryOffset + phentsize > span.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var ph = new byte[phentsize];
|
||||
stream.Seek(entryOffset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(ph, 0, ph.Length);
|
||||
var phSpan = ph.AsSpan();
|
||||
var phSpan = span.Slice((int)entryOffset, phentsize);
|
||||
|
||||
var pType = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(phSpan)
|
||||
@@ -115,12 +111,12 @@ public static class NativeFormatDetector
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(phSpan.Slice(32, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(phSpan.Slice(32, 4));
|
||||
|
||||
if (fileSize > 0 && offset + fileSize <= (ulong)stream.Length)
|
||||
if (fileSize > 0 && offset + fileSize <= (ulong)span.Length)
|
||||
{
|
||||
var buffer = new byte[fileSize];
|
||||
stream.Seek((long)offset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(buffer, 0, buffer.Length);
|
||||
var str = System.Text.Encoding.ASCII.GetString(buffer).TrimEnd('\0');
|
||||
var interpSpan = span.Slice((int)offset, (int)fileSize);
|
||||
var terminator = interpSpan.IndexOf((byte)0);
|
||||
var count = terminator >= 0 ? terminator : interpSpan.Length;
|
||||
var str = Encoding.ASCII.GetString(interpSpan[..count]);
|
||||
if (!string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
interp = str;
|
||||
@@ -136,12 +132,10 @@ public static class NativeFormatDetector
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(phSpan.Slice(32, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(phSpan.Slice(32, 4));
|
||||
|
||||
if (fileSize > 0 && offset + fileSize <= (ulong)stream.Length)
|
||||
if (fileSize > 0 && offset + fileSize <= (ulong)span.Length)
|
||||
{
|
||||
var buffer = new byte[fileSize];
|
||||
stream.Seek((long)offset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(buffer, 0, buffer.Length);
|
||||
ParseElfNote(buffer, dataEncoding == 2, ref buildId);
|
||||
var noteSpan = span.Slice((int)offset, (int)fileSize);
|
||||
buildId ??= ParseElfNote(noteSpan, dataEncoding == 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +187,7 @@ public static class NativeFormatDetector
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsMachO(ReadOnlySpan<byte> span, Stream stream, out NativeBinaryIdentity identity)
|
||||
private static bool IsMachO(ReadOnlySpan<byte> span, out NativeBinaryIdentity identity)
|
||||
{
|
||||
identity = default!;
|
||||
if (span.Length < 12)
|
||||
@@ -237,55 +231,46 @@ public static class NativeFormatDetector
|
||||
var arch = MapMachCpuType(cputype);
|
||||
var endianness = bigEndian ? "be" : "le";
|
||||
|
||||
var uuid = ExtractMachUuid(stream, bigEndian);
|
||||
string? uuid = null;
|
||||
if (!isFat)
|
||||
{
|
||||
var headerSize = is64 ? 32 : 28;
|
||||
if (span.Length >= headerSize + 8)
|
||||
{
|
||||
var ncmds = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(span.Slice(16, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(16, 4));
|
||||
var offset = headerSize;
|
||||
for (uint i = 0; i < ncmds && offset + 8 <= span.Length; i++)
|
||||
{
|
||||
var cmd = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(span.Slice(offset, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset, 4));
|
||||
var cmdsize = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(span.Slice(offset + 4, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset + 4, 4));
|
||||
|
||||
if (cmd == 0x1B && cmdsize >= 24 && offset + cmdsize <= span.Length) // LC_UUID
|
||||
{
|
||||
var uuidSpan = span.Slice(offset + 8, 16);
|
||||
uuid = Convert.ToHexString(uuidSpan).ToLowerInvariant();
|
||||
break;
|
||||
}
|
||||
|
||||
if (cmdsize == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
offset += (int)cmdsize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
identity = new NativeBinaryIdentity(NativeFormat.MachO, arch, "darwin", Endianness: endianness, BuildId: null, Uuid: uuid, InterpreterPath: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ExtractMachUuid(Stream stream, bool bigEndian)
|
||||
{
|
||||
try
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new BinaryReader(stream, System.Text.Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
var magic = reader.ReadUInt32();
|
||||
var is64 = magic is 0xFEEDFACF or 0xCFFAEDFE;
|
||||
var headerSize = is64 ? 32 : 28;
|
||||
stream.Seek(16, SeekOrigin.Begin);
|
||||
var ncmds = ReadUInt32(reader, bigEndian);
|
||||
_ = ReadUInt32(reader, bigEndian); // sizeofcmds
|
||||
|
||||
stream.Seek(headerSize, SeekOrigin.Begin);
|
||||
for (var i = 0; i < ncmds; i++)
|
||||
{
|
||||
var cmdStart = stream.Position;
|
||||
var cmd = ReadUInt32(reader, bigEndian);
|
||||
var cmdsize = ReadUInt32(reader, bigEndian);
|
||||
if (cmd == 0x1B) // LC_UUID
|
||||
{
|
||||
var uuidBytes = reader.ReadBytes(16);
|
||||
return new Guid(uuidBytes).ToString();
|
||||
}
|
||||
|
||||
stream.Seek(cmdStart + cmdsize, SeekOrigin.Begin);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(BinaryReader reader, bool bigEndian)
|
||||
{
|
||||
var data = reader.ReadBytes(4);
|
||||
return bigEndian ? BinaryPrimitives.ReadUInt32BigEndian(data) : BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
}
|
||||
|
||||
private static string? MapElfMachine(ushort machine) => machine switch
|
||||
{
|
||||
0x03 => "x86",
|
||||
@@ -330,45 +315,48 @@ public static class NativeFormatDetector
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static void ParseElfNote(ReadOnlySpan<byte> note, bool bigEndian, ref string? buildId)
|
||||
private static string? ParseElfNote(ReadOnlySpan<byte> note, bool bigEndian)
|
||||
{
|
||||
if (note.Length < 12)
|
||||
var offset = 0;
|
||||
while (offset + 12 <= note.Length)
|
||||
{
|
||||
return;
|
||||
var namesz = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(note.Slice(offset))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(note.Slice(offset));
|
||||
var descsz = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(note.Slice(offset + 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(note.Slice(offset + 4));
|
||||
var type = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(note.Slice(offset + 8))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(note.Slice(offset + 8));
|
||||
|
||||
var nameStart = offset + 12;
|
||||
var namePadded = AlignTo4(namesz);
|
||||
var descStart = nameStart + namePadded;
|
||||
var descPadded = AlignTo4(descsz);
|
||||
var next = descStart + descPadded;
|
||||
|
||||
if (next > note.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (type == 3 && namesz >= 3)
|
||||
{
|
||||
var name = note.Slice(nameStart, (int)Math.Min(namesz, (uint)(note.Length - nameStart)));
|
||||
if (name[0] == (byte)'G' && name[1] == (byte)'N' && name[2] == (byte)'U')
|
||||
{
|
||||
var desc = note.Slice(descStart, (int)Math.Min(descsz, (uint)(note.Length - descStart)));
|
||||
return Convert.ToHexString(desc).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
offset = next;
|
||||
}
|
||||
|
||||
var namesz = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(note)
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(note);
|
||||
var descsz = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(note.Slice(4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(note.Slice(4));
|
||||
var type = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(note.Slice(8))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(note.Slice(8));
|
||||
|
||||
var offset = 12;
|
||||
var nameEnd = offset + (int)namesz;
|
||||
if (nameEnd > note.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var name = System.Text.Encoding.ASCII.GetString(note.Slice(offset, (int)namesz)).TrimEnd('\0');
|
||||
offset = Align(nameEnd, 4);
|
||||
var descEnd = offset + (int)descsz;
|
||||
if (descEnd > note.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (name == "GNU" && type == 3 && descsz > 0)
|
||||
{
|
||||
var desc = note.Slice(offset, (int)descsz);
|
||||
buildId = Convert.ToHexString(desc).ToLowerInvariant();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment) => (value + (alignment - 1)) & ~(alignment - 1);
|
||||
private static int AlignTo4(uint value) => (int)((value + 3) & ~3u);
|
||||
}
|
||||
#pragma warning restore CA2022
|
||||
|
||||
@@ -8,12 +8,16 @@ using System.Threading.Tasks;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
|
||||
|
||||
@@ -294,6 +298,8 @@ internal static class Program
|
||||
string casRoot,
|
||||
string generatorVersion)
|
||||
{
|
||||
var surfaceEnv = TryResolveSurfaceEnvironment();
|
||||
|
||||
var layerFragmentsPath = GetOption(args, "--surface-layer-fragments")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_LAYER_FRAGMENTS");
|
||||
var entryTraceGraphPath = GetOption(args, "--surface-entrytrace-graph")
|
||||
@@ -310,15 +316,18 @@ internal static class Program
|
||||
|
||||
var cacheRoot = GetOption(args, "--surface-cache-root")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_CACHE_ROOT")
|
||||
?? surfaceEnv?.CacheRoot.FullName
|
||||
?? casRoot;
|
||||
var bucket = GetOption(args, "--surface-bucket")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_BUCKET")
|
||||
?? surfaceEnv?.SurfaceFsBucket
|
||||
?? SurfaceCasLayout.DefaultBucket;
|
||||
var rootPrefix = GetOption(args, "--surface-root-prefix")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ROOT_PREFIX")
|
||||
?? SurfaceCasLayout.DefaultRootPrefix;
|
||||
var tenant = GetOption(args, "--surface-tenant")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_TENANT")
|
||||
?? surfaceEnv?.Tenant
|
||||
?? "default";
|
||||
var component = GetOption(args, "--surface-component")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT")
|
||||
@@ -362,6 +371,36 @@ internal static class Program
|
||||
ManifestOutputPath: manifestOutput);
|
||||
}
|
||||
|
||||
private static SurfaceEnvironmentSettings? TryResolveSurfaceEnvironment()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var env = SurfaceEnvironmentFactory.Create(provider, options =>
|
||||
{
|
||||
options.ComponentName = "Scanner.BuildXPlugin";
|
||||
options.AddPrefix("SCANNER");
|
||||
options.AddPrefix("SURFACE");
|
||||
options.RequireSurfaceEndpoint = false;
|
||||
});
|
||||
|
||||
return env.Settings;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silent fallback to legacy options/env without breaking plugin execution.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetOption(string[] args, string optionName)
|
||||
{
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.FS\\StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.Env\\StellaOps.Scanner.Surface.Env.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Aligns surface manifest store options with environment-derived cache settings.
|
||||
/// </summary>
|
||||
public sealed class SurfaceManifestStoreOptionsConfigurator : IConfigureOptions<SurfaceManifestStoreOptions>
|
||||
{
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
private readonly IOptions<SurfaceCacheOptions> _cacheOptions;
|
||||
|
||||
public SurfaceManifestStoreOptionsConfigurator(
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
IOptions<SurfaceCacheOptions> cacheOptions)
|
||||
{
|
||||
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
}
|
||||
|
||||
public void Configure(SurfaceManifestStoreOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var settings = _surfaceEnvironment.Settings;
|
||||
options.Bucket = settings.SurfaceFsBucket;
|
||||
options.Scheme = settings.SurfaceFsEndpoint.Scheme;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.RootDirectory))
|
||||
{
|
||||
options.RootDirectory = Path.Combine(
|
||||
_cacheOptions.Value.ResolveRoot(),
|
||||
"manifests");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -99,10 +100,15 @@ builder.Services.AddSurfaceEnvironment(options =>
|
||||
});
|
||||
builder.Services.AddSurfaceValidation();
|
||||
builder.Services.AddSurfaceFileCache();
|
||||
builder.Services.AddSurfaceManifestStore();
|
||||
builder.Services.AddSurfaceSecrets();
|
||||
builder.Services.AddSingleton<IConfigureOptions<ScannerWebServiceOptions>, ScannerSurfaceSecretConfigurator>();
|
||||
builder.Services.AddSingleton<IConfigureOptions<SurfaceCacheOptions>>(sp =>
|
||||
new SurfaceCacheOptionsConfigurator(sp.GetRequiredService<ISurfaceEnvironment>()));
|
||||
builder.Services.AddSingleton<IConfigureOptions<SurfaceManifestStoreOptions>>(sp =>
|
||||
new SurfaceManifestStoreOptionsConfigurator(
|
||||
sp.GetRequiredService<ISurfaceEnvironment>(),
|
||||
sp.GetRequiredService<IOptions<SurfaceCacheOptions>>()));
|
||||
builder.Services.AddSingleton<ISurfacePointerService, SurfacePointerService>();
|
||||
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
|
||||
if (bootstrapOptions.Events is { Enabled: true } eventsOptions
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -39,9 +40,14 @@ builder.Services.AddSurfaceEnvironment(options =>
|
||||
});
|
||||
builder.Services.AddSurfaceValidation();
|
||||
builder.Services.AddSurfaceFileCache();
|
||||
builder.Services.AddSurfaceManifestStore();
|
||||
builder.Services.AddSurfaceSecrets();
|
||||
builder.Services.AddSingleton<IConfigureOptions<SurfaceCacheOptions>>(sp =>
|
||||
new SurfaceCacheOptionsConfigurator(sp.GetRequiredService<ISurfaceEnvironment>()));
|
||||
builder.Services.AddSingleton<IConfigureOptions<SurfaceManifestStoreOptions>>(sp =>
|
||||
new SurfaceManifestStoreOptionsConfigurator(
|
||||
sp.GetRequiredService<ISurfaceEnvironment>(),
|
||||
sp.GetRequiredService<IOptions<SurfaceCacheOptions>>()));
|
||||
builder.Services.AddSingleton<ScannerWorkerMetrics>();
|
||||
builder.Services.AddSingleton<ScanProgressReporter>();
|
||||
builder.Services.AddSingleton<ScanJobProcessor>();
|
||||
@@ -143,7 +149,7 @@ await host.RunAsync();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
internal sealed class SurfaceCacheOptionsConfigurator : IConfigureOptions<SurfaceCacheOptions>
|
||||
public sealed class SurfaceCacheOptionsConfigurator : IConfigureOptions<SurfaceCacheOptions>
|
||||
{
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
|
||||
@@ -159,3 +165,33 @@ internal sealed class SurfaceCacheOptionsConfigurator : IConfigureOptions<Surfac
|
||||
options.RootDirectory = settings.CacheRoot.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SurfaceManifestStoreOptionsConfigurator : IConfigureOptions<SurfaceManifestStoreOptions>
|
||||
{
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
private readonly IOptions<SurfaceCacheOptions> _cacheOptions;
|
||||
|
||||
public SurfaceManifestStoreOptionsConfigurator(
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
IOptions<SurfaceCacheOptions> cacheOptions)
|
||||
{
|
||||
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
}
|
||||
|
||||
public void Configure(SurfaceManifestStoreOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var settings = _surfaceEnvironment.Settings;
|
||||
options.Bucket = settings.SurfaceFsBucket;
|
||||
options.Scheme = settings.SurfaceFsEndpoint.Scheme;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.RootDirectory))
|
||||
{
|
||||
options.RootDirectory = Path.Combine(
|
||||
_cacheOptions.Value.ResolveRoot(),
|
||||
"manifests");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Observations;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno;
|
||||
@@ -18,6 +19,8 @@ public sealed class DenoLanguageAnalyzer : ILanguageAnalyzer
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
await TryWriteRuntimeShimAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var workspace = await DenoWorkspaceNormalizer.NormalizeAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var moduleGraph = DenoModuleGraphResolver.Resolve(workspace, cancellationToken);
|
||||
var compatibility = DenoNpmCompatibilityAdapter.Analyze(workspace, moduleGraph, cancellationToken);
|
||||
@@ -62,6 +65,8 @@ public sealed class DenoLanguageAnalyzer : ILanguageAnalyzer
|
||||
metadata: observationMetadata,
|
||||
evidence: observationEvidence);
|
||||
|
||||
TryIngestRuntimeTrace(context);
|
||||
|
||||
// Task 5+ will convert moduleGraph + compatibility and bundle insights into SBOM components and evidence records.
|
||||
GC.KeepAlive(moduleGraph);
|
||||
GC.KeepAlive(compatibility);
|
||||
@@ -70,6 +75,18 @@ public sealed class DenoLanguageAnalyzer : ILanguageAnalyzer
|
||||
GC.KeepAlive(observationDocument);
|
||||
}
|
||||
|
||||
private static async ValueTask TryWriteRuntimeShimAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DenoRuntimeShim.WriteAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Shim is best-effort; failure should not block static analysis.
|
||||
}
|
||||
}
|
||||
|
||||
private void TryPersistObservation(
|
||||
LanguageAnalyzerContext context,
|
||||
byte[] observationBytes,
|
||||
@@ -111,4 +128,59 @@ public sealed class DenoLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private void TryIngestRuntimeTrace(LanguageAnalyzerContext context)
|
||||
{
|
||||
if (context.AnalysisStore is not { } analysisStore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tracePath = Path.Combine(context.RootPath, "deno-runtime.ndjson");
|
||||
if (!File.Exists(tracePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] content;
|
||||
try
|
||||
{
|
||||
content = File.ReadAllBytes(tracePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (metadata, hash) = DenoRuntimeTraceProbe.Analyze(content);
|
||||
var runtimeMeta = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["deno.runtime.hash"] = hash,
|
||||
["deno.runtime.event_count"] = metadata.EventCount.ToString(CultureInfo.InvariantCulture),
|
||||
["deno.runtime.permission_uses"] = metadata.PermissionUses.ToString(CultureInfo.InvariantCulture),
|
||||
["deno.runtime.module_loads"] = metadata.ModuleLoads.ToString(CultureInfo.InvariantCulture),
|
||||
["deno.runtime.remote_origins"] = string.Join(',', metadata.RemoteOrigins),
|
||||
["deno.runtime.permissions"] = string.Join(',', metadata.UniquePermissions),
|
||||
["deno.runtime.npm_resolutions"] = metadata.NpmResolutions.ToString(CultureInfo.InvariantCulture),
|
||||
["deno.runtime.wasm_loads"] = metadata.WasmLoads.ToString(CultureInfo.InvariantCulture),
|
||||
["deno.runtime.dynamic_imports"] = metadata.DynamicImports.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var payload = new AnalyzerObservationPayload(
|
||||
analyzerId: Id,
|
||||
kind: "deno.runtime.v1",
|
||||
mediaType: "application/x-ndjson",
|
||||
content: content,
|
||||
metadata: runtimeMeta,
|
||||
view: "runtime");
|
||||
|
||||
analysisStore.Set("deno.runtime", payload);
|
||||
|
||||
// Also emit policy signals into AnalysisStore for downstream consumption.
|
||||
var signals = DenoPolicySignalEmitter.FromTrace(hash, metadata);
|
||||
foreach (var signal in signals)
|
||||
{
|
||||
analysisStore.Set(signal.Key, signal.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the TypeScript runtime shim that captures Deno runtime events into NDJSON.
|
||||
/// This shim is written to disk alongside the analyzer to be invoked by the worker/CLI.
|
||||
/// </summary>
|
||||
internal static class DenoRuntimeShim
|
||||
{
|
||||
private const string ShimFileName = "trace-shim.ts";
|
||||
|
||||
public static string FileName => ShimFileName;
|
||||
|
||||
public static async Task<string> WriteAsync(string directory, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var path = Path.Combine(directory, ShimFileName);
|
||||
await File.WriteAllTextAsync(path, ShimSource, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
return path;
|
||||
}
|
||||
|
||||
// NOTE: This shim is intentionally self contained and avoids network calls.
|
||||
private const string ShimSource = """
|
||||
// deno-runtime trace shim (offline, deterministic)
|
||||
// Emits module load, permission use, npm resolution, and wasm load events.
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function addEvent(evt: Record<string, unknown>) {
|
||||
// Deterministic key order via stringify on object literal insertion order.
|
||||
events.push(evt);
|
||||
}
|
||||
|
||||
function hashPath(input: string): string {
|
||||
const data = new TextEncoder().encode(input);
|
||||
const hash = crypto.subtle.digestSync("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function relPath(abs: string): { normalized: string; path_sha256: string } {
|
||||
const cwd = Deno.cwd();
|
||||
const rel = abs.startsWith(cwd) ? abs.slice(cwd.length + 1) : abs;
|
||||
const normalized = rel.replaceAll("\\", "/");
|
||||
return { normalized, path_sha256: hashPath(normalized) };
|
||||
}
|
||||
|
||||
// Wrap permission requests
|
||||
const originalPermissions = Deno.permissions;
|
||||
Deno.permissions = {
|
||||
...originalPermissions,
|
||||
request: async (...args: Parameters<typeof originalPermissions.request>) => {
|
||||
const res = await originalPermissions.request(...args);
|
||||
const name = args[0]?.name ?? "unknown";
|
||||
const module = relPath(import.meta.url);
|
||||
addEvent({
|
||||
type: "deno.permission.use",
|
||||
ts: nowIso(),
|
||||
permission: name,
|
||||
module,
|
||||
details: "permissions.request",
|
||||
});
|
||||
return res;
|
||||
},
|
||||
query: (...args: Parameters<typeof originalPermissions.query>) =>
|
||||
originalPermissions.query(...args),
|
||||
revoke: (...args: Parameters<typeof originalPermissions.revoke>) =>
|
||||
originalPermissions.revoke(...args),
|
||||
};
|
||||
|
||||
// Hook dynamic import calls by wrapping import()
|
||||
const originalImport = globalThis.import ?? ((specifier: string) => import(specifier));
|
||||
globalThis.import = async (specifier: string) => {
|
||||
const mod = typeof specifier === "string" ? specifier : String(specifier);
|
||||
addEvent({
|
||||
type: "deno.module.load",
|
||||
ts: nowIso(),
|
||||
module: relPath(mod),
|
||||
reason: "dynamic-import",
|
||||
permissions: [],
|
||||
origin: mod.startsWith("http") ? mod : undefined,
|
||||
});
|
||||
return originalImport(specifier);
|
||||
};
|
||||
|
||||
// Hook WebAssembly loads
|
||||
const originalInstantiate = WebAssembly.instantiate;
|
||||
WebAssembly.instantiate = async (
|
||||
bufferSource: BufferSource | WebAssembly.Module,
|
||||
importObject?: WebAssembly.Imports,
|
||||
) => {
|
||||
addEvent({
|
||||
type: "deno.wasm.load",
|
||||
ts: nowIso(),
|
||||
module: relPath("wasm://buffer"),
|
||||
importer: relPath(import.meta.url).normalized,
|
||||
reason: "instantiate",
|
||||
});
|
||||
return originalInstantiate(bufferSource, importObject);
|
||||
};
|
||||
|
||||
// Capture npm resolution hints from env when present
|
||||
const npmMeta = Deno.env.get("STELLA_NPM_SPECIFIER");
|
||||
if (npmMeta) {
|
||||
addEvent({
|
||||
type: "deno.npm.resolution",
|
||||
ts: nowIso(),
|
||||
specifier: npmMeta,
|
||||
package: npmMeta,
|
||||
version: "",
|
||||
resolved: "file://$DENO_DIR/npm",
|
||||
exists: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Write NDJSON on exit
|
||||
function flush() {
|
||||
const sorted = events.sort((a, b) => {
|
||||
const at = String(a.ts);
|
||||
const bt = String(b.ts);
|
||||
if (at === bt) return String(a.type).localeCompare(String(b.type));
|
||||
return at.localeCompare(bt);
|
||||
});
|
||||
const data = sorted.map((e) => JSON.stringify(e)).join("\\n") + "\\n";
|
||||
Deno.writeTextFileSync("deno-runtime.ndjson", data);
|
||||
}
|
||||
|
||||
addEvent({
|
||||
type: "deno.runtime.start",
|
||||
ts: nowIso(),
|
||||
module: relPath(import.meta.url),
|
||||
reason: "shim-start",
|
||||
});
|
||||
|
||||
globalThis.addEventListener("unload", () => {
|
||||
flush();
|
||||
});
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
|
||||
internal static class DenoRuntimeTraceProbe
|
||||
{
|
||||
public static (DenoRuntimeTraceMetadata Metadata, string Sha256) Analyze(byte[] content)
|
||||
{
|
||||
var metadata = ComputeMetadata(content);
|
||||
var sha = ComputeSha256(content);
|
||||
return (metadata, sha);
|
||||
}
|
||||
|
||||
private static DenoRuntimeTraceMetadata ComputeMetadata(byte[] content)
|
||||
{
|
||||
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;
|
||||
var events = 0;
|
||||
|
||||
if (content is null || content.Length == 0)
|
||||
{
|
||||
return new DenoRuntimeTraceMetadata(0, 0, 0, Array.Empty<string>(), Array.Empty<string>(), 0, 0, 0);
|
||||
}
|
||||
|
||||
var span = new ReadOnlySpan<byte>(content);
|
||||
var start = 0;
|
||||
|
||||
while (start < span.Length)
|
||||
{
|
||||
var end = span.Slice(start).IndexOf((byte)'\n');
|
||||
var lineLength = end >= 0 ? end : span.Length - start;
|
||||
var line = span.Slice(start, lineLength);
|
||||
start += lineLength + 1;
|
||||
|
||||
if (line.IsEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(line);
|
||||
if (!document.RootElement.TryGetProperty("type", out var typeProp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var type = typeProp.GetString() ?? string.Empty;
|
||||
events++;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "deno.module.load":
|
||||
moduleLoads++;
|
||||
if (document.RootElement.TryGetProperty("origin", out var originProp)
|
||||
&& originProp.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(originProp.GetString()))
|
||||
{
|
||||
origins.Add(originProp.GetString()!);
|
||||
}
|
||||
|
||||
if (document.RootElement.TryGetProperty("reason", out var reasonProp)
|
||||
&& string.Equals(reasonProp.GetString(), "dynamic-import", StringComparison.Ordinal))
|
||||
{
|
||||
dynamicImports++;
|
||||
}
|
||||
|
||||
if (document.RootElement.TryGetProperty("permissions", out var permsProp)
|
||||
&& permsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var perm in permsProp.EnumerateArray())
|
||||
{
|
||||
if (perm.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(perm.GetString()))
|
||||
{
|
||||
permissions.Add(perm.GetString()!.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "deno.permission.use":
|
||||
permissionUses++;
|
||||
if (document.RootElement.TryGetProperty("permission", out var permProp)
|
||||
&& permProp.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(permProp.GetString()))
|
||||
{
|
||||
permissions.Add(permProp.GetString()!.Trim().ToLowerInvariant());
|
||||
}
|
||||
break;
|
||||
|
||||
case "deno.npm.resolution":
|
||||
npmResolutions++;
|
||||
break;
|
||||
|
||||
case "deno.wasm.load":
|
||||
wasmLoads++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return new DenoRuntimeTraceMetadata(
|
||||
EventCount: events,
|
||||
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);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] content)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(content ?? Array.Empty<byte>());
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -406,12 +406,29 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
|
||||
var locator = BuildLocator(archive, entry.OriginalPath);
|
||||
locators.Add(locator);
|
||||
|
||||
var sha256 = TryComputeSha256(archive, entry);
|
||||
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"framework-config",
|
||||
locator,
|
||||
value: null,
|
||||
sha256: null));
|
||||
sha256: sha256));
|
||||
}
|
||||
|
||||
private static string? TryComputeSha256(JavaArchive archive, JavaArchiveEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = archive.OpenEntry(entry);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSpringFactories(string path)
|
||||
|
||||
@@ -19,7 +19,8 @@ internal sealed class NodePackage
|
||||
bool declaredOnly = false,
|
||||
string? lockSource = null,
|
||||
string? lockLocator = null,
|
||||
string? packageSha256 = null)
|
||||
string? packageSha256 = null,
|
||||
bool isYarnPnp = false)
|
||||
{
|
||||
Name = name;
|
||||
Version = version;
|
||||
@@ -38,6 +39,7 @@ internal sealed class NodePackage
|
||||
LockSource = lockSource;
|
||||
LockLocator = lockLocator;
|
||||
PackageSha256 = packageSha256;
|
||||
IsYarnPnp = isYarnPnp;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
@@ -75,8 +77,10 @@ internal sealed class NodePackage
|
||||
public string? LockLocator { get; }
|
||||
|
||||
public string? PackageSha256 { get; }
|
||||
|
||||
public string RelativePathNormalized => string.IsNullOrEmpty(RelativePath) ? string.Empty : RelativePath.Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
public bool IsYarnPnp { get; }
|
||||
|
||||
public string RelativePathNormalized => string.IsNullOrEmpty(RelativePath) ? string.Empty : RelativePath.Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
public string ComponentKey => $"purl::{Purl}";
|
||||
|
||||
@@ -217,6 +221,11 @@ internal sealed class NodePackage
|
||||
entries.Add(new KeyValuePair<string, string?>("lockLocator", LockLocator));
|
||||
}
|
||||
|
||||
if (IsYarnPnp)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("yarnPnp", "true"));
|
||||
}
|
||||
|
||||
return entries
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
@@ -1,175 +1,179 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||
|
||||
internal static class NodePackageCollector
|
||||
{
|
||||
private static readonly string[] IgnoredDirectories =
|
||||
{
|
||||
".bin",
|
||||
".cache",
|
||||
".store",
|
||||
"__pycache__"
|
||||
};
|
||||
|
||||
public static IReadOnlyList<NodePackage> CollectPackages(LanguageAnalyzerContext context, NodeLockData lockData, CancellationToken cancellationToken)
|
||||
{
|
||||
var packages = new List<NodePackage>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pendingNodeModuleRoots = new List<string>();
|
||||
|
||||
var rootPackageJson = Path.Combine(context.RootPath, "package.json");
|
||||
var workspaceIndex = NodeWorkspaceIndex.Create(context.RootPath);
|
||||
|
||||
if (File.Exists(rootPackageJson))
|
||||
{
|
||||
var rootPackage = TryCreatePackage(context, rootPackageJson, string.Empty, lockData, workspaceIndex, cancellationToken);
|
||||
if (rootPackage is not null)
|
||||
{
|
||||
packages.Add(rootPackage);
|
||||
visited.Add(rootPackage.RelativePathNormalized);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var workspaceRelative in workspaceIndex.GetMembers())
|
||||
{
|
||||
var workspaceAbsolute = Path.Combine(context.RootPath, workspaceRelative.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!Directory.Exists(workspaceAbsolute))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessPackageDirectory(context, workspaceAbsolute, lockData, workspaceIndex, includeNestedNodeModules: false, packages, visited, cancellationToken);
|
||||
|
||||
var workspaceNodeModules = Path.Combine(workspaceAbsolute, "node_modules");
|
||||
if (Directory.Exists(workspaceNodeModules))
|
||||
{
|
||||
pendingNodeModuleRoots.Add(workspaceNodeModules);
|
||||
}
|
||||
}
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||
|
||||
internal static class NodePackageCollector
|
||||
{
|
||||
private static readonly string[] IgnoredDirectories =
|
||||
{
|
||||
".bin",
|
||||
".cache",
|
||||
".store",
|
||||
"__pycache__"
|
||||
};
|
||||
|
||||
public static IReadOnlyList<NodePackage> CollectPackages(LanguageAnalyzerContext context, NodeLockData lockData, CancellationToken cancellationToken)
|
||||
{
|
||||
var packages = new List<NodePackage>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pendingNodeModuleRoots = new List<string>();
|
||||
|
||||
var rootPackageJson = Path.Combine(context.RootPath, "package.json");
|
||||
var workspaceIndex = NodeWorkspaceIndex.Create(context.RootPath);
|
||||
var yarnPnpPresent = HasYarnPnp(context.RootPath);
|
||||
|
||||
if (File.Exists(rootPackageJson))
|
||||
{
|
||||
var rootPackage = TryCreatePackage(context, rootPackageJson, string.Empty, lockData, workspaceIndex, yarnPnpPresent, cancellationToken);
|
||||
if (rootPackage is not null)
|
||||
{
|
||||
packages.Add(rootPackage);
|
||||
visited.Add(rootPackage.RelativePathNormalized);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var workspaceRelative in workspaceIndex.GetMembers())
|
||||
{
|
||||
var workspaceAbsolute = Path.Combine(context.RootPath, workspaceRelative.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!Directory.Exists(workspaceAbsolute))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessPackageDirectory(context, workspaceAbsolute, lockData, workspaceIndex, includeNestedNodeModules: false, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
|
||||
var workspaceNodeModules = Path.Combine(workspaceAbsolute, "node_modules");
|
||||
if (Directory.Exists(workspaceNodeModules))
|
||||
{
|
||||
pendingNodeModuleRoots.Add(workspaceNodeModules);
|
||||
}
|
||||
}
|
||||
|
||||
var nodeModules = Path.Combine(context.RootPath, "node_modules");
|
||||
TraverseDirectory(context, nodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
TraverseDirectory(context, nodeModules, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
|
||||
foreach (var pendingRoot in pendingNodeModuleRoots.OrderBy(static path => path, StringComparer.Ordinal))
|
||||
{
|
||||
TraverseDirectory(context, pendingRoot, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
TraverseDirectory(context, pendingRoot, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
|
||||
TraverseTarballs(context, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
TraverseTarballs(context, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
|
||||
AppendDeclaredPackages(packages, lockData);
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
private static void TraverseDirectory(
|
||||
LanguageAnalyzerContext context,
|
||||
string directory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var child in Directory.EnumerateDirectories(directory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var name = Path.GetFileName(child);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldSkipDirectory(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(name, ".pnpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
TraversePnpmStore(context, child, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.StartsWith('@'))
|
||||
{
|
||||
foreach (var scoped in Directory.EnumerateDirectories(child))
|
||||
{
|
||||
ProcessPackageDirectory(context, scoped, lockData, workspaceIndex, includeNestedNodeModules: true, packages, visited, cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessPackageDirectory(context, child, lockData, workspaceIndex, includeNestedNodeModules: true, packages, visited, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraversePnpmStore(
|
||||
LanguageAnalyzerContext context,
|
||||
string pnpmDirectory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var storeEntry in Directory.EnumerateDirectories(pnpmDirectory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var nestedNodeModules = Path.Combine(storeEntry, "node_modules");
|
||||
if (Directory.Exists(nestedNodeModules))
|
||||
{
|
||||
TraverseDirectory(context, nestedNodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessPackageDirectory(
|
||||
LanguageAnalyzerContext context,
|
||||
string directory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
bool includeNestedNodeModules,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packageJsonPath = Path.Combine(directory, "package.json");
|
||||
var relativeDirectory = NormalizeRelativeDirectory(context, directory);
|
||||
|
||||
if (!visited.Add(relativeDirectory))
|
||||
{
|
||||
// Already processed this path.
|
||||
if (includeNestedNodeModules)
|
||||
{
|
||||
TraverseNestedNodeModules(context, directory, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(packageJsonPath))
|
||||
{
|
||||
var package = TryCreatePackage(context, packageJsonPath, relativeDirectory, lockData, workspaceIndex, cancellationToken);
|
||||
if (package is not null)
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeNestedNodeModules)
|
||||
{
|
||||
TraverseNestedNodeModules(context, directory, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void TraverseDirectory(
|
||||
LanguageAnalyzerContext context,
|
||||
string directory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
bool yarnPnpPresent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var child in Directory.EnumerateDirectories(directory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var name = Path.GetFileName(child);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldSkipDirectory(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(name, ".pnpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
TraversePnpmStore(context, child, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.StartsWith('@'))
|
||||
{
|
||||
foreach (var scoped in Directory.EnumerateDirectories(child))
|
||||
{
|
||||
ProcessPackageDirectory(context, scoped, lockData, workspaceIndex, includeNestedNodeModules: true, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessPackageDirectory(context, child, lockData, workspaceIndex, includeNestedNodeModules: true, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraversePnpmStore(
|
||||
LanguageAnalyzerContext context,
|
||||
string pnpmDirectory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
bool yarnPnpPresent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var storeEntry in Directory.EnumerateDirectories(pnpmDirectory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var nestedNodeModules = Path.Combine(storeEntry, "node_modules");
|
||||
if (Directory.Exists(nestedNodeModules))
|
||||
{
|
||||
TraverseDirectory(context, nestedNodeModules, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessPackageDirectory(
|
||||
LanguageAnalyzerContext context,
|
||||
string directory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
bool includeNestedNodeModules,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
bool yarnPnpPresent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packageJsonPath = Path.Combine(directory, "package.json");
|
||||
var relativeDirectory = NormalizeRelativeDirectory(context, directory);
|
||||
|
||||
if (!visited.Add(relativeDirectory))
|
||||
{
|
||||
// Already processed this path.
|
||||
if (includeNestedNodeModules)
|
||||
{
|
||||
TraverseNestedNodeModules(context, directory, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(packageJsonPath))
|
||||
{
|
||||
var package = TryCreatePackage(context, packageJsonPath, relativeDirectory, lockData, workspaceIndex, yarnPnpPresent, cancellationToken);
|
||||
if (package is not null)
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeNestedNodeModules)
|
||||
{
|
||||
TraverseNestedNodeModules(context, directory, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraverseNestedNodeModules(
|
||||
LanguageAnalyzerContext context,
|
||||
string directory,
|
||||
@@ -177,10 +181,11 @@ internal static class NodePackageCollector
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
bool yarnPnpPresent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nestedNodeModules = Path.Combine(directory, "node_modules");
|
||||
TraverseDirectory(context, nestedNodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
TraverseDirectory(context, nestedNodeModules, lockData, workspaceIndex, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
|
||||
private static void TraverseTarballs(
|
||||
@@ -189,6 +194,7 @@ internal static class NodePackageCollector
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
bool yarnPnpPresent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
@@ -201,7 +207,7 @@ internal static class NodePackageCollector
|
||||
foreach (var tgzPath in Directory.EnumerateFiles(context.RootPath, "*.tgz", enumerationOptions))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
TryProcessTarball(context, tgzPath, packages, visited, cancellationToken);
|
||||
TryProcessTarball(context, tgzPath, packages, visited, yarnPnpPresent, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +216,7 @@ internal static class NodePackageCollector
|
||||
string tgzPath,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
bool yarnPnpPresent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -256,7 +263,8 @@ internal static class NodePackageCollector
|
||||
locator,
|
||||
usedByEntrypoint,
|
||||
cancellationToken,
|
||||
packageSha256: sha256Hex);
|
||||
packageSha256: sha256Hex,
|
||||
yarnPnpPresent: yarnPnpPresent);
|
||||
|
||||
if (package is null)
|
||||
{
|
||||
@@ -361,13 +369,20 @@ internal static class NodePackageCollector
|
||||
|
||||
return $"{entry.Source}:{entry.Locator}";
|
||||
}
|
||||
|
||||
|
||||
private static bool HasYarnPnp(string rootPath)
|
||||
{
|
||||
return File.Exists(Path.Combine(rootPath, ".pnp.cjs"))
|
||||
|| File.Exists(Path.Combine(rootPath, ".pnp.data.cjs"));
|
||||
}
|
||||
|
||||
private static NodePackage? TryCreatePackage(
|
||||
LanguageAnalyzerContext context,
|
||||
string packageJsonPath,
|
||||
string relativeDirectory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
bool yarnPnpPresent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -385,16 +400,18 @@ internal static class NodePackageCollector
|
||||
cancellationToken,
|
||||
lockData,
|
||||
workspaceIndex,
|
||||
packageJsonPath);
|
||||
packageJsonPath,
|
||||
packageSha256: null,
|
||||
yarnPnpPresent: yarnPnpPresent);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static NodePackage? TryCreatePackageFromJson(
|
||||
@@ -407,7 +424,8 @@ internal static class NodePackageCollector
|
||||
NodeLockData? lockData = null,
|
||||
NodeWorkspaceIndex? workspaceIndex = null,
|
||||
string? packageJsonPath = null,
|
||||
string? packageSha256 = null)
|
||||
string? packageSha256 = null,
|
||||
bool yarnPnpPresent = false)
|
||||
{
|
||||
if (!root.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
@@ -467,20 +485,21 @@ internal static class NodePackageCollector
|
||||
declaredOnly: false,
|
||||
lockSource: lockSource,
|
||||
lockLocator: lockLocator,
|
||||
packageSha256: packageSha256);
|
||||
packageSha256: packageSha256,
|
||||
isYarnPnp: yarnPnpPresent);
|
||||
}
|
||||
|
||||
private static string NormalizeRelativeDirectory(LanguageAnalyzerContext context, string directory)
|
||||
{
|
||||
var relative = context.GetRelativePath(directory);
|
||||
if (string.IsNullOrEmpty(relative) || relative == ".")
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return relative.Replace(Path.DirectorySeparatorChar, '/');
|
||||
}
|
||||
|
||||
|
||||
private static string NormalizeRelativeDirectory(LanguageAnalyzerContext context, string directory)
|
||||
{
|
||||
var relative = context.GetRelativePath(directory);
|
||||
if (string.IsNullOrEmpty(relative) || relative == ".")
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return relative.Replace(Path.DirectorySeparatorChar, '/');
|
||||
}
|
||||
|
||||
private static string BuildLocator(string relativeDirectory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(relativeDirectory))
|
||||
@@ -512,103 +531,119 @@ internal static class NodePackageCollector
|
||||
|
||||
return relative.Replace(Path.DirectorySeparatorChar, '/');
|
||||
}
|
||||
|
||||
private static bool ShouldSkipDirectory(string name)
|
||||
{
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (name[0] == '.')
|
||||
{
|
||||
return !string.Equals(name, ".pnpm", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return IgnoredDirectories.Any(ignored => string.Equals(name, ignored, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractWorkspaceTargets(string relativeDirectory, JsonElement root, NodeWorkspaceIndex workspaceIndex)
|
||||
{
|
||||
var dependencies = workspaceIndex.ResolveWorkspaceTargets(relativeDirectory, TryGetProperty(root, "dependencies"));
|
||||
var devDependencies = workspaceIndex.ResolveWorkspaceTargets(relativeDirectory, TryGetProperty(root, "devDependencies"));
|
||||
var peerDependencies = workspaceIndex.ResolveWorkspaceTargets(relativeDirectory, TryGetProperty(root, "peerDependencies"));
|
||||
|
||||
if (dependencies.Count == 0 && devDependencies.Count == 0 && peerDependencies.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var combined = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in dependencies)
|
||||
{
|
||||
combined.Add(item);
|
||||
}
|
||||
foreach (var item in devDependencies)
|
||||
{
|
||||
combined.Add(item);
|
||||
}
|
||||
foreach (var item in peerDependencies)
|
||||
{
|
||||
combined.Add(item);
|
||||
}
|
||||
|
||||
return combined.OrderBy(static x => x, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static JsonElement? TryGetProperty(JsonElement element, string propertyName)
|
||||
=> element.TryGetProperty(propertyName, out var property) ? property : null;
|
||||
|
||||
private static IReadOnlyList<NodeLifecycleScript> ExtractLifecycleScripts(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("scripts", out var scriptsElement) || scriptsElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return Array.Empty<NodeLifecycleScript>();
|
||||
}
|
||||
|
||||
var lifecycleScripts = new Dictionary<string, NodeLifecycleScript>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var script in scriptsElement.EnumerateObject())
|
||||
{
|
||||
if (!IsLifecycleScriptName(script.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (script.Value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var command = script.Value.GetString();
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var canonicalName = script.Name.Trim().ToLowerInvariant();
|
||||
var lifecycleScript = new NodeLifecycleScript(canonicalName, command);
|
||||
|
||||
if (!lifecycleScripts.ContainsKey(canonicalName))
|
||||
{
|
||||
NodeAnalyzerMetrics.RecordLifecycleScript(canonicalName);
|
||||
}
|
||||
|
||||
lifecycleScripts[canonicalName] = lifecycleScript;
|
||||
}
|
||||
|
||||
if (lifecycleScripts.Count == 0)
|
||||
{
|
||||
return Array.Empty<NodeLifecycleScript>();
|
||||
}
|
||||
|
||||
return lifecycleScripts.Values
|
||||
.OrderBy(static script => script.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsLifecycleScriptName(string name)
|
||||
=> name.Equals("preinstall", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("install", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("postinstall", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldSkipDirectory(string name)
|
||||
{
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (name[0] == '.')
|
||||
{
|
||||
return !string.Equals(name, ".pnpm", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return IgnoredDirectories.Any(ignored => string.Equals(name, ignored, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool HasYarnPnp(string rootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var pnpCjs = Path.Combine(rootPath, ".pnp.cjs");
|
||||
var pnpData = Path.Combine(rootPath, ".pnp.data.json");
|
||||
var yarnCache = Path.Combine(rootPath, ".yarn", "cache");
|
||||
|
||||
return File.Exists(pnpCjs)
|
||||
|| File.Exists(pnpData)
|
||||
|| Directory.Exists(yarnCache);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractWorkspaceTargets(string relativeDirectory, JsonElement root, NodeWorkspaceIndex workspaceIndex)
|
||||
{
|
||||
var dependencies = workspaceIndex.ResolveWorkspaceTargets(relativeDirectory, TryGetProperty(root, "dependencies"));
|
||||
var devDependencies = workspaceIndex.ResolveWorkspaceTargets(relativeDirectory, TryGetProperty(root, "devDependencies"));
|
||||
var peerDependencies = workspaceIndex.ResolveWorkspaceTargets(relativeDirectory, TryGetProperty(root, "peerDependencies"));
|
||||
|
||||
if (dependencies.Count == 0 && devDependencies.Count == 0 && peerDependencies.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var combined = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in dependencies)
|
||||
{
|
||||
combined.Add(item);
|
||||
}
|
||||
foreach (var item in devDependencies)
|
||||
{
|
||||
combined.Add(item);
|
||||
}
|
||||
foreach (var item in peerDependencies)
|
||||
{
|
||||
combined.Add(item);
|
||||
}
|
||||
|
||||
return combined.OrderBy(static x => x, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static JsonElement? TryGetProperty(JsonElement element, string propertyName)
|
||||
=> element.TryGetProperty(propertyName, out var property) ? property : null;
|
||||
|
||||
private static IReadOnlyList<NodeLifecycleScript> ExtractLifecycleScripts(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("scripts", out var scriptsElement) || scriptsElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return Array.Empty<NodeLifecycleScript>();
|
||||
}
|
||||
|
||||
var lifecycleScripts = new Dictionary<string, NodeLifecycleScript>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var script in scriptsElement.EnumerateObject())
|
||||
{
|
||||
if (!IsLifecycleScriptName(script.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (script.Value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var command = script.Value.GetString();
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var canonicalName = script.Name.Trim().ToLowerInvariant();
|
||||
var lifecycleScript = new NodeLifecycleScript(canonicalName, command);
|
||||
|
||||
if (!lifecycleScripts.ContainsKey(canonicalName))
|
||||
{
|
||||
NodeAnalyzerMetrics.RecordLifecycleScript(canonicalName);
|
||||
}
|
||||
|
||||
lifecycleScripts[canonicalName] = lifecycleScript;
|
||||
}
|
||||
|
||||
if (lifecycleScripts.Count == 0)
|
||||
{
|
||||
return Array.Empty<NodeLifecycleScript>();
|
||||
}
|
||||
|
||||
return lifecycleScripts.Values
|
||||
.OrderBy(static script => script.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsLifecycleScriptName(string name)
|
||||
=> name.Equals("preinstall", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("install", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("postinstall", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Deno;
|
||||
|
||||
public sealed class DenoRuntimeShimTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WritesShimToDirectory()
|
||||
{
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
try
|
||||
{
|
||||
var path = await DenoRuntimeShim.WriteAsync(root, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
var content = await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("deno-runtime.ndjson", content);
|
||||
Assert.Contains("deno.module.load", content);
|
||||
Assert.Contains("deno.permission.use", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Deno;
|
||||
|
||||
public sealed class DenoRuntimeTraceProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputesMetadataAndHashFromNdjson()
|
||||
{
|
||||
const string ndjson =
|
||||
@"{\""type\"":\"\"deno.module.load\"",\""ts\"":\"\"2025-11-17T12:00:00Z\"",\""reason\"":\"\"dynamic-import\"",\""permissions\"":[\"\"fs\""],\""origin\"":\"\"https://deno.land\""}
|
||||
{\""type\"":\"\"deno.permission.use\"",\""ts\"":\"\"2025-11-17T12:00:01Z\"",\""permission\"":\"\"NET\""}
|
||||
{\""type\"":\"\"deno.npm.resolution\"",\""ts\"":\"\"2025-11-17T12:00:02Z\""}
|
||||
{\""type\"":\"\"deno.wasm.load\"",\""ts\"":\"\"2025-11-17T12:00:03Z\""}
|
||||
";
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(ndjson);
|
||||
var (metadata, hash) = DenoRuntimeTraceProbe.Analyze(bytes);
|
||||
|
||||
Assert.Equal(4, metadata.EventCount);
|
||||
Assert.Equal(1, metadata.ModuleLoads);
|
||||
Assert.Equal(1, metadata.PermissionUses);
|
||||
Assert.Equal(1, metadata.NpmResolutions);
|
||||
Assert.Equal(1, metadata.WasmLoads);
|
||||
Assert.Equal(1, metadata.DynamicImports);
|
||||
Assert.Equal(new[] { "https://deno.land" }, metadata.RemoteOrigins);
|
||||
Assert.Equal(new[] { "fs", "net" }, metadata.UniquePermissions);
|
||||
|
||||
Assert.Equal("8f67e4b77f2ea4155d9101c5e6a45922e4ac1e19006955c3e6c2afe1938f0a8d", hash);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,13 @@ public sealed class JavaLanguageAnalyzerTests
|
||||
Assert.Equal("demo-framework.jar!META-INF/services/jakarta.ws.rs.ext.RuntimeDelegate", metadata.GetProperty("config.jaxrs").GetString());
|
||||
Assert.Equal("demo-framework.jar!logback.xml", metadata.GetProperty("config.logging").GetString());
|
||||
Assert.Equal("demo-framework.jar!META-INF/native-image/demo/reflect-config.json", metadata.GetProperty("config.graal").GetString());
|
||||
|
||||
var evidence = component.GetProperty("evidence").EnumerateArray().ToArray();
|
||||
Assert.Contains(evidence, e =>
|
||||
string.Equals(e.GetProperty("source").GetString(), "framework-config", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(e.GetProperty("locator").GetString(), "demo-framework.jar!META-INF/spring.factories", StringComparison.OrdinalIgnoreCase) &&
|
||||
e.TryGetProperty("sha256", out var sha) &&
|
||||
!string.IsNullOrWhiteSpace(sha.GetString()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "node",
|
||||
"componentKey": "purl::pkg:npm/yarn-pnp-demo@1.0.0",
|
||||
"purl": "pkg:npm/yarn-pnp-demo@1.0.0",
|
||||
"name": "yarn-pnp-demo",
|
||||
"version": "1.0.0",
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"path": ".",
|
||||
"yarnPnp": "true"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "package.json",
|
||||
"locator": "package.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "yarn-pnp-demo",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -62,4 +62,42 @@ public sealed class NodeLanguageAnalyzerTests
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task YarnPnpCachePackagesAreParsedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "node", "yarn-pnp");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new NodeLanguageAnalyzer()
|
||||
};
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task YarnPnpFlagIsEmittedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "node", "yarn-pnp");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new NodeLanguageAnalyzer()
|
||||
};
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,70 @@ public sealed class SurfaceEnvironmentBuilderTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Throws_WhenEndpointMissing_AndRequired()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var exception = Assert.Throws<SurfaceEnvironmentException>(() =>
|
||||
SurfaceEnvironmentFactory.Create(services, options => options.RequireSurfaceEndpoint = true));
|
||||
|
||||
Assert.Equal("SURFACE_FS_ENDPOINT", exception.Variable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Throws_WhenEndpointInvalid()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "not-a-uri");
|
||||
try
|
||||
{
|
||||
var services = CreateServices();
|
||||
var ex = Assert.Throws<SurfaceEnvironmentException>(() => SurfaceEnvironmentFactory.Create(services));
|
||||
Assert.Equal("SURFACE_FS_ENDPOINT", ex.Variable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Throws_WhenTlsCertificateMissing()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_TLS_CERT_PATH", "/does/not/exist.pem");
|
||||
try
|
||||
{
|
||||
var services = CreateServices();
|
||||
var ex = Assert.Throws<SurfaceEnvironmentException>(() => SurfaceEnvironmentFactory.Create(services));
|
||||
Assert.Equal("SURFACE_TLS_CERT_PATH", ex.Variable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_TLS_CERT_PATH", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesTenantResolver_WhenNotProvided()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
|
||||
try
|
||||
{
|
||||
var services = CreateServices();
|
||||
var environment = SurfaceEnvironmentFactory.Create(services, options =>
|
||||
{
|
||||
options.TenantResolver = _ => "resolved-tenant";
|
||||
});
|
||||
|
||||
Assert.Equal("resolved-tenant", environment.Settings.Tenant);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static IServiceProvider CreateServices(Action<IServiceCollection>? configure = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class SurfaceManifestStoreOptionsConfiguratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Configure_UsesSurfaceEnvironmentAndCacheRoot()
|
||||
{
|
||||
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
|
||||
var settings = new SurfaceEnvironmentSettings(
|
||||
new Uri("https://surface.example"),
|
||||
"surface-bucket",
|
||||
null,
|
||||
cacheRoot,
|
||||
1024,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false),
|
||||
"tenant-a",
|
||||
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()));
|
||||
|
||||
var environment = new StubSurfaceEnvironment(settings);
|
||||
var cacheOptions = Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
|
||||
var configurator = new SurfaceManifestStoreOptionsConfigurator(environment, cacheOptions);
|
||||
var options = new SurfaceManifestStoreOptions();
|
||||
|
||||
configurator.Configure(options);
|
||||
|
||||
Assert.Equal("https", options.Scheme);
|
||||
Assert.Equal("surface-bucket", options.Bucket);
|
||||
Assert.Equal(Path.Combine(cacheRoot.FullName, "manifests"), options.RootDirectory);
|
||||
}
|
||||
|
||||
private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public StubSurfaceEnvironment(SurfaceEnvironmentSettings settings)
|
||||
{
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentSettings Settings { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; } = new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Worker;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class SurfaceManifestStoreOptionsConfiguratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Configure_UsesSurfaceEnvironmentEndpointAndBucket()
|
||||
{
|
||||
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
|
||||
var settings = new SurfaceEnvironmentSettings(
|
||||
new Uri("https://surface.example"),
|
||||
"surface-bucket",
|
||||
null,
|
||||
cacheRoot,
|
||||
1024,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false),
|
||||
"tenant-a",
|
||||
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()));
|
||||
|
||||
var environment = new StubSurfaceEnvironment(settings);
|
||||
var cacheOptions = Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
|
||||
var configurator = new SurfaceManifestStoreOptionsConfigurator(environment, cacheOptions);
|
||||
var options = new SurfaceManifestStoreOptions();
|
||||
|
||||
configurator.Configure(options);
|
||||
|
||||
Assert.Equal("https", options.Scheme);
|
||||
Assert.Equal("surface-bucket", options.Bucket);
|
||||
Assert.Equal(Path.Combine(cacheRoot.FullName, "manifests"), options.RootDirectory);
|
||||
}
|
||||
|
||||
private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public StubSurfaceEnvironment(SurfaceEnvironmentSettings settings)
|
||||
{
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentSettings Settings { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; } = new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user