Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Publishes orchestrator events to the internal bus consumed by downstream services.
/// </summary>
internal interface IPlatformEventPublisher
{
/// <summary>
/// Publishes the supplied event envelope.
/// </summary>
Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Abstraction for creating Redis connections so publishers can be tested without real infrastructure.
/// </summary>
internal interface IRedisConnectionFactory
{
ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Coordinates generation and publication of scanner-related platform events.
/// </summary>
public interface IReportEventDispatcher
{
Task PublishAsync(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
HttpContext httpContext,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
public interface IScanCoordinator
{
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,80 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Utilities;
namespace StellaOps.Scanner.WebService.Services;
public sealed class InMemoryScanCoordinator : IScanCoordinator
{
private sealed record ScanEntry(ScanSnapshot Snapshot);
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
private readonly IScanProgressPublisher progressPublisher;
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.progressPublisher = progressPublisher ?? throw new ArgumentNullException(nameof(progressPublisher));
}
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(submission);
var normalizedTarget = submission.Target.Normalize();
var metadata = submission.Metadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var scanId = ScanIdGenerator.Create(normalizedTarget, submission.Force, submission.ClientRequestId, metadata);
var now = timeProvider.GetUtcNow();
var eventData = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["force"] = submission.Force,
};
foreach (var pair in metadata)
{
eventData[$"meta.{pair.Key}"] = pair.Value;
}
ScanEntry entry = scans.AddOrUpdate(
scanId.Value,
_ => new ScanEntry(new ScanSnapshot(
scanId,
normalizedTarget,
ScanStatus.Pending,
now,
now,
null)),
(_, existing) =>
{
if (submission.Force)
{
var snapshot = existing.Snapshot with
{
Status = ScanStatus.Pending,
UpdatedAt = now,
FailureReason = null
};
return new ScanEntry(snapshot);
}
return existing;
});
var created = entry.Snapshot.CreatedAt == now;
var state = entry.Snapshot.Status.ToString();
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
}
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
{
if (scans.TryGetValue(scanId.Value, out var entry))
{
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
}
return ValueTask.FromResult<ScanSnapshot?>(null);
}
}

View File

@@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// No-op fallback publisher used until queue adapters register a concrete implementation.
/// </summary>
internal sealed class NullPlatformEventPublisher : IPlatformEventPublisher
{
private readonly ILogger<NullPlatformEventPublisher> _logger;
public NullPlatformEventPublisher(ILogger<NullPlatformEventPublisher> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
{
if (@event is null)
{
throw new ArgumentNullException(nameof(@event));
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Suppressing publish for orchestrator event {EventKind} (tenant {Tenant}).", @event.Kind, @event.Tenant);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal static class PolicyDtoMapper
{
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
public static PolicyPreviewRequest ToDomain(PolicyPreviewRequestDto request)
{
ArgumentNullException.ThrowIfNull(request);
var findings = BuildFindings(request.Findings);
var baseline = BuildBaseline(request.Baseline);
var proposedPolicy = ToSnapshotContent(request.Policy);
return new PolicyPreviewRequest(
request.ImageDigest!.Trim(),
findings,
baseline,
SnapshotOverride: null,
ProposedPolicy: proposedPolicy);
}
public static PolicyPreviewResponseDto ToDto(PolicyPreviewResponse response)
{
ArgumentNullException.ThrowIfNull(response);
var diffs = response.Diffs.Select(ToDiffDto).ToImmutableArray();
var issues = response.Issues.Select(ToIssueDto).ToImmutableArray();
return new PolicyPreviewResponseDto
{
Success = response.Success,
PolicyDigest = response.PolicyDigest,
RevisionId = response.RevisionId,
Changed = response.ChangedCount,
Diffs = diffs,
Issues = issues
};
}
public static PolicyPreviewIssueDto ToIssueDto(PolicyIssue issue)
{
ArgumentNullException.ThrowIfNull(issue);
return new PolicyPreviewIssueDto
{
Code = issue.Code,
Message = issue.Message,
Severity = issue.Severity.ToString(),
Path = issue.Path
};
}
public static PolicyDocumentFormat ParsePolicyFormat(string? format)
=> string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)
? PolicyDocumentFormat.Json
: PolicyDocumentFormat.Yaml;
private static ImmutableArray<PolicyFinding> BuildFindings(IReadOnlyList<PolicyPreviewFindingDto>? findings)
{
if (findings is null || findings.Count == 0)
{
return ImmutableArray<PolicyFinding>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyFinding>(findings.Count);
foreach (var finding in findings)
{
if (finding is null)
{
continue;
}
var tags = finding.Tags is { Count: > 0 }
? finding.Tags.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim())
.ToImmutableArray()
: ImmutableArray<string>.Empty;
var severity = ParseSeverity(finding.Severity);
var candidate = PolicyFinding.Create(
finding.Id!.Trim(),
severity,
environment: Normalize(finding.Environment),
source: Normalize(finding.Source),
vendor: Normalize(finding.Vendor),
license: Normalize(finding.License),
image: Normalize(finding.Image),
repository: Normalize(finding.Repository),
package: Normalize(finding.Package),
purl: Normalize(finding.Purl),
cve: Normalize(finding.Cve),
path: Normalize(finding.Path),
layerDigest: Normalize(finding.LayerDigest),
tags: tags);
builder.Add(candidate);
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyVerdict> BuildBaseline(IReadOnlyList<PolicyPreviewVerdictDto>? baseline)
{
if (baseline is null || baseline.Count == 0)
{
return ImmutableArray<PolicyVerdict>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyVerdict>(baseline.Count);
foreach (var verdict in baseline)
{
if (verdict is null || string.IsNullOrWhiteSpace(verdict.FindingId))
{
continue;
}
var inputs = verdict.Inputs is { Count: > 0 }
? CreateImmutableDeterministicDictionary(verdict.Inputs)
: ImmutableDictionary<string, double>.Empty;
var status = ParseVerdictStatus(verdict.Status);
builder.Add(new PolicyVerdict(
verdict.FindingId!.Trim(),
status,
verdict.RuleName,
verdict.RuleAction,
verdict.Notes,
verdict.Score ?? 0,
verdict.ConfigVersion ?? PolicyScoringConfig.Default.Version,
inputs,
verdict.QuietedBy,
verdict.Quiet ?? false,
verdict.UnknownConfidence,
verdict.ConfidenceBand,
verdict.UnknownAgeDays,
verdict.SourceTrust,
verdict.Reachability));
}
return builder.ToImmutable();
}
private static PolicyPreviewDiffDto ToDiffDto(PolicyVerdictDiff diff)
{
ArgumentNullException.ThrowIfNull(diff);
return new PolicyPreviewDiffDto
{
FindingId = diff.Projected.FindingId,
Baseline = ToVerdictDto(diff.Baseline),
Projected = ToVerdictDto(diff.Projected),
Changed = diff.Changed
};
}
internal static PolicyPreviewVerdictDto ToVerdictDto(PolicyVerdict verdict)
{
ArgumentNullException.ThrowIfNull(verdict);
IReadOnlyDictionary<string, double>? inputs = null;
var verdictInputs = verdict.GetInputs();
if (verdictInputs.Count > 0)
{
inputs = CreateDeterministicInputs(verdictInputs);
}
var sourceTrust = verdict.SourceTrust;
if (string.IsNullOrWhiteSpace(sourceTrust))
{
sourceTrust = ExtractSuffix(verdictInputs, "trustWeight.");
}
var reachability = verdict.Reachability;
if (string.IsNullOrWhiteSpace(reachability))
{
reachability = ExtractSuffix(verdictInputs, "reachability.");
}
return new PolicyPreviewVerdictDto
{
FindingId = verdict.FindingId,
Status = verdict.Status.ToString(),
RuleName = verdict.RuleName,
RuleAction = verdict.RuleAction,
Notes = verdict.Notes,
Score = verdict.Score,
ConfigVersion = verdict.ConfigVersion,
Inputs = inputs,
QuietedBy = verdict.QuietedBy,
Quiet = verdict.Quiet,
UnknownConfidence = verdict.UnknownConfidence,
ConfidenceBand = verdict.ConfidenceBand,
UnknownAgeDays = verdict.UnknownAgeDays,
SourceTrust = sourceTrust,
Reachability = reachability
};
}
private static ImmutableDictionary<string, double> CreateImmutableDeterministicDictionary(IEnumerable<KeyValuePair<string, double>> inputs)
{
var sorted = CreateDeterministicInputs(inputs);
var builder = ImmutableDictionary.CreateBuilder<string, double>(OrdinalIgnoreCase);
foreach (var pair in sorted)
{
builder[pair.Key] = pair.Value;
}
return builder.ToImmutable();
}
private static IReadOnlyDictionary<string, double> CreateDeterministicInputs(IEnumerable<KeyValuePair<string, double>> inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
var dictionary = new SortedDictionary<string, double>(InputKeyComparer.Instance);
foreach (var pair in inputs)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
dictionary[key] = pair.Value;
}
return dictionary;
}
private sealed class InputKeyComparer : IComparer<string>
{
public static InputKeyComparer Instance { get; } = new();
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y))
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var px = GetPriority(x);
var py = GetPriority(y);
if (px != py)
{
return px.CompareTo(py);
}
return string.Compare(x, y, StringComparison.Ordinal);
}
private static int GetPriority(string key)
{
if (string.Equals(key, "reachabilityWeight", StringComparison.OrdinalIgnoreCase))
{
return 0;
}
if (string.Equals(key, "baseScore", StringComparison.OrdinalIgnoreCase))
{
return 1;
}
if (string.Equals(key, "severityWeight", StringComparison.OrdinalIgnoreCase))
{
return 2;
}
if (string.Equals(key, "trustWeight", StringComparison.OrdinalIgnoreCase))
{
return 3;
}
if (key.StartsWith("trustWeight.", StringComparison.OrdinalIgnoreCase))
{
return 4;
}
if (key.StartsWith("reachability.", StringComparison.OrdinalIgnoreCase))
{
return 5;
}
return 6;
}
}
private static PolicySnapshotContent? ToSnapshotContent(PolicyPreviewPolicyDto? policy)
{
if (policy is null || string.IsNullOrWhiteSpace(policy.Content))
{
return null;
}
var format = ParsePolicyFormat(policy.Format);
return new PolicySnapshotContent(
policy.Content,
format,
policy.Actor,
Source: null,
policy.Description);
}
private static PolicySeverity ParseSeverity(string? value)
{
if (Enum.TryParse<PolicySeverity>(value, true, out var severity))
{
return severity;
}
return PolicySeverity.Unknown;
}
private static PolicyVerdictStatus ParseVerdictStatus(string? value)
{
if (Enum.TryParse<PolicyVerdictStatus>(value, true, out var status))
{
return status;
}
return PolicyVerdictStatus.Pass;
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? ExtractSuffix(ImmutableDictionary<string, double> inputs, string prefix)
{
foreach (var key in inputs.Keys)
{
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && key.Length > prefix.Length)
{
return key.Substring(prefix.Length);
}
}
return null;
}
}

View File

@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Production Redis connection factory bridging to <see cref="ConnectionMultiplexer"/>.
/// </summary>
internal sealed class RedisConnectionFactory : IRedisConnectionFactory
{
public async ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var connectTask = ConnectionMultiplexer.ConnectAsync(options);
var connection = await connectTask.WaitAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Serialization;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAsyncDisposable
{
private readonly ScannerWebServiceOptions.EventsOptions _options;
private readonly ILogger<RedisPlatformEventPublisher> _logger;
private readonly IRedisConnectionFactory _connectionFactory;
private readonly TimeSpan _publishTimeout;
private readonly string _streamKey;
private readonly long? _maxStreamLength;
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private IConnectionMultiplexer? _connection;
private bool _disposed;
public RedisPlatformEventPublisher(
IOptions<ScannerWebServiceOptions> options,
IRedisConnectionFactory connectionFactory,
ILogger<RedisPlatformEventPublisher> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(connectionFactory);
_options = options.Value.Events ?? throw new InvalidOperationException("Events options are required when redis publisher is registered.");
if (!_options.Enabled)
{
throw new InvalidOperationException("RedisPlatformEventPublisher requires events emission to be enabled.");
}
if (!string.Equals(_options.Driver, "redis", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"RedisPlatformEventPublisher cannot be used with driver '{_options.Driver}'.");
}
_connectionFactory = connectionFactory;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_streamKey = string.IsNullOrWhiteSpace(_options.Stream) ? "stella.events" : _options.Stream;
_publishTimeout = TimeSpan.FromSeconds(_options.PublishTimeoutSeconds <= 0 ? 5 : _options.PublishTimeoutSeconds);
_maxStreamLength = _options.MaxStreamLength > 0 ? _options.MaxStreamLength : null;
}
public async Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(@event);
cancellationToken.ThrowIfCancellationRequested();
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var payload = OrchestratorEventSerializer.Serialize(@event);
var entries = new NameValueEntry[]
{
new("event", payload),
new("kind", @event.Kind),
new("tenant", @event.Tenant),
new("occurredAt", @event.OccurredAt.ToString("O")),
new("idempotencyKey", @event.IdempotencyKey)
};
int? maxLength = null;
if (_maxStreamLength.HasValue)
{
var clamped = Math.Min(_maxStreamLength.Value, int.MaxValue);
maxLength = (int)clamped;
}
var publishTask = maxLength.HasValue
? database.StreamAddAsync(_streamKey, entries, maxLength: maxLength, useApproximateMaxLength: true)
: database.StreamAddAsync(_streamKey, entries);
if (_publishTimeout > TimeSpan.Zero)
{
await publishTask.WaitAsync(_publishTimeout, cancellationToken).ConfigureAwait(false);
}
else
{
await publishTask.ConfigureAwait(false);
}
}
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_connection is not null && _connection.IsConnected)
{
return _connection.GetDatabase();
}
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is null || !_connection.IsConnected)
{
var config = ConfigurationOptions.Parse(_options.Dsn);
config.AbortOnConnectFail = false;
if (_options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName))
{
config.ClientName = clientName;
}
if (_options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl))
{
config.Ssl = ssl;
}
_connection = await _connectionFactory.ConnectAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Connected Redis platform event publisher to stream {Stream}.", _streamKey);
}
}
finally
{
_connectionGate.Release();
}
return _connection!.GetDatabase();
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
try
{
await _connection.CloseAsync();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error while closing Redis platform event publisher connection.");
}
_connection.Dispose();
}
_connectionGate.Dispose();
}
}

