Stabilzie modules

This commit is contained in:
master
2026-02-16 07:32:38 +02:00
parent ab794e167c
commit 45c0f1bb59
45 changed files with 3055 additions and 156 deletions

View File

@@ -171,4 +171,6 @@ public sealed record FindingSummaryFilter
public string? Status { get; init; }
public string? Severity { get; init; }
public decimal? MinConfidence { get; init; }
public string? SortBy { get; init; }
public string SortDirection { get; init; } = "desc";
}

View File

@@ -47,7 +47,9 @@ public static class FindingSummaryEndpoints
[FromQuery] int pageSize = 50,
[FromQuery] string? status = null,
[FromQuery] string? severity = null,
[FromQuery] decimal? minConfidence = null) =>
[FromQuery] decimal? minConfidence = null,
[FromQuery] string? sortBy = null,
[FromQuery] string sortDirection = "desc") =>
{
var filter = new FindingSummaryFilter
{
@@ -55,7 +57,9 @@ public static class FindingSummaryEndpoints
PageSize = Math.Clamp(pageSize, 1, 100),
Status = status,
Severity = severity,
MinConfidence = minConfidence
MinConfidence = minConfidence,
SortBy = sortBy,
SortDirection = sortDirection
};
var result = await service.GetSummariesAsync(filter, ct);

View File

@@ -223,10 +223,16 @@ builder.Services.AddSingleton<VexConsensusService>();
// Finding summary, evidence graph, reachability, and runtime timeline endpoints
builder.Services.AddSingleton<IFindingSummaryBuilder, FindingSummaryBuilder>();
builder.Services.AddSingleton<IFindingRepository, InMemoryFindingRepository>();
builder.Services.AddSingleton<IFindingRepository, ProjectionBackedFindingRepository>();
builder.Services.AddSingleton<IFindingSummaryService, FindingSummaryService>();
builder.Services.AddSingleton<IEvidenceRepository, NullEvidenceRepository>();
builder.Services.AddSingleton<IAttestationVerifier, NullAttestationVerifier>();
builder.Services.AddSingleton<IEvidenceRepository, ProjectionBackedEvidenceRepository>();
builder.Services.AddHttpClient("rekor", client =>
{
var rekorUrl = builder.Configuration.GetValue<string>("findings:ledger:rekorUrl") ?? "https://rekor.sigstore.dev";
client.BaseAddress = new Uri(rekorUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<IAttestationVerifier, RekorAttestationVerifier>();
builder.Services.AddSingleton<IEvidenceGraphBuilder, EvidenceGraphBuilder>();
builder.Services.AddSingleton<IEvidenceContentService, NullEvidenceContentService>();
builder.Services.AddSingleton<IReachabilityMapService, NullReachabilityMapService>();

View File

@@ -41,15 +41,52 @@ public sealed class FindingSummaryService : IFindingSummaryService
ct);
var summaries = findings.Select(f => _builder.Build(f)).ToList();
var sorted = ApplySort(summaries, filter.SortBy, filter.SortDirection);
return new FindingSummaryPage
{
Items = summaries,
Items = sorted,
TotalCount = totalCount,
Page = filter.Page,
PageSize = filter.PageSize
};
}
private static IReadOnlyList<FindingSummary> ApplySort(
List<FindingSummary> summaries,
string? sortBy,
string sortDirection)
{
if (string.IsNullOrEmpty(sortBy))
return summaries;
var descending = string.Equals(sortDirection, "desc", StringComparison.OrdinalIgnoreCase);
IEnumerable<FindingSummary> ordered = sortBy.ToLowerInvariant() switch
{
"cvss" => descending
? summaries.OrderByDescending(s => s.CvssScore ?? 0m)
: summaries.OrderBy(s => s.CvssScore ?? 0m),
"severity" => descending
? summaries.OrderByDescending(s => s.Severity)
: summaries.OrderBy(s => s.Severity),
"status" => descending
? summaries.OrderByDescending(s => s.Status)
: summaries.OrderBy(s => s.Status),
"component" => descending
? summaries.OrderByDescending(s => s.Component)
: summaries.OrderBy(s => s.Component),
"firstseen" => descending
? summaries.OrderByDescending(s => s.FirstSeen)
: summaries.OrderBy(s => s.FirstSeen),
"lastupdated" => descending
? summaries.OrderByDescending(s => s.LastUpdated)
: summaries.OrderBy(s => s.LastUpdated),
_ => summaries
};
return ordered.ToList();
}
}
/// <summary>

View File

@@ -0,0 +1,196 @@
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
using StellaOps.Findings.Ledger.Services;
namespace StellaOps.Findings.Ledger.WebService.Services;
internal sealed class ProjectionBackedEvidenceRepository : IEvidenceRepository
{
private readonly IFindingProjectionRepository _projectionRepo;
private readonly AttestationPointerService _attestationPointerService;
private readonly ILedgerEventRepository _eventRepo;
private readonly IConfiguration _configuration;
private readonly ILogger<ProjectionBackedEvidenceRepository> _logger;
public ProjectionBackedEvidenceRepository(
IFindingProjectionRepository projectionRepo,
AttestationPointerService attestationPointerService,
ILedgerEventRepository eventRepo,
IConfiguration configuration,
ILogger<ProjectionBackedEvidenceRepository> logger)
{
_projectionRepo = projectionRepo ?? throw new ArgumentNullException(nameof(projectionRepo));
_attestationPointerService = attestationPointerService ?? throw new ArgumentNullException(nameof(attestationPointerService));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_configuration = configuration;
_logger = logger;
}
public async Task<FullEvidence?> GetFullEvidenceAsync(Guid findingId, CancellationToken ct)
{
var tenantId = _configuration.GetValue<string>("findings:ledger:defaultTenantId") ?? "default";
var policyVersion = _configuration.GetValue<string>("findings:ledger:defaultPolicyVersion") ?? "1.0.0";
var findingIdStr = findingId.ToString();
var projection = await _projectionRepo.GetAsync(tenantId, findingIdStr, policyVersion, ct);
if (projection is null)
{
_logger.LogDebug("No projection found for finding {FindingId}.", findingId);
return null;
}
// Get attestation pointers for provenance evidence
var pointers = await _attestationPointerService.GetPointersAsync(tenantId, findingIdStr, ct);
// Get evidence references from ledger events
var evidenceRefs = await _eventRepo.GetEvidenceReferencesAsync(tenantId, findingIdStr, ct);
// Extract vulnerability ID from projection labels or finding ID
var vulnId = GetLabelString(projection.Labels, "vulnId")
?? GetLabelString(projection.Labels, "vulnerability_id")
?? ExtractVulnIdFromFindingId(findingIdStr);
var now = DateTimeOffset.UtcNow;
// Build verdict evidence from projection
var verdict = new VerdictEvidence
{
Status = projection.Status,
Digest = projection.CycleHash,
Issuer = "stella-ops-ledger",
Timestamp = projection.UpdatedAt
};
// Build policy trace if policy rationale exists
PolicyTraceEvidence? policyTrace = null;
if (projection.PolicyRationale.Count > 0 || projection.ExplainRef is not null)
{
var policyPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.PolicyAttestation);
policyTrace = new PolicyTraceEvidence
{
PolicyName = projection.PolicyVersion,
PolicyVersion = projection.RiskProfileVersion ?? "1.0.0",
Digest = projection.CycleHash,
Issuer = "stella-ops-policy",
Timestamp = projection.UpdatedAt,
AttestationDigest = policyPointer?.AttestationRef.Digest
};
}
// Build VEX evidence from VEX attestation pointers
var vexStatements = pointers
.Where(p => p.AttestationType == AttestationType.VexAttestation)
.Select(p => new VexEvidence
{
Status = projection.Status,
Justification = GetLabelString(projection.Labels, "justification"),
Digest = p.AttestationRef.Digest,
Issuer = p.AttestationRef.SignerInfo?.Issuer ?? "unknown",
Timestamp = p.CreatedAt,
AttestationDigest = p.AttestationRef.Digest
})
.ToList();
// Build reachability evidence if available
ReachabilityEvidence? reachability = null;
var reachable = GetLabelBool(projection.Labels, "reachable");
if (reachable.HasValue)
{
var scanPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.ScanAttestation);
reachability = new ReachabilityEvidence
{
State = reachable.Value ? "reachable" : "unreachable",
Confidence = projection.RiskScore.HasValue ? Math.Clamp(projection.RiskScore.Value / 100m, 0m, 1m) : 0.5m,
Digest = scanPointer?.AttestationRef.Digest ?? projection.CycleHash,
Issuer = "stella-ops-scanner",
Timestamp = projection.UpdatedAt,
AttestationDigest = scanPointer?.AttestationRef.Digest
};
}
// Build provenance evidence from SLSA attestation pointers
ProvenanceEvidence? provenance = null;
var slsaPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.SlsaProvenance);
if (slsaPointer is not null)
{
provenance = new ProvenanceEvidence
{
BuilderType = slsaPointer.AttestationRef.PredicateType ?? "https://slsa.dev/provenance/v1",
RepoUrl = slsaPointer.Metadata?.GetValueOrDefault("repoUrl") as string,
Digest = slsaPointer.AttestationRef.Digest,
Issuer = slsaPointer.AttestationRef.SignerInfo?.Issuer ?? "unknown",
Timestamp = slsaPointer.CreatedAt,
AttestationDigest = slsaPointer.AttestationRef.Digest
};
}
// Build SBOM component evidence
SbomComponentEvidence? sbomComponent = null;
var sbomPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.SbomAttestation);
if (sbomPointer is not null)
{
var purl = GetLabelString(projection.Labels, "componentPurl")
?? GetLabelString(projection.Labels, "purl")
?? "pkg:unknown/unknown";
sbomComponent = new SbomComponentEvidence
{
ComponentName = ExtractComponentName(purl),
Purl = purl,
Version = GetLabelString(projection.Labels, "version") ?? "unknown",
Digest = sbomPointer.AttestationRef.Digest,
Issuer = sbomPointer.AttestationRef.SignerInfo?.Issuer ?? "unknown",
Timestamp = sbomPointer.CreatedAt
};
}
return new FullEvidence
{
VulnerabilityId = vulnId,
Verdict = verdict,
PolicyTrace = policyTrace,
VexStatements = vexStatements,
Reachability = reachability,
RuntimeObservations = Array.Empty<RuntimeEvidence>(),
SbomComponent = sbomComponent,
Provenance = provenance
};
}
private static string ExtractVulnIdFromFindingId(string findingId)
{
var parts = findingId.Split('|');
return parts.Length > 2 ? parts[2] : findingId;
}
private static string ExtractComponentName(string purl)
{
var parts = purl.Split('/');
var namePart = parts.LastOrDefault() ?? purl;
return namePart.Split('@').FirstOrDefault() ?? namePart;
}
private static string? GetLabelString(System.Text.Json.Nodes.JsonObject labels, string key)
{
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value
&& value.TryGetValue(out string? result))
{
return string.IsNullOrWhiteSpace(result) ? null : result;
}
return null;
}
private static bool? GetLabelBool(System.Text.Json.Nodes.JsonObject labels, string key)
{
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value)
{
if (value.TryGetValue(out bool boolResult))
return boolResult;
if (value.TryGetValue(out string? strResult))
return bool.TryParse(strResult, out var parsed) ? parsed : null;
}
return null;
}
}

