save work

This commit is contained in:
StellaOps Bot
2025-12-19 09:40:41 +02:00
parent 2eafe98d44
commit 43882078a4
44 changed files with 3044 additions and 492 deletions

View File

@@ -1,7 +1,10 @@
using System.Collections.Frozen;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine;
namespace StellaOps.Scanner.Analyzers.Native.Index;
@@ -13,6 +16,7 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
{
private readonly BuildIdIndexOptions _options;
private readonly ILogger<OfflineBuildIdIndex> _logger;
private readonly IDsseSigningService? _dsseSigningService;
private FrozenDictionary<string, BuildIdLookupResult> _index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
private bool _isLoaded;
@@ -24,13 +28,17 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
/// <summary>
/// Creates a new offline Build-ID index.
/// </summary>
public OfflineBuildIdIndex(IOptions<BuildIdIndexOptions> options, ILogger<OfflineBuildIdIndex> logger)
public OfflineBuildIdIndex(
IOptions<BuildIdIndexOptions> options,
ILogger<OfflineBuildIdIndex> logger,
IDsseSigningService? dsseSigningService = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_logger = logger;
_dsseSigningService = dsseSigningService;
}
/// <inheritdoc />
@@ -99,7 +107,17 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
return;
}
// TODO: BID-006 - Verify DSSE signature if RequireSignature is true
if (_options.RequireSignature)
{
var verified = await VerifySignatureAsync(_options.IndexPath, cancellationToken).ConfigureAwait(false);
if (!verified)
{
_logger.LogError("Build-ID index signature verification failed; refusing to load index.");
_index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
_isLoaded = true;
return;
}
}
var entries = new Dictionary<string, BuildIdLookupResult>(StringComparer.OrdinalIgnoreCase);
var lineNumber = 0;
@@ -204,4 +222,195 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
}
private static bool IsHex(string s) => s.All(c => char.IsAsciiHexDigit(c));
private async Task<bool> VerifySignatureAsync(string indexPath, CancellationToken cancellationToken)
{
if (_dsseSigningService is null)
{
_logger.LogError("RequireSignature is enabled but no DSSE signing service is configured.");
return false;
}
var signaturePath = ResolveSignaturePath(indexPath);
if (string.IsNullOrWhiteSpace(signaturePath) || !File.Exists(signaturePath))
{
_logger.LogError("Build-ID index signature file not found at {SignaturePath}.", signaturePath);
return false;
}
var indexSha256 = ComputeSha256Hex(indexPath);
if (string.IsNullOrWhiteSpace(indexSha256))
{
_logger.LogError("Failed to compute SHA-256 for Build-ID index at {IndexPath}.", indexPath);
return false;
}
DsseEnvelope? envelope;
try
{
var json = await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false);
envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse Build-ID index signature file at {SignaturePath}.", signaturePath);
return false;
}
if (envelope is null)
{
_logger.LogError("Build-ID index signature file at {SignaturePath} did not contain a DSSE envelope.", signaturePath);
return false;
}
DsseVerificationOutcome outcome;
try
{
outcome = await _dsseSigningService.VerifyAsync(envelope, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "DSSE verification failed for Build-ID index signature file at {SignaturePath}.", signaturePath);
return false;
}
if (!outcome.IsValid)
{
_logger.LogError("DSSE signature invalid for Build-ID index: {FailureReason}", outcome.FailureReason ?? "unknown");
return false;
}
if (!outcome.IsTrusted)
{
_logger.LogError("DSSE signature was not trusted for Build-ID index: {FailureReason}", outcome.FailureReason ?? "dsse_untrusted");
return false;
}
if (!TryDecodeBase64(envelope.Payload, out var payloadBytes))
{
_logger.LogError("DSSE envelope payload is not valid base64 for Build-ID index signature file at {SignaturePath}.", signaturePath);
return false;
}
try
{
using var doc = JsonDocument.Parse(payloadBytes);
if (!TryExtractSha256(doc.RootElement, out var expectedSha256))
{
_logger.LogError("DSSE payload did not contain an index SHA-256 digest.");
return false;
}
var expectedHex = NormalizeSha256(expectedSha256);
if (string.IsNullOrWhiteSpace(expectedHex))
{
_logger.LogError("DSSE payload index SHA-256 digest was empty/invalid.");
return false;
}
if (!string.Equals(expectedHex, indexSha256, StringComparison.Ordinal))
{
_logger.LogError(
"Build-ID index SHA-256 mismatch (expected {Expected}, computed {Computed}).",
expectedHex,
indexSha256);
return false;
}
}
catch (JsonException ex)
{
_logger.LogError(ex, "DSSE payload is not valid JSON for Build-ID index signature.");
return false;
}
return true;
}
private string ResolveSignaturePath(string indexPath)
{
if (!string.IsNullOrWhiteSpace(_options.SignaturePath))
{
return _options.SignaturePath!;
}
return indexPath + ".dsse.json";
}
private static bool TryExtractSha256(JsonElement root, out string sha256)
{
sha256 = string.Empty;
if (TryGetString(root, out sha256, "IndexSha256", "indexSha256", "index_sha256"))
{
return true;
}
if (TryGetString(root, out sha256, "Digest", "digest", "sha256"))
{
return true;
}
return false;
}
private static bool TryGetString(JsonElement root, out string value, params string[] propertyNames)
{
foreach (var name in propertyNames)
{
if (root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String)
{
value = element.GetString() ?? string.Empty;
return !string.IsNullOrWhiteSpace(value);
}
}
value = string.Empty;
return false;
}
private static string NormalizeSha256(string value)
{
var trimmed = value.Trim();
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[7..];
}
return trimmed.ToLowerInvariant();
}
private static string ComputeSha256Hex(string filePath)
{
try
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(filePath);
var hash = sha256.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
catch
{
return string.Empty;
}
}
private static bool TryDecodeBase64(string? value, out byte[] bytes)
{
if (string.IsNullOrWhiteSpace(value))
{
bytes = Array.Empty<byte>();
return false;
}
try
{
bytes = Convert.FromBase64String(value);
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
}

View File

@@ -333,6 +333,29 @@ public static class MachOReader
stream.Position = currentPos;
}
continue;
case LC_DYLD_INFO:
case LC_DYLD_INFO_ONLY:
if (exports.Count == 0 && TryReadBytes(stream, cmdDataSize, out var dyldInfoBytes) && dyldInfoBytes.Length >= 40)
{
// dyld_info_command: export_off/export_size are the last two uint32 fields
var exportOff = ReadUInt32(dyldInfoBytes, 32, swapBytes);
var exportSize = ReadUInt32(dyldInfoBytes, 36, swapBytes);
TryParseExportsTrie(stream, startOffset, exportOff, exportSize, exports);
}
continue;
case LC_DYLD_EXPORTS_TRIE:
if (exports.Count == 0 && TryReadBytes(stream, cmdDataSize, out var exportsTrieBytes) && exportsTrieBytes.Length >= 8)
{
// linkedit_data_command: dataoff/datasize
var dataOff = ReadUInt32(exportsTrieBytes, 0, swapBytes);
var dataSize = ReadUInt32(exportsTrieBytes, 4, swapBytes);
TryParseExportsTrie(stream, startOffset, dataOff, dataSize, exports);
}
continue;
}
@@ -344,6 +367,16 @@ public static class MachOReader
}
}
IReadOnlyList<string> exportList = exports;
if (exports.Count > 0)
{
exportList = exports
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.Ordinal)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToList();
}
return new MachOIdentity(
cpuTypeName,
cpuSubtype,
@@ -353,7 +386,7 @@ public static class MachOReader
minOsVersion,
sdkVersion,
codeSignature,
exports);
exportList);
}
/// <summary>
@@ -452,7 +485,7 @@ public static class MachOReader
// CodeDirectory has a complex structure, we'll extract key fields
stream.Position = blobStart;
if (!TryReadBytes(stream, Math.Min(length, 52), out var cdBytes))
if (!TryReadBytes(stream, Math.Min(length, 56), out var cdBytes))
{
return (null, null, null, false);
}
@@ -550,6 +583,164 @@ public static class MachOReader
return keys;
}
private static void TryParseExportsTrie(Stream stream, long startOffset, uint dataOff, uint dataSize, List<string> exports)
{
const int MaxTrieSizeBytes = 16 * 1024 * 1024;
if (dataOff == 0 || dataSize == 0 || dataSize > MaxTrieSizeBytes)
{
return;
}
if (!stream.CanSeek)
{
return;
}
long endOffset;
try
{
endOffset = checked(startOffset + dataOff + dataSize);
}
catch (OverflowException)
{
return;
}
if (endOffset > stream.Length)
{
return;
}
var currentPos = stream.Position;
try
{
stream.Position = startOffset + dataOff;
if (!TryReadBytes(stream, (int)dataSize, out var trieBytes))
{
return;
}
exports.AddRange(ParseExportsTrie(trieBytes));
}
finally
{
stream.Position = currentPos;
}
}
private static IReadOnlyList<string> ParseExportsTrie(ReadOnlySpan<byte> trie)
{
const int MaxExports = 10_000;
var exports = new List<string>();
if (trie.IsEmpty)
{
return exports;
}
var visited = new HashSet<int>();
var stack = new Stack<(int Offset, string Prefix)>();
stack.Push((0, string.Empty));
while (stack.Count > 0 && exports.Count < MaxExports)
{
var (nodeOffset, prefix) = stack.Pop();
if (nodeOffset < 0 || nodeOffset >= trie.Length)
{
continue;
}
if (!visited.Add(nodeOffset))
{
continue;
}
var cursor = nodeOffset;
if (!TryReadUleb128(trie, ref cursor, out var terminalSize))
{
continue;
}
if (terminalSize > (ulong)(trie.Length - cursor))
{
continue;
}
if (terminalSize > 0 && !string.IsNullOrEmpty(prefix))
{
exports.Add(prefix);
}
cursor += (int)terminalSize;
if (cursor >= trie.Length)
{
continue;
}
var childCount = trie[cursor++];
for (var i = 0; i < childCount; i++)
{
if (cursor >= trie.Length)
{
break;
}
// Edge string is null-terminated
var remaining = trie[cursor..];
var terminator = remaining.IndexOf((byte)0);
if (terminator < 0)
{
break;
}
var edge = Encoding.UTF8.GetString(remaining[..terminator]);
cursor += terminator + 1;
if (!TryReadUleb128(trie, ref cursor, out var childOffsetUleb))
{
break;
}
if (childOffsetUleb > int.MaxValue)
{
continue;
}
var childOffset = (int)childOffsetUleb;
var nextPrefix = string.IsNullOrEmpty(prefix) ? edge : prefix + edge;
stack.Push((childOffset, nextPrefix));
}
}
exports.Sort(StringComparer.Ordinal);
return exports;
}
private static bool TryReadUleb128(ReadOnlySpan<byte> data, ref int offset, out ulong value)
{
value = 0;
var shift = 0;
while (offset < data.Length && shift <= 63)
{
var b = data[offset++];
value |= (ulong)(b & 0x7Fu) << shift;
if ((b & 0x80) == 0)
{
return true;
}
shift += 7;
}
value = 0;
return false;
}
/// <summary>
/// Get CPU type name from CPU type value.
/// </summary>

View File

@@ -13,6 +13,10 @@
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Native.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-*" />

View File

@@ -0,0 +1,7 @@
# Scanner Native Analyzer Tasks
| Task ID | Sprint | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- | --- |
| BID-3500-0011 | `docs/implplan/SPRINT_3500_0011_0001_buildid_mapping_index.md` | DONE | Offline Build-ID→PURL index (NDJSON) with DSSE verification + SHA-256 binding; test evidence under `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Index/`. | 2025-12-19 |
| PE-3500-0010-0001 | `docs/implplan/SPRINT_3500_0010_0001_pe_full_parser.md` | DONE | Completed golden fixtures (MSVC/MinGW/Clang) via `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Fixtures/PeBuilder.cs` and added positive parsing tests in `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/PeReaderTests.cs`. | 2025-12-19 |
| MACH-3500-0010-0002 | `docs/implplan/SPRINT_3500_0010_0002_macho_full_parser.md` | DONE | Implemented export trie parsing (LC_DYLD_INFO(_ONLY)/LC_DYLD_EXPORTS_TRIE) + added signed/unsigned fixtures and tests in `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/MachOReaderTests.cs`. | 2025-12-19 |