View File

@@ -0,0 +1,583 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class ReportEventDispatcher : IReportEventDispatcher
{
private const string DefaultTenant = "default";
private const string Source = "scanner.webservice";
private readonly IPlatformEventPublisher _publisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ReportEventDispatcher> _logger;
private readonly string[] _apiBaseSegments;
private readonly string _reportsSegment;
private readonly string _policySegment;
public ReportEventDispatcher(
IPlatformEventPublisher publisher,
IOptions<ScannerWebServiceOptions> options,
TimeProvider timeProvider,
ILogger<ReportEventDispatcher> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var apiOptions = options.Value.Api ?? new ScannerWebServiceOptions.ApiOptions();
_apiBaseSegments = SplitSegments(apiOptions.BasePath);
_reportsSegment = string.IsNullOrWhiteSpace(apiOptions.ReportsSegment)
? "reports"
: apiOptions.ReportsSegment.Trim('/');
_policySegment = string.IsNullOrWhiteSpace(apiOptions.PolicySegment)
? "policy"
: apiOptions.PolicySegment.Trim('/');
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishAsync(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
HttpContext httpContext,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(preview);
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(httpContext);
cancellationToken.ThrowIfCancellationRequested();
var now = _timeProvider.GetUtcNow();
var occurredAt = document.GeneratedAt == default ? now : document.GeneratedAt;
var tenant = ResolveTenant(httpContext);
var scope = BuildScope(request, document);
var attributes = BuildAttributes(document);
var links = BuildLinks(httpContext, document, envelope);
var correlationId = document.ReportId;
var (traceId, spanId) = ResolveTraceContext();
var reportEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerReportReady,
Version = 1,
Tenant = tenant,
OccurredAt = occurredAt,
RecordedAt = now,
Source = Source,
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerReportReady, tenant, document.ReportId),
CorrelationId = correlationId,
TraceId = traceId,
SpanId = spanId,
Scope = scope,
Attributes = attributes,
Payload = BuildReportReadyPayload(request, preview, document, envelope, links, correlationId)
};
await PublishSafelyAsync(reportEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
var scanCompletedEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerScanCompleted,
Version = 1,
Tenant = tenant,
OccurredAt = occurredAt,
RecordedAt = now,
Source = Source,
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerScanCompleted, tenant, correlationId),
CorrelationId = correlationId,
TraceId = traceId,
SpanId = spanId,
Scope = scope,
Attributes = attributes,
Payload = BuildScanCompletedPayload(request, preview, document, envelope, links, correlationId)
};
await PublishSafelyAsync(scanCompletedEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
}
private async Task PublishSafelyAsync(OrchestratorEvent @event, string reportId, CancellationToken cancellationToken)
{
try
{
await _publisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to publish orchestrator event {EventKind} for report {ReportId}.",
@event.Kind,
reportId);
}
}
private static string ResolveTenant(HttpContext context)
{
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenant))
{
return tenant.Trim();
}
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
{
var headerValue = headerTenant.ToString();
if (!string.IsNullOrWhiteSpace(headerValue))
{
return headerValue.Trim();
}
}
return DefaultTenant;
}
private static OrchestratorEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document)
{
var repository = ResolveRepository(request);
var (ns, repo) = SplitRepository(repository);
var digest = string.IsNullOrWhiteSpace(document.ImageDigest)
? request.ImageDigest ?? string.Empty
: document.ImageDigest;
return new OrchestratorEventScope
{
Namespace = ns,
Repo = string.IsNullOrWhiteSpace(repo) ? "(unknown)" : repo,
Digest = string.IsNullOrWhiteSpace(digest) ? "(unknown)" : digest
};
}
private static ImmutableSortedDictionary<string, string> BuildAttributes(ReportDocumentDto document)
{
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["reportId"] = document.ReportId;
builder["verdict"] = document.Verdict;
if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId))
{
builder["policyRevisionId"] = document.Policy.RevisionId!;
}
if (!string.IsNullOrWhiteSpace(document.Policy.Digest))
{
builder["policyDigest"] = document.Policy.Digest!;
}
return builder.ToImmutable();
}
private static ReportReadyEventPayload BuildReportReadyPayload(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
ReportLinksPayload links,
string correlationId)
{
return new ReportReadyEventPayload
{
ReportId = document.ReportId,
ScanId = correlationId,
ImageDigest = document.ImageDigest,
GeneratedAt = document.GeneratedAt,
Verdict = MapVerdict(document.Verdict),
Summary = document.Summary,
Delta = BuildDelta(preview, request),
QuietedFindingCount = document.Summary.Quieted,
Policy = document.Policy,
Links = links,
Dsse = envelope,
Report = document
};
}
private static ScanCompletedEventPayload BuildScanCompletedPayload(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
ReportLinksPayload links,
string correlationId)
{
return new ScanCompletedEventPayload
{
ReportId = document.ReportId,
ScanId = correlationId,
ImageDigest = document.ImageDigest,
Verdict = MapVerdict(document.Verdict),
Summary = document.Summary,
Delta = BuildDelta(preview, request),
Policy = document.Policy,
Findings = BuildFindingSummaries(request),
Links = links,
Dsse = envelope,
Report = document
};
}
private ReportLinksPayload BuildLinks(HttpContext context, ReportDocumentDto document, DsseEnvelopeDto? envelope)
{
if (!context.Request.Host.HasValue)
{
return new ReportLinksPayload();
}
var uiLink = BuildAbsoluteUri(context, "ui", "reports", document.ReportId);
var reportLink = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId));
var policyLink = string.IsNullOrWhiteSpace(document.Policy.RevisionId)
? null
: BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _policySegment, "revisions", document.Policy.RevisionId));
var attestationLink = envelope is null
? null
: BuildAbsoluteUri(context, "ui", "attestations", document.ReportId);
return new ReportLinksPayload
{
Ui = uiLink,
Report = reportLink,
Policy = policyLink,
Attestation = attestationLink
};
}
private static ReportDeltaPayload? BuildDelta(PolicyPreviewResponse preview, ReportRequestDto request)
{
if (preview.Diffs.IsDefaultOrEmpty)
{
return null;
}
var findings = BuildFindingsIndex(request.Findings);
var kevIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var newCritical = 0;
var newHigh = 0;
foreach (var diff in preview.Diffs)
{
var projected = diff.Projected;
if (projected is null || string.IsNullOrWhiteSpace(projected.FindingId))
{
continue;
}
findings.TryGetValue(projected.FindingId, out var finding);
if (IsNewlyImportant(diff))
{
var severity = finding?.Severity;
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase))
{
newCritical++;
}
else if (string.Equals(severity, "High", StringComparison.OrdinalIgnoreCase))
{
newHigh++;
}
var kevId = ResolveKevIdentifier(finding);
if (!string.IsNullOrWhiteSpace(kevId))
{
kevIds.Add(kevId);
}
}
}
if (newCritical == 0 && newHigh == 0 && kevIds.Count == 0)
{
return null;
}
return new ReportDeltaPayload
{
NewCritical = newCritical > 0 ? newCritical : null,
NewHigh = newHigh > 0 ? newHigh : null,
Kev = kevIds.Count > 0 ? kevIds.ToArray() : null
};
}
private static string BuildAbsoluteUri(HttpContext context, params string[] segments)
=> BuildAbsoluteUri(context, segments.AsEnumerable());
private static string BuildAbsoluteUri(HttpContext context, IEnumerable<string> segments)
{
var normalized = segments
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.Select(segment => segment.Trim('/'))
.Where(segment => segment.Length > 0)
.ToArray();
if (!context.Request.Host.HasValue || normalized.Length == 0)
{
return string.Empty;
}
var scheme = string.IsNullOrWhiteSpace(context.Request.Scheme) ? "https" : context.Request.Scheme;
var builder = new UriBuilder(scheme, context.Request.Host.Host)
{
Port = context.Request.Host.Port ?? -1,
Path = "/" + string.Join('/', normalized.Select(Uri.EscapeDataString)),
Query = string.Empty,
Fragment = string.Empty
};
return builder.Uri.ToString();
}
private string[] ConcatSegments(IEnumerable<string> prefix, params string[] suffix)
{
var segments = new List<string>();
foreach (var segment in prefix)
{
if (!string.IsNullOrWhiteSpace(segment))
{
segments.Add(segment.Trim('/'));
}
}
foreach (var segment in suffix)
{
if (!string.IsNullOrWhiteSpace(segment))
{
segments.Add(segment.Trim('/'));
}
}
return segments.ToArray();
}
private static string[] SplitSegments(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return Array.Empty<string>();
}
return path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static ImmutableDictionary<string, PolicyPreviewFindingDto> BuildFindingsIndex(
IReadOnlyList<PolicyPreviewFindingDto>? findings)
{
if (findings is null || findings.Count == 0)
{
return ImmutableDictionary<string, PolicyPreviewFindingDto>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, PolicyPreviewFindingDto>(StringComparer.Ordinal);
foreach (var finding in findings)
{
if (string.IsNullOrWhiteSpace(finding.Id))
{
continue;
}
if (!builder.ContainsKey(finding.Id))
{
builder.Add(finding.Id, finding);
}
}
return builder.ToImmutable();
}
private static IReadOnlyList<FindingSummaryPayload> BuildFindingSummaries(ReportRequestDto request)
{
if (request.Findings is not { Count: > 0 })
{
return Array.Empty<FindingSummaryPayload>();
}
var summaries = new List<FindingSummaryPayload>(request.Findings.Count);
foreach (var finding in request.Findings)
{
if (string.IsNullOrWhiteSpace(finding.Id))
{
continue;
}
summaries.Add(new FindingSummaryPayload
{
Id = finding.Id,
Severity = finding.Severity,
Cve = finding.Cve,
Purl = finding.Purl,
Reachability = ResolveReachability(finding.Tags)
});
}
return summaries;
}
private static string ResolveRepository(ReportRequestDto request)
{
if (request.Findings is { Count: > 0 })
{
foreach (var finding in request.Findings)
{
if (!string.IsNullOrWhiteSpace(finding.Repository))
{
return finding.Repository!.Trim();
}
if (!string.IsNullOrWhiteSpace(finding.Image))
{
return finding.Image!.Trim();
}
}
}
return string.Empty;
}
private static (string? Namespace, string Repo) SplitRepository(string repository)
{
if (string.IsNullOrWhiteSpace(repository))
{
return (null, string.Empty);
}
var normalized = repository.Trim();
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
return (null, normalized);
}
if (segments.Length == 1)
{
return (null, segments[0]);
}
var repo = segments[^1];
var ns = string.Join('/', segments[..^1]);
return (ns, repo);
}
private static bool IsNewlyImportant(PolicyVerdictDiff diff)
{
var projected = diff.Projected.Status;
var baseline = diff.Baseline.Status;
return projected switch
{
PolicyVerdictStatus.Blocked or PolicyVerdictStatus.Escalated
=> baseline != PolicyVerdictStatus.Blocked && baseline != PolicyVerdictStatus.Escalated,
PolicyVerdictStatus.Warned or PolicyVerdictStatus.Deferred or PolicyVerdictStatus.RequiresVex
=> baseline != PolicyVerdictStatus.Warned
&& baseline != PolicyVerdictStatus.Deferred
&& baseline != PolicyVerdictStatus.RequiresVex
&& baseline != PolicyVerdictStatus.Blocked
&& baseline != PolicyVerdictStatus.Escalated,
_ => false
};
}
private static string? ResolveKevIdentifier(PolicyPreviewFindingDto? finding)
{
if (finding is null)
{
return null;
}
var tags = finding.Tags;
if (tags is not null)
{
foreach (var tag in tags)
{
if (string.IsNullOrWhiteSpace(tag))
{
continue;
}
if (string.Equals(tag, "kev", StringComparison.OrdinalIgnoreCase))
{
return finding.Cve;
}
if (tag.StartsWith("kev:", StringComparison.OrdinalIgnoreCase))
{
var value = tag["kev:".Length..];
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
}
}
return finding.Cve;
}
private static string? ResolveReachability(IReadOnlyList<string>? tags)
{
if (tags is null)
{
return null;
}
foreach (var tag in tags)
{
if (string.IsNullOrWhiteSpace(tag))
{
continue;
}
if (tag.StartsWith("reachability:", StringComparison.OrdinalIgnoreCase))
{
return tag["reachability:".Length..];
}
}
return null;
}
private static string MapVerdict(string verdict)
=> verdict.ToLowerInvariant() switch
{
"blocked" or "fail" => "fail",
"escalated" => "fail",
"warn" or "warned" or "deferred" or "requiresvex" => "warn",
_ => "pass"
};
private static string BuildIdempotencyKey(string kind, string tenant, string identifier)
=> $"{kind}:{tenant}:{identifier}".ToLowerInvariant();
private static (string? TraceId, string? SpanId) ResolveTraceContext()
{
var activity = Activity.Current;
if (activity is null)
{
return (null, null);
}
var traceId = activity.TraceId.ToString();
var spanId = activity.SpanId.ToString();
return (traceId, spanId);
}
}

