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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,7 +1,7 @@
namespace StellaOps.Scanner.WebService.Constants;
internal static class ProblemTypes
{
namespace StellaOps.Scanner.WebService.Constants;
internal static class ProblemTypes
{
public const string Validation = "https://stellaops.org/problems/validation";
public const string Conflict = "https://stellaops.org/problems/conflict";
public const string NotFound = "https://stellaops.org/problems/not-found";

View File

@@ -1,38 +1,38 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record PolicyDiagnosticsRequestDto
{
[JsonPropertyName("policy")]
public PolicyPreviewPolicyDto? Policy { get; init; }
}
public sealed record PolicyDiagnosticsResponseDto
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("ruleCount")]
public int RuleCount { get; init; }
[JsonPropertyName("errorCount")]
public int ErrorCount { get; init; }
[JsonPropertyName("warningCount")]
public int WarningCount { get; init; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("issues")]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
[JsonPropertyName("recommendations")]
public IReadOnlyList<string> Recommendations { get; init; } = Array.Empty<string>();
}
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record PolicyDiagnosticsRequestDto
{
[JsonPropertyName("policy")]
public PolicyPreviewPolicyDto? Policy { get; init; }
}
public sealed record PolicyDiagnosticsResponseDto
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("ruleCount")]
public int RuleCount { get; init; }
[JsonPropertyName("errorCount")]
public int ErrorCount { get; init; }
[JsonPropertyName("warningCount")]
public int WarningCount { get; init; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("issues")]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
[JsonPropertyName("recommendations")]
public IReadOnlyList<string> Recommendations { get; init; } = Array.Empty<string>();
}

View File

@@ -1,180 +1,180 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record PolicyPreviewRequestDto
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("findings")]
public IReadOnlyList<PolicyPreviewFindingDto>? Findings { get; init; }
[JsonPropertyName("baseline")]
public IReadOnlyList<PolicyPreviewVerdictDto>? Baseline { get; init; }
[JsonPropertyName("policy")]
public PolicyPreviewPolicyDto? Policy { get; init; }
}
public sealed record PolicyPreviewFindingDto
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("environment")]
public string? Environment { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("vendor")]
public string? Vendor { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
[JsonPropertyName("image")]
public string? Image { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
[JsonPropertyName("package")]
public string? Package { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cve")]
public string? Cve { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
[JsonPropertyName("layerDigest")]
public string? LayerDigest { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
public sealed record PolicyPreviewVerdictDto
{
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("ruleName")]
public string? RuleName { get; init; }
[JsonPropertyName("ruleAction")]
public string? RuleAction { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("configVersion")]
public string? ConfigVersion { get; init; }
[JsonPropertyName("inputs")]
public IReadOnlyDictionary<string, double>? Inputs { get; init; }
[JsonPropertyName("quietedBy")]
public string? QuietedBy { get; init; }
[JsonPropertyName("quiet")]
public bool? Quiet { get; init; }
[JsonPropertyName("unknownConfidence")]
public double? UnknownConfidence { get; init; }
[JsonPropertyName("confidenceBand")]
public string? ConfidenceBand { get; init; }
[JsonPropertyName("unknownAgeDays")]
public double? UnknownAgeDays { get; init; }
[JsonPropertyName("sourceTrust")]
public string? SourceTrust { get; init; }
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
public sealed record PolicyPreviewPolicyDto
{
[JsonPropertyName("content")]
public string? Content { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed record PolicyPreviewResponseDto
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("policyDigest")]
public string? PolicyDigest { get; init; }
[JsonPropertyName("revisionId")]
public string? RevisionId { get; init; }
[JsonPropertyName("changed")]
public int Changed { get; init; }
[JsonPropertyName("diffs")]
public IReadOnlyList<PolicyPreviewDiffDto> Diffs { get; init; } = Array.Empty<PolicyPreviewDiffDto>();
[JsonPropertyName("issues")]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
}
public sealed record PolicyPreviewDiffDto
{
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
[JsonPropertyName("baseline")]
public PolicyPreviewVerdictDto? Baseline { get; init; }
[JsonPropertyName("projected")]
public PolicyPreviewVerdictDto? Projected { get; init; }
[JsonPropertyName("changed")]
public bool Changed { get; init; }
}
public sealed record PolicyPreviewIssueDto
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("severity")]
public string Severity { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
}
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record PolicyPreviewRequestDto
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("findings")]
public IReadOnlyList<PolicyPreviewFindingDto>? Findings { get; init; }
[JsonPropertyName("baseline")]
public IReadOnlyList<PolicyPreviewVerdictDto>? Baseline { get; init; }
[JsonPropertyName("policy")]
public PolicyPreviewPolicyDto? Policy { get; init; }
}
public sealed record PolicyPreviewFindingDto
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("environment")]
public string? Environment { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("vendor")]
public string? Vendor { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
[JsonPropertyName("image")]
public string? Image { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
[JsonPropertyName("package")]
public string? Package { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cve")]
public string? Cve { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
[JsonPropertyName("layerDigest")]
public string? LayerDigest { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
public sealed record PolicyPreviewVerdictDto
{
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("ruleName")]
public string? RuleName { get; init; }
[JsonPropertyName("ruleAction")]
public string? RuleAction { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("configVersion")]
public string? ConfigVersion { get; init; }
[JsonPropertyName("inputs")]
public IReadOnlyDictionary<string, double>? Inputs { get; init; }
[JsonPropertyName("quietedBy")]
public string? QuietedBy { get; init; }
[JsonPropertyName("quiet")]
public bool? Quiet { get; init; }
[JsonPropertyName("unknownConfidence")]
public double? UnknownConfidence { get; init; }
[JsonPropertyName("confidenceBand")]
public string? ConfidenceBand { get; init; }
[JsonPropertyName("unknownAgeDays")]
public double? UnknownAgeDays { get; init; }
[JsonPropertyName("sourceTrust")]
public string? SourceTrust { get; init; }
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
public sealed record PolicyPreviewPolicyDto
{
[JsonPropertyName("content")]
public string? Content { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed record PolicyPreviewResponseDto
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("policyDigest")]
public string? PolicyDigest { get; init; }
[JsonPropertyName("revisionId")]
public string? RevisionId { get; init; }
[JsonPropertyName("changed")]
public int Changed { get; init; }
[JsonPropertyName("diffs")]
public IReadOnlyList<PolicyPreviewDiffDto> Diffs { get; init; } = Array.Empty<PolicyPreviewDiffDto>();
[JsonPropertyName("issues")]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
}
public sealed record PolicyPreviewDiffDto
{
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
[JsonPropertyName("baseline")]
public PolicyPreviewVerdictDto? Baseline { get; init; }
[JsonPropertyName("projected")]
public PolicyPreviewVerdictDto? Projected { get; init; }
[JsonPropertyName("changed")]
public bool Changed { get; init; }
}
public sealed record PolicyPreviewIssueDto
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("severity")]
public string Severity { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
}

View File

@@ -1,60 +1,60 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ReportRequestDto
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("findings")]
public IReadOnlyList<PolicyPreviewFindingDto>? Findings { get; init; }
[JsonPropertyName("baseline")]
public IReadOnlyList<PolicyPreviewVerdictDto>? Baseline { get; init; }
}
public sealed record ReportResponseDto
{
[JsonPropertyName("report")]
public ReportDocumentDto Report { get; init; } = new();
[JsonPropertyName("dsse")]
public DsseEnvelopeDto? Dsse { get; init; }
}
public sealed record ReportDocumentDto
{
[JsonPropertyName("reportId")]
[JsonPropertyOrder(0)]
public string ReportId { get; init; } = string.Empty;
[JsonPropertyName("imageDigest")]
[JsonPropertyOrder(1)]
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
[JsonPropertyOrder(2)]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("verdict")]
[JsonPropertyOrder(3)]
public string Verdict { get; init; } = string.Empty;
[JsonPropertyName("policy")]
[JsonPropertyOrder(4)]
public ReportPolicyDto Policy { get; init; } = new();
[JsonPropertyName("summary")]
[JsonPropertyOrder(5)]
public ReportSummaryDto Summary { get; init; } = new();
[JsonPropertyName("verdicts")]
[JsonPropertyOrder(6)]
public IReadOnlyList<PolicyPreviewVerdictDto> Verdicts { get; init; } = Array.Empty<PolicyPreviewVerdictDto>();
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ReportRequestDto
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("findings")]
public IReadOnlyList<PolicyPreviewFindingDto>? Findings { get; init; }
[JsonPropertyName("baseline")]
public IReadOnlyList<PolicyPreviewVerdictDto>? Baseline { get; init; }
}
public sealed record ReportResponseDto
{
[JsonPropertyName("report")]
public ReportDocumentDto Report { get; init; } = new();
[JsonPropertyName("dsse")]
public DsseEnvelopeDto? Dsse { get; init; }
}
public sealed record ReportDocumentDto
{
[JsonPropertyName("reportId")]
[JsonPropertyOrder(0)]
public string ReportId { get; init; } = string.Empty;
[JsonPropertyName("imageDigest")]
[JsonPropertyOrder(1)]
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
[JsonPropertyOrder(2)]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("verdict")]
[JsonPropertyOrder(3)]
public string Verdict { get; init; } = string.Empty;
[JsonPropertyName("policy")]
[JsonPropertyOrder(4)]
public ReportPolicyDto Policy { get; init; } = new();
[JsonPropertyName("summary")]
[JsonPropertyOrder(5)]
public ReportSummaryDto Summary { get; init; } = new();
[JsonPropertyName("verdicts")]
[JsonPropertyOrder(6)]
public IReadOnlyList<PolicyPreviewVerdictDto> Verdicts { get; init; } = Array.Empty<PolicyPreviewVerdictDto>();
[JsonPropertyName("issues")]
[JsonPropertyOrder(7)]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
@@ -68,64 +68,64 @@ public sealed record ReportDocumentDto
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<LinksetSummaryDto>? Linksets { get; init; }
}
public sealed record ReportPolicyDto
{
[JsonPropertyName("revisionId")]
[JsonPropertyOrder(0)]
public string? RevisionId { get; init; }
[JsonPropertyName("digest")]
[JsonPropertyOrder(1)]
public string? Digest { get; init; }
}
public sealed record ReportSummaryDto
{
[JsonPropertyName("total")]
[JsonPropertyOrder(0)]
public int Total { get; init; }
[JsonPropertyName("blocked")]
[JsonPropertyOrder(1)]
public int Blocked { get; init; }
[JsonPropertyName("warned")]
[JsonPropertyOrder(2)]
public int Warned { get; init; }
[JsonPropertyName("ignored")]
[JsonPropertyOrder(3)]
public int Ignored { get; init; }
[JsonPropertyName("quieted")]
[JsonPropertyOrder(4)]
public int Quieted { get; init; }
}
public sealed record DsseEnvelopeDto
{
[JsonPropertyName("payloadType")]
[JsonPropertyOrder(0)]
public string PayloadType { get; init; } = string.Empty;
[JsonPropertyName("payload")]
[JsonPropertyOrder(1)]
public string Payload { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
[JsonPropertyOrder(2)]
public IReadOnlyList<DsseSignatureDto> Signatures { get; init; } = Array.Empty<DsseSignatureDto>();
}
public sealed record DsseSignatureDto
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty;
[JsonPropertyName("signature")]
public string Signature { get; init; } = string.Empty;
}
public sealed record ReportPolicyDto
{
[JsonPropertyName("revisionId")]
[JsonPropertyOrder(0)]
public string? RevisionId { get; init; }
[JsonPropertyName("digest")]
[JsonPropertyOrder(1)]
public string? Digest { get; init; }
}
public sealed record ReportSummaryDto
{
[JsonPropertyName("total")]
[JsonPropertyOrder(0)]
public int Total { get; init; }
[JsonPropertyName("blocked")]
[JsonPropertyOrder(1)]
public int Blocked { get; init; }
[JsonPropertyName("warned")]
[JsonPropertyOrder(2)]
public int Warned { get; init; }
[JsonPropertyName("ignored")]
[JsonPropertyOrder(3)]
public int Ignored { get; init; }
[JsonPropertyName("quieted")]
[JsonPropertyOrder(4)]
public int Quieted { get; init; }
}
public sealed record DsseEnvelopeDto
{
[JsonPropertyName("payloadType")]
[JsonPropertyOrder(0)]
public string PayloadType { get; init; } = string.Empty;
[JsonPropertyName("payload")]
[JsonPropertyOrder(1)]
public string Payload { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
[JsonPropertyOrder(2)]
public IReadOnlyList<DsseSignatureDto> Signatures { get; init; } = Array.Empty<DsseSignatureDto>();
}
public sealed record DsseSignatureDto
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty;
[JsonPropertyName("signature")]
public string Signature { get; init; } = string.Empty;
}

View File

