feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies.
- Documented roles and guidelines in AGENTS.md for Scheduler module.
- Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs.
- Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics.
- Developed API endpoints for managing resolver jobs and retrieving metrics.
- Defined models for resolver job requests and responses.
- Integrated dependency injection for resolver job services.
- Implemented ImpactIndexSnapshot for persisting impact index data.
- Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring.
- Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService.
- Created dotnet-filter.sh script to handle command-line arguments for dotnet.
- Established nuget-prime project for managing package downloads.
This commit is contained in:
master
2025-11-18 07:52:15 +02:00
parent e69b57d467
commit 8355e2ff75
299 changed files with 13293 additions and 2444 deletions

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace StellaOps.Findings.Ledger.Domain;
public sealed record EvidenceReference(
Guid EventId,
string EvidenceBundleRef,
DateTimeOffset RecordedAt)
{
[JsonIgnore]
public string EvidenceBundleRefNormalized => EvidenceBundleRef.Trim();
}

View File

@@ -18,7 +18,8 @@ public sealed record LedgerEventDraft(
DateTimeOffset RecordedAt,
JsonObject Payload,
JsonObject CanonicalEnvelope,
string? ProvidedPreviousHash);
string? ProvidedPreviousHash,
string? EvidenceBundleReference = null);
public sealed record LedgerEventRecord(
string TenantId,
@@ -38,7 +39,8 @@ public sealed record LedgerEventRecord(
string EventHash,
string PreviousHash,
string MerkleLeafHash,
string CanonicalJson);
string CanonicalJson,
string? EvidenceBundleReference = null);
public sealed record LedgerChainHead(
long SequenceNumber,

View File

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

View File

@@ -42,6 +42,19 @@ public sealed class InMemoryLedgerEventRepository : ILedgerEventRepository
return Task.CompletedTask;
}
public Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
{
var matches = _events.Values
.Where(e => e.TenantId == tenantId
&& string.Equals(e.FindingId, findingId, StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(e.EvidenceBundleReference))
.OrderByDescending(e => e.RecordedAt)
.Select(e => new EvidenceReference(e.EventId, e.EvidenceBundleReference!, e.RecordedAt))
.ToList();
return Task.FromResult<IReadOnlyList<EvidenceReference>>(matches);
}
private static LedgerEventRecord Clone(LedgerEventRecord record)
{
var clonedBody = (JsonObject)record.EventBody.DeepClone();

View File

@@ -24,7 +24,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
event_body,
event_hash,
previous_hash,
merkle_leaf_hash
merkle_leaf_hash,
evidence_bundle_ref
FROM ledger_events
WHERE tenant_id = @tenant_id
AND event_id = @event_id
@@ -59,7 +60,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
event_body,
event_hash,
previous_hash,
merkle_leaf_hash)
merkle_leaf_hash,
evidence_bundle_ref)
VALUES (
@tenant_id,
@chain_id,
@@ -77,7 +79,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
@event_body,
@event_hash,
@previous_hash,
@merkle_leaf_hash)
@merkle_leaf_hash,
@evidence_bundle_ref)
""";
private readonly LedgerDataSource _dataSource;
@@ -162,6 +165,7 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
command.Parameters.AddWithValue("event_hash", record.EventHash);
command.Parameters.AddWithValue("previous_hash", record.PreviousHash);
command.Parameters.AddWithValue("merkle_leaf_hash", record.MerkleLeafHash);
command.Parameters.AddWithValue("evidence_bundle_ref", (object?)record.EvidenceBundleReference ?? DBNull.Value);
try
{
@@ -194,6 +198,7 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
var eventHash = reader.GetString(12);
var previousHash = reader.GetString(13);
var merkleLeafHash = reader.GetString(14);
var evidenceBundleRef = reader.IsDBNull(15) ? null : reader.GetString(15);
var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(eventBody);
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonicalEnvelope);
@@ -216,6 +221,37 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
eventHash,
previousHash,
merkleLeafHash,
canonicalJson);
canonicalJson,
evidenceBundleRef);
}
public async Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
{
const string sql = """
SELECT event_id, evidence_bundle_ref, recorded_at
FROM ledger_events
WHERE tenant_id = @tenant_id
AND finding_id = @finding_id
AND evidence_bundle_ref IS NOT NULL
ORDER BY recorded_at DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("finding_id", findingId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<EvidenceReference>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(new EvidenceReference(
reader.GetGuid(0),
reader.GetString(1),
reader.GetFieldValue<DateTimeOffset>(2)));
}
return results;
}
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -5,6 +6,7 @@ using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.Policy;
using StellaOps.Findings.Ledger.Options;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.Services;
namespace StellaOps.Findings.Ledger.Infrastructure.Projection;
@@ -74,9 +76,21 @@ public sealed class LedgerProjectionWorker : BackgroundService
foreach (var record in batch)
{
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["tenant"] = record.TenantId,
["chainId"] = record.ChainId,
["eventId"] = record.EventId,
["eventType"] = record.EventType,
["policyVersion"] = record.PolicyVersion
});
using var activity = LedgerTelemetry.StartProjectionApply(record);
var applyStopwatch = Stopwatch.StartNew();
string? evaluationStatus = null;
try
{
await ApplyAsync(record, stoppingToken).ConfigureAwait(false);
evaluationStatus = await ApplyAsync(record, stoppingToken).ConfigureAwait(false);
checkpoint = checkpoint with
{
@@ -86,13 +100,36 @@ public sealed class LedgerProjectionWorker : BackgroundService
};
await _repository.SaveCheckpointAsync(checkpoint, stoppingToken).ConfigureAwait(false);
_logger.LogInformation(
"Projected ledger event {EventId} for tenant {Tenant} chain {ChainId} seq {Sequence} finding {FindingId}.",
record.EventId,
record.TenantId,
record.ChainId,
record.SequenceNumber,
record.FindingId);
activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok);
applyStopwatch.Stop();
var now = _timeProvider.GetUtcNow();
var lagSeconds = Math.Max(0, (now - record.RecordedAt).TotalSeconds);
LedgerMetrics.RecordProjectionApply(
applyStopwatch.Elapsed,
lagSeconds,
record.TenantId,
record.EventType,
record.PolicyVersion,
evaluationStatus ?? string.Empty);
LedgerTimeline.EmitProjectionUpdated(_logger, record, evaluationStatus, evidenceBundleRef: null);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
LedgerTelemetry.MarkError(activity, "projection_cancelled");
return;
}
catch (Exception ex)
{
LedgerTelemetry.MarkError(activity, "projection_failed");
_logger.LogError(ex, "Failed to project ledger event {EventId} for tenant {TenantId}.", record.EventId, record.TenantId);
await DelayAsync(stoppingToken).ConfigureAwait(false);
break;
@@ -101,7 +138,7 @@ public sealed class LedgerProjectionWorker : BackgroundService
}
}
private async Task ApplyAsync(LedgerEventRecord record, CancellationToken cancellationToken)
private async Task<string?> ApplyAsync(LedgerEventRecord record, CancellationToken cancellationToken)
{
var current = await _repository.GetAsync(record.TenantId, record.FindingId, record.PolicyVersion, cancellationToken).ConfigureAwait(false);
var evaluation = await _policyEvaluationService.EvaluateAsync(record, current, cancellationToken).ConfigureAwait(false);
@@ -114,6 +151,8 @@ public sealed class LedgerProjectionWorker : BackgroundService
{
await _repository.InsertActionAsync(result.Action, cancellationToken).ConfigureAwait(false);
}
return evaluation.Status;
}
private async Task DelayAsync(CancellationToken cancellationToken)

