save work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-*" />
|
||||
|
||||
7
src/Scanner/StellaOps.Scanner.Analyzers.Native/TASKS.md
Normal file
7
src/Scanner/StellaOps.Scanner.Analyzers.Native/TASKS.md
Normal 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 |
|
||||
Reference in New Issue
Block a user