View File

@@ -0,0 +1,181 @@
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
using StellaOps.Findings.Ledger.Services;
using StellaOps.Findings.Ledger.WebService.Contracts;
namespace StellaOps.Findings.Ledger.WebService.Services;
internal sealed class ProjectionBackedFindingRepository : IFindingRepository
{
private readonly IFindingProjectionRepository _projectionRepo;
private readonly string _defaultTenantId;
private readonly string _defaultPolicyVersion;
public ProjectionBackedFindingRepository(
IFindingProjectionRepository projectionRepo,
IConfiguration configuration)
{
_projectionRepo = projectionRepo ?? throw new ArgumentNullException(nameof(projectionRepo));
_defaultTenantId = configuration.GetValue<string>("findings:ledger:defaultTenantId") ?? "default";
_defaultPolicyVersion = configuration.GetValue<string>("findings:ledger:defaultPolicyVersion") ?? "1.0.0";
}
public async Task<FindingData?> GetByIdAsync(Guid id, CancellationToken ct)
{
var findingId = id.ToString();
var projection = await _projectionRepo.GetAsync(_defaultTenantId, findingId, _defaultPolicyVersion, ct);
if (projection is null)
return null;
return MapToFindingData(id, projection);
}
public async Task<(IReadOnlyList<FindingData> findings, int totalCount)> GetPagedAsync(
int page,
int pageSize,
string? status,
string? severity,
decimal? minConfidence,
CancellationToken ct)
{
var statuses = status is not null ? new[] { status } : null;
var severities = severity is not null ? new[] { severity } : null;
var query = new ScoredFindingsQuery
{
TenantId = _defaultTenantId,
PolicyVersion = _defaultPolicyVersion,
Statuses = statuses,
Severities = severities,
MinScore = minConfidence.HasValue ? minConfidence.Value * 100m : null,
Limit = pageSize,
Cursor = page > 1 ? ((page - 1) * pageSize).ToString() : null,
SortBy = ScoredFindingsSortField.UpdatedAt,
Descending = true
};
var (projections, totalCount) = await _projectionRepo.QueryScoredAsync(query, ct);
var findings = projections.Select(p =>
{
Guid.TryParse(p.FindingId, out var id);
return MapToFindingData(id, p);
}).ToList();
return (findings, totalCount);
}
private static FindingData MapToFindingData(Guid id, FindingProjection projection)
{
var labels = projection.Labels;
var vulnerabilityId = GetLabelString(labels, "vulnId")
?? GetLabelString(labels, "vulnerability_id")
?? ExtractVulnIdFromFindingId(projection.FindingId);
var componentPurl = GetLabelString(labels, "componentPurl")
?? GetLabelString(labels, "component_purl")
?? GetLabelString(labels, "purl")
?? "pkg:unknown/unknown";
var title = GetLabelString(labels, "title")
?? GetLabelString(labels, "summary");
var isReachable = GetLabelBool(labels, "reachable");
var hasCallGraph = GetLabelBool(labels, "hasCallGraph") ?? false;
var hasRuntimeEvidence = GetLabelBool(labels, "hasRuntimeEvidence") ?? false;
var runtimeConfirmed = GetLabelBool(labels, "runtimeConfirmed") ?? false;
var hasPolicyEvaluation = projection.ExplainRef is not null
|| projection.PolicyRationale.Count > 0;
var policyPassed = string.Equals(projection.Status, "not_affected", StringComparison.OrdinalIgnoreCase)
|| string.Equals(projection.Status, "mitigated", StringComparison.OrdinalIgnoreCase);
var hasAttestation = projection.AttestationCount > 0;
var attestationVerified = projection.VerifiedAttestationCount > 0;
var isAffected = projection.Status switch
{
"affected" => (bool?)true,
"not_affected" => false,
_ => null
};
var isMitigated = string.Equals(projection.Status, "mitigated", StringComparison.OrdinalIgnoreCase)
|| string.Equals(projection.Status, "accepted_risk", StringComparison.OrdinalIgnoreCase);
var mitigationReason = GetLabelString(labels, "mitigationReason")
?? GetLabelString(labels, "justification");
var confidence = projection.RiskScore.HasValue
? Math.Clamp(projection.RiskScore.Value / 100m, 0m, 1m)
: 0.5m;
var cvssScore = projection.Severity;
var severityLabel = projection.RiskSeverity
?? DeriveServerity(cvssScore);
return new FindingData
{
Id = id,
VulnerabilityId = vulnerabilityId,
Title = title,
ComponentPurl = componentPurl,
IsAffected = isAffected,
IsMitigated = isMitigated,
MitigationReason = mitigationReason,
Confidence = confidence,
IsReachable = isReachable,
HasCallGraph = hasCallGraph,
HasRuntimeEvidence = hasRuntimeEvidence,
RuntimeConfirmed = runtimeConfirmed,
HasPolicyEvaluation = hasPolicyEvaluation,
PolicyPassed = policyPassed,
HasAttestation = hasAttestation,
AttestationVerified = attestationVerified,
CvssScore = cvssScore,
Severity = severityLabel,
FirstSeen = projection.UpdatedAt,
LastUpdated = projection.UpdatedAt
};
}
private static string ExtractVulnIdFromFindingId(string findingId)
{
var parts = findingId.Split('|');
return parts.Length > 2 ? parts[2] : findingId;
}
private static string? GetLabelString(System.Text.Json.Nodes.JsonObject labels, string key)
{
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value
&& value.TryGetValue(out string? result))
{
return string.IsNullOrWhiteSpace(result) ? null : result;
}
return null;
}
private static bool? GetLabelBool(System.Text.Json.Nodes.JsonObject labels, string key)
{
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value)
{
if (value.TryGetValue(out bool boolResult))
return boolResult;
if (value.TryGetValue(out string? strResult))
return bool.TryParse(strResult, out var parsed) ? parsed : null;
}
return null;
}
private static string? DeriveServerity(decimal? cvss)
{
if (!cvss.HasValue) return null;
return cvss.Value switch
{
>= 9.0m => "critical",
>= 7.0m => "high",
>= 4.0m => "medium",
>= 0.1m => "low",
_ => "informational"
};
}
}