View File

@@ -15,6 +15,20 @@ internal static class LedgerMetrics
"ledger_events_total",
description: "Number of ledger events appended.");
private static readonly Histogram<double> ProjectionApplySeconds = Meter.CreateHistogram<double>(
"ledger_projection_apply_seconds",
unit: "s",
description: "Duration to apply a ledger event to the finding projection.");
private static readonly Histogram<double> ProjectionLagSeconds = Meter.CreateHistogram<double>(
"ledger_projection_lag_seconds",
unit: "s",
description: "Lag between ledger recorded_at and projection application time.");
private static readonly Counter<long> ProjectionEventsTotal = Meter.CreateCounter<long>(
"ledger_projection_events_total",
description: "Number of ledger events applied to projections.");
public static void RecordWriteSuccess(TimeSpan duration, string? tenantId, string? eventType, string? source)
{
var tags = new TagList
@@ -27,4 +41,25 @@ internal static class LedgerMetrics
WriteLatencySeconds.Record(duration.TotalSeconds, tags);
EventsTotal.Add(1, tags);
}
public static void RecordProjectionApply(
TimeSpan duration,
double lagSeconds,
string? tenantId,
string? eventType,
string? policyVersion,
string? evaluationStatus)
{
var tags = new TagList
{
{ "tenant", tenantId ?? string.Empty },
{ "event_type", eventType ?? string.Empty },
{ "policy_version", policyVersion ?? string.Empty },
{ "evaluation_status", evaluationStatus ?? string.Empty }
};
ProjectionApplySeconds.Record(duration.TotalSeconds, tags);
ProjectionLagSeconds.Record(lagSeconds, tags);
ProjectionEventsTotal.Add(1, tags);
}
}

