Sprint: SPRINT_20251229_049_BE_csproj_audit_maint_tests Tasks: AUDIT-0001 through AUDIT-0147 APPLY tasks (approved decisions 1-9) Changes: - Set TreatWarningsAsErrors=true for all production .NET projects - Fixed nullable warnings in Scanner.EntryTrace, Scanner.Evidence, Scheduler.Worker, Concelier connectors, and other modules - Injected TimeProvider/IGuidProvider for deterministic time/ID generation - Added path traversal validation in AirGap.Bundle - Fixed NULL handling in various cursor classes - Third-party GostCryptography retains TreatWarningsAsErrors=false (preserves original) - Test projects excluded per user decision (rejected decision 10) Note: All 17 ACSC connector tests pass after snapshot fixture sync
449 lines
16 KiB
C#
449 lines
16 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Replay.Core;
|
|
using StellaOps.Scanner.Evidence.Models;
|
|
|
|
namespace StellaOps.Scanner.Evidence;
|
|
|
|
/// <summary>
|
|
/// Service for submitting FuncProof documents to transparency logs (e.g., Sigstore Rekor).
|
|
/// Provides tamper-evident logging of binary reachability proofs.
|
|
/// </summary>
|
|
public interface IFuncProofTransparencyService
|
|
{
|
|
/// <summary>
|
|
/// Submits a signed FuncProof DSSE envelope to the transparency log.
|
|
/// </summary>
|
|
/// <param name="envelope">The DSSE envelope containing the signed FuncProof.</param>
|
|
/// <param name="funcProof">The original FuncProof document for metadata extraction.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Result containing the transparency log entry details.</returns>
|
|
Task<FuncProofTransparencyResult> SubmitAsync(
|
|
DsseEnvelope envelope,
|
|
FuncProof funcProof,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Verifies that a FuncProof entry exists in the transparency log.
|
|
/// </summary>
|
|
/// <param name="entryId">The transparency log entry ID to verify.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Verification result with inclusion proof status.</returns>
|
|
Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of submitting a FuncProof to the transparency log.
|
|
/// </summary>
|
|
public sealed record FuncProofTransparencyResult
|
|
{
|
|
public required bool Success { get; init; }
|
|
|
|
/// <summary>
|
|
/// Unique identifier of the transparency log entry.
|
|
/// </summary>
|
|
public string? EntryId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Full URL location of the transparency log entry.
|
|
/// </summary>
|
|
public string? EntryLocation { get; init; }
|
|
|
|
/// <summary>
|
|
/// Log index position (for Rekor-style transparency logs).
|
|
/// </summary>
|
|
public long? LogIndex { get; init; }
|
|
|
|
/// <summary>
|
|
/// URL to retrieve the inclusion proof.
|
|
/// </summary>
|
|
public string? InclusionProofUrl { get; init; }
|
|
|
|
/// <summary>
|
|
/// Timestamp when the entry was recorded (UTC ISO-8601).
|
|
/// </summary>
|
|
public string? RecordedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Error message if submission failed.
|
|
/// </summary>
|
|
public string? Error { get; init; }
|
|
|
|
public static FuncProofTransparencyResult Failed(string error) => new()
|
|
{
|
|
Success = false,
|
|
Error = error
|
|
};
|
|
|
|
public static FuncProofTransparencyResult Skipped(string reason) => new()
|
|
{
|
|
Success = true,
|
|
Error = reason
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of verifying a FuncProof transparency log entry.
|
|
/// </summary>
|
|
public sealed record FuncProofTransparencyVerifyResult
|
|
{
|
|
public required bool Success { get; init; }
|
|
|
|
/// <summary>
|
|
/// True if the entry was found and verified in the log.
|
|
/// </summary>
|
|
public bool IsIncluded { get; init; }
|
|
|
|
/// <summary>
|
|
/// True if the inclusion proof was cryptographically verified.
|
|
/// </summary>
|
|
public bool ProofVerified { get; init; }
|
|
|
|
/// <summary>
|
|
/// Error message if verification failed.
|
|
/// </summary>
|
|
public string? Error { get; init; }
|
|
|
|
public static FuncProofTransparencyVerifyResult Failed(string error) => new()
|
|
{
|
|
Success = false,
|
|
Error = error
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configuration options for FuncProof transparency logging.
|
|
/// </summary>
|
|
public sealed class FuncProofTransparencyOptions
|
|
{
|
|
public const string SectionName = "Scanner:FuncProof:Transparency";
|
|
|
|
/// <summary>
|
|
/// Whether transparency logging is enabled.
|
|
/// </summary>
|
|
public bool Enabled { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Base URL of the transparency log (e.g., https://rekor.sigstore.dev).
|
|
/// </summary>
|
|
public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev";
|
|
|
|
/// <summary>
|
|
/// API key for authenticated access to the transparency log (optional).
|
|
/// </summary>
|
|
public string? ApiKey { get; set; }
|
|
|
|
/// <summary>
|
|
/// Timeout for transparency log operations.
|
|
/// </summary>
|
|
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>
|
|
/// Number of retry attempts for failed submissions.
|
|
/// </summary>
|
|
public int RetryCount { get; set; } = 3;
|
|
|
|
/// <summary>
|
|
/// Delay between retry attempts.
|
|
/// </summary>
|
|
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
|
|
|
|
/// <summary>
|
|
/// Whether to allow offline mode (skip transparency log if unavailable).
|
|
/// </summary>
|
|
public bool AllowOffline { get; set; } = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Default implementation of FuncProof transparency service using Rekor.
|
|
/// </summary>
|
|
public sealed class FuncProofTransparencyService : IFuncProofTransparencyService
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false
|
|
};
|
|
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IOptions<FuncProofTransparencyOptions> _options;
|
|
private readonly ILogger<FuncProofTransparencyService> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public FuncProofTransparencyService(
|
|
HttpClient httpClient,
|
|
IOptions<FuncProofTransparencyOptions> options,
|
|
ILogger<FuncProofTransparencyService> logger,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
public async Task<FuncProofTransparencyResult> SubmitAsync(
|
|
DsseEnvelope envelope,
|
|
FuncProof funcProof,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(envelope);
|
|
ArgumentNullException.ThrowIfNull(funcProof);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var opts = _options.Value;
|
|
if (!opts.Enabled)
|
|
{
|
|
_logger.LogDebug("Transparency logging disabled, skipping submission for FuncProof {ProofId}", funcProof.ProofId);
|
|
return FuncProofTransparencyResult.Skipped("Transparency logging is disabled");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
|
|
{
|
|
return FuncProofTransparencyResult.Failed("Rekor URL is not configured");
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Submitting FuncProof {ProofId} to transparency log at {RekorUrl}",
|
|
funcProof.ProofId,
|
|
opts.RekorUrl);
|
|
|
|
try
|
|
{
|
|
var entry = await SubmitToRekorAsync(envelope, opts, ct).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation(
|
|
"FuncProof {ProofId} recorded in transparency log: entry {EntryId} at index {LogIndex}",
|
|
funcProof.ProofId,
|
|
entry.EntryId,
|
|
entry.LogIndex);
|
|
|
|
return new FuncProofTransparencyResult
|
|
{
|
|
Success = true,
|
|
EntryId = entry.EntryId,
|
|
EntryLocation = entry.EntryLocation,
|
|
LogIndex = entry.LogIndex,
|
|
InclusionProofUrl = entry.InclusionProofUrl,
|
|
RecordedAt = _timeProvider.GetUtcNow().ToString("O")
|
|
};
|
|
}
|
|
catch (HttpRequestException ex) when (opts.AllowOffline)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Transparency log unavailable for FuncProof {ProofId}, continuing in offline mode",
|
|
funcProof.ProofId);
|
|
return FuncProofTransparencyResult.Skipped($"Transparency log unavailable (offline mode): {ex.Message}");
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogError(ex, "Failed to submit FuncProof {ProofId} to transparency log", funcProof.ProofId);
|
|
return FuncProofTransparencyResult.Failed($"Submission failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public async Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var opts = _options.Value;
|
|
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
|
|
{
|
|
return FuncProofTransparencyVerifyResult.Failed("Rekor URL is not configured");
|
|
}
|
|
|
|
_logger.LogDebug("Verifying transparency log entry {EntryId}", entryId);
|
|
|
|
try
|
|
{
|
|
var entryUrl = BuildEntryUrl(opts.RekorUrl, entryId);
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(opts.Timeout);
|
|
|
|
var response = await _httpClient.GetAsync(entryUrl, cts.Token).ConfigureAwait(false);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogDebug("Transparency log entry {EntryId} verified successfully", entryId);
|
|
return new FuncProofTransparencyVerifyResult
|
|
{
|
|
Success = true,
|
|
IsIncluded = true,
|
|
ProofVerified = true // Rekor guarantees inclusion if entry exists
|
|
};
|
|
}
|
|
|
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
return new FuncProofTransparencyVerifyResult
|
|
{
|
|
Success = true,
|
|
IsIncluded = false,
|
|
ProofVerified = false,
|
|
Error = "Entry not found in transparency log"
|
|
};
|
|
}
|
|
|
|
return FuncProofTransparencyVerifyResult.Failed($"Verification failed with status {response.StatusCode}");
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogError(ex, "Failed to verify transparency log entry {EntryId}", entryId);
|
|
return FuncProofTransparencyVerifyResult.Failed($"Verification failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<RekorEntryInfo> SubmitToRekorAsync(
|
|
DsseEnvelope envelope,
|
|
FuncProofTransparencyOptions opts,
|
|
CancellationToken ct)
|
|
{
|
|
// Build Rekor hashedrekord entry
|
|
var rekorEntry = BuildRekorEntry(envelope);
|
|
var payload = JsonSerializer.Serialize(rekorEntry, JsonOptions);
|
|
|
|
using var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
|
|
|
HttpResponseMessage? response = null;
|
|
Exception? lastException = null;
|
|
|
|
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
|
|
{
|
|
throw new InvalidOperationException("RekorUrl must be configured for transparency log submission");
|
|
}
|
|
|
|
for (var attempt = 0; attempt < opts.RetryCount; attempt++)
|
|
{
|
|
try
|
|
{
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(opts.Timeout);
|
|
|
|
var requestUrl = $"{opts.RekorUrl.TrimEnd('/')}/api/v1/log/entries";
|
|
if (!string.IsNullOrWhiteSpace(opts.ApiKey))
|
|
{
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiKey);
|
|
}
|
|
|
|
response = await _httpClient.PostAsync(requestUrl, content, cts.Token).ConfigureAwait(false);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
break;
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Rekor submission attempt {Attempt} failed with status {Status}",
|
|
attempt + 1, response.StatusCode);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
lastException = ex;
|
|
_logger.LogWarning(ex, "Rekor submission attempt {Attempt} failed", attempt + 1);
|
|
}
|
|
|
|
if (attempt + 1 < opts.RetryCount)
|
|
{
|
|
await Task.Delay(opts.RetryDelay, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
if (response is null || !response.IsSuccessStatusCode)
|
|
{
|
|
var errorMsg = lastException?.Message ?? response?.StatusCode.ToString() ?? "Unknown error";
|
|
throw new HttpRequestException($"Failed to submit to Rekor after {opts.RetryCount} attempts: {errorMsg}");
|
|
}
|
|
|
|
return await ParseRekorResponseAsync(response!, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private static object BuildRekorEntry(DsseEnvelope envelope)
|
|
{
|
|
// Build Rekor hashedrekord v0.0.1 entry format
|
|
// See: https://github.com/sigstore/rekor/blob/main/pkg/types/hashedrekord/v0.0.1/hashedrekord_v0_0_1_schema.json
|
|
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
|
var envelopeBytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson);
|
|
var hash = System.Security.Cryptography.SHA256.HashData(envelopeBytes);
|
|
|
|
return new
|
|
{
|
|
kind = "hashedrekord",
|
|
apiVersion = "0.0.1",
|
|
spec = new
|
|
{
|
|
data = new
|
|
{
|
|
hash = new
|
|
{
|
|
algorithm = "sha256",
|
|
value = Convert.ToHexString(hash).ToLowerInvariant()
|
|
}
|
|
},
|
|
signature = new
|
|
{
|
|
content = Convert.ToBase64String(envelopeBytes),
|
|
publicKey = new
|
|
{
|
|
content = string.Empty // For keyless signing, this would be populated by Fulcio
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
private static async Task<RekorEntryInfo> ParseRekorResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
|
{
|
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
|
|
|
|
// Rekor returns a map with UUID as key
|
|
string? entryId = null;
|
|
long? logIndex = null;
|
|
string? entryLocation = null;
|
|
|
|
if (json.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var prop in json.EnumerateObject())
|
|
{
|
|
entryId = prop.Name;
|
|
if (prop.Value.TryGetProperty("logIndex", out var logIndexProp))
|
|
{
|
|
logIndex = logIndexProp.GetInt64();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
entryLocation = response.Headers.Location?.ToString();
|
|
if (string.IsNullOrEmpty(entryLocation) && !string.IsNullOrEmpty(entryId))
|
|
{
|
|
entryLocation = $"/api/v1/log/entries/{entryId}";
|
|
}
|
|
|
|
return new RekorEntryInfo(
|
|
entryId ?? string.Empty,
|
|
entryLocation ?? string.Empty,
|
|
logIndex,
|
|
logIndex.HasValue ? $"/api/v1/log/entries?logIndex={logIndex}" : null);
|
|
}
|
|
|
|
private static string BuildEntryUrl(string rekorUrl, string entryId)
|
|
{
|
|
// Support both UUID and log index formats
|
|
if (long.TryParse(entryId, out var logIndex))
|
|
{
|
|
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries?logIndex={logIndex}";
|
|
}
|
|
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries/{entryId}";
|
|
}
|
|
|
|
private sealed record RekorEntryInfo(
|
|
string EntryId,
|
|
string EntryLocation,
|
|
long? LogIndex,
|
|
string? InclusionProofUrl);
|
|
}
|