up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
@@ -1,13 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,21 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IScanCoordinator
|
||||
{
|
||||
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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
|
||||
{
|
||||
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);
|
||||
@@ -14,31 +14,31 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
private readonly ConcurrentDictionary<string, string> scansByReference = 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;
|
||||
}
|
||||
|
||||
|
||||
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(
|
||||
@@ -55,14 +55,14 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
if (submission.Force)
|
||||
{
|
||||
var snapshot = existing.Snapshot with
|
||||
{
|
||||
Status = ScanStatus.Pending,
|
||||
UpdatedAt = now,
|
||||
FailureReason = null
|
||||
};
|
||||
return new ScanEntry(snapshot);
|
||||
}
|
||||
|
||||
{
|
||||
Status = ScanStatus.Pending,
|
||||
UpdatedAt = now,
|
||||
FailureReason = null
|
||||
};
|
||||
return new ScanEntry(snapshot);
|
||||
}
|
||||
|
||||
return existing;
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
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))
|
||||
|
||||
@@ -2,21 +2,21 @@ 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));
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,356 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
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;
|
||||
|
||||
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(
|
||||
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)
|
||||
@@ -32,23 +32,23 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
|
||||
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}'.");
|
||||
}
|
||||
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);
|
||||
@@ -65,90 +65,90 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
{
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,264 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
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 ICryptoHmac cryptoHmac;
|
||||
private readonly ICryptoProvider? provider;
|
||||
private readonly CryptoKeyReference? keyReference;
|
||||
private readonly CryptoSignerResolution? signerResolution;
|
||||
private readonly byte[]? hmacKey;
|
||||
|
||||
public ReportSigner(
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
ICryptoHmac cryptoHmac,
|
||||
ILogger<ReportSigner> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
|
||||
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.");
|
||||
}
|
||||
|
||||
var signature = cryptoHmac.ComputeHmacBase64ForPurpose(hmacKey, payload, HmacPurpose.Signing);
|
||||
return new ReportSignature(keyId, algorithmName, 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);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
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 ICryptoHmac cryptoHmac;
|
||||
private readonly ICryptoProvider? provider;
|
||||
private readonly CryptoKeyReference? keyReference;
|
||||
private readonly CryptoSignerResolution? signerResolution;
|
||||
private readonly byte[]? hmacKey;
|
||||
|
||||
public ReportSigner(
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
ICryptoHmac cryptoHmac,
|
||||
ILogger<ReportSigner> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
|
||||
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.");
|
||||
}
|
||||
|
||||
var signature = cryptoHmac.ComputeHmacBase64ForPurpose(hmacKey, payload, HmacPurpose.Signing);
|
||||
return new ReportSignature(keyId, algorithmName, 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);
|
||||
|
||||
@@ -1,214 +1,214 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text;
|
||||
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 payloadJson = 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,
|
||||
PayloadJson = payloadJson
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text;
|
||||
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 payloadJson = 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,
|
||||
PayloadJson = payloadJson
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,173 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -15,27 +15,27 @@ using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
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;
|
||||
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;
|
||||
@@ -44,8 +44,8 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IRuntimeAttestationVerifier _attestationVerifier;
|
||||
private readonly ILogger<RuntimePolicyService> _logger;
|
||||
|
||||
public RuntimePolicyService(
|
||||
|
||||
public RuntimePolicyService(
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
RuntimeEventRepository runtimeEventRepository,
|
||||
@@ -68,17 +68,17 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
_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);
|
||||
|
||||
|
||||
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);
|
||||
|
||||
@@ -93,35 +93,35 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
IReadOnlyList<LinksetSummaryDto> linksets = Array.Empty<LinksetSummaryDto>();
|
||||
@@ -130,14 +130,14 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
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);
|
||||
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)
|
||||
{
|
||||
@@ -147,16 +147,16 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
}
|
||||
}
|
||||
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,
|
||||
{
|
||||
_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,
|
||||
@@ -164,128 +164,128 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
linksets,
|
||||
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,
|
||||
|
||||
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,
|
||||
@@ -293,51 +293,51 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
IReadOnlyList<LinksetSummaryDto> linksets,
|
||||
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();
|
||||
|
||||
{
|
||||
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,
|
||||
@@ -351,165 +351,165 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
buildIds,
|
||||
linksets);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
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,
|
||||
@@ -522,12 +522,12 @@ internal sealed record RuntimePolicyImageDecision(
|
||||
string? QuietedBy,
|
||||
IReadOnlyList<string>? BuildIds,
|
||||
IReadOnlyList<LinksetSummaryDto> Linksets);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
internal sealed record RuntimeImageMetadata(
|
||||
string ImageDigest,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
bool MissingMetadata);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
internal sealed record RuntimeImageMetadata(
|
||||
string ImageDigest,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
bool MissingMetadata);
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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}";
|
||||
|
||||
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,
|
||||
@@ -93,40 +93,40 @@ public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressRe
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user