View File

@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Services;
public interface IReportSigner : IDisposable
{
ReportSignature? Sign(ReadOnlySpan<byte> payload);
}
public sealed class ReportSigner : IReportSigner
{
private enum SigningMode
{
Disabled,
Provider,
Hs256
}
private readonly SigningMode mode;
private readonly string keyId = string.Empty;
private readonly string algorithmName = string.Empty;
private readonly ILogger<ReportSigner> logger;
private readonly ICryptoProviderRegistry cryptoRegistry;
private readonly ICryptoProvider? provider;
private readonly CryptoKeyReference? keyReference;
private readonly CryptoSignerResolution? signerResolution;
private readonly byte[]? hmacKey;
public ReportSigner(
IOptions<ScannerWebServiceOptions> options,
ICryptoProviderRegistry cryptoRegistry,
ILogger<ReportSigner> logger)
{
ArgumentNullException.ThrowIfNull(options);
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
var value = options.Value ?? new ScannerWebServiceOptions();
var features = value.Features ?? new ScannerWebServiceOptions.FeatureFlagOptions();
var signing = value.Signing ?? new ScannerWebServiceOptions.SigningOptions();
if (!features.EnableSignedReports || !signing.Enabled)
{
mode = SigningMode.Disabled;
logger.LogInformation("Report signing disabled (feature flag or signing.enabled=false).");
return;
}
if (string.IsNullOrWhiteSpace(signing.KeyId))
{
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
}
var keyPem = ResolveKeyMaterial(signing);
keyId = signing.KeyId.Trim();
var resolvedMode = ResolveSigningMode(signing.Algorithm, out var canonicalAlgorithm, out var joseAlgorithm);
algorithmName = joseAlgorithm;
switch (resolvedMode)
{
case SigningMode.Provider:
{
provider = ResolveProvider(signing.Provider, canonicalAlgorithm);
var privateKey = DecodeKey(keyPem);
var reference = new CryptoKeyReference(keyId, provider.Name);
var signingKeyDescriptor = new CryptoSigningKey(
reference,
canonicalAlgorithm,
privateKey,
createdAt: DateTimeOffset.UtcNow);
provider.UpsertSigningKey(signingKeyDescriptor);
signerResolution = cryptoRegistry.ResolveSigner(
CryptoCapability.Signing,
canonicalAlgorithm,
reference,
provider.Name);
keyReference = reference;
mode = SigningMode.Provider;
break;
}
case SigningMode.Hs256:
{
hmacKey = DecodeKey(keyPem);
mode = SigningMode.Hs256;
break;
}
default:
mode = SigningMode.Disabled;
break;
}
}
public ReportSignature? Sign(ReadOnlySpan<byte> payload)
{
if (mode == SigningMode.Disabled)
{
return null;
}
if (payload.IsEmpty)
{
throw new ArgumentException("Payload must be non-empty.", nameof(payload));
}
return mode switch
{
SigningMode.Provider => SignWithProvider(payload),
SigningMode.Hs256 => SignHs256(payload),
_ => null
};
}
private ReportSignature SignWithProvider(ReadOnlySpan<byte> payload)
{
var resolution = signerResolution ?? throw new InvalidOperationException("Signing provider has not been initialised.");
var signature = resolution.Signer
.SignAsync(payload.ToArray())
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
return new ReportSignature(keyId, algorithmName, Convert.ToBase64String(signature));
}
private ReportSignature SignHs256(ReadOnlySpan<byte> payload)
{
if (hmacKey is null)
{
throw new InvalidOperationException("HMAC signing has not been initialised.");
}
using var hmac = new HMACSHA256(hmacKey);
var signature = hmac.ComputeHash(payload.ToArray());
return new ReportSignature(keyId, algorithmName, Convert.ToBase64String(signature));
}
public void Dispose()
{
if (provider is not null && keyReference is not null)
{
provider.RemoveSigningKey(keyReference.KeyId);
}
}
private ICryptoProvider ResolveProvider(string? configuredProvider, string canonicalAlgorithm)
{
if (!string.IsNullOrWhiteSpace(configuredProvider))
{
if (!cryptoRegistry.TryResolve(configuredProvider.Trim(), out var hinted))
{
throw new InvalidOperationException($"Configured signing provider '{configuredProvider}' is not registered.");
}
if (!hinted.Supports(CryptoCapability.Signing, canonicalAlgorithm))
{
throw new InvalidOperationException($"Provider '{configuredProvider}' does not support algorithm '{canonicalAlgorithm}'.");
}
return hinted;
}
return cryptoRegistry.ResolveOrThrow(CryptoCapability.Signing, canonicalAlgorithm);
}
private static SigningMode ResolveSigningMode(string? algorithm, out string canonicalAlgorithm, out string joseAlgorithm)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
throw new InvalidOperationException("Signing algorithm must be specified when signing is enabled.");
}
switch (algorithm.Trim().ToLowerInvariant())
{
case "ed25519":
case "eddsa":
canonicalAlgorithm = SignatureAlgorithms.Ed25519;
joseAlgorithm = SignatureAlgorithms.EdDsa;
return SigningMode.Provider;
case "hs256":
canonicalAlgorithm = "HS256";
joseAlgorithm = "HS256";
return SigningMode.Hs256;
default:
throw new InvalidOperationException($"Unsupported signing algorithm '{algorithm}'.");
}
}
private static string ResolveKeyMaterial(ScannerWebServiceOptions.SigningOptions signing)
{
if (!string.IsNullOrWhiteSpace(signing.KeyPem))
{
return signing.KeyPem;
}
if (!string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
try
{
return File.ReadAllText(signing.KeyPemFile);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Unable to read signing key file '{signing.KeyPemFile}'.", ex);
}
}
throw new InvalidOperationException("Signing keyPem must be configured when signing is enabled.");
}
private static byte[] DecodeKey(string keyMaterial)
{
if (string.IsNullOrWhiteSpace(keyMaterial))
{
throw new InvalidOperationException("Signing key material is empty.");
}
var segments = keyMaterial.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var builder = new StringBuilder();
var hadPemMarkers = false;
foreach (var segment in segments)
{
var trimmed = segment.Trim();
if (trimmed.Length == 0)
{
continue;
}
if (trimmed.StartsWith("-----", StringComparison.Ordinal))
{
hadPemMarkers = true;
continue;
}
builder.Append(trimmed);
}
var base64 = hadPemMarkers ? builder.ToString() : keyMaterial.Trim();
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Signing key must be Base64 encoded.", ex);
}
}
}
public sealed record ReportSignature(string KeyId, string Algorithm, string Signature);

