Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Evidence/FuncProofTransparencyService.cs
StellaOps Bot e411fde1a9 feat(audit): Apply TreatWarningsAsErrors=true to 160+ production csproj files
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
2026-01-04 11:21:16 +02:00

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