Stabilzie modules
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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?>
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user