View File

@@ -0,0 +1,215 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text;
using MongoDB.Bson;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal interface IRuntimeEventIngestionService
{
Task<RuntimeEventIngestionResult> IngestAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
string? batchId,
CancellationToken cancellationToken);
}
internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionService
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly RuntimeEventRepository _repository;
private readonly RuntimeEventRateLimiter _rateLimiter;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RuntimeEventIngestionService> _logger;
public RuntimeEventIngestionService(
RuntimeEventRepository repository,
RuntimeEventRateLimiter rateLimiter,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<RuntimeEventIngestionService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RuntimeEventIngestionResult> IngestAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
string? batchId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelopes);
if (envelopes.Count == 0)
{
return RuntimeEventIngestionResult.Empty;
}
var rateDecision = _rateLimiter.Evaluate(envelopes);
if (!rateDecision.Allowed)
{
_logger.LogWarning(
"Runtime event batch rejected due to rate limit ({Scope}={Key}, retryAfter={RetryAfter})",
rateDecision.Scope,
rateDecision.Key,
rateDecision.RetryAfter);
return RuntimeEventIngestionResult.RateLimited(rateDecision.Scope, rateDecision.Key, rateDecision.RetryAfter);
}
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var receivedAt = _timeProvider.GetUtcNow().UtcDateTime;
var expiresAt = receivedAt.AddDays(options.EventTtlDays);
var documents = new List<RuntimeEventDocument>(envelopes.Count);
var totalPayloadBytes = 0;
foreach (var envelope in envelopes)
{
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, SerializerOptions);
totalPayloadBytes += payloadBytes.Length;
if (totalPayloadBytes > options.MaxPayloadBytes)
{
_logger.LogWarning(
"Runtime event batch exceeds payload budget ({PayloadBytes} > {MaxPayloadBytes})",
totalPayloadBytes,
options.MaxPayloadBytes);
return RuntimeEventIngestionResult.PayloadTooLarge(totalPayloadBytes, options.MaxPayloadBytes);
}
var payloadDocument = BsonDocument.Parse(Encoding.UTF8.GetString(payloadBytes));
var runtimeEvent = envelope.Event;
var normalizedDigest = ExtractImageDigest(runtimeEvent);
var normalizedBuildId = NormalizeBuildId(runtimeEvent.Process?.BuildId);
var document = new RuntimeEventDocument
{
EventId = runtimeEvent.EventId,
SchemaVersion = envelope.SchemaVersion,
Tenant = runtimeEvent.Tenant,
Node = runtimeEvent.Node,
Kind = runtimeEvent.Kind.ToString(),
When = runtimeEvent.When.UtcDateTime,
ReceivedAt = receivedAt,
ExpiresAt = expiresAt,
Platform = runtimeEvent.Workload.Platform,
Namespace = runtimeEvent.Workload.Namespace,
Pod = runtimeEvent.Workload.Pod,
Container = runtimeEvent.Workload.Container,
ContainerId = runtimeEvent.Workload.ContainerId,
ImageRef = runtimeEvent.Workload.ImageRef,
ImageDigest = normalizedDigest,
Engine = runtimeEvent.Runtime.Engine,
EngineVersion = runtimeEvent.Runtime.Version,
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
ImageSigned = runtimeEvent.Posture?.ImageSigned,
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
BuildId = normalizedBuildId,
Payload = payloadDocument
};
documents.Add(document);
}
var insertResult = await _repository.InsertAsync(documents, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Runtime ingestion batch processed (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}, payloadBytes={PayloadBytes})",
batchId,
insertResult.InsertedCount,
insertResult.DuplicateCount,
totalPayloadBytes);
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
}
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
{
var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest);
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
var imageRef = runtimeEvent.Workload.ImageRef;
if (string.IsNullOrWhiteSpace(imageRef))
{
return null;
}
var trimmed = imageRef.Trim();
var atIndex = trimmed.LastIndexOf('@');
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
{
var candidate = trimmed[(atIndex + 1)..];
var parsed = NormalizeDigest(candidate);
if (!string.IsNullOrWhiteSpace(parsed))
{
return parsed;
}
}
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return NormalizeDigest(trimmed);
}
return null;
}
private static string? NormalizeDigest(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
var trimmed = candidate.Trim();
if (!trimmed.Contains(':', StringComparison.Ordinal))
{
return null;
}
return trimmed.ToLowerInvariant();
}
private static string? NormalizeBuildId(string? buildId)
{
if (string.IsNullOrWhiteSpace(buildId))
{
return null;
}
return buildId.Trim().ToLowerInvariant();
}
}
internal readonly record struct RuntimeEventIngestionResult(
int Accepted,
int Duplicates,
bool IsRateLimited,
string? RateLimitedScope,
string? RateLimitedKey,
TimeSpan RetryAfter,
bool IsPayloadTooLarge,
int PayloadBytes,
int PayloadLimit)
{
public static RuntimeEventIngestionResult Empty => new(0, 0, false, null, null, TimeSpan.Zero, false, 0, 0);
public static RuntimeEventIngestionResult RateLimited(string? scope, string? key, TimeSpan retryAfter)
=> new(0, 0, true, scope, key, retryAfter, false, 0, 0);
public static RuntimeEventIngestionResult PayloadTooLarge(int payloadBytes, int payloadLimit)
=> new(0, 0, false, null, null, TimeSpan.Zero, true, payloadBytes, payloadLimit);
public static RuntimeEventIngestionResult Success(int accepted, int duplicates, int payloadBytes)
=> new(accepted, duplicates, false, null, null, TimeSpan.Zero, false, payloadBytes, 0);
}