@@ -1,22 +1,22 @@
using System.Text.Json.Serialization;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record RuntimeEventsIngestRequestDto
{
[JsonPropertyName("batchId")]
public string? BatchId { get; init; }
[JsonPropertyName("events")]
public IReadOnlyList<RuntimeEventEnvelope> Events { get; init; } = Array.Empty<RuntimeEventEnvelope>();
}
public sealed record RuntimeEventsIngestResponseDto
{
[JsonPropertyName("accepted")]
public int Accepted { get; init; }
[JsonPropertyName("duplicates")]
public int Duplicates { get; init; }
}
using System.Text.Json.Serialization;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record RuntimeEventsIngestRequestDto
{
[JsonPropertyName("batchId")]
public string? BatchId { get; init; }
[JsonPropertyName("events")]
public IReadOnlyList<RuntimeEventEnvelope> Events { get; init; } = Array.Empty<RuntimeEventEnvelope>();
}
public sealed record RuntimeEventsIngestResponseDto
{
[JsonPropertyName("accepted")]
public int Accepted { get; init; }
[JsonPropertyName("duplicates")]
public int Duplicates { get; init; }
}

View File

@@ -1,71 +1,71 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record RuntimePolicyRequestDto
{
[JsonPropertyName("namespace")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Namespace { get; init; }
[JsonPropertyName("labels")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IDictionary<string, string>? Labels { get; init; }
[JsonPropertyName("images")]
public IReadOnlyList<string> Images { get; init; } = Array.Empty<string>();
}
public sealed record RuntimePolicyResponseDto
{
[JsonPropertyName("ttlSeconds")]
public int TtlSeconds { get; init; }
[JsonPropertyName("expiresAtUtc")]
public DateTimeOffset ExpiresAtUtc { get; init; }
[JsonPropertyName("policyRevision")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyRevision { get; init; }
[JsonPropertyName("results")]
public IReadOnlyDictionary<string, RuntimePolicyImageResponseDto> Results { get; init; } = new Dictionary<string, RuntimePolicyImageResponseDto>(StringComparer.Ordinal);
}
public sealed record RuntimePolicyImageResponseDto
{
[JsonPropertyName("policyVerdict")]
public string PolicyVerdict { get; init; } = "unknown";
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("hasSbomReferrers")]
public bool HasSbomReferrers { get; init; }
[JsonPropertyName("hasSbom")]
public bool HasSbomLegacy { get; init; }
[JsonPropertyName("reasons")]
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
[JsonPropertyName("rekor")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RuntimePolicyRekorDto? Rekor { get; init; }
[JsonPropertyName("confidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Confidence { get; init; }
[JsonPropertyName("quieted")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Quieted { get; init; }
[JsonPropertyName("quietedBy")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? QuietedBy { get; init; }
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record RuntimePolicyRequestDto
{
[JsonPropertyName("namespace")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Namespace { get; init; }
[JsonPropertyName("labels")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IDictionary<string, string>? Labels { get; init; }
[JsonPropertyName("images")]
public IReadOnlyList<string> Images { get; init; } = Array.Empty<string>();
}
public sealed record RuntimePolicyResponseDto
{
[JsonPropertyName("ttlSeconds")]
public int TtlSeconds { get; init; }
[JsonPropertyName("expiresAtUtc")]
public DateTimeOffset ExpiresAtUtc { get; init; }
[JsonPropertyName("policyRevision")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyRevision { get; init; }
[JsonPropertyName("results")]
public IReadOnlyDictionary<string, RuntimePolicyImageResponseDto> Results { get; init; } = new Dictionary<string, RuntimePolicyImageResponseDto>(StringComparer.Ordinal);
}
public sealed record RuntimePolicyImageResponseDto
{
[JsonPropertyName("policyVerdict")]
public string PolicyVerdict { get; init; } = "unknown";
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("hasSbomReferrers")]
public bool HasSbomReferrers { get; init; }
[JsonPropertyName("hasSbom")]
public bool HasSbomLegacy { get; init; }
[JsonPropertyName("reasons")]
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
[JsonPropertyName("rekor")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RuntimePolicyRekorDto? Rekor { get; init; }
[JsonPropertyName("confidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Confidence { get; init; }
[JsonPropertyName("quieted")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Quieted { get; init; }
[JsonPropertyName("quietedBy")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? QuietedBy { get; init; }
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Metadata { get; init; }
@@ -78,139 +78,139 @@ public sealed record RuntimePolicyImageResponseDto
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<LinksetSummaryDto>? Linksets { get; init; }
}
public sealed record RuntimePolicyRekorDto
{
[JsonPropertyName("uuid")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Uuid { get; init; }
[JsonPropertyName("url")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Url { get; init; }
[JsonPropertyName("verified")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Verified { get; init; }
}
/// <summary>
/// Request for policy overlays on graph nodes (for Cartographer integration).
/// </summary>
public sealed record PolicyOverlayRequestDto
{
[JsonPropertyName("tenant")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Tenant { get; init; }
[JsonPropertyName("nodes")]
public IReadOnlyList<PolicyOverlayNodeDto> Nodes { get; init; } = Array.Empty<PolicyOverlayNodeDto>();
[JsonPropertyName("overlayKind")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? OverlayKind { get; init; }
[JsonPropertyName("includeEvidence")]
public bool IncludeEvidence { get; init; }
}
/// <summary>
/// A graph node for policy overlay evaluation.
/// </summary>
public sealed record PolicyOverlayNodeDto
{
[JsonPropertyName("nodeId")]
public string NodeId { get; init; } = string.Empty;
[JsonPropertyName("nodeType")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? NodeType { get; init; }
[JsonPropertyName("purl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Purl { get; init; }
[JsonPropertyName("imageDigest")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImageDigest { get; init; }
[JsonPropertyName("advisoryKey")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? AdvisoryKey { get; init; }
}
/// <summary>
/// Response containing policy overlays for graph nodes.
/// </summary>
public sealed record PolicyOverlayResponseDto
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("policyRevision")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyRevision { get; init; }
[JsonPropertyName("overlays")]
public IReadOnlyList<PolicyOverlayDto> Overlays { get; init; } = Array.Empty<PolicyOverlayDto>();
}
/// <summary>
/// A single policy overlay for a graph node with deterministic ID.
/// </summary>
public sealed record PolicyOverlayDto
{
[JsonPropertyName("overlayId")]
public string OverlayId { get; init; } = string.Empty;
[JsonPropertyName("nodeId")]
public string NodeId { get; init; } = string.Empty;
[JsonPropertyName("overlayKind")]
public string OverlayKind { get; init; } = "policy.overlay.v1";
[JsonPropertyName("verdict")]
public string Verdict { get; init; } = "unknown";
[JsonPropertyName("reasons")]
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
[JsonPropertyName("confidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Confidence { get; init; }
[JsonPropertyName("quieted")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Quieted { get; init; }
[JsonPropertyName("evidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public PolicyOverlayEvidenceDto? Evidence { get; init; }
}
/// <summary>
/// Runtime evidence attached to a policy overlay.
/// </summary>
public sealed record PolicyOverlayEvidenceDto
{
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("hasSbomReferrers")]
public bool HasSbomReferrers { get; init; }
[JsonPropertyName("rekor")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RuntimePolicyRekorDto? Rekor { get; init; }
[JsonPropertyName("buildIds")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? BuildIds { get; init; }
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
public sealed record RuntimePolicyRekorDto
{
[JsonPropertyName("uuid")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Uuid { get; init; }
[JsonPropertyName("url")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Url { get; init; }
[JsonPropertyName("verified")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Verified { get; init; }
}
/// <summary>
/// Request for policy overlays on graph nodes (for Cartographer integration).
/// </summary>
public sealed record PolicyOverlayRequestDto
{
[JsonPropertyName("tenant")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Tenant { get; init; }
[JsonPropertyName("nodes")]
public IReadOnlyList<PolicyOverlayNodeDto> Nodes { get; init; } = Array.Empty<PolicyOverlayNodeDto>();
[JsonPropertyName("overlayKind")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? OverlayKind { get; init; }
[JsonPropertyName("includeEvidence")]
public bool IncludeEvidence { get; init; }
}
/// <summary>
/// A graph node for policy overlay evaluation.
/// </summary>
public sealed record PolicyOverlayNodeDto
{
[JsonPropertyName("nodeId")]
public string NodeId { get; init; } = string.Empty;
[JsonPropertyName("nodeType")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? NodeType { get; init; }
[JsonPropertyName("purl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Purl { get; init; }
[JsonPropertyName("imageDigest")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImageDigest { get; init; }
[JsonPropertyName("advisoryKey")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? AdvisoryKey { get; init; }
}
/// <summary>
/// Response containing policy overlays for graph nodes.
/// </summary>
public sealed record PolicyOverlayResponseDto
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("policyRevision")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyRevision { get; init; }
[JsonPropertyName("overlays")]
public IReadOnlyList<PolicyOverlayDto> Overlays { get; init; } = Array.Empty<PolicyOverlayDto>();
}
/// <summary>
/// A single policy overlay for a graph node with deterministic ID.
/// </summary>
public sealed record PolicyOverlayDto
{
[JsonPropertyName("overlayId")]
public string OverlayId { get; init; } = string.Empty;
[JsonPropertyName("nodeId")]
public string NodeId { get; init; } = string.Empty;
[JsonPropertyName("overlayKind")]
public string OverlayKind { get; init; } = "policy.overlay.v1";
[JsonPropertyName("verdict")]
public string Verdict { get; init; } = "unknown";
[JsonPropertyName("reasons")]
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
[JsonPropertyName("confidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Confidence { get; init; }
[JsonPropertyName("quieted")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Quieted { get; init; }
[JsonPropertyName("evidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public PolicyOverlayEvidenceDto? Evidence { get; init; }
}
/// <summary>
/// Runtime evidence attached to a policy overlay.
/// </summary>
public sealed record PolicyOverlayEvidenceDto
{
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("hasSbomReferrers")]
public bool HasSbomReferrers { get; init; }
[JsonPropertyName("rekor")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RuntimePolicyRekorDto? Rekor { get; init; }
[JsonPropertyName("buildIds")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? BuildIds { get; init; }
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -1,21 +1,21 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ScanSubmitRequest
{
public required ScanImageDescriptor Image { get; init; } = new();
public bool Force { get; init; }
public string? ClientRequestId { get; init; }
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed record ScanImageDescriptor
{
public string? Reference { get; init; }
public string? Digest { get; init; }
}
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ScanSubmitRequest
{
public required ScanImageDescriptor Image { get; init; } = new();
public bool Force { get; init; }
public string? ClientRequestId { get; init; }
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed record ScanImageDescriptor
{
public string? Reference { get; init; }
public string? Digest { get; init; }
}

View File

@@ -1,7 +1,7 @@
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ScanSubmitResponse(
string ScanId,
string Status,
string? Location,
bool Created);
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ScanSubmitResponse(
string ScanId,
string Status,
string? Location,
bool Created);

View File

@@ -1,47 +1,47 @@
using System;
namespace StellaOps.Scanner.WebService.Diagnostics;
/// <summary>
/// Tracks runtime health snapshots for the Scanner WebService.
/// </summary>
public sealed class ServiceStatus
{
private readonly TimeProvider timeProvider;
private readonly DateTimeOffset startedAt;
private ReadySnapshot readySnapshot;
public ServiceStatus(TimeProvider timeProvider)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
startedAt = timeProvider.GetUtcNow();
readySnapshot = ReadySnapshot.CreateInitial(startedAt);
}
public ServiceSnapshot CreateSnapshot()
{
var now = timeProvider.GetUtcNow();
return new ServiceSnapshot(startedAt, now, readySnapshot);
}
public void RecordReadyCheck(bool success, TimeSpan latency, string? error)
{
var now = timeProvider.GetUtcNow();
readySnapshot = new ReadySnapshot(now, latency, success, success ? null : error);
}
public readonly record struct ServiceSnapshot(
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
ReadySnapshot Ready);
public readonly record struct ReadySnapshot(
DateTimeOffset CheckedAt,
TimeSpan? Latency,
bool IsReady,
string? Error)
{
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
=> new ReadySnapshot(timestamp, null, true, null);
}
}
using System;
namespace StellaOps.Scanner.WebService.Diagnostics;
/// <summary>
/// Tracks runtime health snapshots for the Scanner WebService.
/// </summary>
public sealed class ServiceStatus
{
private readonly TimeProvider timeProvider;
private readonly DateTimeOffset startedAt;
private ReadySnapshot readySnapshot;
public ServiceStatus(TimeProvider timeProvider)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
startedAt = timeProvider.GetUtcNow();
readySnapshot = ReadySnapshot.CreateInitial(startedAt);
}
public ServiceSnapshot CreateSnapshot()
{
var now = timeProvider.GetUtcNow();
return new ServiceSnapshot(startedAt, now, readySnapshot);
}
public void RecordReadyCheck(bool success, TimeSpan latency, string? error)
{
var now = timeProvider.GetUtcNow();
readySnapshot = new ReadySnapshot(now, latency, success, success ? null : error);
}
public readonly record struct ServiceSnapshot(
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
ReadySnapshot Ready);
public readonly record struct ReadySnapshot(
DateTimeOffset CheckedAt,
TimeSpan? Latency,
bool IsReady,
string? Error)
{
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
=> new ReadySnapshot(timestamp, null, true, null);
}
}

View File

@@ -1,18 +1,18 @@
namespace StellaOps.Scanner.WebService.Domain;
public readonly record struct ScanId(string Value)
{
public override string ToString() => Value;
public static bool TryParse(string? value, out ScanId scanId)
{
if (!string.IsNullOrWhiteSpace(value))
{
scanId = new ScanId(value.Trim());
return true;
}
scanId = default;
return false;
}
}
namespace StellaOps.Scanner.WebService.Domain;
public readonly record struct ScanId(string Value)
{
public override string ToString() => Value;
public static bool TryParse(string? value, out ScanId scanId)
{
if (!string.IsNullOrWhiteSpace(value))
{
scanId = new ScanId(value.Trim());
return true;
}
scanId = default;
return false;
}
}

View File

@@ -1,12 +1,12 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanProgressEvent(
ScanId ScanId,
int Sequence,
DateTimeOffset Timestamp,
string State,
string? Message,
string CorrelationId,
IReadOnlyDictionary<string, object?> Data);
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanProgressEvent(
ScanId ScanId,
int Sequence,
DateTimeOffset Timestamp,
string State,
string? Message,
string CorrelationId,
IReadOnlyDictionary<string, object?> Data);

View File

@@ -1,5 +1,5 @@
namespace StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanSnapshot(
ScanId ScanId,
ScanTarget Target,

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Scanner.WebService.Domain;
public enum ScanStatus
{
Pending,
Running,
Succeeded,
Failed,
Cancelled
}
namespace StellaOps.Scanner.WebService.Domain;
public enum ScanStatus
{
Pending,
Running,
Succeeded,
Failed,
Cancelled
}

View File

@@ -1,13 +1,13 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanSubmission(
ScanTarget Target,
bool Force,
string? ClientRequestId,
IReadOnlyDictionary<string, string> Metadata);
public sealed record ScanSubmissionResult(
ScanSnapshot Snapshot,
bool Created);
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanSubmission(
ScanTarget Target,
bool Force,
string? ClientRequestId,
IReadOnlyDictionary<string, string> Metadata);
public sealed record ScanSubmissionResult(
ScanSnapshot Snapshot,
bool Created);

View File

@@ -1,11 +1,11 @@
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanTarget(string? Reference, string? Digest)
{
public ScanTarget Normalize()
{
var normalizedReference = string.IsNullOrWhiteSpace(Reference) ? null : Reference.Trim();
var normalizedDigest = string.IsNullOrWhiteSpace(Digest) ? null : Digest.Trim().ToLowerInvariant();
return new ScanTarget(normalizedReference, normalizedDigest);
}
}
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanTarget(string? Reference, string? Digest)
{
public ScanTarget Normalize()
{
var normalizedReference = string.IsNullOrWhiteSpace(Reference) ? null : Reference.Trim();
var normalizedDigest = string.IsNullOrWhiteSpace(Digest) ? null : Digest.Trim().ToLowerInvariant();
return new ScanTarget(normalizedReference, normalizedDigest);
}
}

View File

@@ -1,65 +1,65 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Validation;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class HealthEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static void MapHealthEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var group = endpoints.MapGroup("/");
group.MapGet("/healthz", HandleHealth)
.WithName("scanner.health")
.Produces<HealthDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
group.MapGet("/readyz", HandleReady)
.WithName("scanner.ready")
.Produces<ReadyDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
}
private static IResult HandleHealth(
ServiceStatus status,
IOptions<ScannerWebServiceOptions> options,
HttpContext context)
{
ApplyNoCache(context.Response);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var telemetry = new TelemetrySnapshot(
Enabled: options.Value.Telemetry.Enabled,
Logging: options.Value.Telemetry.EnableLogging,
Metrics: options.Value.Telemetry.EnableMetrics,
Tracing: options.Value.Telemetry.EnableTracing);
var document = new HealthDocument(
Status: "healthy",
StartedAt: snapshot.StartedAt,
CapturedAt: snapshot.CapturedAt,
UptimeSeconds: uptimeSeconds,
Telemetry: telemetry);
return Json(document, StatusCodes.Status200OK);
}
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class HealthEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static void MapHealthEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var group = endpoints.MapGroup("/");
group.MapGet("/healthz", HandleHealth)
.WithName("scanner.health")
.Produces<HealthDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
group.MapGet("/readyz", HandleReady)
.WithName("scanner.ready")
.Produces<ReadyDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
}
private static IResult HandleHealth(
ServiceStatus status,
IOptions<ScannerWebServiceOptions> options,
HttpContext context)
{
ApplyNoCache(context.Response);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var telemetry = new TelemetrySnapshot(
Enabled: options.Value.Telemetry.Enabled,
Logging: options.Value.Telemetry.EnableLogging,
Metrics: options.Value.Telemetry.EnableMetrics,
Tracing: options.Value.Telemetry.EnableTracing);
var document = new HealthDocument(
Status: "healthy",
StartedAt: snapshot.StartedAt,
CapturedAt: snapshot.CapturedAt,
UptimeSeconds: uptimeSeconds,
Telemetry: telemetry);
return Json(document, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleReady(
ServiceStatus status,
ISurfaceValidatorRunner validatorRunner,
@@ -123,36 +123,36 @@ internal static class HealthEndpoints
var statusCode = success ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
return Json(document, statusCode);
}
private static void ApplyNoCache(HttpResponse response)
{
response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
response.Headers.Pragma = "no-cache";
response.Headers["Expires"] = "0";
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, JsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
internal sealed record TelemetrySnapshot(
bool Enabled,
bool Logging,
bool Metrics,
bool Tracing);
internal sealed record HealthDocument(
string Status,
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
double UptimeSeconds,
TelemetrySnapshot Telemetry);
internal sealed record ReadyDocument(
string Status,
DateTimeOffset CheckedAt,
double? LatencyMs,
string? Error);
}
private static void ApplyNoCache(HttpResponse response)
{
response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
response.Headers.Pragma = "no-cache";
response.Headers["Expires"] = "0";
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, JsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
internal sealed record TelemetrySnapshot(
bool Enabled,
bool Logging,
bool Metrics,
bool Tracing);
internal sealed record HealthDocument(
string Status,
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
double UptimeSeconds,
TelemetrySnapshot Telemetry);
internal sealed record ReadyDocument(
string Status,
DateTimeOffset CheckedAt,
double? LatencyMs,
string? Error);
}

View File

@@ -1,88 +1,88 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Zastava.Core.Contracts;
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
namespace StellaOps.Scanner.WebService.Endpoints;
#pragma warning disable ASPDEPR002
internal static class PolicyEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapPolicyEndpoints(this RouteGroupBuilder apiGroup, string policySegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var policyGroup = apiGroup
.MapGroup(NormalizeSegment(policySegment))
.WithTags("Policy");
policyGroup.MapGet("/schema", HandleSchemaAsync)
.WithName("scanner.policy.schema")
.Produces(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Retrieve the embedded policy JSON schema.";
operation.Description = "Returns the policy schema (`policy-schema@1`) used to validate YAML or JSON rulesets.";
return operation;
});
policyGroup.MapPost("/diagnostics", HandleDiagnosticsAsync)
.WithName("scanner.policy.diagnostics")
.Produces<PolicyDiagnosticsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Run policy diagnostics.";
operation.Description = "Accepts YAML or JSON policy content and returns normalization issues plus recommendations (ignore rules, VEX include/exclude, vendor precedence).";
return operation;
});
policyGroup.MapPost("/preview", HandlePreviewAsync)
.WithName("scanner.policy.preview")
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Preview policy impact against findings.";
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
return operation;
});
policyGroup.MapPost("/runtime", HandleRuntimePolicyAsync)
.WithName("scanner.policy.runtime")
.Produces<RuntimePolicyResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Evaluate runtime policy for digests.";
operation.Description = "Returns per-image policy verdicts, signature and SBOM metadata, and cache hints for admission controllers.";
return operation;
});
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Zastava.Core.Contracts;
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
namespace StellaOps.Scanner.WebService.Endpoints;
#pragma warning disable ASPDEPR002
internal static class PolicyEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapPolicyEndpoints(this RouteGroupBuilder apiGroup, string policySegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var policyGroup = apiGroup
.MapGroup(NormalizeSegment(policySegment))
.WithTags("Policy");
policyGroup.MapGet("/schema", HandleSchemaAsync)
.WithName("scanner.policy.schema")
.Produces(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Retrieve the embedded policy JSON schema.";
operation.Description = "Returns the policy schema (`policy-schema@1`) used to validate YAML or JSON rulesets.";
return operation;
});
policyGroup.MapPost("/diagnostics", HandleDiagnosticsAsync)
.WithName("scanner.policy.diagnostics")
.Produces<PolicyDiagnosticsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Run policy diagnostics.";
operation.Description = "Accepts YAML or JSON policy content and returns normalization issues plus recommendations (ignore rules, VEX include/exclude, vendor precedence).";
return operation;
});
policyGroup.MapPost("/preview", HandlePreviewAsync)
.WithName("scanner.policy.preview")
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Preview policy impact against findings.";
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
return operation;
});
policyGroup.MapPost("/runtime", HandleRuntimePolicyAsync)
.WithName("scanner.policy.runtime")
.Produces<RuntimePolicyResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Evaluate runtime policy for digests.";
operation.Description = "Returns per-image policy verdicts, signature and SBOM metadata, and cache hints for admission controllers.";
return operation;
});
policyGroup.MapPost("/overlay", HandlePolicyOverlayAsync)
.WithName("scanner.policy.overlay")
.Produces<PolicyOverlayResponseDto>(StatusCodes.Status200OK)
@@ -107,184 +107,184 @@ internal static class PolicyEndpoints
return operation;
});
}
private static IResult HandleSchemaAsync(HttpContext context)
{
var schema = PolicySchemaResource.ReadSchemaJson();
return Results.Text(schema, "application/schema+json", Encoding.UTF8);
}
private static IResult HandleDiagnosticsAsync(
PolicyDiagnosticsRequestDto request,
TimeProvider timeProvider,
HttpContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Content))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy diagnostics request",
StatusCodes.Status400BadRequest,
detail: "Policy content is required for diagnostics.");
}
var format = PolicyDtoMapper.ParsePolicyFormat(request.Policy.Format);
var binding = PolicyBinder.Bind(request.Policy.Content, format);
var diagnostics = PolicyDiagnostics.Create(binding, timeProvider);
var response = new PolicyDiagnosticsResponseDto
{
Success = diagnostics.ErrorCount == 0,
Version = diagnostics.Version,
RuleCount = diagnostics.RuleCount,
ErrorCount = diagnostics.ErrorCount,
WarningCount = diagnostics.WarningCount,
GeneratedAt = diagnostics.GeneratedAt,
Issues = diagnostics.Issues.Select(PolicyDtoMapper.ToIssueDto).ToImmutableArray(),
Recommendations = diagnostics.Recommendations
};
return Json(response);
}
private static async Task<IResult> HandlePreviewAsync(
PolicyPreviewRequestDto request,
PolicyPreviewService previewService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(previewService);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null)
{
var missingIds = request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id));
if (missingIds)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
}
var domainRequest = PolicyDtoMapper.ToDomain(request);
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var payload = PolicyDtoMapper.ToDto(response);
return Json(payload);
}
private static IResult HandleSchemaAsync(HttpContext context)
{
var schema = PolicySchemaResource.ReadSchemaJson();
return Results.Text(schema, "application/schema+json", Encoding.UTF8);
}
private static IResult HandleDiagnosticsAsync(
PolicyDiagnosticsRequestDto request,
TimeProvider timeProvider,
HttpContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Content))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy diagnostics request",
StatusCodes.Status400BadRequest,
detail: "Policy content is required for diagnostics.");
}
var format = PolicyDtoMapper.ParsePolicyFormat(request.Policy.Format);
var binding = PolicyBinder.Bind(request.Policy.Content, format);
var diagnostics = PolicyDiagnostics.Create(binding, timeProvider);
var response = new PolicyDiagnosticsResponseDto
{
Success = diagnostics.ErrorCount == 0,
Version = diagnostics.Version,
RuleCount = diagnostics.RuleCount,
ErrorCount = diagnostics.ErrorCount,
WarningCount = diagnostics.WarningCount,
GeneratedAt = diagnostics.GeneratedAt,
Issues = diagnostics.Issues.Select(PolicyDtoMapper.ToIssueDto).ToImmutableArray(),
Recommendations = diagnostics.Recommendations
};
return Json(response);
}
private static async Task<IResult> HandlePreviewAsync(
PolicyPreviewRequestDto request,
PolicyPreviewService previewService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(previewService);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null)
{
var missingIds = request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id));
if (missingIds)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
}
var domainRequest = PolicyDtoMapper.ToDomain(request);
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var payload = PolicyDtoMapper.ToDto(response);
return Json(payload);
}
private static async Task<IResult> HandleRuntimePolicyAsync(
RuntimePolicyRequestDto request,
IRuntimePolicyService runtimePolicyService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
if (request.Images is null || request.Images.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "images collection must include at least one digest.");
}
var normalizedImages = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var image in request.Images)
{
if (string.IsNullOrWhiteSpace(image))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "Image digests must be non-empty.");
}
var trimmed = image.Trim();
if (!trimmed.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "Image digests must include an algorithm prefix (e.g. sha256:...).");
}
if (seen.Add(trimmed))
{
normalizedImages.Add(trimmed);
}
}
if (normalizedImages.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "images collection must include at least one unique digest.");
}
var namespaceValue = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim();
var normalizedLabels = new Dictionary<string, string>(StringComparer.Ordinal);
if (request.Labels is not null)
{
foreach (var pair in request.Labels)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
var value = pair.Value?.Trim() ?? string.Empty;
normalizedLabels[key] = value;
}
}
var evaluationRequest = new RuntimePolicyEvaluationRequest(
namespaceValue,
new ReadOnlyDictionary<string, string>(normalizedLabels),
normalizedImages);
var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false);
var resultPayload = MapRuntimePolicyResponse(evaluation);
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
if (request.Images is null || request.Images.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "images collection must include at least one digest.");
}
var normalizedImages = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var image in request.Images)
{
if (string.IsNullOrWhiteSpace(image))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "Image digests must be non-empty.");
}
var trimmed = image.Trim();
if (!trimmed.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "Image digests must include an algorithm prefix (e.g. sha256:...).");
}
if (seen.Add(trimmed))
{
normalizedImages.Add(trimmed);
}
}
if (normalizedImages.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "images collection must include at least one unique digest.");
}
var namespaceValue = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim();
var normalizedLabels = new Dictionary<string, string>(StringComparer.Ordinal);
if (request.Labels is not null)
{
foreach (var pair in request.Labels)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
var value = pair.Value?.Trim() ?? string.Empty;
normalizedLabels[key] = value;
}
}
var evaluationRequest = new RuntimePolicyEvaluationRequest(
namespaceValue,
new ReadOnlyDictionary<string, string>(normalizedLabels),
normalizedImages);
var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false);
var resultPayload = MapRuntimePolicyResponse(evaluation);
return Json(resultPayload);
}
@@ -375,53 +375,53 @@ internal static class PolicyEndpoints
return Json(response);
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/policy";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
private static RuntimePolicyResponseDto MapRuntimePolicyResponse(RuntimePolicyEvaluationResult evaluation)
{
var results = new Dictionary<string, RuntimePolicyImageResponseDto>(evaluation.Results.Count, StringComparer.Ordinal);
foreach (var pair in evaluation.Results)
{
var decision = pair.Value;
RuntimePolicyRekorDto? rekor = null;
if (decision.Rekor is not null)
{
rekor = new RuntimePolicyRekorDto
{
Uuid = decision.Rekor.Uuid,
Url = decision.Rekor.Url,
Verified = decision.Rekor.Verified
};
}
string? metadata = null;
if (decision.Metadata is not null && decision.Metadata.Count > 0)
{
metadata = JsonSerializer.Serialize(decision.Metadata, SerializerOptions);
}
results[pair.Key] = new RuntimePolicyImageResponseDto
{
PolicyVerdict = ToCamelCase(decision.PolicyVerdict),
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
HasSbomLegacy = decision.HasSbomReferrers,
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/policy";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
private static RuntimePolicyResponseDto MapRuntimePolicyResponse(RuntimePolicyEvaluationResult evaluation)
{
var results = new Dictionary<string, RuntimePolicyImageResponseDto>(evaluation.Results.Count, StringComparer.Ordinal);
foreach (var pair in evaluation.Results)
{
var decision = pair.Value;
RuntimePolicyRekorDto? rekor = null;
if (decision.Rekor is not null)
{
rekor = new RuntimePolicyRekorDto
{
Uuid = decision.Rekor.Uuid,
Url = decision.Rekor.Url,
Verified = decision.Rekor.Verified
};
}
string? metadata = null;
if (decision.Metadata is not null && decision.Metadata.Count > 0)
{
metadata = JsonSerializer.Serialize(decision.Metadata, SerializerOptions);
}
results[pair.Key] = new RuntimePolicyImageResponseDto
{
PolicyVerdict = ToCamelCase(decision.PolicyVerdict),
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
HasSbomLegacy = decision.HasSbomReferrers,
Reasons = decision.Reasons.ToArray(),
Rekor = rekor,
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
@@ -432,154 +432,154 @@ internal static class PolicyEndpoints
Linksets = decision.Linksets is { Count: > 0 } ? decision.Linksets.ToArray() : null
};
}
return new RuntimePolicyResponseDto
{
TtlSeconds = evaluation.TtlSeconds,
ExpiresAtUtc = evaluation.ExpiresAtUtc,
PolicyRevision = evaluation.PolicyRevision,
Results = results
};
}
private static string ToCamelCase(RuntimePolicyVerdict verdict)
=> verdict switch
{
RuntimePolicyVerdict.Pass => "pass",
RuntimePolicyVerdict.Warn => "warn",
RuntimePolicyVerdict.Fail => "fail",
RuntimePolicyVerdict.Error => "error",
_ => "unknown"
};
private static async Task<IResult> HandlePolicyOverlayAsync(
PolicyOverlayRequestDto request,
IRuntimePolicyService runtimePolicyService,
ISurfaceEnvironment surfaceEnvironment,
TimeProvider timeProvider,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
ArgumentNullException.ThrowIfNull(surfaceEnvironment);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Nodes is null || request.Nodes.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy overlay request",
StatusCodes.Status400BadRequest,
detail: "nodes collection must include at least one node.");
}
var tenant = !string.IsNullOrWhiteSpace(request.Tenant)
? request.Tenant.Trim()
: surfaceEnvironment.Settings.Tenant;
var overlayKind = !string.IsNullOrWhiteSpace(request.OverlayKind)
? request.OverlayKind.Trim()
: "policy.overlay.v1";
var imageDigests = request.Nodes
.Where(n => !string.IsNullOrWhiteSpace(n.ImageDigest))
.Select(n => n.ImageDigest!.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
RuntimePolicyEvaluationResult? evaluation = null;
if (imageDigests.Count > 0)
{
var evalRequest = new RuntimePolicyEvaluationRequest(
null,
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)),
imageDigests);
evaluation = await runtimePolicyService.EvaluateAsync(evalRequest, cancellationToken).ConfigureAwait(false);
}
var overlays = new List<PolicyOverlayDto>(request.Nodes.Count);
foreach (var node in request.Nodes)
{
if (string.IsNullOrWhiteSpace(node.NodeId))
{
continue;
}
var nodeId = node.NodeId.Trim();
var overlayId = ComputeOverlayId(tenant, nodeId, overlayKind);
string verdict = "unknown";
IReadOnlyList<string> reasons = Array.Empty<string>();
double? confidence = null;
bool? quieted = null;
PolicyOverlayEvidenceDto? evidence = null;
if (!string.IsNullOrWhiteSpace(node.ImageDigest) &&
evaluation?.Results.TryGetValue(node.ImageDigest.Trim(), out var decision) == true)
{
verdict = ToCamelCase(decision.PolicyVerdict);
reasons = decision.Reasons.ToArray();
confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero);
quieted = decision.Quieted;
if (request.IncludeEvidence)
{
RuntimePolicyRekorDto? rekor = null;
if (decision.Rekor is not null)
{
rekor = new RuntimePolicyRekorDto
{
Uuid = decision.Rekor.Uuid,
Url = decision.Rekor.Url,
Verified = decision.Rekor.Verified
};
}
evidence = new PolicyOverlayEvidenceDto
{
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
Rekor = rekor,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null,
Metadata = decision.Metadata is { Count: > 0 }
? new ReadOnlyDictionary<string, string>(decision.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal))
: null
};
}
}
overlays.Add(new PolicyOverlayDto
{
OverlayId = overlayId,
NodeId = nodeId,
OverlayKind = overlayKind,
Verdict = verdict,
Reasons = reasons,
Confidence = confidence,
Quieted = quieted,
Evidence = evidence
});
}
var response = new PolicyOverlayResponseDto
{
Tenant = tenant,
GeneratedAt = timeProvider.GetUtcNow(),
PolicyRevision = evaluation?.PolicyRevision,
Overlays = overlays.OrderBy(o => o.NodeId, StringComparer.Ordinal).ToArray()
};
return Json(response);
}
private static string ComputeOverlayId(string tenant, string nodeId, string overlayKind)
{
var input = $"{tenant}|{nodeId}|{overlayKind}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
#pragma warning restore ASPDEPR002
return new RuntimePolicyResponseDto
{
TtlSeconds = evaluation.TtlSeconds,
ExpiresAtUtc = evaluation.ExpiresAtUtc,
PolicyRevision = evaluation.PolicyRevision,
Results = results
};
}
private static string ToCamelCase(RuntimePolicyVerdict verdict)
=> verdict switch
{
RuntimePolicyVerdict.Pass => "pass",
RuntimePolicyVerdict.Warn => "warn",
RuntimePolicyVerdict.Fail => "fail",
RuntimePolicyVerdict.Error => "error",
_ => "unknown"
};
private static async Task<IResult> HandlePolicyOverlayAsync(
PolicyOverlayRequestDto request,
IRuntimePolicyService runtimePolicyService,
ISurfaceEnvironment surfaceEnvironment,
TimeProvider timeProvider,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
ArgumentNullException.ThrowIfNull(surfaceEnvironment);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Nodes is null || request.Nodes.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy overlay request",
StatusCodes.Status400BadRequest,
detail: "nodes collection must include at least one node.");
}
var tenant = !string.IsNullOrWhiteSpace(request.Tenant)
? request.Tenant.Trim()
: surfaceEnvironment.Settings.Tenant;
var overlayKind = !string.IsNullOrWhiteSpace(request.OverlayKind)
? request.OverlayKind.Trim()
: "policy.overlay.v1";
var imageDigests = request.Nodes
.Where(n => !string.IsNullOrWhiteSpace(n.ImageDigest))
.Select(n => n.ImageDigest!.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
RuntimePolicyEvaluationResult? evaluation = null;
if (imageDigests.Count > 0)
{
var evalRequest = new RuntimePolicyEvaluationRequest(
null,
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)),
imageDigests);
evaluation = await runtimePolicyService.EvaluateAsync(evalRequest, cancellationToken).ConfigureAwait(false);
}
var overlays = new List<PolicyOverlayDto>(request.Nodes.Count);
foreach (var node in request.Nodes)
{
if (string.IsNullOrWhiteSpace(node.NodeId))
{
continue;
}
var nodeId = node.NodeId.Trim();
var overlayId = ComputeOverlayId(tenant, nodeId, overlayKind);
string verdict = "unknown";
IReadOnlyList<string> reasons = Array.Empty<string>();
double? confidence = null;
bool? quieted = null;
PolicyOverlayEvidenceDto? evidence = null;
if (!string.IsNullOrWhiteSpace(node.ImageDigest) &&
evaluation?.Results.TryGetValue(node.ImageDigest.Trim(), out var decision) == true)
{
verdict = ToCamelCase(decision.PolicyVerdict);
reasons = decision.Reasons.ToArray();
confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero);
quieted = decision.Quieted;
if (request.IncludeEvidence)
{
RuntimePolicyRekorDto? rekor = null;
if (decision.Rekor is not null)
{
rekor = new RuntimePolicyRekorDto
{
Uuid = decision.Rekor.Uuid,
Url = decision.Rekor.Url,
Verified = decision.Rekor.Verified
};
}
evidence = new PolicyOverlayEvidenceDto
{
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
Rekor = rekor,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null,
Metadata = decision.Metadata is { Count: > 0 }
? new ReadOnlyDictionary<string, string>(decision.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal))
: null
};
}
}
overlays.Add(new PolicyOverlayDto
{
OverlayId = overlayId,
NodeId = nodeId,
OverlayKind = overlayKind,
Verdict = verdict,
Reasons = reasons,
Confidence = confidence,
Quieted = quieted,
Evidence = evidence
});
}
var response = new PolicyOverlayResponseDto
{
Tenant = tenant,
GeneratedAt = timeProvider.GetUtcNow(),
PolicyRevision = evaluation?.PolicyRevision,
Overlays = overlays.OrderBy(o => o.NodeId, StringComparer.Ordinal).ToArray()
};
return Json(response);
}
private static string ComputeOverlayId(string tenant, string nodeId, string overlayKind)
{
var input = $"{tenant}|{nodeId}|{overlayKind}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
#pragma warning restore ASPDEPR002

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -13,43 +13,43 @@ using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
#pragma warning disable ASPDEPR002
internal static class ReportEndpoints
{
private const string PayloadType = "application/vnd.stellaops.report+json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
{
private const string PayloadType = "application/vnd.stellaops.report+json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static void MapReportEndpoints(this RouteGroupBuilder apiGroup, string reportsSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var reports = apiGroup
.MapGroup(NormalizeSegment(reportsSegment))
.WithTags("Reports");
{
ArgumentNullException.ThrowIfNull(apiGroup);
var reports = apiGroup
.MapGroup(NormalizeSegment(reportsSegment))
.WithTags("Reports");
reports.MapPost("/", HandleCreateReportAsync)
.WithName("scanner.reports.create")
.Produces<ReportResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status503ServiceUnavailable)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Assemble a signed scan report.";
operation.Description = "Aggregates latest findings with the active policy snapshot, returning verdicts plus an optional DSSE envelope.";
return operation;
});
}
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Assemble a signed scan report.";
operation.Description = "Aggregates latest findings with the active policy snapshot, returning verdicts plus an optional DSSE envelope.";
return operation;
});
}
private static async Task<IResult> HandleCreateReportAsync(
ReportRequestDto request,
PolicyPreviewService previewService,
@@ -76,60 +76,60 @@ internal static class ReportEndpoints
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null && request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id)))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
var previewDto = new PolicyPreviewRequestDto
{
ImageDigest = request.ImageDigest,
Findings = request.Findings,
Baseline = request.Baseline,
Policy = null
};
var domainRequest = PolicyDtoMapper.ToDomain(previewDto) with { ProposedPolicy = null };
var preview = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
if (!preview.Success)
{
var issues = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["issues"] = issues
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unable to assemble report",
StatusCodes.Status503ServiceUnavailable,
detail: "No policy snapshot is available or validation failed.",
extensions: extensions);
}
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null && request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id)))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
var previewDto = new PolicyPreviewRequestDto
{
ImageDigest = request.ImageDigest,
Findings = request.Findings,
Baseline = request.Baseline,
Policy = null
};
var domainRequest = PolicyDtoMapper.ToDomain(previewDto) with { ProposedPolicy = null };
var preview = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
if (!preview.Success)
{
var issues = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["issues"] = issues
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unable to assemble report",
StatusCodes.Status503ServiceUnavailable,
detail: "No policy snapshot is available or validation failed.",
extensions: extensions);
}
var projectedVerdicts = preview.Diffs
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
.ToArray();
@@ -177,124 +177,124 @@ internal static class ReportEndpoints
Surface = surfacePointers,
Linksets = linksets.Count == 0 ? null : linksets
};
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
var signature = signer.Sign(payloadBytes);
DsseEnvelopeDto? envelope = null;
if (signature is not null)
{
envelope = new DsseEnvelopeDto
{
PayloadType = PayloadType,
Payload = Convert.ToBase64String(payloadBytes),
Signatures = new[]
{
new DsseSignatureDto
{
KeyId = signature.KeyId,
Algorithm = signature.Algorithm,
Signature = signature.Signature
}
}
};
}
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
var signature = signer.Sign(payloadBytes);
DsseEnvelopeDto? envelope = null;
if (signature is not null)
{
envelope = new DsseEnvelopeDto
{
PayloadType = PayloadType,
Payload = Convert.ToBase64String(payloadBytes),
Signatures = new[]
{
new DsseSignatureDto
{
KeyId = signature.KeyId,
Algorithm = signature.Algorithm,
Signature = signature.Signature
}
}
};
}
var response = new ReportResponseDto
{
Report = document,
Dsse = envelope
};
await eventDispatcher
.PublishAsync(request, preview, document, envelope, context, cancellationToken)
.ConfigureAwait(false);
return Json(response);
}
private static ReportSummaryDto BuildSummary(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return new ReportSummaryDto { Total = 0 };
}
var blocked = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase));
var warned = verdicts.Count(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase));
var ignored = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Ignored), StringComparison.OrdinalIgnoreCase));
var quieted = verdicts.Count(v => v.Quiet is true);
return new ReportSummaryDto
{
Total = verdicts.Count,
Blocked = blocked,
Warned = warned,
Ignored = ignored,
Quieted = quieted
};
}
private static string ComputeVerdict(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return "unknown";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase)))
{
return "blocked";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase)))
{
return "escalated";
}
if (verdicts.Any(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)))
{
return "warn";
}
return "pass";
}
private static string CreateReportId(string imageDigest, string policyDigest)
{
var builder = new StringBuilder();
builder.Append(imageDigest.Trim());
builder.Append('|');
builder.Append(policyDigest ?? string.Empty);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
var hex = Convert.ToHexString(hash.AsSpan(0, 10)).ToLowerInvariant();
return $"report-{hex}";
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/reports";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
await eventDispatcher
.PublishAsync(request, preview, document, envelope, context, cancellationToken)
.ConfigureAwait(false);
return Json(response);
}
private static ReportSummaryDto BuildSummary(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return new ReportSummaryDto { Total = 0 };
}
var blocked = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase));
var warned = verdicts.Count(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase));
var ignored = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Ignored), StringComparison.OrdinalIgnoreCase));
var quieted = verdicts.Count(v => v.Quiet is true);
return new ReportSummaryDto
{
Total = verdicts.Count,
Blocked = blocked,
Warned = warned,
Ignored = ignored,
Quieted = quieted
};
}
private static string ComputeVerdict(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return "unknown";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase)))
{
return "blocked";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase)))
{
return "escalated";
}
if (verdicts.Any(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)))
{
return "warn";
}
return "pass";
}
private static string CreateReportId(string imageDigest, string policyDigest)
{
var builder = new StringBuilder();
builder.Append(imageDigest.Trim());
builder.Append('|');
builder.Append(policyDigest ?? string.Empty);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
var hex = Convert.ToHexString(hash.AsSpan(0, 10)).ToLowerInvariant();
return $"report-{hex}";
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/reports";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
}
#pragma warning restore ASPDEPR002

