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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user