View File

@@ -0,0 +1,173 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class RuntimeEventRateLimiter
{
private readonly ConcurrentDictionary<string, TokenBucket> _tenantBuckets = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, TokenBucket> _nodeBuckets = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
public RuntimeEventRateLimiter(IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor, TimeProvider timeProvider)
{
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public RateLimitDecision Evaluate(IReadOnlyList<RuntimeEventEnvelope> envelopes)
{
ArgumentNullException.ThrowIfNull(envelopes);
if (envelopes.Count == 0)
{
return RateLimitDecision.Success;
}
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var now = _timeProvider.GetUtcNow();
var tenantCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var nodeCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var envelope in envelopes)
{
var tenant = envelope.Event.Tenant;
var node = envelope.Event.Node;
if (tenantCounts.TryGetValue(tenant, out var tenantCount))
{
tenantCounts[tenant] = tenantCount + 1;
}
else
{
tenantCounts[tenant] = 1;
}
var nodeKey = $"{tenant}|{node}";
if (nodeCounts.TryGetValue(nodeKey, out var nodeCount))
{
nodeCounts[nodeKey] = nodeCount + 1;
}
else
{
nodeCounts[nodeKey] = 1;
}
}
var tenantDecision = TryAcquire(
_tenantBuckets,
tenantCounts,
options.PerTenantEventsPerSecond,
options.PerTenantBurst,
now,
scope: "tenant");
if (!tenantDecision.Allowed)
{
return tenantDecision;
}
var nodeDecision = TryAcquire(
_nodeBuckets,
nodeCounts,
options.PerNodeEventsPerSecond,
options.PerNodeBurst,
now,
scope: "node");
return nodeDecision;
}
private static RateLimitDecision TryAcquire(
ConcurrentDictionary<string, TokenBucket> buckets,
IReadOnlyDictionary<string, int> counts,
double ratePerSecond,
int burst,
DateTimeOffset now,
string scope)
{
if (counts.Count == 0)
{
return RateLimitDecision.Success;
}
var acquired = new List<(TokenBucket bucket, double tokens)>();
foreach (var pair in counts)
{
var bucket = buckets.GetOrAdd(
pair.Key,
_ => new TokenBucket(burst, ratePerSecond, now));
lock (bucket.SyncRoot)
{
bucket.Refill(now);
if (bucket.Tokens + 1e-9 < pair.Value)
{
var deficit = pair.Value - bucket.Tokens;
var retryAfterSeconds = deficit / bucket.RefillRatePerSecond;
var retryAfter = retryAfterSeconds <= 0
? TimeSpan.FromSeconds(1)
: TimeSpan.FromSeconds(Math.Min(retryAfterSeconds, 3600));
// undo previously acquired tokens
foreach (var (acquiredBucket, tokens) in acquired)
{
lock (acquiredBucket.SyncRoot)
{
acquiredBucket.Tokens = Math.Min(acquiredBucket.Capacity, acquiredBucket.Tokens + tokens);
}
}
return new RateLimitDecision(false, scope, pair.Key, retryAfter);
}
bucket.Tokens -= pair.Value;
acquired.Add((bucket, pair.Value));
}
}
return RateLimitDecision.Success;
}
private sealed class TokenBucket
{
public TokenBucket(double capacity, double refillRatePerSecond, DateTimeOffset now)
{
Capacity = capacity;
Tokens = capacity;
RefillRatePerSecond = refillRatePerSecond;
LastRefill = now;
}
public double Capacity { get; }
public double Tokens { get; set; }
public double RefillRatePerSecond { get; }
public DateTimeOffset LastRefill { get; set; }
public object SyncRoot { get; } = new();
public void Refill(DateTimeOffset now)
{
if (now <= LastRefill)
{
return;
}
var elapsedSeconds = (now - LastRefill).TotalSeconds;
if (elapsedSeconds <= 0)
{
return;
}
Tokens = Math.Min(Capacity, Tokens + elapsedSeconds * RefillRatePerSecond);
LastRefill = now;
}
}
}
internal readonly record struct RateLimitDecision(bool Allowed, string? Scope, string? Key, TimeSpan RetryAfter)
{
public static RateLimitDecision Success { get; } = new(true, null, null, TimeSpan.Zero);
}