View File

@@ -0,0 +1,79 @@
using System.Diagnostics;
using StellaOps.Findings.Ledger.Domain;
namespace StellaOps.Findings.Ledger.Observability;
/// <summary>
/// Centralised ActivitySource and tagging helpers for ledger telemetry.
/// Keeps tags consistent across writer, projector, and query surfaces.
/// </summary>
internal static class LedgerTelemetry
{
internal const string ActivitySourceName = "StellaOps.Findings.Ledger";
private static readonly ActivitySource ActivitySource = new(ActivitySourceName);
public static Activity? StartLedgerAppend(LedgerEventDraft draft)
{
var activity = ActivitySource.StartActivity("Ledger.Append", ActivityKind.Internal);
if (activity is null)
{
return null;
}
activity.SetTag("tenant", draft.TenantId);
activity.SetTag("chain_id", draft.ChainId);
activity.SetTag("sequence", draft.SequenceNumber);
activity.SetTag("event_id", draft.EventId);
activity.SetTag("event_type", draft.EventType);
activity.SetTag("actor_id", draft.ActorId);
activity.SetTag("actor_type", draft.ActorType);
activity.SetTag("policy_version", draft.PolicyVersion);
activity.SetTag("source", draft.SourceRunId.HasValue ? "policy_run" : "workflow");
return activity;
}
public static void MarkAppendOutcome(Activity? activity, LedgerEventRecord record, TimeSpan duration)
{
if (activity is null)
{
return;
}
activity.SetTag("event_hash", record.EventHash);
activity.SetTag("previous_hash", record.PreviousHash);
activity.SetTag("merkle_leaf_hash", record.MerkleLeafHash);
activity.SetTag("duration_ms", duration.TotalMilliseconds);
activity.SetStatus(ActivityStatusCode.Ok);
}
public static void MarkError(Activity? activity, string reason)
{
if (activity is null)
{
return;
}
activity.SetStatus(ActivityStatusCode.Error, reason);
}
public static Activity? StartProjectionApply(LedgerEventRecord record)
{
var activity = ActivitySource.StartActivity("Ledger.Projection.Apply", ActivityKind.Internal);
if (activity is null)
{
return null;
}
activity.SetTag("tenant", record.TenantId);
activity.SetTag("chain_id", record.ChainId);
activity.SetTag("sequence", record.SequenceNumber);
activity.SetTag("event_id", record.EventId);
activity.SetTag("event_type", record.EventType);
activity.SetTag("policy_version", record.PolicyVersion);
activity.SetTag("finding_id", record.FindingId);
return activity;
}
}

View File

@@ -0,0 +1,65 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
namespace StellaOps.Findings.Ledger.Observability;
/// <summary>
/// Emits structured timeline events for ledger operations.
/// Currently materialised as structured logs; can be swapped for event sink later.
/// </summary>
internal static class LedgerTimeline
{
private static readonly EventId LedgerAppended = new(6101, "ledger.event.appended");
private static readonly EventId ProjectionUpdated = new(6201, "ledger.projection.updated");
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
{
if (logger is null)
{
return;
}
var traceId = Activity.Current?.TraceId.ToHexString() ?? string.Empty;
logger.LogInformation(
LedgerAppended,
"timeline ledger.event.appended tenant={Tenant} chain={ChainId} seq={Sequence} event={EventId} type={EventType} policy={PolicyVersion} finding={FindingId} trace={TraceId} evidence_ref={EvidenceRef}",
record.TenantId,
record.ChainId,
record.SequenceNumber,
record.EventId,
record.EventType,
record.PolicyVersion,
record.FindingId,
traceId,
evidenceBundleRef ?? record.EvidenceBundleReference ?? string.Empty);
}
public static void EmitProjectionUpdated(
ILogger logger,
LedgerEventRecord record,
string? evaluationStatus,
string? evidenceBundleRef = null)
{
if (logger is null)
{
return;
}
var traceId = Activity.Current?.TraceId.ToHexString() ?? string.Empty;
logger.LogInformation(
ProjectionUpdated,
"timeline ledger.projection.updated tenant={Tenant} chain={ChainId} seq={Sequence} event={EventId} policy={PolicyVersion} finding={FindingId} status={Status} trace={TraceId} evidence_ref={EvidenceRef}",
record.TenantId,
record.ChainId,
record.SequenceNumber,
record.EventId,
record.PolicyVersion,
record.FindingId,
evaluationStatus ?? string.Empty,
traceId,
evidenceBundleRef ?? record.EvidenceBundleReference ?? string.Empty);
}
}