View File

@@ -1,253 +1,253 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class RuntimeEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapRuntimeEndpoints(this RouteGroupBuilder apiGroup, string runtimeSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var runtime = apiGroup
.MapGroup(NormalizeSegment(runtimeSegment))
.WithTags("Runtime");
runtime.MapPost("/events", HandleRuntimeEventsAsync)
.WithName("scanner.runtime.events.ingest")
.Produces<RuntimeEventsIngestResponseDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status429TooManyRequests)
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
}
private static async Task<IResult> HandleRuntimeEventsAsync(
RuntimeEventsIngestRequestDto request,
IRuntimeEventIngestionService ingestionService,
IOptions<ScannerWebServiceOptions> options,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(ingestionService);
ArgumentNullException.ThrowIfNull(options);
var runtimeOptions = options.Value.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var validationError = ValidateRequest(request, runtimeOptions, context, out var envelopes);
if (validationError is { } problem)
{
return problem;
}
var result = await ingestionService.IngestAsync(envelopes, request.BatchId, cancellationToken).ConfigureAwait(false);
if (result.IsPayloadTooLarge)
{
var extensions = new Dictionary<string, object?>
{
["payloadBytes"] = result.PayloadBytes,
["maxPayloadBytes"] = result.PayloadLimit
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Runtime event batch too large",
StatusCodes.Status400BadRequest,
detail: "Runtime batch payload exceeds configured budget.",
extensions: extensions);
}
if (result.IsRateLimited)
{
var retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(result.RetryAfter.TotalSeconds));
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
var extensions = new Dictionary<string, object?>
{
["scope"] = result.RateLimitedScope,
["key"] = result.RateLimitedKey,
["retryAfterSeconds"] = retryAfterSeconds
};
return ProblemResultFactory.Create(
context,
ProblemTypes.RateLimited,
"Runtime ingestion rate limited",
StatusCodes.Status429TooManyRequests,
detail: "Runtime ingestion exceeded configured rate limits.",
extensions: extensions);
}
var payload = new RuntimeEventsIngestResponseDto
{
Accepted = result.Accepted,
Duplicates = result.Duplicates
};
return Json(payload, StatusCodes.Status202Accepted);
}
private static IResult? ValidateRequest(
RuntimeEventsIngestRequestDto request,
ScannerWebServiceOptions.RuntimeOptions runtimeOptions,
HttpContext context,
out IReadOnlyList<RuntimeEventEnvelope> envelopes)
{
envelopes = request.Events ?? Array.Empty<RuntimeEventEnvelope>();
if (envelopes.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: "events array must include at least one item.");
}
if (envelopes.Count > runtimeOptions.MaxBatchSize)
{
var extensions = new Dictionary<string, object?>
{
["maxBatchSize"] = runtimeOptions.MaxBatchSize,
["eventCount"] = envelopes.Count
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: "events array exceeds allowed batch size.",
extensions: extensions);
}
var seenEventIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < envelopes.Count; i++)
{
var envelope = envelopes[i];
if (envelope is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}] must not be null.");
}
if (!envelope.IsSupported())
{
var extensions = new Dictionary<string, object?>
{
["schemaVersion"] = envelope.SchemaVersion
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unsupported runtime schema version",
StatusCodes.Status400BadRequest,
detail: "Runtime event schemaVersion is not supported.",
extensions: extensions);
}
var runtimeEvent = envelope.Event;
if (runtimeEvent is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].event must not be null.");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.EventId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].eventId is required.");
}
if (!seenEventIds.Add(runtimeEvent.EventId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"Duplicate eventId detected within batch ('{runtimeEvent.EventId}').");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.Tenant))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].tenant is required.");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.Node))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].node is required.");
}
if (runtimeEvent.Workload is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].workload is required.");
}
}
return null;
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/runtime";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
}
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class RuntimeEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapRuntimeEndpoints(this RouteGroupBuilder apiGroup, string runtimeSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var runtime = apiGroup
.MapGroup(NormalizeSegment(runtimeSegment))
.WithTags("Runtime");
runtime.MapPost("/events", HandleRuntimeEventsAsync)
.WithName("scanner.runtime.events.ingest")
.Produces<RuntimeEventsIngestResponseDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status429TooManyRequests)
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
}
private static async Task<IResult> HandleRuntimeEventsAsync(
RuntimeEventsIngestRequestDto request,
IRuntimeEventIngestionService ingestionService,
IOptions<ScannerWebServiceOptions> options,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(ingestionService);
ArgumentNullException.ThrowIfNull(options);
var runtimeOptions = options.Value.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var validationError = ValidateRequest(request, runtimeOptions, context, out var envelopes);
if (validationError is { } problem)
{
return problem;
}
var result = await ingestionService.IngestAsync(envelopes, request.BatchId, cancellationToken).ConfigureAwait(false);
if (result.IsPayloadTooLarge)
{
var extensions = new Dictionary<string, object?>
{
["payloadBytes"] = result.PayloadBytes,
["maxPayloadBytes"] = result.PayloadLimit
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Runtime event batch too large",
StatusCodes.Status400BadRequest,
detail: "Runtime batch payload exceeds configured budget.",
extensions: extensions);
}
if (result.IsRateLimited)
{
var retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(result.RetryAfter.TotalSeconds));
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
var extensions = new Dictionary<string, object?>
{
["scope"] = result.RateLimitedScope,
["key"] = result.RateLimitedKey,
["retryAfterSeconds"] = retryAfterSeconds
};
return ProblemResultFactory.Create(
context,
ProblemTypes.RateLimited,
"Runtime ingestion rate limited",
StatusCodes.Status429TooManyRequests,
detail: "Runtime ingestion exceeded configured rate limits.",
extensions: extensions);
}
var payload = new RuntimeEventsIngestResponseDto
{
Accepted = result.Accepted,
Duplicates = result.Duplicates
};
return Json(payload, StatusCodes.Status202Accepted);
}
private static IResult? ValidateRequest(
RuntimeEventsIngestRequestDto request,
ScannerWebServiceOptions.RuntimeOptions runtimeOptions,
HttpContext context,
out IReadOnlyList<RuntimeEventEnvelope> envelopes)
{
envelopes = request.Events ?? Array.Empty<RuntimeEventEnvelope>();
if (envelopes.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: "events array must include at least one item.");
}
if (envelopes.Count > runtimeOptions.MaxBatchSize)
{
var extensions = new Dictionary<string, object?>
{
["maxBatchSize"] = runtimeOptions.MaxBatchSize,
["eventCount"] = envelopes.Count
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: "events array exceeds allowed batch size.",
extensions: extensions);
}
var seenEventIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < envelopes.Count; i++)
{
var envelope = envelopes[i];
if (envelope is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}] must not be null.");
}
if (!envelope.IsSupported())
{
var extensions = new Dictionary<string, object?>
{
["schemaVersion"] = envelope.SchemaVersion
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unsupported runtime schema version",
StatusCodes.Status400BadRequest,
detail: "Runtime event schemaVersion is not supported.",
extensions: extensions);
}
var runtimeEvent = envelope.Event;
if (runtimeEvent is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].event must not be null.");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.EventId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].eventId is required.");
}
if (!seenEventIds.Add(runtimeEvent.EventId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"Duplicate eventId detected within batch ('{runtimeEvent.EventId}').");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.Tenant))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].tenant is required.");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.Node))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].node is required.");
}
if (runtimeEvent.Workload is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].workload is required.");
}
}
return null;
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/runtime";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
}