View File

@@ -0,0 +1,513 @@
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
namespace StellaOps.Scanner.WebService.Services;
internal interface IRuntimePolicyService
{
Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
}
internal sealed class RuntimePolicyService : IRuntimePolicyService
{
private const int MaxBuildIdsPerImage = 3;
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
private readonly LinkRepository _linkRepository;
private readonly ArtifactRepository _artifactRepository;
private readonly RuntimeEventRepository _runtimeEventRepository;
private readonly PolicySnapshotStore _policySnapshotStore;
private readonly PolicyPreviewService _policyPreviewService;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly IRuntimeAttestationVerifier _attestationVerifier;
private readonly ILogger<RuntimePolicyService> _logger;
public RuntimePolicyService(
LinkRepository linkRepository,
ArtifactRepository artifactRepository,
RuntimeEventRepository runtimeEventRepository,
PolicySnapshotStore policySnapshotStore,
PolicyPreviewService policyPreviewService,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
IRuntimeAttestationVerifier attestationVerifier,
ILogger<RuntimePolicyService> logger)
{
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var runtimeOptions = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var ttlSeconds = Math.Max(1, runtimeOptions.PolicyCacheTtlSeconds);
var now = _timeProvider.GetUtcNow();
var expiresAt = now.AddSeconds(ttlSeconds);
var stopwatch = Stopwatch.StartNew();
var snapshot = await _policySnapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
var policyRevision = snapshot?.RevisionId;
var policyDigest = snapshot?.Digest;
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
var evaluationTags = new KeyValuePair<string, object?>[]
{
new("policy_revision", policyRevision ?? "none"),
new("namespace", request.Namespace ?? "unspecified")
};
var buildIdObservations = await _runtimeEventRepository
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
.ConfigureAwait(false);
try
{
var evaluated = new HashSet<string>(StringComparer.Ordinal);
foreach (var image in request.Images)
{
if (!evaluated.Add(image))
{
continue;
}
var metadata = await ResolveImageMetadataAsync(image, cancellationToken).ConfigureAwait(false);
var (findings, heuristicReasons) = BuildFindings(image, metadata, request.Namespace);
if (snapshot is null)
{
heuristicReasons.Add("policy.snapshot.missing");
}
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
try
{
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
{
var previewRequest = new PolicyPreviewRequest(
image,
findings,
ImmutableArray<CanonicalPolicyVerdict>.Empty,
snapshot,
ProposedPolicy: null);
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
issues = preview.Issues;
if (!preview.Diffs.IsDefaultOrEmpty)
{
projectedVerdicts = preview.Diffs.Select(diff => diff.Projected).ToImmutableArray();
}
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
}
var normalizedImage = image.Trim().ToLowerInvariant();
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
var decision = await BuildDecisionAsync(
image,
metadata,
heuristicReasons,
projectedVerdicts,
issues,
policyDigest,
buildIdObservation?.BuildIds,
cancellationToken).ConfigureAwait(false);
results[image] = decision;
_logger.LogInformation("Runtime policy evaluated image {ImageDigest} with verdict {Verdict} (Signed: {Signed}, HasSbom: {HasSbom}, Reasons: {ReasonsCount})",
image,
decision.PolicyVerdict,
decision.Signed,
decision.HasSbomReferrers,
decision.Reasons.Count);
}
}
finally
{
stopwatch.Stop();
PolicyEvaluationLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds, evaluationTags);
}
PolicyEvaluations.Add(results.Count, evaluationTags);
var evaluationResult = new RuntimePolicyEvaluationResult(
ttlSeconds,
expiresAt,
policyRevision,
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(results));
return evaluationResult;
}
private async Task<RuntimeImageMetadata> ResolveImageMetadataAsync(string imageDigest, CancellationToken cancellationToken)
{
var links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, imageDigest, cancellationToken).ConfigureAwait(false);
if (links.Count == 0)
{
return new RuntimeImageMetadata(imageDigest, false, false, null, MissingMetadata: true);
}
var hasSbom = false;
var signed = false;
RuntimePolicyRekorReference? rekor = null;
foreach (var link in links)
{
var artifact = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
if (artifact is null)
{
continue;
}
switch (artifact.Type)
{
case ArtifactDocumentType.ImageBom:
hasSbom = true;
break;
case ArtifactDocumentType.Attestation:
signed = true;
if (artifact.Rekor is { } rekorReference)
{
rekor = new RuntimePolicyRekorReference(
Normalize(rekorReference.Uuid),
Normalize(rekorReference.Url),
rekorReference.Index.HasValue);
}
break;
}
}
return new RuntimeImageMetadata(imageDigest, signed, hasSbom, rekor, MissingMetadata: false);
}
private (ImmutableArray<PolicyFinding> Findings, List<string> HeuristicReasons) BuildFindings(string imageDigest, RuntimeImageMetadata metadata, string? @namespace)
{
var findings = ImmutableArray.CreateBuilder<PolicyFinding>();
var heuristics = new List<string>();
findings.Add(PolicyFinding.Create(
$"{imageDigest}#baseline",
PolicySeverity.None,
environment: @namespace,
source: "scanner.runtime"));
if (metadata.MissingMetadata)
{
const string reason = "image.metadata.missing";
heuristics.Add(reason);
findings.Add(PolicyFinding.Create(
$"{imageDigest}#metadata",
PolicySeverity.Critical,
environment: @namespace,
source: "scanner.runtime",
tags: ImmutableArray.Create(reason)));
}
if (!metadata.Signed)
{
const string reason = "unsigned";
heuristics.Add(reason);
findings.Add(PolicyFinding.Create(
$"{imageDigest}#signature",
PolicySeverity.High,
environment: @namespace,
source: "scanner.runtime",
tags: ImmutableArray.Create(reason)));
}
if (!metadata.HasSbomReferrers)
{
const string reason = "missing SBOM";
heuristics.Add(reason);
findings.Add(PolicyFinding.Create(
$"{imageDigest}#sbom",
PolicySeverity.High,
environment: @namespace,
source: "scanner.runtime",
tags: ImmutableArray.Create(reason)));
}
return (findings.ToImmutable(), heuristics);
}
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
string imageDigest,
RuntimeImageMetadata metadata,
List<string> heuristicReasons,
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
ImmutableArray<PolicyIssue> issues,
string? policyDigest,
IReadOnlyList<string>? buildIds,
CancellationToken cancellationToken)
{
var reasons = new List<string>(heuristicReasons);
var overallVerdict = MapVerdict(projectedVerdicts, heuristicReasons);
if (!projectedVerdicts.IsDefaultOrEmpty)
{
foreach (var verdict in projectedVerdicts)
{
if (verdict.Status == CanonicalPolicyVerdictStatus.Pass)
{
continue;
}
if (!string.IsNullOrWhiteSpace(verdict.RuleName))
{
reasons.Add($"policy.rule.{verdict.RuleName}");
}
else
{
reasons.Add($"policy.status.{verdict.Status.ToString().ToLowerInvariant()}");
}
}
}
var confidence = ComputeConfidence(projectedVerdicts, overallVerdict);
var quieted = !projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Any(v => v.Quiet);
var quietedBy = !projectedVerdicts.IsDefaultOrEmpty
? projectedVerdicts.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v.QuietedBy))?.QuietedBy
: null;
var metadataPayload = BuildMetadataPayload(heuristicReasons, projectedVerdicts, issues, policyDigest);
var rekor = metadata.Rekor;
var verified = await _attestationVerifier.VerifyAsync(imageDigest, metadata.Rekor, cancellationToken).ConfigureAwait(false);
if (rekor is not null && verified.HasValue)
{
rekor = rekor with { Verified = verified.Value };
}
var normalizedReasons = reasons
.Where(reason => !string.IsNullOrWhiteSpace(reason))
.Distinct(StringComparer.Ordinal)
.ToArray();
return new RuntimePolicyImageDecision(
overallVerdict,
metadata.Signed,
metadata.HasSbomReferrers,
normalizedReasons,
rekor,
metadataPayload,
confidence,
quieted,
quietedBy,
buildIds);
}
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
{
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
{
var statuses = projectedVerdicts.Select(v => v.Status).ToArray();
if (statuses.Any(status => status == CanonicalPolicyVerdictStatus.Blocked))
{
return RuntimePolicyVerdict.Fail;
}
if (statuses.Any(status =>
status is CanonicalPolicyVerdictStatus.Warned
or CanonicalPolicyVerdictStatus.Deferred
or CanonicalPolicyVerdictStatus.Escalated
or CanonicalPolicyVerdictStatus.RequiresVex))
{
return RuntimePolicyVerdict.Warn;
}
return RuntimePolicyVerdict.Pass;
}
if (heuristicReasons.Contains("image.metadata.missing", StringComparer.Ordinal) ||
heuristicReasons.Contains("unsigned", StringComparer.Ordinal) ||
heuristicReasons.Contains("missing SBOM", StringComparer.Ordinal))
{
return RuntimePolicyVerdict.Fail;
}
if (heuristicReasons.Contains("policy.snapshot.missing", StringComparer.Ordinal))
{
return RuntimePolicyVerdict.Warn;
}
return RuntimePolicyVerdict.Pass;
}
private IDictionary<string, object?>? BuildMetadataPayload(
IReadOnlyList<string> heuristics,
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
ImmutableArray<PolicyIssue> issues,
string? policyDigest)
{
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["heuristics"] = heuristics,
["evaluatedAt"] = _timeProvider.GetUtcNow().UtcDateTime
};
if (!string.IsNullOrWhiteSpace(policyDigest))
{
payload["policyDigest"] = policyDigest;
}
if (!issues.IsDefaultOrEmpty && issues.Length > 0)
{
payload["issues"] = issues.Select(issue => new
{
code = issue.Code,
severity = issue.Severity.ToString(),
message = issue.Message,
path = issue.Path
}).ToArray();
}
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
{
payload["findings"] = projectedVerdicts.Select(verdict => new
{
id = verdict.FindingId,
status = verdict.Status.ToString().ToLowerInvariant(),
rule = verdict.RuleName,
action = verdict.RuleAction,
score = verdict.Score,
quiet = verdict.Quiet,
quietedBy = verdict.QuietedBy,
inputs = verdict.GetInputs(),
confidence = verdict.UnknownConfidence,
confidenceBand = verdict.ConfidenceBand,
sourceTrust = verdict.SourceTrust,
reachability = verdict.Reachability
}).ToArray();
}
return payload.Count == 0 ? null : payload;
}
private static double ComputeConfidence(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, RuntimePolicyVerdict overall)
{
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
{
var confidences = projectedVerdicts
.Select(v => v.UnknownConfidence)
.Where(value => value.HasValue)
.Select(value => value!.Value)
.ToArray();
if (confidences.Length > 0)
{
return Math.Clamp(confidences.Average(), 0.0, 1.0);
}
}
return overall switch
{
RuntimePolicyVerdict.Pass => 0.95,
RuntimePolicyVerdict.Warn => 0.5,
RuntimePolicyVerdict.Fail => 0.1,
_ => 0.25
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
}
internal interface IRuntimeAttestationVerifier
{
ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken);
}
internal sealed class RuntimeAttestationVerifier : IRuntimeAttestationVerifier
{
private readonly ILogger<RuntimeAttestationVerifier> _logger;
public RuntimeAttestationVerifier(ILogger<RuntimeAttestationVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken)
{
if (rekor is null)
{
return ValueTask.FromResult<bool?>(null);
}
if (rekor.Verified.HasValue)
{
return ValueTask.FromResult(rekor.Verified);
}
_logger.LogDebug("No attestation verification metadata available for image {ImageDigest}.", imageDigest);
return ValueTask.FromResult<bool?>(null);
}
}
internal sealed record RuntimePolicyEvaluationRequest(
string? Namespace,
IReadOnlyDictionary<string, string> Labels,
IReadOnlyList<string> Images);
internal sealed record RuntimePolicyEvaluationResult(
int TtlSeconds,
DateTimeOffset ExpiresAtUtc,
string? PolicyRevision,
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
internal sealed record RuntimePolicyImageDecision(
RuntimePolicyVerdict PolicyVerdict,
bool Signed,
bool HasSbomReferrers,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IDictionary<string, object?>? Metadata,
double Confidence,
bool Quieted,
string? QuietedBy,
IReadOnlyList<string>? BuildIds);
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
internal sealed record RuntimeImageMetadata(
string ImageDigest,
bool Signed,
bool HasSbomReferrers,
RuntimePolicyRekorReference? Rekor,
bool MissingMetadata);

View File

@@ -0,0 +1,150 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
public interface IScanProgressPublisher
{
ScanProgressEvent Publish(
ScanId scanId,
string state,
string? message = null,
IReadOnlyDictionary<string, object?>? data = null,
string? correlationId = null);
}
public interface IScanProgressReader
{
bool Exists(ScanId scanId);
IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(ScanId scanId, CancellationToken cancellationToken);
}
public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressReader
{
private sealed class ProgressChannel
{
private readonly List<ScanProgressEvent> history = new();
private readonly Channel<ScanProgressEvent> channel = Channel.CreateUnbounded<ScanProgressEvent>(new UnboundedChannelOptions
{
AllowSynchronousContinuations = true,
SingleReader = false,
SingleWriter = false
});
public int Sequence { get; private set; }
public ScanProgressEvent Append(ScanProgressEvent progressEvent)
{
history.Add(progressEvent);
channel.Writer.TryWrite(progressEvent);
return progressEvent;
}
public IReadOnlyList<ScanProgressEvent> Snapshot()
{
return history.Count == 0
? Array.Empty<ScanProgressEvent>()
: history.ToArray();
}
public ChannelReader<ScanProgressEvent> Reader => channel.Reader;
public int NextSequence() => ++Sequence;
}
private static readonly IReadOnlyDictionary<string, object?> EmptyData =
new ReadOnlyDictionary<string, object?>(new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
private readonly ConcurrentDictionary<string, ProgressChannel> channels = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
public ScanProgressStream(TimeProvider timeProvider)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool Exists(ScanId scanId)
=> channels.ContainsKey(scanId.Value);
public ScanProgressEvent Publish(
ScanId scanId,
string state,
string? message = null,
IReadOnlyDictionary<string, object?>? data = null,
string? correlationId = null)
{
var channel = channels.GetOrAdd(scanId.Value, _ => new ProgressChannel());
ScanProgressEvent progressEvent;
lock (channel)
{
var sequence = channel.NextSequence();
var correlation = correlationId ?? $"{scanId.Value}:{sequence:D4}";
progressEvent = new ScanProgressEvent(
scanId,
sequence,
timeProvider.GetUtcNow(),
state,
message,
correlation,
NormalizePayload(data));
channel.Append(progressEvent);
}
return progressEvent;
}
public async IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(
ScanId scanId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (!channels.TryGetValue(scanId.Value, out var channel))
{
yield break;
}
IReadOnlyList<ScanProgressEvent> snapshot;
lock (channel)
{
snapshot = channel.Snapshot();
}
foreach (var progressEvent in snapshot)
{
yield return progressEvent;
}
var reader = channel.Reader;
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (reader.TryRead(out var progressEvent))
{
yield return progressEvent;
}
}
}
private static IReadOnlyDictionary<string, object?> NormalizePayload(IReadOnlyDictionary<string, object?>? data)
{
if (data is null || data.Count == 0)
{
return EmptyData;
}
var sorted = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in data)
{
sorted[pair.Key] = pair.Value;
}
return sorted.Count == 0
? EmptyData
: new ReadOnlyDictionary<string, object?>(sorted);
}
}