View File

@@ -32,10 +32,21 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
public async Task<LedgerWriteResult> AppendAsync(LedgerEventDraft draft, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
using var activity = LedgerTelemetry.StartLedgerAppend(draft);
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["tenant"] = draft.TenantId,
["chainId"] = draft.ChainId,
["sequence"] = draft.SequenceNumber,
["eventId"] = draft.EventId,
["eventType"] = draft.EventType,
["policyVersion"] = draft.PolicyVersion
});
var validationErrors = ValidateDraft(draft);
if (validationErrors.Count > 0)
{
LedgerTelemetry.MarkError(activity, "validation_failed");
return LedgerWriteResult.ValidationFailed([.. validationErrors]);
}
@@ -45,6 +56,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(draft.CanonicalEnvelope);
if (!string.Equals(existing.CanonicalJson, canonicalJson, StringComparison.Ordinal))
{
LedgerTelemetry.MarkError(activity, "event_id_conflict");
return LedgerWriteResult.Conflict(
"event_id_conflict",
$"Event '{draft.EventId}' already exists with a different payload.");
@@ -58,6 +70,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
var expectedSequence = chainHead is null ? 1 : chainHead.SequenceNumber + 1;
if (draft.SequenceNumber != expectedSequence)
{
LedgerTelemetry.MarkError(activity, "sequence_mismatch");
return LedgerWriteResult.Conflict(
"sequence_mismatch",
$"Sequence number '{draft.SequenceNumber}' does not match expected '{expectedSequence}'.");
@@ -66,6 +79,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
if (draft.ProvidedPreviousHash is not null && !string.Equals(draft.ProvidedPreviousHash, previousHash, StringComparison.OrdinalIgnoreCase))
{
LedgerTelemetry.MarkError(activity, "previous_hash_mismatch");
return LedgerWriteResult.Conflict(
"previous_hash_mismatch",
$"Provided previous hash '{draft.ProvidedPreviousHash}' does not match chain head hash '{previousHash}'.");
@@ -93,7 +107,8 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
hashResult.EventHash,
previousHash,
hashResult.MerkleLeafHash,
hashResult.CanonicalJson);
hashResult.CanonicalJson,
draft.EvidenceBundleReference);
try
{
@@ -102,10 +117,29 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
stopwatch.Stop();
LedgerMetrics.RecordWriteSuccess(stopwatch.Elapsed, draft.TenantId, draft.EventType, DetermineSource(draft));
LedgerTelemetry.MarkAppendOutcome(activity, record, stopwatch.Elapsed);
_logger.LogInformation(
"Ledger append committed for tenant {Tenant} chain {ChainId} seq {Sequence} event {EventId} ({EventType}) hash {Hash} prev {PrevHash}.",
record.TenantId,
record.ChainId,
record.SequenceNumber,
record.EventId,
record.EventType,
record.EventHash,
record.PreviousHash);
LedgerTimeline.EmitLedgerAppended(_logger, record, evidenceBundleRef: null);
}
catch (Exception ex) when (IsDuplicateKeyException(ex))
{
_logger.LogWarning(ex, "Ledger append detected concurrent duplicate for {EventId}", draft.EventId);
LedgerTelemetry.MarkError(activity, "duplicate_event");
_logger.LogWarning(
ex,
"Ledger append detected concurrent duplicate for tenant {Tenant} chain {ChainId} seq {Sequence} event {EventId}.",
draft.TenantId,
draft.ChainId,
draft.SequenceNumber,
draft.EventId);
var persisted = await _repository.GetByEventIdAsync(draft.TenantId, draft.EventId, cancellationToken).ConfigureAwait(false);
if (persisted is null)
{

View File

@@ -0,0 +1,8 @@
-- LEDGER-OBS-53-001: persist evidence bundle references alongside ledger entries.
ALTER TABLE ledger_events
ADD COLUMN evidence_bundle_ref text NULL;
CREATE INDEX IF NOT EXISTS ix_ledger_events_finding_evidence_ref
ON ledger_events (tenant_id, finding_id, recorded_at DESC)
WHERE evidence_bundle_ref IS NOT NULL;