feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -7,10 +7,10 @@
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
+- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- Current sprint file (e.g., `docs/implplan/SPRINT_131_scanner_surface.md`).
|
||||
- Current sprint file (e.g., `docs/implplan/SPRINT_0131_0001_0001_scanner_surface.md`).
|
||||
|
||||
## Working Directory & Boundaries
|
||||
- Primary scope: `src/Scanner/**` (analyzers, worker, web service, plugins, __Libraries, __Tests, __Benchmarks, docs).
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
root = false
|
||||
|
||||
[*.cs]
|
||||
dotnet_diagnostic.CA2022.severity = none
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Native;
|
||||
|
||||
public sealed record NativeBinaryIdentity(
|
||||
NativeFormat Format,
|
||||
string? CpuArchitecture,
|
||||
string? OperatingSystem,
|
||||
string? Endianness,
|
||||
string? BuildId,
|
||||
string? Uuid,
|
||||
string? InterpreterPath);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Native;
|
||||
|
||||
public enum NativeFormat
|
||||
{
|
||||
Unknown = 0,
|
||||
Elf = 1,
|
||||
Pe = 2,
|
||||
MachO = 3,
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
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;
|
||||
|
||||
public static class NativeFormatDetector
|
||||
{
|
||||
public static bool TryDetect(Stream stream, out NativeBinaryIdentity identity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
Span<byte> header = stackalloc byte[512];
|
||||
var read = stream.ReadAtLeast(header, 4, throwOnEndOfStream: false);
|
||||
if (read < 4)
|
||||
{
|
||||
identity = new NativeBinaryIdentity(NativeFormat.Unknown, null, null, null, null, null, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
var span = header[..read];
|
||||
|
||||
if (IsElf(span, stream, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsPe(span, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsMachO(span, stream, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
identity = new NativeBinaryIdentity(NativeFormat.Unknown, null, null, null, null, null, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsElf(ReadOnlySpan<byte> span, Stream stream, out NativeBinaryIdentity identity)
|
||||
{
|
||||
identity = default!;
|
||||
if (span.Length < 20)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (span[0] != 0x7F || span[1] != (byte)'E' || span[2] != (byte)'L' || span[3] != (byte)'F')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var elfClass = span[4]; // 1=32,2=64
|
||||
var dataEncoding = span[5]; // 1=LE,2=BE
|
||||
var osAbi = span[7];
|
||||
var endianness = dataEncoding == 2 ? "be" : "le";
|
||||
|
||||
ushort machine;
|
||||
if (dataEncoding == 2)
|
||||
{
|
||||
machine = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(18, 2));
|
||||
}
|
||||
else
|
||||
{
|
||||
machine = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(18, 2));
|
||||
}
|
||||
|
||||
var arch = MapElfMachine(machine);
|
||||
var os = MapElfOs(osAbi);
|
||||
|
||||
var phoff = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt64BigEndian(span.Slice(32, 8))
|
||||
: BinaryPrimitives.ReadUInt64LittleEndian(span.Slice(32, 8));
|
||||
|
||||
var phentsize = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt16BigEndian(span.Slice(54, 2))
|
||||
: BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(54, 2));
|
||||
var phnum = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt16BigEndian(span.Slice(56, 2))
|
||||
: BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(56, 2));
|
||||
|
||||
string? buildId = null;
|
||||
string? interp = null;
|
||||
|
||||
if (phentsize > 0 && phnum > 0 && phoff > 0)
|
||||
{
|
||||
for (var i = 0; i < phnum; i++)
|
||||
{
|
||||
var entryOffset = (long)(phoff + (ulong)(i * phentsize));
|
||||
if (entryOffset + phentsize > stream.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var ph = new byte[phentsize];
|
||||
stream.Seek(entryOffset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(ph, 0, ph.Length);
|
||||
var phSpan = ph.AsSpan();
|
||||
|
||||
var pType = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(phSpan)
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(phSpan);
|
||||
|
||||
if (pType == 3 && interp is null) // PT_INTERP
|
||||
{
|
||||
ulong offset = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt64BigEndian(phSpan.Slice(8, 8))
|
||||
: BinaryPrimitives.ReadUInt64LittleEndian(phSpan.Slice(8, 8));
|
||||
uint fileSize = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(phSpan.Slice(32, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(phSpan.Slice(32, 4));
|
||||
|
||||
if (fileSize > 0 && offset + fileSize <= (ulong)stream.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');
|
||||
if (!string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
interp = str;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (pType == 4 && buildId is null) // PT_NOTE
|
||||
{
|
||||
ulong offset = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt64BigEndian(phSpan.Slice(8, 8))
|
||||
: BinaryPrimitives.ReadUInt64LittleEndian(phSpan.Slice(8, 8));
|
||||
uint fileSize = dataEncoding == 2
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(phSpan.Slice(32, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(phSpan.Slice(32, 4));
|
||||
|
||||
if (fileSize > 0 && offset + fileSize <= (ulong)stream.Length)
|
||||
{
|
||||
var buffer = new byte[fileSize];
|
||||
stream.Seek((long)offset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(buffer, 0, buffer.Length);
|
||||
ParseElfNote(buffer, dataEncoding == 2, ref buildId);
|
||||
}
|
||||
}
|
||||
|
||||
if (buildId is not null && interp is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
identity = new NativeBinaryIdentity(NativeFormat.Elf, arch, os, endianness, buildId, null, interp);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPe(ReadOnlySpan<byte> span, out NativeBinaryIdentity identity)
|
||||
{
|
||||
identity = default!;
|
||||
if (span.Length < 0x40)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (span[0] != 'M' || span[1] != 'Z')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var peHeaderOffset = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(0x3C, 4));
|
||||
if (peHeaderOffset < 0 || peHeaderOffset + 6 > span.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (peHeaderOffset + 6 > span.Length)
|
||||
{
|
||||
identity = new NativeBinaryIdentity(NativeFormat.Unknown, null, null, null, null, null, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (span[peHeaderOffset] != 'P' || span[peHeaderOffset + 1] != 'E' || span[peHeaderOffset + 2] != 0 || span[peHeaderOffset + 3] != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var machine = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(peHeaderOffset + 4, 2));
|
||||
var arch = MapPeMachine(machine);
|
||||
|
||||
identity = new NativeBinaryIdentity(NativeFormat.Pe, arch, "windows", Endianness: "le", BuildId: null, Uuid: null, InterpreterPath: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsMachO(ReadOnlySpan<byte> span, Stream stream, out NativeBinaryIdentity identity)
|
||||
{
|
||||
identity = default!;
|
||||
if (span.Length < 12)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var magic = BinaryPrimitives.ReadUInt32BigEndian(span);
|
||||
var isFat = magic is 0xCAFEBABE or 0xBEBAFECA;
|
||||
var is64 = magic is 0xFEEDFACF or 0xCFFAEDFE;
|
||||
var is32 = magic is 0xFEEDFACE or 0xCEFAEDFE;
|
||||
|
||||
if (!(isFat || is64 || is32))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool bigEndian = magic is 0xCAFEBABE or 0xFEEDFACE or 0xFEEDFACF;
|
||||
|
||||
uint cputype;
|
||||
if (isFat)
|
||||
{
|
||||
var cputypeOffset = 8; // first architecture entry
|
||||
if (span.Length < cputypeOffset + 4)
|
||||
{
|
||||
identity = new NativeBinaryIdentity(NativeFormat.Unknown, null, null, null, null, null, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
cputype = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(span.Slice(cputypeOffset, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(cputypeOffset, 4));
|
||||
}
|
||||
else
|
||||
{
|
||||
cputype = bigEndian
|
||||
? BinaryPrimitives.ReadUInt32BigEndian(span.Slice(4, 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(4, 4));
|
||||
}
|
||||
|
||||
var arch = MapMachCpuType(cputype);
|
||||
var endianness = bigEndian ? "be" : "le";
|
||||
|
||||
var uuid = ExtractMachUuid(stream, bigEndian);
|
||||
|
||||
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",
|
||||
0x08 => "mips",
|
||||
0x14 => "powerpc",
|
||||
0x28 => "arm",
|
||||
0x32 => "ia64",
|
||||
0x3E => "x86_64",
|
||||
0xB7 => "aarch64",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? MapElfOs(byte abi) => abi switch
|
||||
{
|
||||
0x00 => "linux",
|
||||
0x03 => "linux",
|
||||
0x06 => "solaris",
|
||||
0x07 => "aix",
|
||||
0x08 => "irix",
|
||||
0x09 => "freebsd",
|
||||
0x0C => "openbsd",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? MapPeMachine(ushort machine) => machine switch
|
||||
{
|
||||
0x014c => "x86",
|
||||
0x0200 => "ia64",
|
||||
0x8664 => "x86_64",
|
||||
0x01c0 => "arm",
|
||||
0x01c4 => "armv7",
|
||||
0xAA64 => "arm64",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? MapMachCpuType(uint cpuType) => cpuType switch
|
||||
{
|
||||
0x00000007 => "x86",
|
||||
0x01000007 => "x86_64",
|
||||
0x0000000C => "arm",
|
||||
0x0100000C => "arm64",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static void ParseElfNote(ReadOnlySpan<byte> note, bool bigEndian, ref string? buildId)
|
||||
{
|
||||
if (note.Length < 12)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment) => (value + (alignment - 1)) & ~(alignment - 1);
|
||||
}
|
||||
#pragma warning restore CA2022
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<NoWarn>CA2022</NoWarn>
|
||||
<WarningsNotAsErrors>CA2022</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||
|
||||
internal sealed record NodeVersionTarget(
|
||||
string Kind,
|
||||
string Version,
|
||||
string Locator,
|
||||
string? Sha256);
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Deno;
|
||||
|
||||
public sealed class DenoPolicySignalEmitterTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmitsSignalsFromMetadata()
|
||||
{
|
||||
var metadata = new DenoRuntimeTraceMetadata(
|
||||
EventCount: 5,
|
||||
ModuleLoads: 2,
|
||||
PermissionUses: 3,
|
||||
RemoteOrigins: new[] { "https://deno.land", "https://esm.sh" },
|
||||
UniquePermissions: new[] { "env", "fs" },
|
||||
NpmResolutions: 4,
|
||||
WasmLoads: 1,
|
||||
DynamicImports: 2);
|
||||
|
||||
var signals = DenoPolicySignalEmitter.FromTrace("abc123", metadata);
|
||||
|
||||
Assert.Equal("abc123", signals["surface.lang.deno.runtime.hash"]);
|
||||
Assert.Equal("env,fs", signals["surface.lang.deno.permissions"]);
|
||||
Assert.Equal("https://deno.land,https://esm.sh", signals["surface.lang.deno.remote_origins"]);
|
||||
Assert.Equal("4", signals["surface.lang.deno.npm_modules"]);
|
||||
Assert.Equal("1", signals["surface.lang.deno.wasm_modules"]);
|
||||
Assert.Equal("2", signals["surface.lang.deno.dynamic_imports"]);
|
||||
Assert.Equal("2", signals["surface.lang.deno.module_loads"]);
|
||||
Assert.Equal("3", signals["surface.lang.deno.permission_uses"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Deno;
|
||||
|
||||
public sealed class DenoRuntimePathHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProducesNormalizedRelativePathAndStableHash()
|
||||
{
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
try
|
||||
{
|
||||
var absolute = Path.Combine(root, "subdir", "main.ts");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(absolute)!);
|
||||
File.WriteAllText(absolute, "// sample");
|
||||
|
||||
var identity = DenoRuntimePathHasher.Create(root, absolute);
|
||||
|
||||
Assert.Equal("subdir/main.ts", identity.Normalized);
|
||||
Assert.Equal("2d0ef79c25b433a216f41853e89d8e1e1e1ef0b0e77d12b37a7f4f7c2a25f635", identity.PathSha256);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsesDotForRootPath()
|
||||
{
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
try
|
||||
{
|
||||
var identity = DenoRuntimePathHasher.Create(root, root);
|
||||
Assert.Equal(".", identity.Normalized);
|
||||
Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", identity.PathSha256);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Deno;
|
||||
|
||||
public sealed class DenoRuntimeTraceRecorderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildsOrderedSnapshotAndHash()
|
||||
{
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
try
|
||||
{
|
||||
var recorder = new DenoRuntimeTraceRecorder(root);
|
||||
|
||||
recorder.AddPermissionUse(
|
||||
absoluteModulePath: Path.Combine(root, "c.ts"),
|
||||
permission: "NET",
|
||||
details: "fetch",
|
||||
timestamp: DateTimeOffset.Parse("2025-11-17T12:00:02Z"));
|
||||
|
||||
recorder.AddModuleLoad(
|
||||
absoluteModulePath: Path.Combine(root, "b.ts"),
|
||||
reason: "dynamic-import",
|
||||
permissions: new[] { "fs" },
|
||||
origin: null,
|
||||
timestamp: DateTimeOffset.Parse("2025-11-17T12:00:01Z"));
|
||||
|
||||
recorder.AddModuleLoad(
|
||||
absoluteModulePath: Path.Combine(root, "a.ts"),
|
||||
reason: "static-import",
|
||||
permissions: Array.Empty<string>(),
|
||||
origin: "https://deno.land/x/std",
|
||||
timestamp: DateTimeOffset.Parse("2025-11-17T12:00:00Z"));
|
||||
|
||||
var snapshot = recorder.Build();
|
||||
|
||||
// Ensure ordering by timestamp then type
|
||||
var ndjson = System.Text.Encoding.UTF8.GetString(snapshot.Content);
|
||||
var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.StartsWith("{\"type\":\"deno.module.load\",\"ts\":\"2025-11-17T12:00:00+00:00\"", lines[0]);
|
||||
Assert.StartsWith("{\"type\":\"deno.module.load\",\"ts\":\"2025-11-17T12:00:01+00:00\"", lines[1]);
|
||||
Assert.StartsWith("{\"type\":\"deno.permission.use\",\"ts\":\"2025-11-17T12:00:02+00:00\"", lines[2]);
|
||||
|
||||
Assert.Equal(3, snapshot.Metadata.EventCount);
|
||||
Assert.Equal(2, snapshot.Metadata.ModuleLoads);
|
||||
Assert.Equal(1, snapshot.Metadata.PermissionUses);
|
||||
Assert.Equal(new[] { "https://deno.land/x/std" }, snapshot.Metadata.RemoteOrigins);
|
||||
Assert.Equal(new[] { "net" }, snapshot.Metadata.UniquePermissions);
|
||||
Assert.Equal(0, snapshot.Metadata.NpmResolutions);
|
||||
Assert.Equal(0, snapshot.Metadata.WasmLoads);
|
||||
Assert.Equal(1, snapshot.Metadata.DynamicImports);
|
||||
|
||||
// Stable hash check
|
||||
Assert.Equal("198c6e038f1c39a78a52b844f051bfa6eaa5312faa66f1bc73d2f6d1048d8a7a", snapshot.Sha256);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Deno;
|
||||
|
||||
public sealed class DenoRuntimeTraceSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProducesDeterministicNdjsonAndMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var events = new DenoRuntimeEvent[]
|
||||
{
|
||||
new DenoModuleLoadEvent(
|
||||
Ts: DateTimeOffset.Parse("2025-11-17T12:00:00.123Z"),
|
||||
Module: new DenoModuleIdentity("app/main.ts", "abc123"),
|
||||
Reason: "dynamic-import",
|
||||
Permissions: new[] {"fs", "net"},
|
||||
Origin: "https://deno.land/x/std@0.208.0/http/server.ts"),
|
||||
new DenoPermissionUseEvent(
|
||||
Ts: DateTimeOffset.Parse("2025-11-17T12:00:01.234Z"),
|
||||
Permission: "ffi",
|
||||
Module: new DenoModuleIdentity("native/mod.ts", "def456"),
|
||||
Details: "Deno.dlopen")
|
||||
};
|
||||
|
||||
// Act
|
||||
var (content, hash, metadata) = DenoRuntimeTraceSerializer.Serialize(events);
|
||||
|
||||
// Assert
|
||||
var text = Encoding.UTF8.GetString(content);
|
||||
|
||||
Assert.Equal(2, metadata.EventCount);
|
||||
Assert.Equal(1, metadata.ModuleLoads);
|
||||
Assert.Equal(1, metadata.PermissionUses);
|
||||
Assert.Equal(new[] { "https://deno.land/x/std@0.208.0/http/server.ts" }, metadata.RemoteOrigins);
|
||||
Assert.Equal(new[] { "ffi", "fs", "net" }, metadata.UniquePermissions);
|
||||
Assert.Equal(0, metadata.NpmResolutions);
|
||||
Assert.Equal(0, metadata.WasmLoads);
|
||||
Assert.Equal(1, metadata.DynamicImports);
|
||||
|
||||
// Stable hash and NDJSON ordering
|
||||
const string expectedNdjson =
|
||||
@"{\""type\"":\"\"deno.module.load\"",\""ts\"":\"\"2025-11-17T12:00:00.123+00:00\"",\""module\"":{\""normalized\"":\"\"app/main.ts\"",\""path_sha256\"":\"\"abc123\""},\""reason\"":\"\"dynamic-import\"",\""permissions\"":[\"\"fs\"\", \""net\""],\""origin\"":\"\"https://deno.land/x/std@0.208.0/http/server.ts\""}
|
||||
{\""type\"":\"\"deno.permission.use\"",\""ts\"":\"\"2025-11-17T12:00:01.234+00:00\"",\""permission\"":\"\"ffi\"",\""module\"":{\""normalized\"":\"\"native/mod.ts\"",\""path_sha256\"":\"\"def456\""},\""details\"":\"\"Deno.dlopen\""}
|
||||
";
|
||||
|
||||
Assert.Equal(expectedNdjson.Replace("\r\n", "\n"), text.Replace("\r\n", "\n"));
|
||||
Assert.Equal("fdc6f07fe6b18b4cdd228c44b83e61d63063b7bd3422a2d3ab8000ac8420ceb0", hash);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||
@@ -36,12 +37,12 @@ public sealed class JavaLanguageAnalyzerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockfilesProduceDeclaredOnlyComponentsAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
public async Task LockfilesProduceDeclaredOnlyComponentsAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var jarPath = CreateSampleJar(root, "com.example", "runtime-only", "1.0.0");
|
||||
var lockPath = Path.Combine(root, "gradle.lockfile");
|
||||
@@ -63,18 +64,125 @@ public sealed class JavaLanguageAnalyzerTests
|
||||
Assert.True(ComponentHasMetadata(rootElement, "declared-only", "declaredOnly", "true"));
|
||||
Assert.True(ComponentHasMetadata(rootElement, "declared-only", "lockSource", "gradle.lockfile"));
|
||||
Assert.True(ComponentHasMetadata(rootElement, "runtime-only", "lockMissing", "true"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ComponentHasMetadata(JsonElement root, string componentName, string key, string expected)
|
||||
{
|
||||
foreach (var element in root.EnumerateArray())
|
||||
{
|
||||
if (!element.TryGetProperty("name", out var nameElement) ||
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapturesFrameworkConfigurationHintsAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var jarPath = Path.Combine(root, "demo-framework.jar");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
|
||||
|
||||
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
|
||||
{
|
||||
WritePomProperties(archive, "com.example", "demo-framework", "1.0.0");
|
||||
WriteManifest(archive, "demo-framework", "1.0.0", "com.example");
|
||||
|
||||
CreateTextEntry(archive, "META-INF/spring.factories");
|
||||
CreateTextEntry(archive, "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports");
|
||||
CreateTextEntry(archive, "META-INF/spring/org.springframework.boot.actuate.autoconfigure.AutoConfiguration.imports");
|
||||
CreateTextEntry(archive, "BOOT-INF/classes/application.yml");
|
||||
CreateTextEntry(archive, "WEB-INF/web.xml");
|
||||
CreateTextEntry(archive, "META-INF/web-fragment.xml");
|
||||
CreateTextEntry(archive, "META-INF/persistence.xml");
|
||||
CreateTextEntry(archive, "META-INF/beans.xml");
|
||||
CreateTextEntry(archive, "META-INF/jaxb.index");
|
||||
CreateTextEntry(archive, "META-INF/services/jakarta.ws.rs.ext.RuntimeDelegate");
|
||||
CreateTextEntry(archive, "logback.xml");
|
||||
CreateTextEntry(archive, "META-INF/native-image/demo/reflect-config.json");
|
||||
}
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
root,
|
||||
analyzers,
|
||||
cancellationToken,
|
||||
new LanguageUsageHints(new[] { jarPath }));
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var component = document.RootElement
|
||||
.EnumerateArray()
|
||||
.First(element => string.Equals(element.GetProperty("name").GetString(), "demo-framework", StringComparison.Ordinal));
|
||||
|
||||
var metadata = component.GetProperty("metadata");
|
||||
Assert.Equal("demo-framework.jar!META-INF/spring.factories", metadata.GetProperty("config.spring.factories").GetString());
|
||||
Assert.Equal(
|
||||
"demo-framework.jar!META-INF/spring/org.springframework.boot.actuate.autoconfigure.AutoConfiguration.imports,demo-framework.jar!META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
|
||||
metadata.GetProperty("config.spring.imports").GetString());
|
||||
Assert.Equal("demo-framework.jar!BOOT-INF/classes/application.yml", metadata.GetProperty("config.spring.properties").GetString());
|
||||
Assert.Equal("demo-framework.jar!WEB-INF/web.xml", metadata.GetProperty("config.web.xml").GetString());
|
||||
Assert.Equal("demo-framework.jar!META-INF/web-fragment.xml", metadata.GetProperty("config.web.fragment").GetString());
|
||||
Assert.Equal("demo-framework.jar!META-INF/persistence.xml", metadata.GetProperty("config.jpa").GetString());
|
||||
Assert.Equal("demo-framework.jar!META-INF/beans.xml", metadata.GetProperty("config.cdi").GetString());
|
||||
Assert.Equal("demo-framework.jar!META-INF/jaxb.index", metadata.GetProperty("config.jaxb").GetString());
|
||||
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());
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapturesJniHintsAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var jarPath = Path.Combine(root, "demo-jni.jar");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
|
||||
|
||||
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
|
||||
{
|
||||
WritePomProperties(archive, "com.example", "demo-jni", "1.0.0");
|
||||
WriteManifest(archive, "demo-jni", "1.0.0", "com.example");
|
||||
|
||||
CreateBinaryEntry(archive, "com/example/App.class", "System.loadLibrary(\"foo\")");
|
||||
CreateTextEntry(archive, "lib/native/libfoo.so");
|
||||
CreateTextEntry(archive, "META-INF/native-image/demo/jni-config.json");
|
||||
}
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
root,
|
||||
analyzers,
|
||||
cancellationToken,
|
||||
new LanguageUsageHints(new[] { jarPath }));
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var component = document.RootElement
|
||||
.EnumerateArray()
|
||||
.First(element => string.Equals(element.GetProperty("name").GetString(), "demo-jni", StringComparison.Ordinal));
|
||||
|
||||
var metadata = component.GetProperty("metadata");
|
||||
Assert.Equal("libfoo.so", metadata.GetProperty("jni.nativeLibs").GetString());
|
||||
Assert.Equal("demo-jni.jar!META-INF/native-image/demo/jni-config.json", metadata.GetProperty("jni.graalConfig").GetString());
|
||||
Assert.Equal("demo-jni.jar!com/example/App.class", metadata.GetProperty("jni.loadCalls").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ComponentHasMetadata(JsonElement root, string componentName, string key, string expected)
|
||||
{
|
||||
foreach (var element in root.EnumerateArray())
|
||||
{
|
||||
if (!element.TryGetProperty("name", out var nameElement) ||
|
||||
!string.Equals(nameElement.GetString(), componentName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
@@ -96,13 +204,53 @@ public sealed class JavaLanguageAnalyzerTests
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string CreateSampleJar(string root, string groupId, string artifactId, string version)
|
||||
{
|
||||
var jarPath = Path.Combine(root, $"{artifactId}-{version}.jar");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void WritePomProperties(ZipArchive archive, string groupId, string artifactId, string version)
|
||||
{
|
||||
var pomPropertiesPath = $"META-INF/maven/{groupId}/{artifactId}/pom.properties";
|
||||
var pomPropertiesEntry = archive.CreateEntry(pomPropertiesPath);
|
||||
using var writer = new StreamWriter(pomPropertiesEntry.Open(), Encoding.UTF8);
|
||||
writer.WriteLine($"groupId={groupId}");
|
||||
writer.WriteLine($"artifactId={artifactId}");
|
||||
writer.WriteLine($"version={version}");
|
||||
writer.WriteLine("packaging=jar");
|
||||
writer.WriteLine("name=Sample");
|
||||
}
|
||||
|
||||
private static void WriteManifest(ZipArchive archive, string artifactId, string version, string groupId)
|
||||
{
|
||||
var manifestEntry = archive.CreateEntry("META-INF/MANIFEST.MF");
|
||||
using var writer = new StreamWriter(manifestEntry.Open(), Encoding.UTF8);
|
||||
writer.WriteLine("Manifest-Version: 1.0");
|
||||
writer.WriteLine($"Implementation-Title: {artifactId}");
|
||||
writer.WriteLine($"Implementation-Version: {version}");
|
||||
writer.WriteLine($"Implementation-Vendor: {groupId}");
|
||||
}
|
||||
|
||||
private static void CreateTextEntry(ZipArchive archive, string path, string? content = null)
|
||||
{
|
||||
var entry = archive.CreateEntry(path);
|
||||
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
writer.Write(content);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateBinaryEntry(ZipArchive archive, string path, string content)
|
||||
{
|
||||
var entry = archive.CreateEntry(path);
|
||||
using var stream = entry.Open();
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
private static string CreateSampleJar(string root, string groupId, string artifactId, string version)
|
||||
{
|
||||
var jarPath = Path.Combine(root, $"{artifactId}-{version}.jar");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
|
||||
|
||||
using var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create);
|
||||
var pomPropertiesPath = $"META-INF/maven/{groupId}/{artifactId}/pom.properties";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "node",
|
||||
"componentKey": "purl::pkg:npm/tar-demo@1.2.3",
|
||||
"purl": "pkg:npm/tar-demo@1.2.3",
|
||||
"name": "tar-demo",
|
||||
"version": "1.2.3",
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"installScripts": "true",
|
||||
"path": "tgz",
|
||||
"policyHint.installLifecycle": "install",
|
||||
"script.install": "echo install"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "package.json",
|
||||
"locator": "tgz/tar-demo.tgz!package/package.json",
|
||||
"sha256": "dd27b49de19040a8b5738d4ad0d17ef2041e5ac8a6c5300dbace9be8fcf3ed67"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "package.json:scripts",
|
||||
"locator": "tgz/tar-demo.tgz!package/package.json#scripts.install",
|
||||
"value": "echo install"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
18.17.1
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM node:18.17.1-alpine
|
||||
CMD ["node", "index.js"]
|
||||
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "node",
|
||||
"componentKey": "purl::pkg:npm/version-targets@1.0.0",
|
||||
"purl": "pkg:npm/version-targets@1.0.0",
|
||||
"name": "version-targets",
|
||||
"version": "1.0.0",
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"nodeVersion": "18.17.1;18.17.1-alpine",
|
||||
"nodeVersionSource.dockerfile": "18.17.1-alpine",
|
||||
"nodeVersionSource.nvmrc": "18.17.1",
|
||||
"path": "."
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "node-version:dockerfile",
|
||||
"locator": "Dockerfile",
|
||||
"value": "18.17.1-alpine",
|
||||
"sha256": "b38d145059ea1b7018105f769070f1d07276b30719ce20358f673bef9655bcdf"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "node-version:nvmrc",
|
||||
"locator": ".nvmrc",
|
||||
"value": "18.17.1",
|
||||
"sha256": "cbc986933feddabb31649808506d635bb5d74667ba2da9aafc46ffe706ec745b"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "package.json",
|
||||
"locator": "package.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "node",
|
||||
"componentKey": "purl::pkg:npm/tar-demo@1.2.3",
|
||||
"purl": "pkg:npm/tar-demo@1.2.3",
|
||||
"name": "tar-demo",
|
||||
"version": "1.2.3",
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"installScripts": "true",
|
||||
"path": "tgz",
|
||||
"policyHint.installLifecycle": "install",
|
||||
"script.install": "echo install"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "package.json",
|
||||
"locator": "tgz/tar-demo.tgz!package/package.json",
|
||||
"sha256": "dd27b49de19040a8b5738d4ad0d17ef2041e5ac8a6c5300dbace9be8fcf3ed67"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "package.json:scripts",
|
||||
"locator": "tgz/tar-demo.tgz!package/package.json#scripts.install",
|
||||
"value": "echo install"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "version-targets",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -7,11 +7,11 @@ namespace StellaOps.Scanner.Analyzers.Lang.Node.Tests;
|
||||
public sealed class NodeLanguageAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WorkspaceFixtureProducesDeterministicOutputAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "node", "workspaces");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
public async Task WorkspaceFixtureProducesDeterministicOutputAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "node", "workspaces");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
@@ -20,8 +20,46 @@ public sealed class NodeLanguageAnalyzerTests
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VersionTargetsAreCapturedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "node", "version-targets");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new NodeLanguageAnalyzer()
|
||||
};
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TarballPackageIsParsedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "node", "version-targets");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new NodeLanguageAnalyzer()
|
||||
};
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Native;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
|
||||
public class NativeFormatDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void DetectsElf64LittleEndian()
|
||||
{
|
||||
var bytes = new byte[64];
|
||||
bytes[0] = 0x7F; bytes[1] = (byte)'E'; bytes[2] = (byte)'L'; bytes[3] = (byte)'F';
|
||||
bytes[4] = 0x02; // 64-bit
|
||||
bytes[5] = 0x01; // little endian
|
||||
bytes[7] = 0x00; // System V / Linux
|
||||
bytes[18] = 0x3E; // e_machine low byte (x86_64)
|
||||
bytes[19] = 0x00;
|
||||
|
||||
using var stream = new MemoryStream(bytes);
|
||||
var detected = NativeFormatDetector.TryDetect(stream, out var id);
|
||||
|
||||
Assert.True(detected);
|
||||
Assert.Equal(NativeFormat.Elf, id.Format);
|
||||
Assert.Equal("x86_64", id.CpuArchitecture);
|
||||
Assert.Equal("linux", id.OperatingSystem);
|
||||
Assert.Equal("le", id.Endianness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectsElfInterpreterAndBuildId()
|
||||
{
|
||||
// Minimal ELF64 with two program headers: PT_INTERP and PT_NOTE (GNU build-id)
|
||||
var buffer = new byte[512];
|
||||
|
||||
// ELF header
|
||||
buffer[0] = 0x7F; buffer[1] = (byte)'E'; buffer[2] = (byte)'L'; buffer[3] = (byte)'F';
|
||||
buffer[4] = 0x02; // 64-bit
|
||||
buffer[5] = 0x01; // little endian
|
||||
buffer[7] = 0x00; // System V
|
||||
buffer[18] = 0x3E; buffer[19] = 0x00; // x86_64
|
||||
|
||||
// e_phoff (offset 32) = 0x40
|
||||
BitConverter.GetBytes((ulong)0x40).CopyTo(buffer, 32);
|
||||
// e_phentsize (offset 54) = 56, e_phnum (56) = 2
|
||||
BitConverter.GetBytes((ushort)56).CopyTo(buffer, 54);
|
||||
BitConverter.GetBytes((ushort)2).CopyTo(buffer, 56);
|
||||
|
||||
// Program header 0: PT_INTERP
|
||||
var ph0 = 0x40;
|
||||
BitConverter.GetBytes((uint)3).CopyTo(buffer, ph0); // p_type
|
||||
BitConverter.GetBytes((uint)0).CopyTo(buffer, ph0 + 4); // p_flags
|
||||
BitConverter.GetBytes((ulong)0x100).CopyTo(buffer, ph0 + 8); // p_offset
|
||||
BitConverter.GetBytes((ulong)0).CopyTo(buffer, ph0 + 16); // p_vaddr
|
||||
BitConverter.GetBytes((ulong)0).CopyTo(buffer, ph0 + 24); // p_paddr
|
||||
BitConverter.GetBytes((ulong)0x18).CopyTo(buffer, ph0 + 32); // p_filesz
|
||||
BitConverter.GetBytes((ulong)0x18).CopyTo(buffer, ph0 + 40); // p_memsz
|
||||
BitConverter.GetBytes((ulong)0).CopyTo(buffer, ph0 + 48); // p_align
|
||||
|
||||
// Program header 1: PT_NOTE
|
||||
var ph1 = ph0 + 56;
|
||||
BitConverter.GetBytes((uint)4).CopyTo(buffer, ph1); // p_type
|
||||
BitConverter.GetBytes((uint)0).CopyTo(buffer, ph1 + 4); // p_flags
|
||||
BitConverter.GetBytes((ulong)0x120).CopyTo(buffer, ph1 + 8); // p_offset
|
||||
BitConverter.GetBytes((ulong)0).CopyTo(buffer, ph1 + 16); // p_vaddr
|
||||
BitConverter.GetBytes((ulong)0).CopyTo(buffer, ph1 + 24); // p_paddr
|
||||
BitConverter.GetBytes((ulong)0x20).CopyTo(buffer, ph1 + 32); // p_filesz
|
||||
BitConverter.GetBytes((ulong)0x20).CopyTo(buffer, ph1 + 40); // p_memsz
|
||||
BitConverter.GetBytes((ulong)0).CopyTo(buffer, ph1 + 48); // p_align
|
||||
|
||||
// PT_INTERP data
|
||||
var interpBytes = System.Text.Encoding.ASCII.GetBytes("/lib64/ld-linux-x86-64.so.2\0");
|
||||
Array.Copy(interpBytes, 0, buffer, 0x100, interpBytes.Length);
|
||||
|
||||
// PT_NOTE data (GNU build-id type 3)
|
||||
var note = new byte[0x20];
|
||||
BitConverter.GetBytes((uint)4).CopyTo(note, 0); // namesz
|
||||
BitConverter.GetBytes((uint)16).CopyTo(note, 4); // descsz
|
||||
BitConverter.GetBytes((uint)3).CopyTo(note, 8); // type
|
||||
Array.Copy(System.Text.Encoding.ASCII.GetBytes("GNU\0"), 0, note, 12, 4);
|
||||
var buildId = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray();
|
||||
Array.Copy(buildId, 0, note, 16, 16);
|
||||
Array.Copy(note, 0, buffer, 0x120, note.Length);
|
||||
|
||||
using var stream = new MemoryStream(buffer);
|
||||
var detected = NativeFormatDetector.TryDetect(stream, out var id);
|
||||
|
||||
Assert.True(detected);
|
||||
Assert.Equal(NativeFormat.Elf, id.Format);
|
||||
Assert.Equal("x86_64", id.CpuArchitecture);
|
||||
Assert.Equal("/lib64/ld-linux-x86-64.so.2", id.InterpreterPath);
|
||||
Assert.Equal("0102030405060708090a0b0c0d0e0f10", id.BuildId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectsPe()
|
||||
{
|
||||
var bytes = new byte[256];
|
||||
bytes[0] = (byte)'M'; bytes[1] = (byte)'Z';
|
||||
var peOffset = 0x80;
|
||||
BitConverter.GetBytes(peOffset).CopyTo(bytes, 0x3C);
|
||||
bytes[peOffset] = (byte)'P';
|
||||
bytes[peOffset + 1] = (byte)'E';
|
||||
bytes[peOffset + 2] = 0; bytes[peOffset + 3] = 0;
|
||||
bytes[peOffset + 4] = 0x64; // machine 0x8664 little-endian
|
||||
bytes[peOffset + 5] = 0x86;
|
||||
|
||||
using var stream = new MemoryStream(bytes);
|
||||
var detected = NativeFormatDetector.TryDetect(stream, out var id);
|
||||
|
||||
Assert.True(detected);
|
||||
Assert.Equal(NativeFormat.Pe, id.Format);
|
||||
Assert.Equal("x86_64", id.CpuArchitecture);
|
||||
Assert.Equal("windows", id.OperatingSystem);
|
||||
Assert.Equal("le", id.Endianness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectsMachO64()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
// 0xFEEDFACF (little-endian 64-bit)
|
||||
bytes[0] = 0xFE; bytes[1] = 0xED; bytes[2] = 0xFA; bytes[3] = 0xCF;
|
||||
// cputype 0x01000007 (x86_64) big endian ordering for this magic
|
||||
bytes[4] = 0x01; bytes[5] = 0x00; bytes[6] = 0x00; bytes[7] = 0x07;
|
||||
|
||||
using var stream = new MemoryStream(bytes);
|
||||
var detected = NativeFormatDetector.TryDetect(stream, out var id);
|
||||
|
||||
Assert.True(detected);
|
||||
Assert.Equal(NativeFormat.MachO, id.Format);
|
||||
Assert.Equal("x86_64", id.CpuArchitecture);
|
||||
Assert.Equal("darwin", id.OperatingSystem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractsMachOUuid()
|
||||
{
|
||||
var buffer = new byte[128];
|
||||
// Mach-O 64 little endian magic 0xCFFAEDFE
|
||||
buffer[0] = 0xCF; buffer[1] = 0xFA; buffer[2] = 0xED; buffer[3] = 0xFE;
|
||||
// cputype (little endian path) write 0x01000007 at bytes 4-7
|
||||
buffer[4] = 0x07; buffer[5] = 0x00; buffer[6] = 0x00; buffer[7] = 0x01;
|
||||
// ncmds at offset 16 (little endian)
|
||||
BitConverter.GetBytes((uint)1).CopyTo(buffer, 16);
|
||||
// sizeofcmds at offset 20
|
||||
BitConverter.GetBytes((uint)32).CopyTo(buffer, 20);
|
||||
// load command starts at 32
|
||||
var cmdOffset = 32;
|
||||
BitConverter.GetBytes((uint)0x1B).CopyTo(buffer, cmdOffset); // LC_UUID
|
||||
BitConverter.GetBytes((uint)32).CopyTo(buffer, cmdOffset + 4); // cmdsize
|
||||
var uuid = Guid.NewGuid();
|
||||
uuid.ToByteArray().CopyTo(buffer, cmdOffset + 8);
|
||||
|
||||
using var stream = new MemoryStream(buffer);
|
||||
var detected = NativeFormatDetector.TryDetect(stream, out var id);
|
||||
|
||||
Assert.True(detected);
|
||||
Assert.Equal(NativeFormat.MachO, id.Format);
|
||||
Assert.Equal(uuid.ToString(), id.Uuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsUnknownForUnsupported()
|
||||
{
|
||||
var bytes = new byte[] { 0x00, 0x01, 0x02, 0x03 };
|
||||
using var stream = new MemoryStream(bytes);
|
||||
|
||||
var detected = NativeFormatDetector.TryDetect(stream, out var id);
|
||||
|
||||
Assert.False(detected);
|
||||
Assert.Equal(NativeFormat.Unknown, id.Format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user