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;
}
}
}