View File

@@ -1,38 +1,38 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.WebService.Extensions;
/// <summary>
/// Scanner-specific configuration helpers.
/// </summary>
public static class ConfigurationExtensions
{
public static IConfigurationBuilder AddScannerYaml(this IConfigurationBuilder builder, string path)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var payload = JsonSerializer.Serialize(yamlObject);
var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
return builder.AddJsonStream(stream);
}
}
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.WebService.Extensions;
/// <summary>
/// Scanner-specific configuration helpers.
/// </summary>
public static class ConfigurationExtensions
{
public static IConfigurationBuilder AddScannerYaml(this IConfigurationBuilder builder, string path)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var payload = JsonSerializer.Serialize(yamlObject);
var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
return builder.AddJsonStream(stream);
}
}

View File

@@ -1,17 +1,17 @@
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Scanner.WebService.Extensions;
internal static class OpenApiRegistrationExtensions
{
public static IServiceCollection AddOpenApiIfAvailable(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
var extensionType = Type.GetType("Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions, Microsoft.AspNetCore.OpenApi");
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Scanner.WebService.Extensions;
internal static class OpenApiRegistrationExtensions
{
public static IServiceCollection AddOpenApiIfAvailable(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
var extensionType = Type.GetType("Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions, Microsoft.AspNetCore.OpenApi");
if (extensionType is not null)
{
var method = extensionType
@@ -50,12 +50,12 @@ internal static class OpenApiRegistrationExtensions
services.AddEndpointsApiExplorer();
return services;
}
public static WebApplication MapOpenApiIfAvailable(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
var extensionType = Type.GetType("Microsoft.AspNetCore.Builder.OpenApiApplicationBuilderExtensions, Microsoft.AspNetCore.OpenApi");
public static WebApplication MapOpenApiIfAvailable(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
var extensionType = Type.GetType("Microsoft.AspNetCore.Builder.OpenApiApplicationBuilderExtensions, Microsoft.AspNetCore.OpenApi");
if (extensionType is not null)
{
var method = extensionType

View File

@@ -1,55 +1,55 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Hosting;
internal static class ScannerPluginHostFactory
{
public static PluginHostOptions Build(ScannerWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
var baseDirectory = options.Plugins.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = Path.Combine(contentRootPath, "..");
}
else if (!Path.IsPathRooted(baseDirectory))
{
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
}
var pluginsDirectory = options.Plugins.Directory;
if (string.IsNullOrWhiteSpace(pluginsDirectory))
{
pluginsDirectory = Path.Combine("plugins", "scanner");
}
if (!Path.IsPathRooted(pluginsDirectory))
{
pluginsDirectory = Path.Combine(baseDirectory, pluginsDirectory);
}
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
PrimaryPrefix = "StellaOps.Scanner"
};
foreach (var additionalPrefix in options.Plugins.OrderedPlugins)
{
hostOptions.PluginOrder.Add(additionalPrefix);
}
foreach (var pattern in options.Plugins.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
return hostOptions;
}
}
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Hosting;
internal static class ScannerPluginHostFactory
{
public static PluginHostOptions Build(ScannerWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
var baseDirectory = options.Plugins.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = Path.Combine(contentRootPath, "..");
}
else if (!Path.IsPathRooted(baseDirectory))
{
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
}
var pluginsDirectory = options.Plugins.Directory;
if (string.IsNullOrWhiteSpace(pluginsDirectory))
{
pluginsDirectory = Path.Combine("plugins", "scanner");
}
if (!Path.IsPathRooted(pluginsDirectory))
{
pluginsDirectory = Path.Combine(baseDirectory, pluginsDirectory);
}
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
PrimaryPrefix = "StellaOps.Scanner"
};
foreach (var additionalPrefix in options.Plugins.OrderedPlugins)
{
hostOptions.PluginOrder.Add(additionalPrefix);
}
foreach (var pattern in options.Plugins.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
return hostOptions;
}
}

View File

@@ -1,53 +1,53 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Scanner.WebService.Infrastructure;
internal static class ProblemResultFactory
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static IResult Create(
HttpContext context,
string type,
string title,
int statusCode,
string? detail = null,
IDictionary<string, object?>? extensions = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(type);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
var problem = new ProblemDetails
{
Type = type,
Title = title,
Detail = detail,
Status = statusCode,
Instance = context.Request.Path
};
problem.Extensions["traceId"] = traceId;
if (extensions is not null)
{
foreach (var entry in extensions)
{
problem.Extensions[entry.Key] = entry.Value;
}
}
var payload = JsonSerializer.Serialize(problem, JsonOptions);
return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode);
}
}
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Scanner.WebService.Infrastructure;
internal static class ProblemResultFactory
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static IResult Create(
HttpContext context,
string type,
string title,
int statusCode,
string? detail = null,
IDictionary<string, object?>? extensions = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(type);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
var problem = new ProblemDetails
{
Type = type,
Title = title,
Detail = detail,
Status = statusCode,
Instance = context.Request.Path
};
problem.Extensions["traceId"] = traceId;
if (extensions is not null)
{
foreach (var entry in extensions)
{
problem.Extensions[entry.Key] = entry.Value;
}
}
var payload = JsonSerializer.Serialize(problem, JsonOptions);
return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode);
}
}

View File

@@ -1,69 +1,69 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Scanner.WebService.Options;
/// <summary>
/// Post-configuration helpers for <see cref="ScannerWebServiceOptions"/>.
/// </summary>
public static class ScannerWebServiceOptionsPostConfigure
{
public static void Apply(ScannerWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
if (string.IsNullOrWhiteSpace(options.Plugins.Directory))
{
options.Plugins.Directory = Path.Combine("plugins", "scanner");
}
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
var authority = options.Authority;
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
{
authority.ClientSecret = ReadSecretFile(authority.ClientSecretFile!, contentRootPath);
}
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
var artifactStore = options.ArtifactStore;
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Scanner.WebService.Options;
/// <summary>
/// Post-configuration helpers for <see cref="ScannerWebServiceOptions"/>.
/// </summary>
public static class ScannerWebServiceOptionsPostConfigure
{
public static void Apply(ScannerWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
if (string.IsNullOrWhiteSpace(options.Plugins.Directory))
{
options.Plugins.Directory = Path.Combine("plugins", "scanner");
}
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
var authority = options.Authority;
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
{
authority.ClientSecret = ReadSecretFile(authority.ClientSecretFile!, contentRootPath);
}
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
var artifactStore = options.ArtifactStore;
options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
var signing = options.Signing;
if (string.IsNullOrWhiteSpace(signing.KeyPem)
&& !string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
signing.KeyPem = ReadAllText(signing.KeyPemFile!, contentRootPath);
}
if (string.IsNullOrWhiteSpace(signing.CertificatePem)
&& !string.IsNullOrWhiteSpace(signing.CertificatePemFile))
{
signing.CertificatePem = ReadAllText(signing.CertificatePemFile!, contentRootPath);
}
if (string.IsNullOrWhiteSpace(signing.CertificateChainPem)
&& !string.IsNullOrWhiteSpace(signing.CertificateChainPemFile))
{
signing.CertificateChainPem = ReadAllText(signing.CertificateChainPemFile!, contentRootPath);
}
options.Events ??= new ScannerWebServiceOptions.EventsOptions();
var eventsOptions = options.Events;
eventsOptions.DriverSettings ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(eventsOptions.Driver))
{
eventsOptions.Driver = "redis";
}
if (string.IsNullOrWhiteSpace(eventsOptions.Stream))
{
eventsOptions.Stream = "stella.events";
}
var signing = options.Signing;
if (string.IsNullOrWhiteSpace(signing.KeyPem)
&& !string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
signing.KeyPem = ReadAllText(signing.KeyPemFile!, contentRootPath);
}
if (string.IsNullOrWhiteSpace(signing.CertificatePem)
&& !string.IsNullOrWhiteSpace(signing.CertificatePemFile))
{
signing.CertificatePem = ReadAllText(signing.CertificatePemFile!, contentRootPath);
}
if (string.IsNullOrWhiteSpace(signing.CertificateChainPem)
&& !string.IsNullOrWhiteSpace(signing.CertificateChainPemFile))
{
signing.CertificateChainPem = ReadAllText(signing.CertificateChainPemFile!, contentRootPath);
}
options.Events ??= new ScannerWebServiceOptions.EventsOptions();
var eventsOptions = options.Events;
eventsOptions.DriverSettings ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(eventsOptions.Driver))
{
eventsOptions.Driver = "redis";
}
if (string.IsNullOrWhiteSpace(eventsOptions.Stream))
{
eventsOptions.Stream = "stella.events";
}
if (string.IsNullOrWhiteSpace(eventsOptions.Dsn)
&& string.Equals(options.Queue?.Driver, "redis", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(options.Queue?.Dsn))
@@ -74,37 +74,37 @@ public static class ScannerWebServiceOptionsPostConfigure
options.Runtime ??= new ScannerWebServiceOptions.RuntimeOptions();
}
private static string ReadSecretFile(string path, string contentRootPath)
{
var resolvedPath = ResolvePath(path, contentRootPath);
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"Secret file '{resolvedPath}' was not found.");
}
var secret = File.ReadAllText(resolvedPath).Trim();
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException($"Secret file '{resolvedPath}' is empty.");
}
return secret;
}
private static string ReadAllText(string path, string contentRootPath)
{
var resolvedPath = ResolvePath(path, contentRootPath);
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"File '{resolvedPath}' was not found.");
}
return File.ReadAllText(resolvedPath);
}
private static string ResolvePath(string path, string contentRootPath)
=> Path.IsPathRooted(path)
? path
: Path.GetFullPath(Path.Combine(contentRootPath, path));
}
private static string ReadSecretFile(string path, string contentRootPath)
{
var resolvedPath = ResolvePath(path, contentRootPath);
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"Secret file '{resolvedPath}' was not found.");
}
var secret = File.ReadAllText(resolvedPath).Trim();
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException($"Secret file '{resolvedPath}' is empty.");
}
return secret;
}
private static string ReadAllText(string path, string contentRootPath)
{
var resolvedPath = ResolvePath(path, contentRootPath);
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"File '{resolvedPath}' was not found.");
}
return File.ReadAllText(resolvedPath);
}
private static string ResolvePath(string path, string contentRootPath)
=> Path.IsPathRooted(path)
? path
: Path.GetFullPath(Path.Combine(contentRootPath, path));
}