View File

@@ -0,0 +1,179 @@
using Microsoft.Extensions.Logging;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Findings.Ledger.WebService.Services;
internal sealed class RekorAttestationVerifier : IAttestationVerifier
{
private readonly HttpClient _httpClient;
private readonly ILogger<RekorAttestationVerifier> _logger;
public RekorAttestationVerifier(
IHttpClientFactory httpClientFactory,
ILogger<RekorAttestationVerifier> logger)
{
_httpClient = httpClientFactory.CreateClient("rekor");
_logger = logger;
}
public async Task<AttestationVerificationResult> VerifyAsync(string digest, CancellationToken ct)
{
try
{
return await VerifyCoreAsync(digest, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex,
"Rekor transparency log unavailable for digest {Digest}; returning unverified result (offline-first fallback).",
digest);
return CreateUnverifiedResult();
}
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
{
_logger.LogWarning(ex,
"Rekor request timed out for digest {Digest}; returning unverified result.",
digest);
return CreateUnverifiedResult();
}
}
private async Task<AttestationVerificationResult> VerifyCoreAsync(string digest, CancellationToken ct)
{
// Query Rekor's search API for entries matching the digest
var searchPayload = new { hash = $"sha256:{digest}" };
var searchResponse = await _httpClient.PostAsJsonAsync(
"/api/v1/index/retrieve",
searchPayload,
ct).ConfigureAwait(false);
if (!searchResponse.IsSuccessStatusCode)
{
_logger.LogDebug("Rekor search returned {StatusCode} for digest {Digest}.",
searchResponse.StatusCode, digest);
return CreateUnverifiedResult();
}
var uuids = await searchResponse.Content.ReadFromJsonAsync<List<string>>(ct).ConfigureAwait(false);
if (uuids is null || uuids.Count == 0)
{
_logger.LogDebug("No Rekor entries found for digest {Digest}.", digest);
return new AttestationVerificationResult
{
IsValid = false,
SignerIdentity = null,
SignedAt = null,
KeyId = null,
RekorLogIndex = null
};
}
// Fetch the first matching log entry
var entryUuid = uuids[0];
var entryResponse = await _httpClient.GetAsync(
$"/api/v1/log/entries/{entryUuid}",
ct).ConfigureAwait(false);
if (!entryResponse.IsSuccessStatusCode)
{
_logger.LogDebug("Failed to fetch Rekor entry {Uuid}: {StatusCode}.",
entryUuid, entryResponse.StatusCode);
return CreateUnverifiedResult();
}
var entryJson = await entryResponse.Content.ReadFromJsonAsync<JsonObject>(ct).ConfigureAwait(false);
if (entryJson is null)
{
return CreateUnverifiedResult();
}
return ParseRekorEntry(entryJson, entryUuid);
}
private AttestationVerificationResult ParseRekorEntry(JsonObject entryJson, string entryUuid)
{
// Rekor entries are keyed by UUID
foreach (var entry in entryJson)
{
if (entry.Value is not JsonObject entryData)
continue;
var logIndex = entryData["logIndex"]?.GetValue<long>();
var integratedTime = entryData["integratedTime"]?.GetValue<long>();
var logId = entryData["logID"]?.GetValue<string>();
DateTimeOffset? signedAt = integratedTime.HasValue
? DateTimeOffset.FromUnixTimeSeconds(integratedTime.Value)
: null;
// Extract signer identity from the attestation body
string? signerIdentity = null;
string? keyId = null;
string? predicateType = null;
if (entryData["attestation"]?.AsObject() is { } attestation)
{
signerIdentity = attestation["signerIdentity"]?.GetValue<string>();
keyId = attestation["keyId"]?.GetValue<string>();
}
if (entryData["body"] is JsonValue bodyValue && bodyValue.TryGetValue(out string? bodyB64))
{
try
{
var bodyBytes = Convert.FromBase64String(bodyB64);
var bodyDoc = JsonDocument.Parse(bodyBytes);
var spec = bodyDoc.RootElement.GetProperty("spec");
if (spec.TryGetProperty("signature", out var sig)
&& sig.TryGetProperty("publicKey", out var pk)
&& pk.TryGetProperty("content", out var pkContent))
{
keyId ??= pkContent.GetString();
}
if (spec.TryGetProperty("data", out var data)
&& data.TryGetProperty("predicateType", out var pt))
{
predicateType = pt.GetString();
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse Rekor entry body for {Uuid}.", entryUuid);
}
}
// Verification: entry exists in the transparency log and has a valid integrated time
var isValid = logIndex.HasValue && integratedTime.HasValue;
return new AttestationVerificationResult
{
IsValid = isValid,
SignerIdentity = signerIdentity,
SignedAt = signedAt,
KeyId = keyId,
RekorLogIndex = logIndex,
RekorEntryId = entryUuid,
PredicateType = predicateType,
Scope = "finding"
};
}
return CreateUnverifiedResult();
}
private static AttestationVerificationResult CreateUnverifiedResult()
{
return new AttestationVerificationResult
{
IsValid = false,
SignerIdentity = null,
SignedAt = null,
KeyId = null,
RekorLogIndex = null
};
}
}

View File

@@ -13,4 +13,6 @@ public interface ILedgerEventRepository
Task AppendAsync(LedgerEventRecord record, CancellationToken cancellationToken);
Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken);
Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken);
}