View File

@@ -1,85 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Options;
/// <summary>
/// Validation helpers for <see cref="ScannerWebServiceOptions"/>.
/// </summary>
public static class ScannerWebServiceOptionsValidator
{
private static readonly HashSet<string> SupportedStorageDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"mongo"
};
private static readonly HashSet<string> SupportedQueueDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"redis",
"nats",
"rabbitmq"
};
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Options;
/// <summary>
/// Validation helpers for <see cref="ScannerWebServiceOptions"/>.
/// </summary>
public static class ScannerWebServiceOptionsValidator
{
private static readonly HashSet<string> SupportedStorageDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"mongo"
};
private static readonly HashSet<string> SupportedQueueDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"redis",
"nats",
"rabbitmq"
};
private static readonly HashSet<string> SupportedArtifactDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"minio",
"s3",
"rustfs"
};
private static readonly HashSet<string> SupportedEventDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"redis"
};
public static void Validate(ScannerWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.SchemaVersion <= 0)
{
throw new InvalidOperationException("Scanner configuration requires a positive schemaVersion.");
}
options.Storage ??= new ScannerWebServiceOptions.StorageOptions();
ValidateStorage(options.Storage);
options.Queue ??= new ScannerWebServiceOptions.QueueOptions();
ValidateQueue(options.Queue);
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
ValidateArtifactStore(options.ArtifactStore);
options.Features ??= new ScannerWebServiceOptions.FeatureFlagOptions();
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
options.Telemetry ??= new ScannerWebServiceOptions.TelemetryOptions();
ValidateTelemetry(options.Telemetry);
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
ValidateAuthority(options.Authority);
options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
ValidateSigning(options.Signing);
options.Api ??= new ScannerWebServiceOptions.ApiOptions();
if (string.IsNullOrWhiteSpace(options.Api.BasePath))
{
throw new InvalidOperationException("API basePath must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.ScansSegment))
{
throw new InvalidOperationException("API scansSegment must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.ReportsSegment))
{
throw new InvalidOperationException("API reportsSegment must be configured.");
}
private static readonly HashSet<string> SupportedEventDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"redis"
};
public static void Validate(ScannerWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.SchemaVersion <= 0)
{
throw new InvalidOperationException("Scanner configuration requires a positive schemaVersion.");
}
options.Storage ??= new ScannerWebServiceOptions.StorageOptions();
ValidateStorage(options.Storage);
options.Queue ??= new ScannerWebServiceOptions.QueueOptions();
ValidateQueue(options.Queue);
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
ValidateArtifactStore(options.ArtifactStore);
options.Features ??= new ScannerWebServiceOptions.FeatureFlagOptions();
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
options.Telemetry ??= new ScannerWebServiceOptions.TelemetryOptions();
ValidateTelemetry(options.Telemetry);
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
ValidateAuthority(options.Authority);
options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
ValidateSigning(options.Signing);
options.Api ??= new ScannerWebServiceOptions.ApiOptions();
if (string.IsNullOrWhiteSpace(options.Api.BasePath))
{
throw new InvalidOperationException("API basePath must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.ScansSegment))
{
throw new InvalidOperationException("API scansSegment must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.ReportsSegment))
{
throw new InvalidOperationException("API reportsSegment must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.PolicySegment))
{
throw new InvalidOperationException("API policySegment must be configured.");
@@ -96,63 +96,63 @@ public static class ScannerWebServiceOptionsValidator
options.Runtime ??= new ScannerWebServiceOptions.RuntimeOptions();
ValidateRuntime(options.Runtime);
}
private static void ValidateStorage(ScannerWebServiceOptions.StorageOptions storage)
{
if (!SupportedStorageDrivers.Contains(storage.Driver))
{
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Supported drivers: mongo.");
}
if (string.IsNullOrWhiteSpace(storage.Dsn))
{
throw new InvalidOperationException("Storage DSN must be configured.");
}
if (storage.CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Storage commandTimeoutSeconds must be greater than zero.");
}
if (storage.HealthCheckTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Storage healthCheckTimeoutSeconds must be greater than zero.");
}
}
private static void ValidateQueue(ScannerWebServiceOptions.QueueOptions queue)
{
if (!SupportedQueueDrivers.Contains(queue.Driver))
{
throw new InvalidOperationException($"Unsupported queue driver '{queue.Driver}'. Supported drivers: redis, nats, rabbitmq.");
}
if (string.IsNullOrWhiteSpace(queue.Dsn))
{
throw new InvalidOperationException("Queue DSN must be configured.");
}
if (string.IsNullOrWhiteSpace(queue.Namespace))
{
throw new InvalidOperationException("Queue namespace must be configured.");
}
if (queue.VisibilityTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Queue visibilityTimeoutSeconds must be greater than zero.");
}
if (queue.LeaseHeartbeatSeconds <= 0)
{
throw new InvalidOperationException("Queue leaseHeartbeatSeconds must be greater than zero.");
}
if (queue.MaxDeliveryAttempts <= 0)
{
throw new InvalidOperationException("Queue maxDeliveryAttempts must be greater than zero.");
}
}
private static void ValidateStorage(ScannerWebServiceOptions.StorageOptions storage)
{
if (!SupportedStorageDrivers.Contains(storage.Driver))
{
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Supported drivers: mongo.");
}
if (string.IsNullOrWhiteSpace(storage.Dsn))
{
throw new InvalidOperationException("Storage DSN must be configured.");
}
if (storage.CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Storage commandTimeoutSeconds must be greater than zero.");
}
if (storage.HealthCheckTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Storage healthCheckTimeoutSeconds must be greater than zero.");
}
}
private static void ValidateQueue(ScannerWebServiceOptions.QueueOptions queue)
{
if (!SupportedQueueDrivers.Contains(queue.Driver))
{
throw new InvalidOperationException($"Unsupported queue driver '{queue.Driver}'. Supported drivers: redis, nats, rabbitmq.");
}
if (string.IsNullOrWhiteSpace(queue.Dsn))
{
throw new InvalidOperationException("Queue DSN must be configured.");
}
if (string.IsNullOrWhiteSpace(queue.Namespace))
{
throw new InvalidOperationException("Queue namespace must be configured.");
}
if (queue.VisibilityTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Queue visibilityTimeoutSeconds must be greater than zero.");
}
if (queue.LeaseHeartbeatSeconds <= 0)
{
throw new InvalidOperationException("Queue leaseHeartbeatSeconds must be greater than zero.");
}
if (queue.MaxDeliveryAttempts <= 0)
{
throw new InvalidOperationException("Queue maxDeliveryAttempts must be greater than zero.");
}
}
private static void ValidateArtifactStore(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore)
{
if (!SupportedArtifactDrivers.Contains(artifactStore.Driver))
@@ -200,225 +200,225 @@ public static class ScannerWebServiceOptionsValidator
throw new InvalidOperationException("Artifact store objectLockRetentionDays must be greater than zero when object lock is enabled.");
}
}
private static void ValidateEvents(ScannerWebServiceOptions.EventsOptions eventsOptions)
{
if (!eventsOptions.Enabled)
{
return;
}
if (!SupportedEventDrivers.Contains(eventsOptions.Driver))
{
throw new InvalidOperationException($"Unsupported events driver '{eventsOptions.Driver}'. Supported drivers: redis.");
}
if (string.IsNullOrWhiteSpace(eventsOptions.Dsn))
{
throw new InvalidOperationException("Events DSN must be configured when event emission is enabled.");
}
if (string.IsNullOrWhiteSpace(eventsOptions.Stream))
{
throw new InvalidOperationException("Events stream must be configured when event emission is enabled.");
}
if (eventsOptions.PublishTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Events publishTimeoutSeconds must be greater than zero.");
}
if (eventsOptions.MaxStreamLength < 0)
{
throw new InvalidOperationException("Events maxStreamLength must be zero or greater.");
}
}
private static void ValidateEvents(ScannerWebServiceOptions.EventsOptions eventsOptions)
{
if (!eventsOptions.Enabled)
{
return;
}
if (!SupportedEventDrivers.Contains(eventsOptions.Driver))
{
throw new InvalidOperationException($"Unsupported events driver '{eventsOptions.Driver}'. Supported drivers: redis.");
}
if (string.IsNullOrWhiteSpace(eventsOptions.Dsn))
{
throw new InvalidOperationException("Events DSN must be configured when event emission is enabled.");
}
if (string.IsNullOrWhiteSpace(eventsOptions.Stream))
{
throw new InvalidOperationException("Events stream must be configured when event emission is enabled.");
}
if (eventsOptions.PublishTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Events publishTimeoutSeconds must be greater than zero.");
}
if (eventsOptions.MaxStreamLength < 0)
{
throw new InvalidOperationException("Events maxStreamLength must be zero or greater.");
}
}
private static void ValidateTelemetry(ScannerWebServiceOptions.TelemetryOptions telemetry)
{
if (string.IsNullOrWhiteSpace(telemetry.MinimumLogLevel))
{
throw new InvalidOperationException("Telemetry minimumLogLevel must be configured.");
}
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _))
{
throw new InvalidOperationException($"Telemetry minimumLogLevel '{telemetry.MinimumLogLevel}' is invalid.");
}
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint) && !Uri.TryCreate(telemetry.OtlpEndpoint, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Telemetry OTLP endpoint must be an absolute URI when specified.");
}
foreach (var attribute in telemetry.ResourceAttributes)
{
if (string.IsNullOrWhiteSpace(attribute.Key))
{
throw new InvalidOperationException("Telemetry resource attribute keys must be non-empty.");
}
}
foreach (var header in telemetry.OtlpHeaders)
{
if (string.IsNullOrWhiteSpace(header.Key))
{
throw new InvalidOperationException("Telemetry OTLP header keys must be non-empty.");
}
}
{
if (string.IsNullOrWhiteSpace(telemetry.MinimumLogLevel))
{
throw new InvalidOperationException("Telemetry minimumLogLevel must be configured.");
}
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _))
{
throw new InvalidOperationException($"Telemetry minimumLogLevel '{telemetry.MinimumLogLevel}' is invalid.");
}
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint) && !Uri.TryCreate(telemetry.OtlpEndpoint, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Telemetry OTLP endpoint must be an absolute URI when specified.");
}
foreach (var attribute in telemetry.ResourceAttributes)
{
if (string.IsNullOrWhiteSpace(attribute.Key))
{
throw new InvalidOperationException("Telemetry resource attribute keys must be non-empty.");
}
}
foreach (var header in telemetry.OtlpHeaders)
{
if (string.IsNullOrWhiteSpace(header.Key))
{
throw new InvalidOperationException("Telemetry OTLP header keys must be non-empty.");
}
}
}
private static void ValidateAuthority(ScannerWebServiceOptions.AuthorityOptions authority)
{
authority.Resilience ??= new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions();
NormalizeList(authority.Audiences, toLower: false);
NormalizeList(authority.RequiredScopes, toLower: true);
NormalizeList(authority.BypassNetworks, toLower: false);
NormalizeList(authority.ClientScopes, toLower: true);
NormalizeResilience(authority.Resilience);
if (authority.RequiredScopes.Count == 0)
{
authority.RequiredScopes.Add(ScannerAuthorityScopes.ScansEnqueue);
}
if (authority.ClientScopes.Count == 0)
{
foreach (var scope in authority.RequiredScopes)
{
authority.ClientScopes.Add(scope);
}
}
if (authority.BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero.");
}
if (authority.TokenClockSkewSeconds < 0 || authority.TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Authority tokenClockSkewSeconds must be between 0 and 300 seconds.");
}
if (!authority.Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(authority.Issuer))
{
throw new InvalidOperationException("Authority issuer must be configured when authority is enabled.");
}
if (!Uri.TryCreate(authority.Issuer, UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
}
if (authority.RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Authority issuer must use HTTPS when requireHttpsMetadata is enabled.");
}
if (!string.IsNullOrWhiteSpace(authority.MetadataAddress) && !Uri.TryCreate(authority.MetadataAddress, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Authority metadataAddress must be an absolute URI when specified.");
}
if (authority.Audiences.Count == 0)
{
throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled.");
}
if (!authority.AllowAnonymousFallback)
{
if (string.IsNullOrWhiteSpace(authority.ClientId))
{
throw new InvalidOperationException("Authority clientId must be configured when anonymous fallback is disabled.");
}
if (string.IsNullOrWhiteSpace(authority.ClientSecret))
{
throw new InvalidOperationException("Authority clientSecret must be configured when anonymous fallback is disabled.");
}
}
}
private static void ValidateSigning(ScannerWebServiceOptions.SigningOptions signing)
{
if (signing.EnvelopeTtlSeconds <= 0)
{
throw new InvalidOperationException("Signing envelopeTtlSeconds must be greater than zero.");
}
if (!signing.Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(signing.KeyId))
{
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
}
if (string.IsNullOrWhiteSpace(signing.Algorithm))
{
throw new InvalidOperationException("Signing algorithm must be configured when signing is enabled.");
}
if (string.IsNullOrWhiteSpace(signing.KeyPem) && string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
throw new InvalidOperationException("Signing requires keyPem or keyPemFile when enabled.");
}
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = values.Count - 1; i >= 0; i--)
{
var entry = values[i];
if (string.IsNullOrWhiteSpace(entry))
{
values.RemoveAt(i);
continue;
}
var normalized = toLower ? entry.Trim().ToLowerInvariant() : entry.Trim();
if (!seen.Add(normalized))
{
values.RemoveAt(i);
continue;
}
values[i] = normalized;
}
}
private static void NormalizeResilience(ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions resilience)
{
if (resilience.RetryDelays is null)
{
return;
}
foreach (var delay in resilience.RetryDelays.ToArray())
{
if (delay <= TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience retryDelays must be greater than zero.");
}
}
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value < TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero.");
}
{
authority.Resilience ??= new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions();
NormalizeList(authority.Audiences, toLower: false);
NormalizeList(authority.RequiredScopes, toLower: true);
NormalizeList(authority.BypassNetworks, toLower: false);
NormalizeList(authority.ClientScopes, toLower: true);
NormalizeResilience(authority.Resilience);
if (authority.RequiredScopes.Count == 0)
{
authority.RequiredScopes.Add(ScannerAuthorityScopes.ScansEnqueue);
}
if (authority.ClientScopes.Count == 0)
{
foreach (var scope in authority.RequiredScopes)
{
authority.ClientScopes.Add(scope);
}
}
if (authority.BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero.");
}
if (authority.TokenClockSkewSeconds < 0 || authority.TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Authority tokenClockSkewSeconds must be between 0 and 300 seconds.");
}
if (!authority.Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(authority.Issuer))
{
throw new InvalidOperationException("Authority issuer must be configured when authority is enabled.");
}
if (!Uri.TryCreate(authority.Issuer, UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
}
if (authority.RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Authority issuer must use HTTPS when requireHttpsMetadata is enabled.");
}
if (!string.IsNullOrWhiteSpace(authority.MetadataAddress) && !Uri.TryCreate(authority.MetadataAddress, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Authority metadataAddress must be an absolute URI when specified.");
}
if (authority.Audiences.Count == 0)
{
throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled.");
}
if (!authority.AllowAnonymousFallback)
{
if (string.IsNullOrWhiteSpace(authority.ClientId))
{
throw new InvalidOperationException("Authority clientId must be configured when anonymous fallback is disabled.");
}
if (string.IsNullOrWhiteSpace(authority.ClientSecret))
{
throw new InvalidOperationException("Authority clientSecret must be configured when anonymous fallback is disabled.");
}
}
}
private static void ValidateSigning(ScannerWebServiceOptions.SigningOptions signing)
{
if (signing.EnvelopeTtlSeconds <= 0)
{
throw new InvalidOperationException("Signing envelopeTtlSeconds must be greater than zero.");
}
if (!signing.Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(signing.KeyId))
{
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
}
if (string.IsNullOrWhiteSpace(signing.Algorithm))
{
throw new InvalidOperationException("Signing algorithm must be configured when signing is enabled.");
}
if (string.IsNullOrWhiteSpace(signing.KeyPem) && string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
throw new InvalidOperationException("Signing requires keyPem or keyPemFile when enabled.");
}
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = values.Count - 1; i >= 0; i--)
{
var entry = values[i];
if (string.IsNullOrWhiteSpace(entry))
{
values.RemoveAt(i);
continue;
}
var normalized = toLower ? entry.Trim().ToLowerInvariant() : entry.Trim();
if (!seen.Add(normalized))
{
values.RemoveAt(i);
continue;
}
values[i] = normalized;
}
}
private static void NormalizeResilience(ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions resilience)
{
if (resilience.RetryDelays is null)
{
return;
}
foreach (var delay in resilience.RetryDelays.ToArray())
{
if (delay <= TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience retryDelays must be greater than zero.");
}
}
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value < TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero.");
}
}
private static void ValidateRuntime(ScannerWebServiceOptions.RuntimeOptions runtime)

View File

@@ -1,26 +1,26 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.WebService.Security;
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AnonymousAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(authenticationType: Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.WebService.Security;
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AnonymousAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(authenticationType: Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Scanner.WebService.Security;
/// <summary>
/// Canonical scope names consumed by the Scanner WebService.
/// </summary>
internal static class ScannerAuthorityScopes
{
namespace StellaOps.Scanner.WebService.Security;
/// <summary>
/// Canonical scope names consumed by the Scanner WebService.
/// </summary>
internal static class ScannerAuthorityScopes
{
public const string ScansEnqueue = "scanner.scans.enqueue";
public const string ScansRead = "scanner.scans.read";
public const string ReportsRead = "scanner.reports.read";

View File

@@ -1,7 +1,7 @@
namespace StellaOps.Scanner.WebService.Security;
internal static class ScannerPolicies
{
namespace StellaOps.Scanner.WebService.Security;
internal static class ScannerPolicies
{
public const string ScansEnqueue = "scanner.api";
public const string ScansRead = "scanner.scans.read";
public const string Reports = "scanner.reports";

View File

@@ -1,230 +1,230 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Serialization;
internal static class OrchestratorEventSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
public static string Serialize(OrchestratorEvent @event)
=> JsonSerializer.Serialize(@event, CompactOptions);
public static string SerializeIndented(OrchestratorEvent @event)
=> JsonSerializer.Serialize(@event, PrettyOptions);
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = writeIndented,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
return options;
}
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
{
private static readonly ImmutableDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
{
[typeof(OrchestratorEvent)] = new[]
{
"eventId",
"kind",
"version",
"tenant",
"occurredAt",
"recordedAt",
"source",
"idempotencyKey",
"correlationId",
"traceId",
"spanId",
"scope",
"payload",
"attributes"
},
[typeof(OrchestratorEventScope)] = new[]
{
"namespace",
"repo",
"digest",
"component",
"image"
},
[typeof(ReportReadyEventPayload)] = new[]
{
"reportId",
"scanId",
"imageDigest",
"generatedAt",
"verdict",
"summary",
"delta",
"quietedFindingCount",
"policy",
"links",
"dsse",
"report"
},
[typeof(ScanCompletedEventPayload)] = new[]
{
"reportId",
"scanId",
"imageDigest",
"verdict",
"summary",
"delta",
"policy",
"findings",
"links",
"dsse",
"report"
},
[typeof(ReportDeltaPayload)] = new[]
{
"newCritical",
"newHigh",
"kev"
},
[typeof(ReportLinksPayload)] = new[]
{
"report",
"policy",
"attestation"
},
[typeof(LinkTarget)] = new[]
{
"ui",
"api"
},
[typeof(FindingSummaryPayload)] = new[]
{
"id",
"severity",
"cve",
"purl",
"reachability"
},
[typeof(ReportPolicyDto)] = new[]
{
"revisionId",
"digest"
},
[typeof(ReportSummaryDto)] = new[]
{
"total",
"blocked",
"warned",
"ignored",
"quieted"
},
[typeof(ReportDocumentDto)] = new[]
{
"reportId",
"imageDigest",
"generatedAt",
"verdict",
"policy",
"summary",
"verdicts",
"issues"
},
[typeof(DsseEnvelopeDto)] = new[]
{
"payloadType",
"payload",
"signatures"
},
[typeof(DsseSignatureDto)] = new[]
{
"keyId",
"algorithm",
"signature"
}
}.ToImmutableDictionary();
private readonly IJsonTypeInfoResolver _inner;
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options)
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
{
var ordered = info.Properties
.OrderBy(property => GetOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
ConfigurePolymorphism(info);
return info;
}
private static int GetOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is { } index and >= 0)
{
return index;
}
if (type.BaseType is not null)
{
return GetOrder(type.BaseType, propertyName);
}
return int.MaxValue;
}
private static void ConfigurePolymorphism(JsonTypeInfo info)
{
if (info.Type != typeof(OrchestratorEventPayload))
{
return;
}
info.PolymorphismOptions ??= new JsonPolymorphismOptions();
AddDerivedType(info.PolymorphismOptions, typeof(ReportReadyEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanCompletedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanStartedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanFailedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(SbomGeneratedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(VulnerabilityDetectedEventPayload));
}
private static void AddDerivedType(JsonPolymorphismOptions options, Type derivedType)
{
if (options.DerivedTypes.Any(d => d.DerivedType == derivedType))
{
return;
}
options.DerivedTypes.Add(new JsonDerivedType(derivedType));
}
}
}
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Serialization;
internal static class OrchestratorEventSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
public static string Serialize(OrchestratorEvent @event)
=> JsonSerializer.Serialize(@event, CompactOptions);
public static string SerializeIndented(OrchestratorEvent @event)
=> JsonSerializer.Serialize(@event, PrettyOptions);
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = writeIndented,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
return options;
}
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
{
private static readonly ImmutableDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
{
[typeof(OrchestratorEvent)] = new[]
{
"eventId",
"kind",
"version",
"tenant",
"occurredAt",
"recordedAt",
"source",
"idempotencyKey",
"correlationId",
"traceId",
"spanId",
"scope",
"payload",
"attributes"
},
[typeof(OrchestratorEventScope)] = new[]
{
"namespace",
"repo",
"digest",
"component",
"image"
},
[typeof(ReportReadyEventPayload)] = new[]
{
"reportId",
"scanId",
"imageDigest",
"generatedAt",
"verdict",
"summary",
"delta",
"quietedFindingCount",
"policy",
"links",
"dsse",
"report"
},
[typeof(ScanCompletedEventPayload)] = new[]
{
"reportId",
"scanId",
"imageDigest",
"verdict",
"summary",
"delta",
"policy",
"findings",
"links",
"dsse",
"report"
},
[typeof(ReportDeltaPayload)] = new[]
{
"newCritical",
"newHigh",
"kev"
},
[typeof(ReportLinksPayload)] = new[]
{
"report",
"policy",
"attestation"
},
[typeof(LinkTarget)] = new[]
{
"ui",
"api"
},
[typeof(FindingSummaryPayload)] = new[]
{
"id",
"severity",
"cve",
"purl",
"reachability"
},
[typeof(ReportPolicyDto)] = new[]
{
"revisionId",
"digest"
},
[typeof(ReportSummaryDto)] = new[]
{
"total",
"blocked",
"warned",
"ignored",
"quieted"
},
[typeof(ReportDocumentDto)] = new[]
{
"reportId",
"imageDigest",
"generatedAt",
"verdict",
"policy",
"summary",
"verdicts",
"issues"
},
[typeof(DsseEnvelopeDto)] = new[]
{
"payloadType",
"payload",
"signatures"
},
[typeof(DsseSignatureDto)] = new[]
{
"keyId",
"algorithm",
"signature"
}
}.ToImmutableDictionary();
private readonly IJsonTypeInfoResolver _inner;
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options)
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
{
var ordered = info.Properties
.OrderBy(property => GetOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
ConfigurePolymorphism(info);
return info;
}
private static int GetOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is { } index and >= 0)
{
return index;
}
if (type.BaseType is not null)
{
return GetOrder(type.BaseType, propertyName);
}
return int.MaxValue;
}
private static void ConfigurePolymorphism(JsonTypeInfo info)
{
if (info.Type != typeof(OrchestratorEventPayload))
{
return;
}
info.PolymorphismOptions ??= new JsonPolymorphismOptions();
AddDerivedType(info.PolymorphismOptions, typeof(ReportReadyEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanCompletedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanStartedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanFailedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(SbomGeneratedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(VulnerabilityDetectedEventPayload));
}
private static void AddDerivedType(JsonPolymorphismOptions options, Type derivedType)
{
if (options.DerivedTypes.Any(d => d.DerivedType == derivedType))
{
return;
}
options.DerivedTypes.Add(new JsonDerivedType(derivedType));
}
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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))

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -1,48 +1,48 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Utilities;
internal static class ScanIdGenerator
{
public static ScanId Create(
ScanTarget target,
bool force,
string? clientRequestId,
IReadOnlyDictionary<string, string>? metadata)
{
ArgumentNullException.ThrowIfNull(target);
var builder = new StringBuilder();
builder.Append('|');
builder.Append(target.Reference?.Trim().ToLowerInvariant() ?? string.Empty);
builder.Append('|');
builder.Append(target.Digest?.Trim().ToLowerInvariant() ?? string.Empty);
builder.Append("|force:");
builder.Append(force ? '1' : '0');
builder.Append("|client:");
builder.Append(clientRequestId?.Trim().ToLowerInvariant() ?? string.Empty);
if (metadata is not null && metadata.Count > 0)
{
foreach (var pair in metadata.OrderBy(static entry => entry.Key, StringComparer.OrdinalIgnoreCase))
{
var key = pair.Key?.Trim().ToLowerInvariant() ?? string.Empty;
var value = pair.Value?.Trim() ?? string.Empty;
builder.Append('|');
builder.Append(key);
builder.Append('=');
builder.Append(value);
}
}
var canonical = builder.ToString();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
var hex = Convert.ToHexString(hash).ToLowerInvariant();
var trimmed = hex.Length > 40 ? hex[..40] : hex;
return new ScanId(trimmed);
}
}
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Utilities;
internal static class ScanIdGenerator
{
public static ScanId Create(
ScanTarget target,
bool force,
string? clientRequestId,
IReadOnlyDictionary<string, string>? metadata)
{
ArgumentNullException.ThrowIfNull(target);
var builder = new StringBuilder();
builder.Append('|');
builder.Append(target.Reference?.Trim().ToLowerInvariant() ?? string.Empty);
builder.Append('|');
builder.Append(target.Digest?.Trim().ToLowerInvariant() ?? string.Empty);
builder.Append("|force:");
builder.Append(force ? '1' : '0');
builder.Append("|client:");
builder.Append(clientRequestId?.Trim().ToLowerInvariant() ?? string.Empty);
if (metadata is not null && metadata.Count > 0)
{
foreach (var pair in metadata.OrderBy(static entry => entry.Key, StringComparer.OrdinalIgnoreCase))
{
var key = pair.Key?.Trim().ToLowerInvariant() ?? string.Empty;
var value = pair.Value?.Trim() ?? string.Empty;
builder.Append('|');
builder.Append(key);
builder.Append('=');
builder.Append(value);
}
}
var canonical = builder.ToString();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
var hex = Convert.ToHexString(hash).ToLowerInvariant();
var trimmed = hex.Length > 40 ? hex[..40] : hex;
return new ScanId(trimmed);
}
}