View File

@@ -43,6 +43,17 @@ public sealed class InMemoryLedgerEventRepository : ILedgerEventRepository
return Task.CompletedTask;
}
public Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
{
if (_chains.TryGetValue((tenantId, chainId), out var list))
{
IReadOnlyList<LedgerEventRecord> result = list.Values.ToList();
return Task.FromResult(result);
}
return Task.FromResult<IReadOnlyList<LedgerEventRecord>>(Array.Empty<LedgerEventRecord>());
}
public Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
{
var matches = _events.Values

View File

@@ -226,6 +226,49 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
evidenceBundleRef);
}
public async Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
{
const string sql = """
SELECT chain_id,
sequence_no,
event_type,
policy_version,
finding_id,
artifact_id,
source_run_id,
actor_id,
actor_type,
occurred_at,
recorded_at,
event_body,
event_hash,
previous_hash,
merkle_leaf_hash,
evidence_bundle_ref,
event_id
FROM ledger_events
WHERE tenant_id = @tenant_id
AND chain_id = @chain_id
ORDER BY sequence_no ASC
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("chain_id", chainId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<LedgerEventRecord>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var eventId = reader.GetGuid(16);
results.Add(MapLedgerEventRecord(tenantId, eventId, reader));
}
return results;
}
public async Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
{
const string sql = """

View File

@@ -79,11 +79,12 @@ public sealed class LedgerProjectionWorker : BackgroundService
continue;
}
var orderedBatch = batch.OrderBy(r => r.SequenceNumber).ToList();
var batchStopwatch = Stopwatch.StartNew();
var batchTenant = batch[0].TenantId;
var batchTenant = orderedBatch[0].TenantId;
var batchFailed = false;
foreach (var record in batch)
foreach (var record in orderedBatch)
{
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{

View File

@@ -154,17 +154,50 @@ public sealed class DecisionService : IDecisionService
string alertId,
CancellationToken cancellationToken)
{
// Decision history would need to be fetched from projections
// or by querying events for the alert's chain.
// For now, return empty list as the full implementation requires
// additional repository support.
_logger.LogInformation(
"Getting decision history for alert {AlertId} in tenant {TenantId}",
alertId, tenantId);
// This would need to be implemented with a projection repository
// or by scanning ledger events for the alert's chain
return Array.Empty<DecisionEvent>();
var chainId = LedgerChainIdGenerator.FromTenantSubject(tenantId, alertId);
var events = await _repository.GetByChainIdAsync(tenantId, chainId, cancellationToken).ConfigureAwait(false);
var decisions = new List<DecisionEvent>();
foreach (var record in events.Where(e =>
string.Equals(e.EventType, LedgerEventConstants.EventFindingStatusChanged, StringComparison.Ordinal)))
{
var payload = record.EventBody;
decisions.Add(new DecisionEvent
{
Id = payload["decision_id"]?.GetValue<string>() ?? record.EventId.ToString("N"),
AlertId = alertId,
ArtifactId = payload["artifact_id"]?.GetValue<string>() ?? record.ArtifactId,
ActorId = record.ActorId,
Timestamp = record.OccurredAt,
DecisionStatus = payload["decision_status"]?.GetValue<string>() ?? "unknown",
ReasonCode = payload["reason_code"]?.GetValue<string>() ?? "unknown",
ReasonText = payload["reason_text"]?.GetValue<string>(),
EvidenceHashes = ExtractEvidenceHashes(payload),
ReplayToken = payload["replay_token"]?.GetValue<string>() ?? string.Empty,
PolicyContext = payload["policy_context"]?.GetValue<string>()
});
}
return decisions.OrderBy(d => d.Timestamp).ToList();
}
private static List<string> ExtractEvidenceHashes(JsonObject payload)
{
var hashes = new List<string>();
if (payload["evidence_hashes"] is JsonArray hashArray)
{
foreach (var item in hashArray)
{
var value = item?.GetValue<string>();
if (value is not null)
hashes.Add(value);
}
}
return hashes;
}
private static void ValidateDecision(DecisionEvent decision)

View File

@@ -196,6 +196,9 @@ public sealed class LedgerEventWriteServiceTests
public Task<LedgerChainHead?> GetChainHeadAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
=> Task.FromResult<LedgerChainHead?>(null);
public Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<LedgerEventRecord>>(Array.Empty<LedgerEventRecord>());
}
private sealed class CapturingMerkleScheduler : IMerkleAnchorScheduler