up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,49 +1,49 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Sends provenance placeholders to the Attestor service for asynchronous DSSE signing.
|
||||
/// </summary>
|
||||
public sealed class AttestorClient
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
public AttestorClient(HttpClient httpClient)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task SendPlaceholderAsync(Uri attestorUri, DescriptorDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
if (attestorUri is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(attestorUri));
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
var payload = new AttestorProvenanceRequest(
|
||||
ImageDigest: document.Subject.Digest,
|
||||
SbomDigest: document.Artifact.Digest,
|
||||
ExpectedDsseSha256: document.Provenance.ExpectedDsseSha256,
|
||||
Nonce: document.Provenance.Nonce,
|
||||
PredicateType: document.Provenance.PredicateType,
|
||||
Schema: document.Schema);
|
||||
|
||||
using var response = await httpClient.PostAsJsonAsync(attestorUri, payload, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new BuildxPluginException($"Attestor rejected provenance placeholder ({(int)response.StatusCode}): {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Sends provenance placeholders to the Attestor service for asynchronous DSSE signing.
|
||||
/// </summary>
|
||||
public sealed class AttestorClient
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
public AttestorClient(HttpClient httpClient)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task SendPlaceholderAsync(Uri attestorUri, DescriptorDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
if (attestorUri is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(attestorUri));
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
var payload = new AttestorProvenanceRequest(
|
||||
ImageDigest: document.Subject.Digest,
|
||||
SbomDigest: document.Artifact.Digest,
|
||||
ExpectedDsseSha256: document.Provenance.ExpectedDsseSha256,
|
||||
Nonce: document.Provenance.Nonce,
|
||||
PredicateType: document.Provenance.PredicateType,
|
||||
Schema: document.Schema);
|
||||
|
||||
using var response = await httpClient.PostAsJsonAsync(attestorUri, payload, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new BuildxPluginException($"Attestor rejected provenance placeholder ({(int)response.StatusCode}): {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
|
||||
|
||||
public sealed record AttestorProvenanceRequest(
|
||||
[property: JsonPropertyName("imageDigest")] string ImageDigest,
|
||||
[property: JsonPropertyName("sbomDigest")] string SbomDigest,
|
||||
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
|
||||
[property: JsonPropertyName("nonce")] string Nonce,
|
||||
[property: JsonPropertyName("predicateType")] string PredicateType,
|
||||
[property: JsonPropertyName("schema")] string Schema);
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
|
||||
|
||||
public sealed record AttestorProvenanceRequest(
|
||||
[property: JsonPropertyName("imageDigest")] string ImageDigest,
|
||||
[property: JsonPropertyName("sbomDigest")] string SbomDigest,
|
||||
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
|
||||
[property: JsonPropertyName("nonce")] string Nonce,
|
||||
[property: JsonPropertyName("predicateType")] string PredicateType,
|
||||
[property: JsonPropertyName("schema")] string Schema);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
|
||||
|
||||
/// <summary>
|
||||
/// Represents user-facing errors raised by the BuildX plug-in.
|
||||
/// </summary>
|
||||
public sealed class BuildxPluginException : Exception
|
||||
{
|
||||
public BuildxPluginException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BuildxPluginException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
|
||||
|
||||
/// <summary>
|
||||
/// Represents user-facing errors raised by the BuildX plug-in.
|
||||
/// </summary>
|
||||
public sealed class BuildxPluginException : Exception
|
||||
{
|
||||
public BuildxPluginException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BuildxPluginException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Result of persisting bytes into the local CAS.
|
||||
/// </summary>
|
||||
public sealed record CasWriteResult(string Algorithm, string Digest, string Path);
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Result of persisting bytes into the local CAS.
|
||||
/// </summary>
|
||||
public sealed record CasWriteResult(string Algorithm, string Digest, string Path);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal filesystem-backed CAS used when the BuildX generator runs inside CI.
|
||||
/// </summary>
|
||||
public sealed class LocalCasClient
|
||||
{
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal filesystem-backed CAS used when the BuildX generator runs inside CI.
|
||||
/// </summary>
|
||||
public sealed class LocalCasClient
|
||||
{
|
||||
private readonly string rootDirectory;
|
||||
private readonly string algorithm;
|
||||
private readonly ICryptoHash hash;
|
||||
@@ -24,49 +24,49 @@ public sealed class LocalCasClient
|
||||
this.hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
|
||||
algorithm = options.Algorithm.ToLowerInvariant();
|
||||
if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Only the sha256 algorithm is supported.", nameof(options));
|
||||
}
|
||||
|
||||
if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Only the sha256 algorithm is supported.", nameof(options));
|
||||
}
|
||||
|
||||
rootDirectory = Path.GetFullPath(options.RootDirectory);
|
||||
}
|
||||
|
||||
public Task<CasWriteResult> VerifyWriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadOnlyMemory<byte> probe = "stellaops-buildx-probe"u8.ToArray();
|
||||
return WriteAsync(probe, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CasWriteResult> WriteAsync(ReadOnlyMemory<byte> content, CancellationToken cancellationToken)
|
||||
{
|
||||
var digest = ComputeDigest(content.Span);
|
||||
var path = BuildObjectPath(digest);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
bufferSize: 16 * 1024,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
await stream.WriteAsync(content, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new CasWriteResult(algorithm, digest, path);
|
||||
}
|
||||
|
||||
private string BuildObjectPath(string digest)
|
||||
{
|
||||
// Layout: <root>/<algorithm>/<first two>/<rest>.bin
|
||||
var prefix = digest.Substring(0, 2);
|
||||
var suffix = digest[2..];
|
||||
return Path.Combine(rootDirectory, algorithm, prefix, $"{suffix}.bin");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public Task<CasWriteResult> VerifyWriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadOnlyMemory<byte> probe = "stellaops-buildx-probe"u8.ToArray();
|
||||
return WriteAsync(probe, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CasWriteResult> WriteAsync(ReadOnlyMemory<byte> content, CancellationToken cancellationToken)
|
||||
{
|
||||
var digest = ComputeDigest(content.Span);
|
||||
var path = BuildObjectPath(digest);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
bufferSize: 16 * 1024,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
await stream.WriteAsync(content, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new CasWriteResult(algorithm, digest, path);
|
||||
}
|
||||
|
||||
private string BuildObjectPath(string digest)
|
||||
{
|
||||
// Layout: <root>/<algorithm>/<first two>/<rest>.bin
|
||||
var prefix = digest.Substring(0, 2);
|
||||
var suffix = digest[2..];
|
||||
return Path.Combine(rootDirectory, algorithm, prefix, $"{suffix}.bin");
|
||||
}
|
||||
|
||||
private string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
return hash.ComputeHashHex(content, HashAlgorithms.Sha256);
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the on-disk content-addressable store used during CI.
|
||||
/// </summary>
|
||||
public sealed record LocalCasOptions
|
||||
{
|
||||
private string rootDirectory = string.Empty;
|
||||
private string algorithm = "sha256";
|
||||
|
||||
public string RootDirectory
|
||||
{
|
||||
get => rootDirectory;
|
||||
init
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Root directory must be provided.", nameof(value));
|
||||
}
|
||||
|
||||
rootDirectory = value;
|
||||
}
|
||||
}
|
||||
|
||||
public string Algorithm
|
||||
{
|
||||
get => algorithm;
|
||||
init
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Algorithm must be provided.", nameof(value));
|
||||
}
|
||||
|
||||
algorithm = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the on-disk content-addressable store used during CI.
|
||||
/// </summary>
|
||||
public sealed record LocalCasOptions
|
||||
{
|
||||
private string rootDirectory = string.Empty;
|
||||
private string algorithm = "sha256";
|
||||
|
||||
public string RootDirectory
|
||||
{
|
||||
get => rootDirectory;
|
||||
init
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Root directory must be provided.", nameof(value));
|
||||
}
|
||||
|
||||
rootDirectory = value;
|
||||
}
|
||||
}
|
||||
|
||||
public string Algorithm
|
||||
{
|
||||
get => algorithm;
|
||||
init
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Algorithm must be provided.", nameof(value));
|
||||
}
|
||||
|
||||
algorithm = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OCI artifact descriptor emitted by the BuildX generator.
|
||||
/// </summary>
|
||||
public sealed record DescriptorArtifact(
|
||||
[property: JsonPropertyName("mediaType")] string MediaType,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("size")] long Size,
|
||||
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string> Annotations);
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OCI artifact descriptor emitted by the BuildX generator.
|
||||
/// </summary>
|
||||
public sealed record DescriptorArtifact(
|
||||
[property: JsonPropertyName("mediaType")] string MediaType,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("size")] long Size,
|
||||
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Root payload describing BuildX generator output with provenance placeholders.
|
||||
/// </summary>
|
||||
public sealed record DescriptorDocument(
|
||||
[property: JsonPropertyName("schema")] string Schema,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("generator")] DescriptorGeneratorMetadata Generator,
|
||||
[property: JsonPropertyName("subject")] DescriptorSubject Subject,
|
||||
[property: JsonPropertyName("artifact")] DescriptorArtifact Artifact,
|
||||
[property: JsonPropertyName("provenance")] DescriptorProvenance Provenance,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Root payload describing BuildX generator output with provenance placeholders.
|
||||
/// </summary>
|
||||
public sealed record DescriptorDocument(
|
||||
[property: JsonPropertyName("schema")] string Schema,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("generator")] DescriptorGeneratorMetadata Generator,
|
||||
[property: JsonPropertyName("subject")] DescriptorSubject Subject,
|
||||
[property: JsonPropertyName("artifact")] DescriptorArtifact Artifact,
|
||||
[property: JsonPropertyName("provenance")] DescriptorProvenance Provenance,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Builds immutable OCI descriptors enriched with provenance placeholders.
|
||||
/// </summary>
|
||||
public sealed class DescriptorGenerator
|
||||
{
|
||||
public const string Schema = "stellaops.buildx.descriptor.v1";
|
||||
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Builds immutable OCI descriptors enriched with provenance placeholders.
|
||||
/// </summary>
|
||||
public sealed class DescriptorGenerator
|
||||
{
|
||||
public const string Schema = "stellaops.buildx.descriptor.v1";
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ICryptoHash _hash;
|
||||
|
||||
@@ -24,66 +24,66 @@ public sealed class DescriptorGenerator
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
}
|
||||
|
||||
public async Task<DescriptorDocument> CreateAsync(DescriptorRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
throw new BuildxPluginException("Image digest must be provided.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SbomPath))
|
||||
{
|
||||
throw new BuildxPluginException("SBOM path must be provided.");
|
||||
}
|
||||
|
||||
var sbomFile = new FileInfo(request.SbomPath);
|
||||
if (!sbomFile.Exists)
|
||||
{
|
||||
throw new BuildxPluginException($"SBOM file '{request.SbomPath}' was not found.");
|
||||
}
|
||||
|
||||
|
||||
public async Task<DescriptorDocument> CreateAsync(DescriptorRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
throw new BuildxPluginException("Image digest must be provided.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SbomPath))
|
||||
{
|
||||
throw new BuildxPluginException("SBOM path must be provided.");
|
||||
}
|
||||
|
||||
var sbomFile = new FileInfo(request.SbomPath);
|
||||
if (!sbomFile.Exists)
|
||||
{
|
||||
throw new BuildxPluginException($"SBOM file '{request.SbomPath}' was not found.");
|
||||
}
|
||||
|
||||
var sbomDigest = await ComputeFileDigestAsync(sbomFile, cancellationToken).ConfigureAwait(false);
|
||||
var nonce = ComputeDeterministicNonce(request, sbomFile, sbomDigest);
|
||||
var expectedDsseSha = ComputeExpectedDsseDigest(request.ImageDigest, sbomDigest, nonce);
|
||||
|
||||
var artifactAnnotations = BuildArtifactAnnotations(request, nonce, expectedDsseSha);
|
||||
|
||||
var subject = new DescriptorSubject(
|
||||
MediaType: request.SubjectMediaType,
|
||||
Digest: request.ImageDigest);
|
||||
|
||||
var artifact = new DescriptorArtifact(
|
||||
MediaType: request.SbomMediaType,
|
||||
Digest: sbomDigest,
|
||||
Size: sbomFile.Length,
|
||||
Annotations: artifactAnnotations);
|
||||
|
||||
var provenance = new DescriptorProvenance(
|
||||
Status: "pending",
|
||||
ExpectedDsseSha256: expectedDsseSha,
|
||||
Nonce: nonce,
|
||||
AttestorUri: request.AttestorUri,
|
||||
PredicateType: request.PredicateType);
|
||||
|
||||
var generatorMetadata = new DescriptorGeneratorMetadata(
|
||||
Name: request.GeneratorName ?? "StellaOps.Scanner.Sbomer.BuildXPlugin",
|
||||
Version: request.GeneratorVersion);
|
||||
|
||||
var metadata = BuildDocumentMetadata(request, sbomFile, sbomDigest);
|
||||
|
||||
|
||||
var subject = new DescriptorSubject(
|
||||
MediaType: request.SubjectMediaType,
|
||||
Digest: request.ImageDigest);
|
||||
|
||||
var artifact = new DescriptorArtifact(
|
||||
MediaType: request.SbomMediaType,
|
||||
Digest: sbomDigest,
|
||||
Size: sbomFile.Length,
|
||||
Annotations: artifactAnnotations);
|
||||
|
||||
var provenance = new DescriptorProvenance(
|
||||
Status: "pending",
|
||||
ExpectedDsseSha256: expectedDsseSha,
|
||||
Nonce: nonce,
|
||||
AttestorUri: request.AttestorUri,
|
||||
PredicateType: request.PredicateType);
|
||||
|
||||
var generatorMetadata = new DescriptorGeneratorMetadata(
|
||||
Name: request.GeneratorName ?? "StellaOps.Scanner.Sbomer.BuildXPlugin",
|
||||
Version: request.GeneratorVersion);
|
||||
|
||||
var metadata = BuildDocumentMetadata(request, sbomFile, sbomDigest);
|
||||
|
||||
return new DescriptorDocument(
|
||||
Schema: Schema,
|
||||
GeneratedAt: _timeProvider.GetUtcNow(),
|
||||
Generator: generatorMetadata,
|
||||
Subject: subject,
|
||||
Artifact: artifact,
|
||||
Provenance: provenance,
|
||||
Generator: generatorMetadata,
|
||||
Subject: subject,
|
||||
Artifact: artifact,
|
||||
Provenance: provenance,
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
@@ -136,63 +136,63 @@ public sealed class DescriptorGenerator
|
||||
var digest = _hash.ComputeHash(bytes, HashAlgorithms.Sha256);
|
||||
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildArtifactAnnotations(DescriptorRequest request, string nonce, string expectedDsse)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["org.opencontainers.artifact.type"] = request.SbomArtifactType,
|
||||
["org.stellaops.scanner.version"] = request.GeneratorVersion,
|
||||
["org.stellaops.sbom.kind"] = request.SbomKind,
|
||||
["org.stellaops.sbom.format"] = request.SbomFormat,
|
||||
["org.stellaops.provenance.status"] = "pending",
|
||||
["org.stellaops.provenance.dsse.sha256"] = expectedDsse,
|
||||
["org.stellaops.provenance.nonce"] = nonce
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.LicenseId))
|
||||
{
|
||||
annotations["org.stellaops.license.id"] = request.LicenseId!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SbomName))
|
||||
{
|
||||
annotations["org.opencontainers.image.title"] = request.SbomName!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Repository))
|
||||
{
|
||||
annotations["org.stellaops.repository"] = request.Repository!;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildDocumentMetadata(DescriptorRequest request, FileInfo fileInfo, string sbomDigest)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbomDigest"] = sbomDigest,
|
||||
["sbomPath"] = fileInfo.FullName,
|
||||
["sbomMediaType"] = request.SbomMediaType,
|
||||
["subjectMediaType"] = request.SubjectMediaType
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Repository))
|
||||
{
|
||||
metadata["repository"] = request.Repository!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.BuildRef))
|
||||
{
|
||||
metadata["buildRef"] = request.BuildRef!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.AttestorUri))
|
||||
{
|
||||
metadata["attestorUri"] = request.AttestorUri!;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildArtifactAnnotations(DescriptorRequest request, string nonce, string expectedDsse)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["org.opencontainers.artifact.type"] = request.SbomArtifactType,
|
||||
["org.stellaops.scanner.version"] = request.GeneratorVersion,
|
||||
["org.stellaops.sbom.kind"] = request.SbomKind,
|
||||
["org.stellaops.sbom.format"] = request.SbomFormat,
|
||||
["org.stellaops.provenance.status"] = "pending",
|
||||
["org.stellaops.provenance.dsse.sha256"] = expectedDsse,
|
||||
["org.stellaops.provenance.nonce"] = nonce
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.LicenseId))
|
||||
{
|
||||
annotations["org.stellaops.license.id"] = request.LicenseId!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SbomName))
|
||||
{
|
||||
annotations["org.opencontainers.image.title"] = request.SbomName!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Repository))
|
||||
{
|
||||
annotations["org.stellaops.repository"] = request.Repository!;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildDocumentMetadata(DescriptorRequest request, FileInfo fileInfo, string sbomDigest)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbomDigest"] = sbomDigest,
|
||||
["sbomPath"] = fileInfo.FullName,
|
||||
["sbomMediaType"] = request.SbomMediaType,
|
||||
["subjectMediaType"] = request.SubjectMediaType
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Repository))
|
||||
{
|
||||
metadata["repository"] = request.Repository!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.BuildRef))
|
||||
{
|
||||
metadata["buildRef"] = request.BuildRef!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.AttestorUri))
|
||||
{
|
||||
metadata["attestorUri"] = request.AttestorUri!;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
public sealed record DescriptorGeneratorMetadata(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string Version);
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
public sealed record DescriptorGeneratorMetadata(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string Version);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Provenance placeholders that the Attestor will fulfil post-build.
|
||||
/// </summary>
|
||||
public sealed record DescriptorProvenance(
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
|
||||
[property: JsonPropertyName("nonce")] string Nonce,
|
||||
[property: JsonPropertyName("attestorUri")] string? AttestorUri,
|
||||
[property: JsonPropertyName("predicateType")] string PredicateType);
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Provenance placeholders that the Attestor will fulfil post-build.
|
||||
/// </summary>
|
||||
public sealed record DescriptorProvenance(
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
|
||||
[property: JsonPropertyName("nonce")] string Nonce,
|
||||
[property: JsonPropertyName("attestorUri")] string? AttestorUri,
|
||||
[property: JsonPropertyName("predicateType")] string PredicateType);
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating BuildX descriptor artifacts.
|
||||
/// </summary>
|
||||
public sealed record DescriptorRequest
|
||||
{
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
public string SbomPath { get; init; } = string.Empty;
|
||||
public string SbomMediaType { get; init; } = "application/vnd.cyclonedx+json";
|
||||
public string SbomFormat { get; init; } = "cyclonedx-json";
|
||||
public string SbomArtifactType { get; init; } = "application/vnd.stellaops.sbom.layer+json";
|
||||
public string SbomKind { get; init; } = "inventory";
|
||||
public string SubjectMediaType { get; init; } = "application/vnd.oci.image.manifest.v1+json";
|
||||
public string GeneratorVersion { get; init; } = "0.0.0";
|
||||
public string? GeneratorName { get; init; }
|
||||
public string? LicenseId { get; init; }
|
||||
public string? SbomName { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? BuildRef { get; init; }
|
||||
public string? AttestorUri { get; init; }
|
||||
public string PredicateType { get; init; } = "https://slsa.dev/provenance/v1";
|
||||
|
||||
public DescriptorRequest Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ImageDigest))
|
||||
{
|
||||
throw new BuildxPluginException("Image digest is required.");
|
||||
}
|
||||
|
||||
if (!ImageDigest.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
throw new BuildxPluginException("Image digest must include the algorithm prefix, e.g. 'sha256:...'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SbomPath))
|
||||
{
|
||||
throw new BuildxPluginException("SBOM path is required.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating BuildX descriptor artifacts.
|
||||
/// </summary>
|
||||
public sealed record DescriptorRequest
|
||||
{
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
public string SbomPath { get; init; } = string.Empty;
|
||||
public string SbomMediaType { get; init; } = "application/vnd.cyclonedx+json";
|
||||
public string SbomFormat { get; init; } = "cyclonedx-json";
|
||||
public string SbomArtifactType { get; init; } = "application/vnd.stellaops.sbom.layer+json";
|
||||
public string SbomKind { get; init; } = "inventory";
|
||||
public string SubjectMediaType { get; init; } = "application/vnd.oci.image.manifest.v1+json";
|
||||
public string GeneratorVersion { get; init; } = "0.0.0";
|
||||
public string? GeneratorName { get; init; }
|
||||
public string? LicenseId { get; init; }
|
||||
public string? SbomName { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? BuildRef { get; init; }
|
||||
public string? AttestorUri { get; init; }
|
||||
public string PredicateType { get; init; } = "https://slsa.dev/provenance/v1";
|
||||
|
||||
public DescriptorRequest Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ImageDigest))
|
||||
{
|
||||
throw new BuildxPluginException("Image digest is required.");
|
||||
}
|
||||
|
||||
if (!ImageDigest.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
throw new BuildxPluginException("Image digest must include the algorithm prefix, e.g. 'sha256:...'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SbomPath))
|
||||
{
|
||||
throw new BuildxPluginException("SBOM path is required.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
public sealed record DescriptorSubject(
|
||||
[property: JsonPropertyName("mediaType")] string MediaType,
|
||||
[property: JsonPropertyName("digest")] string Digest);
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
|
||||
public sealed record DescriptorSubject(
|
||||
[property: JsonPropertyName("mediaType")] string MediaType,
|
||||
[property: JsonPropertyName("digest")] string Digest);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Describes default Content Addressable Storage configuration for the plug-in.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginCas
|
||||
{
|
||||
[JsonPropertyName("protocol")]
|
||||
public string Protocol { get; init; } = "filesystem";
|
||||
|
||||
[JsonPropertyName("defaultRoot")]
|
||||
public string DefaultRoot { get; init; } = "cas";
|
||||
|
||||
[JsonPropertyName("compression")]
|
||||
public string Compression { get; init; } = "zstd";
|
||||
}
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Describes default Content Addressable Storage configuration for the plug-in.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginCas
|
||||
{
|
||||
[JsonPropertyName("protocol")]
|
||||
public string Protocol { get; init; } = "filesystem";
|
||||
|
||||
[JsonPropertyName("defaultRoot")]
|
||||
public string DefaultRoot { get; init; } = "cas";
|
||||
|
||||
[JsonPropertyName("compression")]
|
||||
public string Compression { get; init; } = "zstd";
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Describes how the buildx plug-in executable should be invoked.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginEntryPoint
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "dotnet";
|
||||
|
||||
[JsonPropertyName("executable")]
|
||||
public string Executable { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("arguments")]
|
||||
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Describes how the buildx plug-in executable should be invoked.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginEntryPoint
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "dotnet";
|
||||
|
||||
[JsonPropertyName("executable")]
|
||||
public string Executable { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("arguments")]
|
||||
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Provides distribution information for the container image form-factor.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginImage
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("platforms")]
|
||||
public IReadOnlyList<string> Platforms { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Provides distribution information for the container image form-factor.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginImage
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("platforms")]
|
||||
public IReadOnlyList<string> Platforms { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical manifest describing a buildx generator plug-in.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginManifest
|
||||
{
|
||||
public const string CurrentSchemaVersion = "1.0";
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public BuildxPluginEntryPoint EntryPoint { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("requiresRestart")]
|
||||
public bool RequiresRestart { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("capabilities")]
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cas")]
|
||||
public BuildxPluginCas Cas { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public BuildxPluginImage? Image { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string? SourceDirectory { get; init; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical manifest describing a buildx generator plug-in.
|
||||
/// </summary>
|
||||
public sealed record BuildxPluginManifest
|
||||
{
|
||||
public const string CurrentSchemaVersion = "1.0";
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public BuildxPluginEntryPoint EntryPoint { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("requiresRestart")]
|
||||
public bool RequiresRestart { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("capabilities")]
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cas")]
|
||||
public BuildxPluginCas Cas { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public BuildxPluginImage? Image { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string? SourceDirectory { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,189 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Loads buildx plug-in manifests from the restart-time plug-in directory.
|
||||
/// </summary>
|
||||
public sealed class BuildxPluginManifestLoader
|
||||
{
|
||||
public const string DefaultSearchPattern = "*.manifest.json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string manifestDirectory;
|
||||
private readonly string searchPattern;
|
||||
|
||||
public BuildxPluginManifestLoader(string manifestDirectory, string? searchPattern = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestDirectory))
|
||||
{
|
||||
throw new ArgumentException("Manifest directory is required.", nameof(manifestDirectory));
|
||||
}
|
||||
|
||||
this.manifestDirectory = Path.GetFullPath(manifestDirectory);
|
||||
this.searchPattern = string.IsNullOrWhiteSpace(searchPattern)
|
||||
? DefaultSearchPattern
|
||||
: searchPattern;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all manifests in the configured directory.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BuildxPluginManifest>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(manifestDirectory))
|
||||
{
|
||||
return Array.Empty<BuildxPluginManifest>();
|
||||
}
|
||||
|
||||
var manifests = new List<BuildxPluginManifest>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(manifestDirectory, searchPattern, SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (IsHiddenPath(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifest = await DeserializeManifestAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
manifests.Add(manifest);
|
||||
}
|
||||
|
||||
return manifests
|
||||
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the manifest with the specified identifier.
|
||||
/// </summary>
|
||||
public async Task<BuildxPluginManifest> LoadByIdAsync(string manifestId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestId))
|
||||
{
|
||||
throw new ArgumentException("Manifest identifier is required.", nameof(manifestId));
|
||||
}
|
||||
|
||||
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var manifest = manifests.FirstOrDefault(m => string.Equals(m.Id, manifestId, StringComparison.OrdinalIgnoreCase));
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Buildx plug-in manifest '{manifestId}' was not found in '{manifestDirectory}'.");
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the first available manifest.
|
||||
/// </summary>
|
||||
public async Task<BuildxPluginManifest> LoadDefaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
throw new BuildxPluginException($"No buildx plug-in manifests were discovered under '{manifestDirectory}'.");
|
||||
}
|
||||
|
||||
return manifests[0];
|
||||
}
|
||||
|
||||
private static bool IsHiddenPath(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var segment = Path.GetFileName(directory);
|
||||
if (segment.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<BuildxPluginManifest> DeserializeManifestAsync(string file, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
|
||||
BuildxPluginManifest? manifest;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = await JsonSerializer.DeserializeAsync<BuildxPluginManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new BuildxPluginException($"Failed to parse manifest '{file}'.", ex);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' is empty or invalid.");
|
||||
}
|
||||
|
||||
ValidateManifest(manifest, file);
|
||||
|
||||
var directory = Path.GetDirectoryName(file);
|
||||
return manifest with
|
||||
{
|
||||
SourcePath = file,
|
||||
SourceDirectory = directory
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateManifest(BuildxPluginManifest manifest, string file)
|
||||
{
|
||||
if (!string.Equals(manifest.SchemaVersion, BuildxPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BuildxPluginException(
|
||||
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{BuildxPluginManifest.CurrentSchemaVersion}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify a non-empty 'id'.");
|
||||
}
|
||||
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify an 'entryPoint'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Executable))
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify an executable entry point.");
|
||||
}
|
||||
|
||||
if (!manifest.RequiresRestart)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must enforce restart-required activation.");
|
||||
}
|
||||
|
||||
if (manifest.Cas is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must define CAS defaults.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Cas.DefaultRoot))
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify a CAS default root directory.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Loads buildx plug-in manifests from the restart-time plug-in directory.
|
||||
/// </summary>
|
||||
public sealed class BuildxPluginManifestLoader
|
||||
{
|
||||
public const string DefaultSearchPattern = "*.manifest.json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string manifestDirectory;
|
||||
private readonly string searchPattern;
|
||||
|
||||
public BuildxPluginManifestLoader(string manifestDirectory, string? searchPattern = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestDirectory))
|
||||
{
|
||||
throw new ArgumentException("Manifest directory is required.", nameof(manifestDirectory));
|
||||
}
|
||||
|
||||
this.manifestDirectory = Path.GetFullPath(manifestDirectory);
|
||||
this.searchPattern = string.IsNullOrWhiteSpace(searchPattern)
|
||||
? DefaultSearchPattern
|
||||
: searchPattern;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all manifests in the configured directory.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BuildxPluginManifest>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(manifestDirectory))
|
||||
{
|
||||
return Array.Empty<BuildxPluginManifest>();
|
||||
}
|
||||
|
||||
var manifests = new List<BuildxPluginManifest>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(manifestDirectory, searchPattern, SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (IsHiddenPath(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifest = await DeserializeManifestAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
manifests.Add(manifest);
|
||||
}
|
||||
|
||||
return manifests
|
||||
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the manifest with the specified identifier.
|
||||
/// </summary>
|
||||
public async Task<BuildxPluginManifest> LoadByIdAsync(string manifestId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestId))
|
||||
{
|
||||
throw new ArgumentException("Manifest identifier is required.", nameof(manifestId));
|
||||
}
|
||||
|
||||
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var manifest = manifests.FirstOrDefault(m => string.Equals(m.Id, manifestId, StringComparison.OrdinalIgnoreCase));
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Buildx plug-in manifest '{manifestId}' was not found in '{manifestDirectory}'.");
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the first available manifest.
|
||||
/// </summary>
|
||||
public async Task<BuildxPluginManifest> LoadDefaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
throw new BuildxPluginException($"No buildx plug-in manifests were discovered under '{manifestDirectory}'.");
|
||||
}
|
||||
|
||||
return manifests[0];
|
||||
}
|
||||
|
||||
private static bool IsHiddenPath(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var segment = Path.GetFileName(directory);
|
||||
if (segment.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<BuildxPluginManifest> DeserializeManifestAsync(string file, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
|
||||
BuildxPluginManifest? manifest;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = await JsonSerializer.DeserializeAsync<BuildxPluginManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new BuildxPluginException($"Failed to parse manifest '{file}'.", ex);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' is empty or invalid.");
|
||||
}
|
||||
|
||||
ValidateManifest(manifest, file);
|
||||
|
||||
var directory = Path.GetDirectoryName(file);
|
||||
return manifest with
|
||||
{
|
||||
SourcePath = file,
|
||||
SourceDirectory = directory
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateManifest(BuildxPluginManifest manifest, string file)
|
||||
{
|
||||
if (!string.Equals(manifest.SchemaVersion, BuildxPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BuildxPluginException(
|
||||
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{BuildxPluginManifest.CurrentSchemaVersion}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify a non-empty 'id'.");
|
||||
}
|
||||
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify an 'entryPoint'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Executable))
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify an executable entry point.");
|
||||
}
|
||||
|
||||
if (!manifest.RequiresRestart)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must enforce restart-required activation.");
|
||||
}
|
||||
|
||||
if (manifest.Cas is null)
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must define CAS defaults.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Cas.DefaultRoot))
|
||||
{
|
||||
throw new BuildxPluginException($"Manifest '{file}' must specify a CAS default root directory.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public sealed record ScanSnapshot(
|
||||
ScanId ScanId,
|
||||
ScanTarget Target,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for creating Redis connections so publishers can be tested without real infrastructure.
|
||||
/// </summary>
|
||||
internal interface IRedisConnectionFactory
|
||||
{
|
||||
ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for creating Redis connections so publishers can be tested without real infrastructure.
|
||||
/// </summary>
|
||||
internal interface IRedisConnectionFactory
|
||||
{
|
||||
ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates generation and publication of scanner-related platform events.
|
||||
/// </summary>
|
||||
public interface IReportEventDispatcher
|
||||
{
|
||||
Task PublishAsync(
|
||||
ReportRequestDto request,
|
||||
PolicyPreviewResponse preview,
|
||||
ReportDocumentDto document,
|
||||
DsseEnvelopeDto? envelope,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates generation and publication of scanner-related platform events.
|
||||
/// </summary>
|
||||
public interface IReportEventDispatcher
|
||||
{
|
||||
Task PublishAsync(
|
||||
ReportRequestDto request,
|
||||
PolicyPreviewResponse preview,
|
||||
ReportDocumentDto document,
|
||||
DsseEnvelopeDto? envelope,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IScanCoordinator
|
||||
{
|
||||
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Utilities;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Utilities;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
private sealed record ScanEntry(ScanSnapshot Snapshot);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -14,31 +14,31 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
private readonly ConcurrentDictionary<string, string> scansByReference = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IScanProgressPublisher progressPublisher;
|
||||
|
||||
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.progressPublisher = progressPublisher ?? throw new ArgumentNullException(nameof(progressPublisher));
|
||||
}
|
||||
|
||||
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(submission);
|
||||
|
||||
var normalizedTarget = submission.Target.Normalize();
|
||||
var metadata = submission.Metadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var scanId = ScanIdGenerator.Create(normalizedTarget, submission.Force, submission.ClientRequestId, metadata);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var eventData = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["force"] = submission.Force,
|
||||
};
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
eventData[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
|
||||
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.progressPublisher = progressPublisher ?? throw new ArgumentNullException(nameof(progressPublisher));
|
||||
}
|
||||
|
||||
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(submission);
|
||||
|
||||
var normalizedTarget = submission.Target.Normalize();
|
||||
var metadata = submission.Metadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var scanId = ScanIdGenerator.Create(normalizedTarget, submission.Force, submission.ClientRequestId, metadata);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var eventData = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["force"] = submission.Force,
|
||||
};
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
eventData[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
ScanEntry entry = scans.AddOrUpdate(
|
||||
scanId.Value,
|
||||
_ => new ScanEntry(new ScanSnapshot(
|
||||
@@ -55,14 +55,14 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
if (submission.Force)
|
||||
{
|
||||
var snapshot = existing.Snapshot with
|
||||
{
|
||||
Status = ScanStatus.Pending,
|
||||
UpdatedAt = now,
|
||||
FailureReason = null
|
||||
};
|
||||
return new ScanEntry(snapshot);
|
||||
}
|
||||
|
||||
{
|
||||
Status = ScanStatus.Pending,
|
||||
UpdatedAt = now,
|
||||
FailureReason = null
|
||||
};
|
||||
return new ScanEntry(snapshot);
|
||||
}
|
||||
|
||||
return existing;
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
|
||||
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
|
||||
}
|
||||
|
||||
|
||||
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (scans.TryGetValue(scanId.Value, out var entry))
|
||||
|
||||
@@ -2,21 +2,21 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// No-op fallback publisher used until queue adapters register a concrete implementation.
|
||||
/// </summary>
|
||||
internal sealed class NullPlatformEventPublisher : IPlatformEventPublisher
|
||||
{
|
||||
private readonly ILogger<NullPlatformEventPublisher> _logger;
|
||||
|
||||
public NullPlatformEventPublisher(ILogger<NullPlatformEventPublisher> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// No-op fallback publisher used until queue adapters register a concrete implementation.
|
||||
/// </summary>
|
||||
internal sealed class NullPlatformEventPublisher : IPlatformEventPublisher
|
||||
{
|
||||
private readonly ILogger<NullPlatformEventPublisher> _logger;
|
||||
|
||||
public NullPlatformEventPublisher(ILogger<NullPlatformEventPublisher> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (@event is null)
|
||||
|
||||
@@ -1,356 +1,356 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal static class PolicyDtoMapper
|
||||
{
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static PolicyPreviewRequest ToDomain(PolicyPreviewRequestDto request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var findings = BuildFindings(request.Findings);
|
||||
var baseline = BuildBaseline(request.Baseline);
|
||||
var proposedPolicy = ToSnapshotContent(request.Policy);
|
||||
|
||||
return new PolicyPreviewRequest(
|
||||
request.ImageDigest!.Trim(),
|
||||
findings,
|
||||
baseline,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: proposedPolicy);
|
||||
}
|
||||
|
||||
public static PolicyPreviewResponseDto ToDto(PolicyPreviewResponse response)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
var diffs = response.Diffs.Select(ToDiffDto).ToImmutableArray();
|
||||
var issues = response.Issues.Select(ToIssueDto).ToImmutableArray();
|
||||
|
||||
return new PolicyPreviewResponseDto
|
||||
{
|
||||
Success = response.Success,
|
||||
PolicyDigest = response.PolicyDigest,
|
||||
RevisionId = response.RevisionId,
|
||||
Changed = response.ChangedCount,
|
||||
Diffs = diffs,
|
||||
Issues = issues
|
||||
};
|
||||
}
|
||||
|
||||
public static PolicyPreviewIssueDto ToIssueDto(PolicyIssue issue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(issue);
|
||||
|
||||
return new PolicyPreviewIssueDto
|
||||
{
|
||||
Code = issue.Code,
|
||||
Message = issue.Message,
|
||||
Severity = issue.Severity.ToString(),
|
||||
Path = issue.Path
|
||||
};
|
||||
}
|
||||
|
||||
public static PolicyDocumentFormat ParsePolicyFormat(string? format)
|
||||
=> string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)
|
||||
? PolicyDocumentFormat.Json
|
||||
: PolicyDocumentFormat.Yaml;
|
||||
|
||||
private static ImmutableArray<PolicyFinding> BuildFindings(IReadOnlyList<PolicyPreviewFindingDto>? findings)
|
||||
{
|
||||
if (findings is null || findings.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicyFinding>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyFinding>(findings.Count);
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
if (finding is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tags = finding.Tags is { Count: > 0 }
|
||||
? finding.Tags.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag.Trim())
|
||||
.ToImmutableArray()
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
var severity = ParseSeverity(finding.Severity);
|
||||
var candidate = PolicyFinding.Create(
|
||||
finding.Id!.Trim(),
|
||||
severity,
|
||||
environment: Normalize(finding.Environment),
|
||||
source: Normalize(finding.Source),
|
||||
vendor: Normalize(finding.Vendor),
|
||||
license: Normalize(finding.License),
|
||||
image: Normalize(finding.Image),
|
||||
repository: Normalize(finding.Repository),
|
||||
package: Normalize(finding.Package),
|
||||
purl: Normalize(finding.Purl),
|
||||
cve: Normalize(finding.Cve),
|
||||
path: Normalize(finding.Path),
|
||||
layerDigest: Normalize(finding.LayerDigest),
|
||||
tags: tags);
|
||||
|
||||
builder.Add(candidate);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdict> BuildBaseline(IReadOnlyList<PolicyPreviewVerdictDto>? baseline)
|
||||
{
|
||||
if (baseline is null || baseline.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicyVerdict>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyVerdict>(baseline.Count);
|
||||
foreach (var verdict in baseline)
|
||||
{
|
||||
if (verdict is null || string.IsNullOrWhiteSpace(verdict.FindingId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var inputs = verdict.Inputs is { Count: > 0 }
|
||||
? CreateImmutableDeterministicDictionary(verdict.Inputs)
|
||||
: ImmutableDictionary<string, double>.Empty;
|
||||
|
||||
var status = ParseVerdictStatus(verdict.Status);
|
||||
builder.Add(new PolicyVerdict(
|
||||
verdict.FindingId!.Trim(),
|
||||
status,
|
||||
verdict.RuleName,
|
||||
verdict.RuleAction,
|
||||
verdict.Notes,
|
||||
verdict.Score ?? 0,
|
||||
verdict.ConfigVersion ?? PolicyScoringConfig.Default.Version,
|
||||
inputs,
|
||||
verdict.QuietedBy,
|
||||
verdict.Quiet ?? false,
|
||||
verdict.UnknownConfidence,
|
||||
verdict.ConfidenceBand,
|
||||
verdict.UnknownAgeDays,
|
||||
verdict.SourceTrust,
|
||||
verdict.Reachability));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyPreviewDiffDto ToDiffDto(PolicyVerdictDiff diff)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(diff);
|
||||
|
||||
return new PolicyPreviewDiffDto
|
||||
{
|
||||
FindingId = diff.Projected.FindingId,
|
||||
Baseline = ToVerdictDto(diff.Baseline),
|
||||
Projected = ToVerdictDto(diff.Projected),
|
||||
Changed = diff.Changed
|
||||
};
|
||||
}
|
||||
|
||||
internal static PolicyPreviewVerdictDto ToVerdictDto(PolicyVerdict verdict)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(verdict);
|
||||
|
||||
IReadOnlyDictionary<string, double>? inputs = null;
|
||||
var verdictInputs = verdict.GetInputs();
|
||||
if (verdictInputs.Count > 0)
|
||||
{
|
||||
inputs = CreateDeterministicInputs(verdictInputs);
|
||||
}
|
||||
|
||||
var sourceTrust = verdict.SourceTrust;
|
||||
if (string.IsNullOrWhiteSpace(sourceTrust))
|
||||
{
|
||||
sourceTrust = ExtractSuffix(verdictInputs, "trustWeight.");
|
||||
}
|
||||
|
||||
var reachability = verdict.Reachability;
|
||||
if (string.IsNullOrWhiteSpace(reachability))
|
||||
{
|
||||
reachability = ExtractSuffix(verdictInputs, "reachability.");
|
||||
}
|
||||
|
||||
return new PolicyPreviewVerdictDto
|
||||
{
|
||||
FindingId = verdict.FindingId,
|
||||
Status = verdict.Status.ToString(),
|
||||
RuleName = verdict.RuleName,
|
||||
RuleAction = verdict.RuleAction,
|
||||
Notes = verdict.Notes,
|
||||
Score = verdict.Score,
|
||||
ConfigVersion = verdict.ConfigVersion,
|
||||
Inputs = inputs,
|
||||
QuietedBy = verdict.QuietedBy,
|
||||
Quiet = verdict.Quiet,
|
||||
UnknownConfidence = verdict.UnknownConfidence,
|
||||
ConfidenceBand = verdict.ConfidenceBand,
|
||||
UnknownAgeDays = verdict.UnknownAgeDays,
|
||||
SourceTrust = sourceTrust,
|
||||
Reachability = reachability
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> CreateImmutableDeterministicDictionary(IEnumerable<KeyValuePair<string, double>> inputs)
|
||||
{
|
||||
var sorted = CreateDeterministicInputs(inputs);
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(OrdinalIgnoreCase);
|
||||
foreach (var pair in sorted)
|
||||
{
|
||||
builder[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, double> CreateDeterministicInputs(IEnumerable<KeyValuePair<string, double>> inputs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputs);
|
||||
|
||||
var dictionary = new SortedDictionary<string, double>(InputKeyComparer.Instance);
|
||||
foreach (var pair in inputs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = pair.Key.Trim();
|
||||
dictionary[key] = pair.Value;
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private sealed class InputKeyComparer : IComparer<string>
|
||||
{
|
||||
public static InputKeyComparer Instance { get; } = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var px = GetPriority(x);
|
||||
var py = GetPriority(y);
|
||||
if (px != py)
|
||||
{
|
||||
return px.CompareTo(py);
|
||||
}
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int GetPriority(string key)
|
||||
{
|
||||
if (string.Equals(key, "reachabilityWeight", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (string.Equals(key, "baseScore", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.Equals(key, "severityWeight", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (string.Equals(key, "trustWeight", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (key.StartsWith("trustWeight.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (key.StartsWith("reachability.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicySnapshotContent? ToSnapshotContent(PolicyPreviewPolicyDto? policy)
|
||||
{
|
||||
if (policy is null || string.IsNullOrWhiteSpace(policy.Content))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var format = ParsePolicyFormat(policy.Format);
|
||||
return new PolicySnapshotContent(
|
||||
policy.Content,
|
||||
format,
|
||||
policy.Actor,
|
||||
Source: null,
|
||||
policy.Description);
|
||||
}
|
||||
|
||||
private static PolicySeverity ParseSeverity(string? value)
|
||||
{
|
||||
if (Enum.TryParse<PolicySeverity>(value, true, out var severity))
|
||||
{
|
||||
return severity;
|
||||
}
|
||||
|
||||
return PolicySeverity.Unknown;
|
||||
}
|
||||
|
||||
private static PolicyVerdictStatus ParseVerdictStatus(string? value)
|
||||
{
|
||||
if (Enum.TryParse<PolicyVerdictStatus>(value, true, out var status))
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
return PolicyVerdictStatus.Pass;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? ExtractSuffix(ImmutableDictionary<string, double> inputs, string prefix)
|
||||
{
|
||||
foreach (var key in inputs.Keys)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && key.Length > prefix.Length)
|
||||
{
|
||||
return key.Substring(prefix.Length);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal static class PolicyDtoMapper
|
||||
{
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static PolicyPreviewRequest ToDomain(PolicyPreviewRequestDto request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var findings = BuildFindings(request.Findings);
|
||||
var baseline = BuildBaseline(request.Baseline);
|
||||
var proposedPolicy = ToSnapshotContent(request.Policy);
|
||||
|
||||
return new PolicyPreviewRequest(
|
||||
request.ImageDigest!.Trim(),
|
||||
findings,
|
||||
baseline,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: proposedPolicy);
|
||||
}
|
||||
|
||||
public static PolicyPreviewResponseDto ToDto(PolicyPreviewResponse response)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
var diffs = response.Diffs.Select(ToDiffDto).ToImmutableArray();
|
||||
var issues = response.Issues.Select(ToIssueDto).ToImmutableArray();
|
||||
|
||||
return new PolicyPreviewResponseDto
|
||||
{
|
||||
Success = response.Success,
|
||||
PolicyDigest = response.PolicyDigest,
|
||||
RevisionId = response.RevisionId,
|
||||
Changed = response.ChangedCount,
|
||||
Diffs = diffs,
|
||||
Issues = issues
|
||||
};
|
||||
}
|
||||
|
||||
public static PolicyPreviewIssueDto ToIssueDto(PolicyIssue issue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(issue);
|
||||
|
||||
return new PolicyPreviewIssueDto
|
||||
{
|
||||
Code = issue.Code,
|
||||
Message = issue.Message,
|
||||
Severity = issue.Severity.ToString(),
|
||||
Path = issue.Path
|
||||
};
|
||||
}
|
||||
|
||||
public static PolicyDocumentFormat ParsePolicyFormat(string? format)
|
||||
=> string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)
|
||||
? PolicyDocumentFormat.Json
|
||||
: PolicyDocumentFormat.Yaml;
|
||||
|
||||
private static ImmutableArray<PolicyFinding> BuildFindings(IReadOnlyList<PolicyPreviewFindingDto>? findings)
|
||||
{
|
||||
if (findings is null || findings.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicyFinding>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyFinding>(findings.Count);
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
if (finding is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tags = finding.Tags is { Count: > 0 }
|
||||
? finding.Tags.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag.Trim())
|
||||
.ToImmutableArray()
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
var severity = ParseSeverity(finding.Severity);
|
||||
var candidate = PolicyFinding.Create(
|
||||
finding.Id!.Trim(),
|
||||
severity,
|
||||
environment: Normalize(finding.Environment),
|
||||
source: Normalize(finding.Source),
|
||||
vendor: Normalize(finding.Vendor),
|
||||
license: Normalize(finding.License),
|
||||
image: Normalize(finding.Image),
|
||||
repository: Normalize(finding.Repository),
|
||||
package: Normalize(finding.Package),
|
||||
purl: Normalize(finding.Purl),
|
||||
cve: Normalize(finding.Cve),
|
||||
path: Normalize(finding.Path),
|
||||
layerDigest: Normalize(finding.LayerDigest),
|
||||
tags: tags);
|
||||
|
||||
builder.Add(candidate);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdict> BuildBaseline(IReadOnlyList<PolicyPreviewVerdictDto>? baseline)
|
||||
{
|
||||
if (baseline is null || baseline.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicyVerdict>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyVerdict>(baseline.Count);
|
||||
foreach (var verdict in baseline)
|
||||
{
|
||||
if (verdict is null || string.IsNullOrWhiteSpace(verdict.FindingId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var inputs = verdict.Inputs is { Count: > 0 }
|
||||
? CreateImmutableDeterministicDictionary(verdict.Inputs)
|
||||
: ImmutableDictionary<string, double>.Empty;
|
||||
|
||||
var status = ParseVerdictStatus(verdict.Status);
|
||||
builder.Add(new PolicyVerdict(
|
||||
verdict.FindingId!.Trim(),
|
||||
status,
|
||||
verdict.RuleName,
|
||||
verdict.RuleAction,
|
||||
verdict.Notes,
|
||||
verdict.Score ?? 0,
|
||||
verdict.ConfigVersion ?? PolicyScoringConfig.Default.Version,
|
||||
inputs,
|
||||
verdict.QuietedBy,
|
||||
verdict.Quiet ?? false,
|
||||
verdict.UnknownConfidence,
|
||||
verdict.ConfidenceBand,
|
||||
verdict.UnknownAgeDays,
|
||||
verdict.SourceTrust,
|
||||
verdict.Reachability));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyPreviewDiffDto ToDiffDto(PolicyVerdictDiff diff)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(diff);
|
||||
|
||||
return new PolicyPreviewDiffDto
|
||||
{
|
||||
FindingId = diff.Projected.FindingId,
|
||||
Baseline = ToVerdictDto(diff.Baseline),
|
||||
Projected = ToVerdictDto(diff.Projected),
|
||||
Changed = diff.Changed
|
||||
};
|
||||
}
|
||||
|
||||
internal static PolicyPreviewVerdictDto ToVerdictDto(PolicyVerdict verdict)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(verdict);
|
||||
|
||||
IReadOnlyDictionary<string, double>? inputs = null;
|
||||
var verdictInputs = verdict.GetInputs();
|
||||
if (verdictInputs.Count > 0)
|
||||
{
|
||||
inputs = CreateDeterministicInputs(verdictInputs);
|
||||
}
|
||||
|
||||
var sourceTrust = verdict.SourceTrust;
|
||||
if (string.IsNullOrWhiteSpace(sourceTrust))
|
||||
{
|
||||
sourceTrust = ExtractSuffix(verdictInputs, "trustWeight.");
|
||||
}
|
||||
|
||||
var reachability = verdict.Reachability;
|
||||
if (string.IsNullOrWhiteSpace(reachability))
|
||||
{
|
||||
reachability = ExtractSuffix(verdictInputs, "reachability.");
|
||||
}
|
||||
|
||||
return new PolicyPreviewVerdictDto
|
||||
{
|
||||
FindingId = verdict.FindingId,
|
||||
Status = verdict.Status.ToString(),
|
||||
RuleName = verdict.RuleName,
|
||||
RuleAction = verdict.RuleAction,
|
||||
Notes = verdict.Notes,
|
||||
Score = verdict.Score,
|
||||
ConfigVersion = verdict.ConfigVersion,
|
||||
Inputs = inputs,
|
||||
QuietedBy = verdict.QuietedBy,
|
||||
Quiet = verdict.Quiet,
|
||||
UnknownConfidence = verdict.UnknownConfidence,
|
||||
ConfidenceBand = verdict.ConfidenceBand,
|
||||
UnknownAgeDays = verdict.UnknownAgeDays,
|
||||
SourceTrust = sourceTrust,
|
||||
Reachability = reachability
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> CreateImmutableDeterministicDictionary(IEnumerable<KeyValuePair<string, double>> inputs)
|
||||
{
|
||||
var sorted = CreateDeterministicInputs(inputs);
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(OrdinalIgnoreCase);
|
||||
foreach (var pair in sorted)
|
||||
{
|
||||
builder[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, double> CreateDeterministicInputs(IEnumerable<KeyValuePair<string, double>> inputs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputs);
|
||||
|
||||
var dictionary = new SortedDictionary<string, double>(InputKeyComparer.Instance);
|
||||
foreach (var pair in inputs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = pair.Key.Trim();
|
||||
dictionary[key] = pair.Value;
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private sealed class InputKeyComparer : IComparer<string>
|
||||
{
|
||||
public static InputKeyComparer Instance { get; } = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var px = GetPriority(x);
|
||||
var py = GetPriority(y);
|
||||
if (px != py)
|
||||
{
|
||||
return px.CompareTo(py);
|
||||
}
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int GetPriority(string key)
|
||||
{
|
||||
if (string.Equals(key, "reachabilityWeight", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (string.Equals(key, "baseScore", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.Equals(key, "severityWeight", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (string.Equals(key, "trustWeight", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (key.StartsWith("trustWeight.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (key.StartsWith("reachability.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicySnapshotContent? ToSnapshotContent(PolicyPreviewPolicyDto? policy)
|
||||
{
|
||||
if (policy is null || string.IsNullOrWhiteSpace(policy.Content))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var format = ParsePolicyFormat(policy.Format);
|
||||
return new PolicySnapshotContent(
|
||||
policy.Content,
|
||||
format,
|
||||
policy.Actor,
|
||||
Source: null,
|
||||
policy.Description);
|
||||
}
|
||||
|
||||
private static PolicySeverity ParseSeverity(string? value)
|
||||
{
|
||||
if (Enum.TryParse<PolicySeverity>(value, true, out var severity))
|
||||
{
|
||||
return severity;
|
||||
}
|
||||
|
||||
return PolicySeverity.Unknown;
|
||||
}
|
||||
|
||||
private static PolicyVerdictStatus ParseVerdictStatus(string? value)
|
||||
{
|
||||
if (Enum.TryParse<PolicyVerdictStatus>(value, true, out var status))
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
return PolicyVerdictStatus.Pass;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? ExtractSuffix(ImmutableDictionary<string, double> inputs, string prefix)
|
||||
{
|
||||
foreach (var key in inputs.Keys)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && key.Length > prefix.Length)
|
||||
{
|
||||
return key.Substring(prefix.Length);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Production Redis connection factory bridging to <see cref="ConnectionMultiplexer"/>.
|
||||
/// </summary>
|
||||
internal sealed class RedisConnectionFactory : IRedisConnectionFactory
|
||||
{
|
||||
public async ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var connectTask = ConnectionMultiplexer.ConnectAsync(options);
|
||||
var connection = await connectTask.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Production Redis connection factory bridging to <see cref="ConnectionMultiplexer"/>.
|
||||
/// </summary>
|
||||
internal sealed class RedisConnectionFactory : IRedisConnectionFactory
|
||||
{
|
||||
public async ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var connectTask = ConnectionMultiplexer.ConnectAsync(options);
|
||||
var connection = await connectTask.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAsyncDisposable
|
||||
{
|
||||
private readonly ScannerWebServiceOptions.EventsOptions _options;
|
||||
private readonly ILogger<RedisPlatformEventPublisher> _logger;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAsyncDisposable
|
||||
{
|
||||
private readonly ScannerWebServiceOptions.EventsOptions _options;
|
||||
private readonly ILogger<RedisPlatformEventPublisher> _logger;
|
||||
private readonly IRedisConnectionFactory _connectionFactory;
|
||||
private readonly TimeSpan _publishTimeout;
|
||||
private readonly string _streamKey;
|
||||
private readonly long? _maxStreamLength;
|
||||
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public RedisPlatformEventPublisher(
|
||||
private readonly string _streamKey;
|
||||
private readonly long? _maxStreamLength;
|
||||
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public RedisPlatformEventPublisher(
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
IRedisConnectionFactory connectionFactory,
|
||||
ILogger<RedisPlatformEventPublisher> logger)
|
||||
@@ -32,23 +32,23 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
|
||||
ArgumentNullException.ThrowIfNull(connectionFactory);
|
||||
|
||||
_options = options.Value.Events ?? throw new InvalidOperationException("Events options are required when redis publisher is registered.");
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("RedisPlatformEventPublisher requires events emission to be enabled.");
|
||||
}
|
||||
|
||||
if (!string.Equals(_options.Driver, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"RedisPlatformEventPublisher cannot be used with driver '{_options.Driver}'.");
|
||||
}
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("RedisPlatformEventPublisher requires events emission to be enabled.");
|
||||
}
|
||||
|
||||
if (!string.Equals(_options.Driver, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"RedisPlatformEventPublisher cannot be used with driver '{_options.Driver}'.");
|
||||
}
|
||||
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_streamKey = string.IsNullOrWhiteSpace(_options.Stream) ? "stella.events" : _options.Stream;
|
||||
_publishTimeout = TimeSpan.FromSeconds(_options.PublishTimeoutSeconds <= 0 ? 5 : _options.PublishTimeoutSeconds);
|
||||
_maxStreamLength = _options.MaxStreamLength > 0 ? _options.MaxStreamLength : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
@@ -65,90 +65,90 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
|
||||
new("occurredAt", @event.OccurredAt.ToString("O")),
|
||||
new("idempotencyKey", @event.IdempotencyKey)
|
||||
};
|
||||
|
||||
int? maxLength = null;
|
||||
if (_maxStreamLength.HasValue)
|
||||
{
|
||||
var clamped = Math.Min(_maxStreamLength.Value, int.MaxValue);
|
||||
maxLength = (int)clamped;
|
||||
}
|
||||
|
||||
var publishTask = maxLength.HasValue
|
||||
? database.StreamAddAsync(_streamKey, entries, maxLength: maxLength, useApproximateMaxLength: true)
|
||||
: database.StreamAddAsync(_streamKey, entries);
|
||||
|
||||
if (_publishTimeout > TimeSpan.Zero)
|
||||
{
|
||||
await publishTask.WaitAsync(_publishTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await publishTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_connection is not null && _connection.IsConnected)
|
||||
{
|
||||
return _connection.GetDatabase();
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is null || !_connection.IsConnected)
|
||||
{
|
||||
var config = ConfigurationOptions.Parse(_options.Dsn);
|
||||
config.AbortOnConnectFail = false;
|
||||
|
||||
if (_options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName))
|
||||
{
|
||||
config.ClientName = clientName;
|
||||
}
|
||||
|
||||
if (_options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl))
|
||||
{
|
||||
config.Ssl = ssl;
|
||||
}
|
||||
|
||||
|
||||
int? maxLength = null;
|
||||
if (_maxStreamLength.HasValue)
|
||||
{
|
||||
var clamped = Math.Min(_maxStreamLength.Value, int.MaxValue);
|
||||
maxLength = (int)clamped;
|
||||
}
|
||||
|
||||
var publishTask = maxLength.HasValue
|
||||
? database.StreamAddAsync(_streamKey, entries, maxLength: maxLength, useApproximateMaxLength: true)
|
||||
: database.StreamAddAsync(_streamKey, entries);
|
||||
|
||||
if (_publishTimeout > TimeSpan.Zero)
|
||||
{
|
||||
await publishTask.WaitAsync(_publishTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await publishTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_connection is not null && _connection.IsConnected)
|
||||
{
|
||||
return _connection.GetDatabase();
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is null || !_connection.IsConnected)
|
||||
{
|
||||
var config = ConfigurationOptions.Parse(_options.Dsn);
|
||||
config.AbortOnConnectFail = false;
|
||||
|
||||
if (_options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName))
|
||||
{
|
||||
config.ClientName = clientName;
|
||||
}
|
||||
|
||||
if (_options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl))
|
||||
{
|
||||
config.Ssl = ssl;
|
||||
}
|
||||
|
||||
_connection = await _connectionFactory.ConnectAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Connected Redis platform event publisher to stream {Stream}.", _streamKey);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
|
||||
return _connection!.GetDatabase();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error while closing Redis platform event publisher connection.");
|
||||
}
|
||||
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
}
|
||||
}
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
|
||||
return _connection!.GetDatabase();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error while closing Redis platform event publisher connection.");
|
||||
}
|
||||
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,264 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IReportSigner : IDisposable
|
||||
{
|
||||
ReportSignature? Sign(ReadOnlySpan<byte> payload);
|
||||
}
|
||||
|
||||
public sealed class ReportSigner : IReportSigner
|
||||
{
|
||||
private enum SigningMode
|
||||
{
|
||||
Disabled,
|
||||
Provider,
|
||||
Hs256
|
||||
}
|
||||
|
||||
private readonly SigningMode mode;
|
||||
private readonly string keyId = string.Empty;
|
||||
private readonly string algorithmName = string.Empty;
|
||||
private readonly ILogger<ReportSigner> logger;
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly ICryptoHmac cryptoHmac;
|
||||
private readonly ICryptoProvider? provider;
|
||||
private readonly CryptoKeyReference? keyReference;
|
||||
private readonly CryptoSignerResolution? signerResolution;
|
||||
private readonly byte[]? hmacKey;
|
||||
|
||||
public ReportSigner(
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
ICryptoHmac cryptoHmac,
|
||||
ILogger<ReportSigner> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var value = options.Value ?? new ScannerWebServiceOptions();
|
||||
var features = value.Features ?? new ScannerWebServiceOptions.FeatureFlagOptions();
|
||||
var signing = value.Signing ?? new ScannerWebServiceOptions.SigningOptions();
|
||||
|
||||
if (!features.EnableSignedReports || !signing.Enabled)
|
||||
{
|
||||
mode = SigningMode.Disabled;
|
||||
logger.LogInformation("Report signing disabled (feature flag or signing.enabled=false).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.KeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
|
||||
}
|
||||
|
||||
var keyPem = ResolveKeyMaterial(signing);
|
||||
keyId = signing.KeyId.Trim();
|
||||
|
||||
var resolvedMode = ResolveSigningMode(signing.Algorithm, out var canonicalAlgorithm, out var joseAlgorithm);
|
||||
algorithmName = joseAlgorithm;
|
||||
|
||||
switch (resolvedMode)
|
||||
{
|
||||
case SigningMode.Provider:
|
||||
{
|
||||
provider = ResolveProvider(signing.Provider, canonicalAlgorithm);
|
||||
|
||||
var privateKey = DecodeKey(keyPem);
|
||||
var reference = new CryptoKeyReference(keyId, provider.Name);
|
||||
var signingKeyDescriptor = new CryptoSigningKey(
|
||||
reference,
|
||||
canonicalAlgorithm,
|
||||
privateKey,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
provider.UpsertSigningKey(signingKeyDescriptor);
|
||||
|
||||
signerResolution = cryptoRegistry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
canonicalAlgorithm,
|
||||
reference,
|
||||
provider.Name);
|
||||
|
||||
keyReference = reference;
|
||||
mode = SigningMode.Provider;
|
||||
break;
|
||||
}
|
||||
case SigningMode.Hs256:
|
||||
{
|
||||
hmacKey = DecodeKey(keyPem);
|
||||
mode = SigningMode.Hs256;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
mode = SigningMode.Disabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public ReportSignature? Sign(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (mode == SigningMode.Disabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("Payload must be non-empty.", nameof(payload));
|
||||
}
|
||||
|
||||
return mode switch
|
||||
{
|
||||
SigningMode.Provider => SignWithProvider(payload),
|
||||
SigningMode.Hs256 => SignHs256(payload),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private ReportSignature SignWithProvider(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var resolution = signerResolution ?? throw new InvalidOperationException("Signing provider has not been initialised.");
|
||||
|
||||
var signature = resolution.Signer
|
||||
.SignAsync(payload.ToArray())
|
||||
.ConfigureAwait(false)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
return new ReportSignature(keyId, algorithmName, Convert.ToBase64String(signature));
|
||||
}
|
||||
|
||||
private ReportSignature SignHs256(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (hmacKey is null)
|
||||
{
|
||||
throw new InvalidOperationException("HMAC signing has not been initialised.");
|
||||
}
|
||||
|
||||
var signature = cryptoHmac.ComputeHmacBase64ForPurpose(hmacKey, payload, HmacPurpose.Signing);
|
||||
return new ReportSignature(keyId, algorithmName, signature);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (provider is not null && keyReference is not null)
|
||||
{
|
||||
provider.RemoveSigningKey(keyReference.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
private ICryptoProvider ResolveProvider(string? configuredProvider, string canonicalAlgorithm)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredProvider))
|
||||
{
|
||||
if (!cryptoRegistry.TryResolve(configuredProvider.Trim(), out var hinted))
|
||||
{
|
||||
throw new InvalidOperationException($"Configured signing provider '{configuredProvider}' is not registered.");
|
||||
}
|
||||
|
||||
if (!hinted.Supports(CryptoCapability.Signing, canonicalAlgorithm))
|
||||
{
|
||||
throw new InvalidOperationException($"Provider '{configuredProvider}' does not support algorithm '{canonicalAlgorithm}'.");
|
||||
}
|
||||
|
||||
return hinted;
|
||||
}
|
||||
|
||||
return cryptoRegistry.ResolveOrThrow(CryptoCapability.Signing, canonicalAlgorithm);
|
||||
}
|
||||
|
||||
private static SigningMode ResolveSigningMode(string? algorithm, out string canonicalAlgorithm, out string joseAlgorithm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
throw new InvalidOperationException("Signing algorithm must be specified when signing is enabled.");
|
||||
}
|
||||
|
||||
switch (algorithm.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "ed25519":
|
||||
case "eddsa":
|
||||
canonicalAlgorithm = SignatureAlgorithms.Ed25519;
|
||||
joseAlgorithm = SignatureAlgorithms.EdDsa;
|
||||
return SigningMode.Provider;
|
||||
case "hs256":
|
||||
canonicalAlgorithm = "HS256";
|
||||
joseAlgorithm = "HS256";
|
||||
return SigningMode.Hs256;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported signing algorithm '{algorithm}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveKeyMaterial(ScannerWebServiceOptions.SigningOptions signing)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signing.KeyPem))
|
||||
{
|
||||
return signing.KeyPem;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signing.KeyPemFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(signing.KeyPemFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to read signing key file '{signing.KeyPemFile}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Signing keyPem must be configured when signing is enabled.");
|
||||
}
|
||||
|
||||
private static byte[] DecodeKey(string keyMaterial)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyMaterial))
|
||||
{
|
||||
throw new InvalidOperationException("Signing key material is empty.");
|
||||
}
|
||||
|
||||
var segments = keyMaterial.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var builder = new StringBuilder();
|
||||
var hadPemMarkers = false;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("-----", StringComparison.Ordinal))
|
||||
{
|
||||
hadPemMarkers = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(trimmed);
|
||||
}
|
||||
|
||||
var base64 = hadPemMarkers ? builder.ToString() : keyMaterial.Trim();
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Signing key must be Base64 encoded.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReportSignature(string KeyId, string Algorithm, string Signature);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IReportSigner : IDisposable
|
||||
{
|
||||
ReportSignature? Sign(ReadOnlySpan<byte> payload);
|
||||
}
|
||||
|
||||
public sealed class ReportSigner : IReportSigner
|
||||
{
|
||||
private enum SigningMode
|
||||
{
|
||||
Disabled,
|
||||
Provider,
|
||||
Hs256
|
||||
}
|
||||
|
||||
private readonly SigningMode mode;
|
||||
private readonly string keyId = string.Empty;
|
||||
private readonly string algorithmName = string.Empty;
|
||||
private readonly ILogger<ReportSigner> logger;
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly ICryptoHmac cryptoHmac;
|
||||
private readonly ICryptoProvider? provider;
|
||||
private readonly CryptoKeyReference? keyReference;
|
||||
private readonly CryptoSignerResolution? signerResolution;
|
||||
private readonly byte[]? hmacKey;
|
||||
|
||||
public ReportSigner(
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
ICryptoHmac cryptoHmac,
|
||||
ILogger<ReportSigner> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var value = options.Value ?? new ScannerWebServiceOptions();
|
||||
var features = value.Features ?? new ScannerWebServiceOptions.FeatureFlagOptions();
|
||||
var signing = value.Signing ?? new ScannerWebServiceOptions.SigningOptions();
|
||||
|
||||
if (!features.EnableSignedReports || !signing.Enabled)
|
||||
{
|
||||
mode = SigningMode.Disabled;
|
||||
logger.LogInformation("Report signing disabled (feature flag or signing.enabled=false).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.KeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
|
||||
}
|
||||
|
||||
var keyPem = ResolveKeyMaterial(signing);
|
||||
keyId = signing.KeyId.Trim();
|
||||
|
||||
var resolvedMode = ResolveSigningMode(signing.Algorithm, out var canonicalAlgorithm, out var joseAlgorithm);
|
||||
algorithmName = joseAlgorithm;
|
||||
|
||||
switch (resolvedMode)
|
||||
{
|
||||
case SigningMode.Provider:
|
||||
{
|
||||
provider = ResolveProvider(signing.Provider, canonicalAlgorithm);
|
||||
|
||||
var privateKey = DecodeKey(keyPem);
|
||||
var reference = new CryptoKeyReference(keyId, provider.Name);
|
||||
var signingKeyDescriptor = new CryptoSigningKey(
|
||||
reference,
|
||||
canonicalAlgorithm,
|
||||
privateKey,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
provider.UpsertSigningKey(signingKeyDescriptor);
|
||||
|
||||
signerResolution = cryptoRegistry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
canonicalAlgorithm,
|
||||
reference,
|
||||
provider.Name);
|
||||
|
||||
keyReference = reference;
|
||||
mode = SigningMode.Provider;
|
||||
break;
|
||||
}
|
||||
case SigningMode.Hs256:
|
||||
{
|
||||
hmacKey = DecodeKey(keyPem);
|
||||
mode = SigningMode.Hs256;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
mode = SigningMode.Disabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public ReportSignature? Sign(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (mode == SigningMode.Disabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("Payload must be non-empty.", nameof(payload));
|
||||
}
|
||||
|
||||
return mode switch
|
||||
{
|
||||
SigningMode.Provider => SignWithProvider(payload),
|
||||
SigningMode.Hs256 => SignHs256(payload),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private ReportSignature SignWithProvider(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var resolution = signerResolution ?? throw new InvalidOperationException("Signing provider has not been initialised.");
|
||||
|
||||
var signature = resolution.Signer
|
||||
.SignAsync(payload.ToArray())
|
||||
.ConfigureAwait(false)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
return new ReportSignature(keyId, algorithmName, Convert.ToBase64String(signature));
|
||||
}
|
||||
|
||||
private ReportSignature SignHs256(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (hmacKey is null)
|
||||
{
|
||||
throw new InvalidOperationException("HMAC signing has not been initialised.");
|
||||
}
|
||||
|
||||
var signature = cryptoHmac.ComputeHmacBase64ForPurpose(hmacKey, payload, HmacPurpose.Signing);
|
||||
return new ReportSignature(keyId, algorithmName, signature);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (provider is not null && keyReference is not null)
|
||||
{
|
||||
provider.RemoveSigningKey(keyReference.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
private ICryptoProvider ResolveProvider(string? configuredProvider, string canonicalAlgorithm)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredProvider))
|
||||
{
|
||||
if (!cryptoRegistry.TryResolve(configuredProvider.Trim(), out var hinted))
|
||||
{
|
||||
throw new InvalidOperationException($"Configured signing provider '{configuredProvider}' is not registered.");
|
||||
}
|
||||
|
||||
if (!hinted.Supports(CryptoCapability.Signing, canonicalAlgorithm))
|
||||
{
|
||||
throw new InvalidOperationException($"Provider '{configuredProvider}' does not support algorithm '{canonicalAlgorithm}'.");
|
||||
}
|
||||
|
||||
return hinted;
|
||||
}
|
||||
|
||||
return cryptoRegistry.ResolveOrThrow(CryptoCapability.Signing, canonicalAlgorithm);
|
||||
}
|
||||
|
||||
private static SigningMode ResolveSigningMode(string? algorithm, out string canonicalAlgorithm, out string joseAlgorithm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
throw new InvalidOperationException("Signing algorithm must be specified when signing is enabled.");
|
||||
}
|
||||
|
||||
switch (algorithm.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "ed25519":
|
||||
case "eddsa":
|
||||
canonicalAlgorithm = SignatureAlgorithms.Ed25519;
|
||||
joseAlgorithm = SignatureAlgorithms.EdDsa;
|
||||
return SigningMode.Provider;
|
||||
case "hs256":
|
||||
canonicalAlgorithm = "HS256";
|
||||
joseAlgorithm = "HS256";
|
||||
return SigningMode.Hs256;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported signing algorithm '{algorithm}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveKeyMaterial(ScannerWebServiceOptions.SigningOptions signing)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signing.KeyPem))
|
||||
{
|
||||
return signing.KeyPem;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signing.KeyPemFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(signing.KeyPemFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to read signing key file '{signing.KeyPemFile}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Signing keyPem must be configured when signing is enabled.");
|
||||
}
|
||||
|
||||
private static byte[] DecodeKey(string keyMaterial)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyMaterial))
|
||||
{
|
||||
throw new InvalidOperationException("Signing key material is empty.");
|
||||
}
|
||||
|
||||
var segments = keyMaterial.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var builder = new StringBuilder();
|
||||
var hadPemMarkers = false;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("-----", StringComparison.Ordinal))
|
||||
{
|
||||
hadPemMarkers = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(trimmed);
|
||||
}
|
||||
|
||||
var base64 = hadPemMarkers ? builder.ToString() : keyMaterial.Trim();
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Signing key must be Base64 encoded.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReportSignature(string KeyId, string Algorithm, string Signature);
|
||||
|
||||
@@ -1,214 +1,214 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface IRuntimeEventIngestionService
|
||||
{
|
||||
Task<RuntimeEventIngestionResult> IngestAsync(
|
||||
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
||||
string? batchId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly RuntimeEventRepository _repository;
|
||||
private readonly RuntimeEventRateLimiter _rateLimiter;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RuntimeEventIngestionService> _logger;
|
||||
|
||||
public RuntimeEventIngestionService(
|
||||
RuntimeEventRepository repository,
|
||||
RuntimeEventRateLimiter rateLimiter,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventIngestionService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimeEventIngestionResult> IngestAsync(
|
||||
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
||||
string? batchId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelopes);
|
||||
if (envelopes.Count == 0)
|
||||
{
|
||||
return RuntimeEventIngestionResult.Empty;
|
||||
}
|
||||
|
||||
var rateDecision = _rateLimiter.Evaluate(envelopes);
|
||||
if (!rateDecision.Allowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Runtime event batch rejected due to rate limit ({Scope}={Key}, retryAfter={RetryAfter})",
|
||||
rateDecision.Scope,
|
||||
rateDecision.Key,
|
||||
rateDecision.RetryAfter);
|
||||
|
||||
return RuntimeEventIngestionResult.RateLimited(rateDecision.Scope, rateDecision.Key, rateDecision.RetryAfter);
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var receivedAt = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var expiresAt = receivedAt.AddDays(options.EventTtlDays);
|
||||
|
||||
var documents = new List<RuntimeEventDocument>(envelopes.Count);
|
||||
var totalPayloadBytes = 0;
|
||||
|
||||
foreach (var envelope in envelopes)
|
||||
{
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, SerializerOptions);
|
||||
totalPayloadBytes += payloadBytes.Length;
|
||||
if (totalPayloadBytes > options.MaxPayloadBytes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Runtime event batch exceeds payload budget ({PayloadBytes} > {MaxPayloadBytes})",
|
||||
totalPayloadBytes,
|
||||
options.MaxPayloadBytes);
|
||||
return RuntimeEventIngestionResult.PayloadTooLarge(totalPayloadBytes, options.MaxPayloadBytes);
|
||||
}
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var runtimeEvent = envelope.Event;
|
||||
var normalizedDigest = ExtractImageDigest(runtimeEvent);
|
||||
var normalizedBuildId = NormalizeBuildId(runtimeEvent.Process?.BuildId);
|
||||
|
||||
var document = new RuntimeEventDocument
|
||||
{
|
||||
EventId = runtimeEvent.EventId,
|
||||
SchemaVersion = envelope.SchemaVersion,
|
||||
Tenant = runtimeEvent.Tenant,
|
||||
Node = runtimeEvent.Node,
|
||||
Kind = runtimeEvent.Kind.ToString(),
|
||||
When = runtimeEvent.When.UtcDateTime,
|
||||
ReceivedAt = receivedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
Platform = runtimeEvent.Workload.Platform,
|
||||
Namespace = runtimeEvent.Workload.Namespace,
|
||||
Pod = runtimeEvent.Workload.Pod,
|
||||
Container = runtimeEvent.Workload.Container,
|
||||
ContainerId = runtimeEvent.Workload.ContainerId,
|
||||
ImageRef = runtimeEvent.Workload.ImageRef,
|
||||
ImageDigest = normalizedDigest,
|
||||
Engine = runtimeEvent.Runtime.Engine,
|
||||
EngineVersion = runtimeEvent.Runtime.Version,
|
||||
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
|
||||
ImageSigned = runtimeEvent.Posture?.ImageSigned,
|
||||
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
|
||||
BuildId = normalizedBuildId,
|
||||
PayloadJson = payloadJson
|
||||
};
|
||||
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
var insertResult = await _repository.InsertAsync(documents, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Runtime ingestion batch processed (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}, payloadBytes={PayloadBytes})",
|
||||
batchId,
|
||||
insertResult.InsertedCount,
|
||||
insertResult.DuplicateCount,
|
||||
totalPayloadBytes);
|
||||
|
||||
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
|
||||
}
|
||||
|
||||
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
|
||||
{
|
||||
var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest);
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var imageRef = runtimeEvent.Workload.ImageRef;
|
||||
if (string.IsNullOrWhiteSpace(imageRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = imageRef.Trim();
|
||||
var atIndex = trimmed.LastIndexOf('@');
|
||||
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
|
||||
{
|
||||
var candidate = trimmed[(atIndex + 1)..];
|
||||
var parsed = NormalizeDigest(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizeDigest(trimmed);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeDigest(string? candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = candidate.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizeBuildId(string? buildId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(buildId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildId.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct RuntimeEventIngestionResult(
|
||||
int Accepted,
|
||||
int Duplicates,
|
||||
bool IsRateLimited,
|
||||
string? RateLimitedScope,
|
||||
string? RateLimitedKey,
|
||||
TimeSpan RetryAfter,
|
||||
bool IsPayloadTooLarge,
|
||||
int PayloadBytes,
|
||||
int PayloadLimit)
|
||||
{
|
||||
public static RuntimeEventIngestionResult Empty => new(0, 0, false, null, null, TimeSpan.Zero, false, 0, 0);
|
||||
|
||||
public static RuntimeEventIngestionResult RateLimited(string? scope, string? key, TimeSpan retryAfter)
|
||||
=> new(0, 0, true, scope, key, retryAfter, false, 0, 0);
|
||||
|
||||
public static RuntimeEventIngestionResult PayloadTooLarge(int payloadBytes, int payloadLimit)
|
||||
=> new(0, 0, false, null, null, TimeSpan.Zero, true, payloadBytes, payloadLimit);
|
||||
|
||||
public static RuntimeEventIngestionResult Success(int accepted, int duplicates, int payloadBytes)
|
||||
=> new(accepted, duplicates, false, null, null, TimeSpan.Zero, false, payloadBytes, 0);
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface IRuntimeEventIngestionService
|
||||
{
|
||||
Task<RuntimeEventIngestionResult> IngestAsync(
|
||||
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
||||
string? batchId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly RuntimeEventRepository _repository;
|
||||
private readonly RuntimeEventRateLimiter _rateLimiter;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RuntimeEventIngestionService> _logger;
|
||||
|
||||
public RuntimeEventIngestionService(
|
||||
RuntimeEventRepository repository,
|
||||
RuntimeEventRateLimiter rateLimiter,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventIngestionService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimeEventIngestionResult> IngestAsync(
|
||||
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
||||
string? batchId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelopes);
|
||||
if (envelopes.Count == 0)
|
||||
{
|
||||
return RuntimeEventIngestionResult.Empty;
|
||||
}
|
||||
|
||||
var rateDecision = _rateLimiter.Evaluate(envelopes);
|
||||
if (!rateDecision.Allowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Runtime event batch rejected due to rate limit ({Scope}={Key}, retryAfter={RetryAfter})",
|
||||
rateDecision.Scope,
|
||||
rateDecision.Key,
|
||||
rateDecision.RetryAfter);
|
||||
|
||||
return RuntimeEventIngestionResult.RateLimited(rateDecision.Scope, rateDecision.Key, rateDecision.RetryAfter);
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var receivedAt = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var expiresAt = receivedAt.AddDays(options.EventTtlDays);
|
||||
|
||||
var documents = new List<RuntimeEventDocument>(envelopes.Count);
|
||||
var totalPayloadBytes = 0;
|
||||
|
||||
foreach (var envelope in envelopes)
|
||||
{
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, SerializerOptions);
|
||||
totalPayloadBytes += payloadBytes.Length;
|
||||
if (totalPayloadBytes > options.MaxPayloadBytes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Runtime event batch exceeds payload budget ({PayloadBytes} > {MaxPayloadBytes})",
|
||||
totalPayloadBytes,
|
||||
options.MaxPayloadBytes);
|
||||
return RuntimeEventIngestionResult.PayloadTooLarge(totalPayloadBytes, options.MaxPayloadBytes);
|
||||
}
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var runtimeEvent = envelope.Event;
|
||||
var normalizedDigest = ExtractImageDigest(runtimeEvent);
|
||||
var normalizedBuildId = NormalizeBuildId(runtimeEvent.Process?.BuildId);
|
||||
|
||||
var document = new RuntimeEventDocument
|
||||
{
|
||||
EventId = runtimeEvent.EventId,
|
||||
SchemaVersion = envelope.SchemaVersion,
|
||||
Tenant = runtimeEvent.Tenant,
|
||||
Node = runtimeEvent.Node,
|
||||
Kind = runtimeEvent.Kind.ToString(),
|
||||
When = runtimeEvent.When.UtcDateTime,
|
||||
ReceivedAt = receivedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
Platform = runtimeEvent.Workload.Platform,
|
||||
Namespace = runtimeEvent.Workload.Namespace,
|
||||
Pod = runtimeEvent.Workload.Pod,
|
||||
Container = runtimeEvent.Workload.Container,
|
||||
ContainerId = runtimeEvent.Workload.ContainerId,
|
||||
ImageRef = runtimeEvent.Workload.ImageRef,
|
||||
ImageDigest = normalizedDigest,
|
||||
Engine = runtimeEvent.Runtime.Engine,
|
||||
EngineVersion = runtimeEvent.Runtime.Version,
|
||||
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
|
||||
ImageSigned = runtimeEvent.Posture?.ImageSigned,
|
||||
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
|
||||
BuildId = normalizedBuildId,
|
||||
PayloadJson = payloadJson
|
||||
};
|
||||
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
var insertResult = await _repository.InsertAsync(documents, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Runtime ingestion batch processed (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}, payloadBytes={PayloadBytes})",
|
||||
batchId,
|
||||
insertResult.InsertedCount,
|
||||
insertResult.DuplicateCount,
|
||||
totalPayloadBytes);
|
||||
|
||||
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
|
||||
}
|
||||
|
||||
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
|
||||
{
|
||||
var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest);
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var imageRef = runtimeEvent.Workload.ImageRef;
|
||||
if (string.IsNullOrWhiteSpace(imageRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = imageRef.Trim();
|
||||
var atIndex = trimmed.LastIndexOf('@');
|
||||
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
|
||||
{
|
||||
var candidate = trimmed[(atIndex + 1)..];
|
||||
var parsed = NormalizeDigest(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizeDigest(trimmed);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeDigest(string? candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = candidate.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizeBuildId(string? buildId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(buildId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildId.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct RuntimeEventIngestionResult(
|
||||
int Accepted,
|
||||
int Duplicates,
|
||||
bool IsRateLimited,
|
||||
string? RateLimitedScope,
|
||||
string? RateLimitedKey,
|
||||
TimeSpan RetryAfter,
|
||||
bool IsPayloadTooLarge,
|
||||
int PayloadBytes,
|
||||
int PayloadLimit)
|
||||
{
|
||||
public static RuntimeEventIngestionResult Empty => new(0, 0, false, null, null, TimeSpan.Zero, false, 0, 0);
|
||||
|
||||
public static RuntimeEventIngestionResult RateLimited(string? scope, string? key, TimeSpan retryAfter)
|
||||
=> new(0, 0, true, scope, key, retryAfter, false, 0, 0);
|
||||
|
||||
public static RuntimeEventIngestionResult PayloadTooLarge(int payloadBytes, int payloadLimit)
|
||||
=> new(0, 0, false, null, null, TimeSpan.Zero, true, payloadBytes, payloadLimit);
|
||||
|
||||
public static RuntimeEventIngestionResult Success(int accepted, int duplicates, int payloadBytes)
|
||||
=> new(accepted, duplicates, false, null, null, TimeSpan.Zero, false, payloadBytes, 0);
|
||||
}
|
||||
|
||||
@@ -1,173 +1,173 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class RuntimeEventRateLimiter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TokenBucket> _tenantBuckets = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, TokenBucket> _nodeBuckets = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
|
||||
public RuntimeEventRateLimiter(IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor, TimeProvider timeProvider)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public RateLimitDecision Evaluate(IReadOnlyList<RuntimeEventEnvelope> envelopes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelopes);
|
||||
if (envelopes.Count == 0)
|
||||
{
|
||||
return RateLimitDecision.Success;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var tenantCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var nodeCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var envelope in envelopes)
|
||||
{
|
||||
var tenant = envelope.Event.Tenant;
|
||||
var node = envelope.Event.Node;
|
||||
if (tenantCounts.TryGetValue(tenant, out var tenantCount))
|
||||
{
|
||||
tenantCounts[tenant] = tenantCount + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
tenantCounts[tenant] = 1;
|
||||
}
|
||||
|
||||
var nodeKey = $"{tenant}|{node}";
|
||||
if (nodeCounts.TryGetValue(nodeKey, out var nodeCount))
|
||||
{
|
||||
nodeCounts[nodeKey] = nodeCount + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
nodeCounts[nodeKey] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
var tenantDecision = TryAcquire(
|
||||
_tenantBuckets,
|
||||
tenantCounts,
|
||||
options.PerTenantEventsPerSecond,
|
||||
options.PerTenantBurst,
|
||||
now,
|
||||
scope: "tenant");
|
||||
|
||||
if (!tenantDecision.Allowed)
|
||||
{
|
||||
return tenantDecision;
|
||||
}
|
||||
|
||||
var nodeDecision = TryAcquire(
|
||||
_nodeBuckets,
|
||||
nodeCounts,
|
||||
options.PerNodeEventsPerSecond,
|
||||
options.PerNodeBurst,
|
||||
now,
|
||||
scope: "node");
|
||||
|
||||
return nodeDecision;
|
||||
}
|
||||
|
||||
private static RateLimitDecision TryAcquire(
|
||||
ConcurrentDictionary<string, TokenBucket> buckets,
|
||||
IReadOnlyDictionary<string, int> counts,
|
||||
double ratePerSecond,
|
||||
int burst,
|
||||
DateTimeOffset now,
|
||||
string scope)
|
||||
{
|
||||
if (counts.Count == 0)
|
||||
{
|
||||
return RateLimitDecision.Success;
|
||||
}
|
||||
|
||||
var acquired = new List<(TokenBucket bucket, double tokens)>();
|
||||
|
||||
foreach (var pair in counts)
|
||||
{
|
||||
var bucket = buckets.GetOrAdd(
|
||||
pair.Key,
|
||||
_ => new TokenBucket(burst, ratePerSecond, now));
|
||||
|
||||
lock (bucket.SyncRoot)
|
||||
{
|
||||
bucket.Refill(now);
|
||||
if (bucket.Tokens + 1e-9 < pair.Value)
|
||||
{
|
||||
var deficit = pair.Value - bucket.Tokens;
|
||||
var retryAfterSeconds = deficit / bucket.RefillRatePerSecond;
|
||||
var retryAfter = retryAfterSeconds <= 0
|
||||
? TimeSpan.FromSeconds(1)
|
||||
: TimeSpan.FromSeconds(Math.Min(retryAfterSeconds, 3600));
|
||||
|
||||
// undo previously acquired tokens
|
||||
foreach (var (acquiredBucket, tokens) in acquired)
|
||||
{
|
||||
lock (acquiredBucket.SyncRoot)
|
||||
{
|
||||
acquiredBucket.Tokens = Math.Min(acquiredBucket.Capacity, acquiredBucket.Tokens + tokens);
|
||||
}
|
||||
}
|
||||
|
||||
return new RateLimitDecision(false, scope, pair.Key, retryAfter);
|
||||
}
|
||||
|
||||
bucket.Tokens -= pair.Value;
|
||||
acquired.Add((bucket, pair.Value));
|
||||
}
|
||||
}
|
||||
|
||||
return RateLimitDecision.Success;
|
||||
}
|
||||
|
||||
private sealed class TokenBucket
|
||||
{
|
||||
public TokenBucket(double capacity, double refillRatePerSecond, DateTimeOffset now)
|
||||
{
|
||||
Capacity = capacity;
|
||||
Tokens = capacity;
|
||||
RefillRatePerSecond = refillRatePerSecond;
|
||||
LastRefill = now;
|
||||
}
|
||||
|
||||
public double Capacity { get; }
|
||||
public double Tokens { get; set; }
|
||||
public double RefillRatePerSecond { get; }
|
||||
public DateTimeOffset LastRefill { get; set; }
|
||||
public object SyncRoot { get; } = new();
|
||||
|
||||
public void Refill(DateTimeOffset now)
|
||||
{
|
||||
if (now <= LastRefill)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsedSeconds = (now - LastRefill).TotalSeconds;
|
||||
if (elapsedSeconds <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Tokens = Math.Min(Capacity, Tokens + elapsedSeconds * RefillRatePerSecond);
|
||||
LastRefill = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct RateLimitDecision(bool Allowed, string? Scope, string? Key, TimeSpan RetryAfter)
|
||||
{
|
||||
public static RateLimitDecision Success { get; } = new(true, null, null, TimeSpan.Zero);
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class RuntimeEventRateLimiter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TokenBucket> _tenantBuckets = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, TokenBucket> _nodeBuckets = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
|
||||
public RuntimeEventRateLimiter(IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor, TimeProvider timeProvider)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public RateLimitDecision Evaluate(IReadOnlyList<RuntimeEventEnvelope> envelopes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelopes);
|
||||
if (envelopes.Count == 0)
|
||||
{
|
||||
return RateLimitDecision.Success;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var tenantCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var nodeCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var envelope in envelopes)
|
||||
{
|
||||
var tenant = envelope.Event.Tenant;
|
||||
var node = envelope.Event.Node;
|
||||
if (tenantCounts.TryGetValue(tenant, out var tenantCount))
|
||||
{
|
||||
tenantCounts[tenant] = tenantCount + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
tenantCounts[tenant] = 1;
|
||||
}
|
||||
|
||||
var nodeKey = $"{tenant}|{node}";
|
||||
if (nodeCounts.TryGetValue(nodeKey, out var nodeCount))
|
||||
{
|
||||
nodeCounts[nodeKey] = nodeCount + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
nodeCounts[nodeKey] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
var tenantDecision = TryAcquire(
|
||||
_tenantBuckets,
|
||||
tenantCounts,
|
||||
options.PerTenantEventsPerSecond,
|
||||
options.PerTenantBurst,
|
||||
now,
|
||||
scope: "tenant");
|
||||
|
||||
if (!tenantDecision.Allowed)
|
||||
{
|
||||
return tenantDecision;
|
||||
}
|
||||
|
||||
var nodeDecision = TryAcquire(
|
||||
_nodeBuckets,
|
||||
nodeCounts,
|
||||
options.PerNodeEventsPerSecond,
|
||||
options.PerNodeBurst,
|
||||
now,
|
||||
scope: "node");
|
||||
|
||||
return nodeDecision;
|
||||
}
|
||||
|
||||
private static RateLimitDecision TryAcquire(
|
||||
ConcurrentDictionary<string, TokenBucket> buckets,
|
||||
IReadOnlyDictionary<string, int> counts,
|
||||
double ratePerSecond,
|
||||
int burst,
|
||||
DateTimeOffset now,
|
||||
string scope)
|
||||
{
|
||||
if (counts.Count == 0)
|
||||
{
|
||||
return RateLimitDecision.Success;
|
||||
}
|
||||
|
||||
var acquired = new List<(TokenBucket bucket, double tokens)>();
|
||||
|
||||
foreach (var pair in counts)
|
||||
{
|
||||
var bucket = buckets.GetOrAdd(
|
||||
pair.Key,
|
||||
_ => new TokenBucket(burst, ratePerSecond, now));
|
||||
|
||||
lock (bucket.SyncRoot)
|
||||
{
|
||||
bucket.Refill(now);
|
||||
if (bucket.Tokens + 1e-9 < pair.Value)
|
||||
{
|
||||
var deficit = pair.Value - bucket.Tokens;
|
||||
var retryAfterSeconds = deficit / bucket.RefillRatePerSecond;
|
||||
var retryAfter = retryAfterSeconds <= 0
|
||||
? TimeSpan.FromSeconds(1)
|
||||
: TimeSpan.FromSeconds(Math.Min(retryAfterSeconds, 3600));
|
||||
|
||||
// undo previously acquired tokens
|
||||
foreach (var (acquiredBucket, tokens) in acquired)
|
||||
{
|
||||
lock (acquiredBucket.SyncRoot)
|
||||
{
|
||||
acquiredBucket.Tokens = Math.Min(acquiredBucket.Capacity, acquiredBucket.Tokens + tokens);
|
||||
}
|
||||
}
|
||||
|
||||
return new RateLimitDecision(false, scope, pair.Key, retryAfter);
|
||||
}
|
||||
|
||||
bucket.Tokens -= pair.Value;
|
||||
acquired.Add((bucket, pair.Value));
|
||||
}
|
||||
}
|
||||
|
||||
return RateLimitDecision.Success;
|
||||
}
|
||||
|
||||
private sealed class TokenBucket
|
||||
{
|
||||
public TokenBucket(double capacity, double refillRatePerSecond, DateTimeOffset now)
|
||||
{
|
||||
Capacity = capacity;
|
||||
Tokens = capacity;
|
||||
RefillRatePerSecond = refillRatePerSecond;
|
||||
LastRefill = now;
|
||||
}
|
||||
|
||||
public double Capacity { get; }
|
||||
public double Tokens { get; set; }
|
||||
public double RefillRatePerSecond { get; }
|
||||
public DateTimeOffset LastRefill { get; set; }
|
||||
public object SyncRoot { get; } = new();
|
||||
|
||||
public void Refill(DateTimeOffset now)
|
||||
{
|
||||
if (now <= LastRefill)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsedSeconds = (now - LastRefill).TotalSeconds;
|
||||
if (elapsedSeconds <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Tokens = Math.Min(Capacity, Tokens + elapsedSeconds * RefillRatePerSecond);
|
||||
LastRefill = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct RateLimitDecision(bool Allowed, string? Scope, string? Key, TimeSpan RetryAfter)
|
||||
{
|
||||
public static RateLimitDecision Success { get; } = new(true, null, null, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
@@ -15,27 +15,27 @@ using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
|
||||
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
|
||||
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface IRuntimePolicyService
|
||||
{
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
private const int MaxBuildIdsPerImage = 3;
|
||||
|
||||
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
|
||||
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
|
||||
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
|
||||
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
|
||||
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface IRuntimePolicyService
|
||||
{
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
private const int MaxBuildIdsPerImage = 3;
|
||||
|
||||
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
|
||||
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
|
||||
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
private readonly RuntimeEventRepository _runtimeEventRepository;
|
||||
private readonly PolicySnapshotStore _policySnapshotStore;
|
||||
private readonly PolicyPreviewService _policyPreviewService;
|
||||
@@ -44,8 +44,8 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IRuntimeAttestationVerifier _attestationVerifier;
|
||||
private readonly ILogger<RuntimePolicyService> _logger;
|
||||
|
||||
public RuntimePolicyService(
|
||||
|
||||
public RuntimePolicyService(
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
RuntimeEventRepository runtimeEventRepository,
|
||||
@@ -68,17 +68,17 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtimeOptions = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var ttlSeconds = Math.Max(1, runtimeOptions.PolicyCacheTtlSeconds);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.AddSeconds(ttlSeconds);
|
||||
|
||||
|
||||
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtimeOptions = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var ttlSeconds = Math.Max(1, runtimeOptions.PolicyCacheTtlSeconds);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.AddSeconds(ttlSeconds);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var snapshot = await _policySnapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -93,35 +93,35 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
|
||||
var policyRevision = snapshot?.RevisionId;
|
||||
var policyDigest = snapshot?.Digest;
|
||||
|
||||
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
|
||||
var evaluationTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("policy_revision", policyRevision ?? "none"),
|
||||
new("namespace", request.Namespace ?? "unspecified")
|
||||
};
|
||||
|
||||
var buildIdObservations = await _runtimeEventRepository
|
||||
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var evaluated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var image in request.Images)
|
||||
{
|
||||
if (!evaluated.Add(image))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = await ResolveImageMetadataAsync(image, cancellationToken).ConfigureAwait(false);
|
||||
var (findings, heuristicReasons) = BuildFindings(image, metadata, request.Namespace);
|
||||
if (snapshot is null)
|
||||
{
|
||||
heuristicReasons.Add("policy.snapshot.missing");
|
||||
}
|
||||
|
||||
|
||||
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
|
||||
var evaluationTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("policy_revision", policyRevision ?? "none"),
|
||||
new("namespace", request.Namespace ?? "unspecified")
|
||||
};
|
||||
|
||||
var buildIdObservations = await _runtimeEventRepository
|
||||
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var evaluated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var image in request.Images)
|
||||
{
|
||||
if (!evaluated.Add(image))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = await ResolveImageMetadataAsync(image, cancellationToken).ConfigureAwait(false);
|
||||
var (findings, heuristicReasons) = BuildFindings(image, metadata, request.Namespace);
|
||||
if (snapshot is null)
|
||||
{
|
||||
heuristicReasons.Add("policy.snapshot.missing");
|
||||
}
|
||||
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
|
||||
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
|
||||
IReadOnlyList<LinksetSummaryDto> linksets = Array.Empty<LinksetSummaryDto>();
|
||||
@@ -130,14 +130,14 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
|
||||
{
|
||||
var previewRequest = new PolicyPreviewRequest(
|
||||
image,
|
||||
findings,
|
||||
ImmutableArray<CanonicalPolicyVerdict>.Empty,
|
||||
snapshot,
|
||||
ProposedPolicy: null);
|
||||
|
||||
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
|
||||
var previewRequest = new PolicyPreviewRequest(
|
||||
image,
|
||||
findings,
|
||||
ImmutableArray<CanonicalPolicyVerdict>.Empty,
|
||||
snapshot,
|
||||
ProposedPolicy: null);
|
||||
|
||||
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
|
||||
issues = preview.Issues;
|
||||
if (!preview.Diffs.IsDefaultOrEmpty)
|
||||
{
|
||||
@@ -147,16 +147,16 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
|
||||
}
|
||||
|
||||
var normalizedImage = image.Trim().ToLowerInvariant();
|
||||
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
|
||||
|
||||
var decision = await BuildDecisionAsync(
|
||||
image,
|
||||
metadata,
|
||||
{
|
||||
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
|
||||
}
|
||||
|
||||
var normalizedImage = image.Trim().ToLowerInvariant();
|
||||
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
|
||||
|
||||
var decision = await BuildDecisionAsync(
|
||||
image,
|
||||
metadata,
|
||||
heuristicReasons,
|
||||
projectedVerdicts,
|
||||
issues,
|
||||
@@ -164,128 +164,128 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
linksets,
|
||||
buildIdObservation?.BuildIds,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[image] = decision;
|
||||
|
||||
_logger.LogInformation("Runtime policy evaluated image {ImageDigest} with verdict {Verdict} (Signed: {Signed}, HasSbom: {HasSbom}, Reasons: {ReasonsCount})",
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
decision.Signed,
|
||||
decision.HasSbomReferrers,
|
||||
decision.Reasons.Count);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
PolicyEvaluationLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds, evaluationTags);
|
||||
}
|
||||
|
||||
PolicyEvaluations.Add(results.Count, evaluationTags);
|
||||
|
||||
var evaluationResult = new RuntimePolicyEvaluationResult(
|
||||
ttlSeconds,
|
||||
expiresAt,
|
||||
policyRevision,
|
||||
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(results));
|
||||
|
||||
return evaluationResult;
|
||||
}
|
||||
|
||||
private async Task<RuntimeImageMetadata> ResolveImageMetadataAsync(string imageDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
var links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, imageDigest, cancellationToken).ConfigureAwait(false);
|
||||
if (links.Count == 0)
|
||||
{
|
||||
return new RuntimeImageMetadata(imageDigest, false, false, null, MissingMetadata: true);
|
||||
}
|
||||
|
||||
var hasSbom = false;
|
||||
var signed = false;
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
var artifact = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
|
||||
if (artifact is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (artifact.Type)
|
||||
{
|
||||
case ArtifactDocumentType.ImageBom:
|
||||
hasSbom = true;
|
||||
break;
|
||||
case ArtifactDocumentType.Attestation:
|
||||
signed = true;
|
||||
if (artifact.Rekor is { } rekorReference)
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
Normalize(rekorReference.Uuid),
|
||||
Normalize(rekorReference.Url),
|
||||
rekorReference.Index.HasValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new RuntimeImageMetadata(imageDigest, signed, hasSbom, rekor, MissingMetadata: false);
|
||||
}
|
||||
|
||||
private (ImmutableArray<PolicyFinding> Findings, List<string> HeuristicReasons) BuildFindings(string imageDigest, RuntimeImageMetadata metadata, string? @namespace)
|
||||
{
|
||||
var findings = ImmutableArray.CreateBuilder<PolicyFinding>();
|
||||
var heuristics = new List<string>();
|
||||
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#baseline",
|
||||
PolicySeverity.None,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime"));
|
||||
|
||||
if (metadata.MissingMetadata)
|
||||
{
|
||||
const string reason = "image.metadata.missing";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#metadata",
|
||||
PolicySeverity.Critical,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.Signed)
|
||||
{
|
||||
const string reason = "unsigned";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#signature",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.HasSbomReferrers)
|
||||
{
|
||||
const string reason = "missing SBOM";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#sbom",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
return (findings.ToImmutable(), heuristics);
|
||||
}
|
||||
|
||||
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
|
||||
string imageDigest,
|
||||
RuntimeImageMetadata metadata,
|
||||
|
||||
results[image] = decision;
|
||||
|
||||
_logger.LogInformation("Runtime policy evaluated image {ImageDigest} with verdict {Verdict} (Signed: {Signed}, HasSbom: {HasSbom}, Reasons: {ReasonsCount})",
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
decision.Signed,
|
||||
decision.HasSbomReferrers,
|
||||
decision.Reasons.Count);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
PolicyEvaluationLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds, evaluationTags);
|
||||
}
|
||||
|
||||
PolicyEvaluations.Add(results.Count, evaluationTags);
|
||||
|
||||
var evaluationResult = new RuntimePolicyEvaluationResult(
|
||||
ttlSeconds,
|
||||
expiresAt,
|
||||
policyRevision,
|
||||
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(results));
|
||||
|
||||
return evaluationResult;
|
||||
}
|
||||
|
||||
private async Task<RuntimeImageMetadata> ResolveImageMetadataAsync(string imageDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
var links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, imageDigest, cancellationToken).ConfigureAwait(false);
|
||||
if (links.Count == 0)
|
||||
{
|
||||
return new RuntimeImageMetadata(imageDigest, false, false, null, MissingMetadata: true);
|
||||
}
|
||||
|
||||
var hasSbom = false;
|
||||
var signed = false;
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
var artifact = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
|
||||
if (artifact is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (artifact.Type)
|
||||
{
|
||||
case ArtifactDocumentType.ImageBom:
|
||||
hasSbom = true;
|
||||
break;
|
||||
case ArtifactDocumentType.Attestation:
|
||||
signed = true;
|
||||
if (artifact.Rekor is { } rekorReference)
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
Normalize(rekorReference.Uuid),
|
||||
Normalize(rekorReference.Url),
|
||||
rekorReference.Index.HasValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new RuntimeImageMetadata(imageDigest, signed, hasSbom, rekor, MissingMetadata: false);
|
||||
}
|
||||
|
||||
private (ImmutableArray<PolicyFinding> Findings, List<string> HeuristicReasons) BuildFindings(string imageDigest, RuntimeImageMetadata metadata, string? @namespace)
|
||||
{
|
||||
var findings = ImmutableArray.CreateBuilder<PolicyFinding>();
|
||||
var heuristics = new List<string>();
|
||||
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#baseline",
|
||||
PolicySeverity.None,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime"));
|
||||
|
||||
if (metadata.MissingMetadata)
|
||||
{
|
||||
const string reason = "image.metadata.missing";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#metadata",
|
||||
PolicySeverity.Critical,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.Signed)
|
||||
{
|
||||
const string reason = "unsigned";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#signature",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.HasSbomReferrers)
|
||||
{
|
||||
const string reason = "missing SBOM";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#sbom",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
return (findings.ToImmutable(), heuristics);
|
||||
}
|
||||
|
||||
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
|
||||
string imageDigest,
|
||||
RuntimeImageMetadata metadata,
|
||||
List<string> heuristicReasons,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
@@ -293,51 +293,51 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
IReadOnlyList<LinksetSummaryDto> linksets,
|
||||
IReadOnlyList<string>? buildIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var reasons = new List<string>(heuristicReasons);
|
||||
|
||||
var overallVerdict = MapVerdict(projectedVerdicts, heuristicReasons);
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var verdict in projectedVerdicts)
|
||||
{
|
||||
if (verdict.Status == CanonicalPolicyVerdictStatus.Pass)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verdict.RuleName))
|
||||
{
|
||||
reasons.Add($"policy.rule.{verdict.RuleName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add($"policy.status.{verdict.Status.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var confidence = ComputeConfidence(projectedVerdicts, overallVerdict);
|
||||
var quieted = !projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Any(v => v.Quiet);
|
||||
var quietedBy = !projectedVerdicts.IsDefaultOrEmpty
|
||||
? projectedVerdicts.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v.QuietedBy))?.QuietedBy
|
||||
: null;
|
||||
|
||||
var metadataPayload = BuildMetadataPayload(heuristicReasons, projectedVerdicts, issues, policyDigest);
|
||||
|
||||
var rekor = metadata.Rekor;
|
||||
var verified = await _attestationVerifier.VerifyAsync(imageDigest, metadata.Rekor, cancellationToken).ConfigureAwait(false);
|
||||
if (rekor is not null && verified.HasValue)
|
||||
{
|
||||
rekor = rekor with { Verified = verified.Value };
|
||||
}
|
||||
|
||||
var normalizedReasons = reasons
|
||||
.Where(reason => !string.IsNullOrWhiteSpace(reason))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
{
|
||||
var reasons = new List<string>(heuristicReasons);
|
||||
|
||||
var overallVerdict = MapVerdict(projectedVerdicts, heuristicReasons);
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var verdict in projectedVerdicts)
|
||||
{
|
||||
if (verdict.Status == CanonicalPolicyVerdictStatus.Pass)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verdict.RuleName))
|
||||
{
|
||||
reasons.Add($"policy.rule.{verdict.RuleName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add($"policy.status.{verdict.Status.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var confidence = ComputeConfidence(projectedVerdicts, overallVerdict);
|
||||
var quieted = !projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Any(v => v.Quiet);
|
||||
var quietedBy = !projectedVerdicts.IsDefaultOrEmpty
|
||||
? projectedVerdicts.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v.QuietedBy))?.QuietedBy
|
||||
: null;
|
||||
|
||||
var metadataPayload = BuildMetadataPayload(heuristicReasons, projectedVerdicts, issues, policyDigest);
|
||||
|
||||
var rekor = metadata.Rekor;
|
||||
var verified = await _attestationVerifier.VerifyAsync(imageDigest, metadata.Rekor, cancellationToken).ConfigureAwait(false);
|
||||
if (rekor is not null && verified.HasValue)
|
||||
{
|
||||
rekor = rekor with { Verified = verified.Value };
|
||||
}
|
||||
|
||||
var normalizedReasons = reasons
|
||||
.Where(reason => !string.IsNullOrWhiteSpace(reason))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new RuntimePolicyImageDecision(
|
||||
overallVerdict,
|
||||
metadata.Signed,
|
||||
@@ -351,165 +351,165 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
buildIds,
|
||||
linksets);
|
||||
}
|
||||
|
||||
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var statuses = projectedVerdicts.Select(v => v.Status).ToArray();
|
||||
if (statuses.Any(status => status == CanonicalPolicyVerdictStatus.Blocked))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (statuses.Any(status =>
|
||||
status is CanonicalPolicyVerdictStatus.Warned
|
||||
or CanonicalPolicyVerdictStatus.Deferred
|
||||
or CanonicalPolicyVerdictStatus.Escalated
|
||||
or CanonicalPolicyVerdictStatus.RequiresVex))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("image.metadata.missing", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("unsigned", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("missing SBOM", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("policy.snapshot.missing", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
private IDictionary<string, object?>? BuildMetadataPayload(
|
||||
IReadOnlyList<string> heuristics,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
string? policyDigest)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["heuristics"] = heuristics,
|
||||
["evaluatedAt"] = _timeProvider.GetUtcNow().UtcDateTime
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyDigest))
|
||||
{
|
||||
payload["policyDigest"] = policyDigest;
|
||||
}
|
||||
|
||||
if (!issues.IsDefaultOrEmpty && issues.Length > 0)
|
||||
{
|
||||
payload["issues"] = issues.Select(issue => new
|
||||
{
|
||||
code = issue.Code,
|
||||
severity = issue.Severity.ToString(),
|
||||
message = issue.Message,
|
||||
path = issue.Path
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
payload["findings"] = projectedVerdicts.Select(verdict => new
|
||||
{
|
||||
id = verdict.FindingId,
|
||||
status = verdict.Status.ToString().ToLowerInvariant(),
|
||||
rule = verdict.RuleName,
|
||||
action = verdict.RuleAction,
|
||||
score = verdict.Score,
|
||||
quiet = verdict.Quiet,
|
||||
quietedBy = verdict.QuietedBy,
|
||||
inputs = verdict.GetInputs(),
|
||||
confidence = verdict.UnknownConfidence,
|
||||
confidenceBand = verdict.ConfidenceBand,
|
||||
sourceTrust = verdict.SourceTrust,
|
||||
reachability = verdict.Reachability
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
return payload.Count == 0 ? null : payload;
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, RuntimePolicyVerdict overall)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var confidences = projectedVerdicts
|
||||
.Select(v => v.UnknownConfidence)
|
||||
.Where(value => value.HasValue)
|
||||
.Select(value => value!.Value)
|
||||
.ToArray();
|
||||
|
||||
if (confidences.Length > 0)
|
||||
{
|
||||
return Math.Clamp(confidences.Average(), 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
return overall switch
|
||||
{
|
||||
RuntimePolicyVerdict.Pass => 0.95,
|
||||
RuntimePolicyVerdict.Warn => 0.5,
|
||||
RuntimePolicyVerdict.Fail => 0.1,
|
||||
_ => 0.25
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
internal interface IRuntimeAttestationVerifier
|
||||
{
|
||||
ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeAttestationVerifier : IRuntimeAttestationVerifier
|
||||
{
|
||||
private readonly ILogger<RuntimeAttestationVerifier> _logger;
|
||||
|
||||
public RuntimeAttestationVerifier(ILogger<RuntimeAttestationVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken)
|
||||
{
|
||||
if (rekor is null)
|
||||
{
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
|
||||
if (rekor.Verified.HasValue)
|
||||
{
|
||||
return ValueTask.FromResult(rekor.Verified);
|
||||
}
|
||||
|
||||
_logger.LogDebug("No attestation verification metadata available for image {ImageDigest}.", imageDigest);
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
|
||||
|
||||
|
||||
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var statuses = projectedVerdicts.Select(v => v.Status).ToArray();
|
||||
if (statuses.Any(status => status == CanonicalPolicyVerdictStatus.Blocked))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (statuses.Any(status =>
|
||||
status is CanonicalPolicyVerdictStatus.Warned
|
||||
or CanonicalPolicyVerdictStatus.Deferred
|
||||
or CanonicalPolicyVerdictStatus.Escalated
|
||||
or CanonicalPolicyVerdictStatus.RequiresVex))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("image.metadata.missing", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("unsigned", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("missing SBOM", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("policy.snapshot.missing", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
private IDictionary<string, object?>? BuildMetadataPayload(
|
||||
IReadOnlyList<string> heuristics,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
string? policyDigest)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["heuristics"] = heuristics,
|
||||
["evaluatedAt"] = _timeProvider.GetUtcNow().UtcDateTime
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyDigest))
|
||||
{
|
||||
payload["policyDigest"] = policyDigest;
|
||||
}
|
||||
|
||||
if (!issues.IsDefaultOrEmpty && issues.Length > 0)
|
||||
{
|
||||
payload["issues"] = issues.Select(issue => new
|
||||
{
|
||||
code = issue.Code,
|
||||
severity = issue.Severity.ToString(),
|
||||
message = issue.Message,
|
||||
path = issue.Path
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
payload["findings"] = projectedVerdicts.Select(verdict => new
|
||||
{
|
||||
id = verdict.FindingId,
|
||||
status = verdict.Status.ToString().ToLowerInvariant(),
|
||||
rule = verdict.RuleName,
|
||||
action = verdict.RuleAction,
|
||||
score = verdict.Score,
|
||||
quiet = verdict.Quiet,
|
||||
quietedBy = verdict.QuietedBy,
|
||||
inputs = verdict.GetInputs(),
|
||||
confidence = verdict.UnknownConfidence,
|
||||
confidenceBand = verdict.ConfidenceBand,
|
||||
sourceTrust = verdict.SourceTrust,
|
||||
reachability = verdict.Reachability
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
return payload.Count == 0 ? null : payload;
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, RuntimePolicyVerdict overall)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var confidences = projectedVerdicts
|
||||
.Select(v => v.UnknownConfidence)
|
||||
.Where(value => value.HasValue)
|
||||
.Select(value => value!.Value)
|
||||
.ToArray();
|
||||
|
||||
if (confidences.Length > 0)
|
||||
{
|
||||
return Math.Clamp(confidences.Average(), 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
return overall switch
|
||||
{
|
||||
RuntimePolicyVerdict.Pass => 0.95,
|
||||
RuntimePolicyVerdict.Warn => 0.5,
|
||||
RuntimePolicyVerdict.Fail => 0.1,
|
||||
_ => 0.25
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
internal interface IRuntimeAttestationVerifier
|
||||
{
|
||||
ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeAttestationVerifier : IRuntimeAttestationVerifier
|
||||
{
|
||||
private readonly ILogger<RuntimeAttestationVerifier> _logger;
|
||||
|
||||
public RuntimeAttestationVerifier(ILogger<RuntimeAttestationVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken)
|
||||
{
|
||||
if (rekor is null)
|
||||
{
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
|
||||
if (rekor.Verified.HasValue)
|
||||
{
|
||||
return ValueTask.FromResult(rekor.Verified);
|
||||
}
|
||||
|
||||
_logger.LogDebug("No attestation verification metadata available for image {ImageDigest}.", imageDigest);
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
RuntimePolicyVerdict PolicyVerdict,
|
||||
bool Signed,
|
||||
@@ -522,12 +522,12 @@ internal sealed record RuntimePolicyImageDecision(
|
||||
string? QuietedBy,
|
||||
IReadOnlyList<string>? BuildIds,
|
||||
IReadOnlyList<LinksetSummaryDto> Linksets);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
internal sealed record RuntimeImageMetadata(
|
||||
string ImageDigest,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
bool MissingMetadata);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
internal sealed record RuntimeImageMetadata(
|
||||
string ImageDigest,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
bool MissingMetadata);
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IScanProgressPublisher
|
||||
{
|
||||
ScanProgressEvent Publish(
|
||||
ScanId scanId,
|
||||
string state,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, object?>? data = null,
|
||||
string? correlationId = null);
|
||||
}
|
||||
|
||||
public interface IScanProgressReader
|
||||
{
|
||||
bool Exists(ScanId scanId);
|
||||
|
||||
IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(ScanId scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressReader
|
||||
{
|
||||
private sealed class ProgressChannel
|
||||
{
|
||||
private readonly List<ScanProgressEvent> history = new();
|
||||
private readonly Channel<ScanProgressEvent> channel = Channel.CreateUnbounded<ScanProgressEvent>(new UnboundedChannelOptions
|
||||
{
|
||||
AllowSynchronousContinuations = true,
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public int Sequence { get; private set; }
|
||||
|
||||
public ScanProgressEvent Append(ScanProgressEvent progressEvent)
|
||||
{
|
||||
history.Add(progressEvent);
|
||||
channel.Writer.TryWrite(progressEvent);
|
||||
return progressEvent;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ScanProgressEvent> Snapshot()
|
||||
{
|
||||
return history.Count == 0
|
||||
? Array.Empty<ScanProgressEvent>()
|
||||
: history.ToArray();
|
||||
}
|
||||
|
||||
public ChannelReader<ScanProgressEvent> Reader => channel.Reader;
|
||||
|
||||
public int NextSequence() => ++Sequence;
|
||||
}
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IScanProgressPublisher
|
||||
{
|
||||
ScanProgressEvent Publish(
|
||||
ScanId scanId,
|
||||
string state,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, object?>? data = null,
|
||||
string? correlationId = null);
|
||||
}
|
||||
|
||||
public interface IScanProgressReader
|
||||
{
|
||||
bool Exists(ScanId scanId);
|
||||
|
||||
IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(ScanId scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressReader
|
||||
{
|
||||
private sealed class ProgressChannel
|
||||
{
|
||||
private readonly List<ScanProgressEvent> history = new();
|
||||
private readonly Channel<ScanProgressEvent> channel = Channel.CreateUnbounded<ScanProgressEvent>(new UnboundedChannelOptions
|
||||
{
|
||||
AllowSynchronousContinuations = true,
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public int Sequence { get; private set; }
|
||||
|
||||
public ScanProgressEvent Append(ScanProgressEvent progressEvent)
|
||||
{
|
||||
history.Add(progressEvent);
|
||||
channel.Writer.TryWrite(progressEvent);
|
||||
return progressEvent;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ScanProgressEvent> Snapshot()
|
||||
{
|
||||
return history.Count == 0
|
||||
? Array.Empty<ScanProgressEvent>()
|
||||
: history.ToArray();
|
||||
}
|
||||
|
||||
public ChannelReader<ScanProgressEvent> Reader => channel.Reader;
|
||||
|
||||
public int NextSequence() => ++Sequence;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyData =
|
||||
new ReadOnlyDictionary<string, object?>(new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private readonly ConcurrentDictionary<string, ProgressChannel> channels = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public ScanProgressStream(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public bool Exists(ScanId scanId)
|
||||
=> channels.ContainsKey(scanId.Value);
|
||||
|
||||
public ScanProgressEvent Publish(
|
||||
ScanId scanId,
|
||||
string state,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, object?>? data = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var channel = channels.GetOrAdd(scanId.Value, _ => new ProgressChannel());
|
||||
|
||||
ScanProgressEvent progressEvent;
|
||||
lock (channel)
|
||||
{
|
||||
var sequence = channel.NextSequence();
|
||||
var correlation = correlationId ?? $"{scanId.Value}:{sequence:D4}";
|
||||
|
||||
private readonly ConcurrentDictionary<string, ProgressChannel> channels = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public ScanProgressStream(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public bool Exists(ScanId scanId)
|
||||
=> channels.ContainsKey(scanId.Value);
|
||||
|
||||
public ScanProgressEvent Publish(
|
||||
ScanId scanId,
|
||||
string state,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, object?>? data = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var channel = channels.GetOrAdd(scanId.Value, _ => new ProgressChannel());
|
||||
|
||||
ScanProgressEvent progressEvent;
|
||||
lock (channel)
|
||||
{
|
||||
var sequence = channel.NextSequence();
|
||||
var correlation = correlationId ?? $"{scanId.Value}:{sequence:D4}";
|
||||
progressEvent = new ScanProgressEvent(
|
||||
scanId,
|
||||
sequence,
|
||||
@@ -93,40 +93,40 @@ public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressRe
|
||||
message,
|
||||
correlation,
|
||||
NormalizePayload(data));
|
||||
|
||||
channel.Append(progressEvent);
|
||||
}
|
||||
|
||||
return progressEvent;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(
|
||||
ScanId scanId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (!channels.TryGetValue(scanId.Value, out var channel))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IReadOnlyList<ScanProgressEvent> snapshot;
|
||||
lock (channel)
|
||||
{
|
||||
snapshot = channel.Snapshot();
|
||||
}
|
||||
|
||||
foreach (var progressEvent in snapshot)
|
||||
{
|
||||
yield return progressEvent;
|
||||
}
|
||||
|
||||
var reader = channel.Reader;
|
||||
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (reader.TryRead(out var progressEvent))
|
||||
{
|
||||
yield return progressEvent;
|
||||
}
|
||||
|
||||
channel.Append(progressEvent);
|
||||
}
|
||||
|
||||
return progressEvent;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(
|
||||
ScanId scanId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (!channels.TryGetValue(scanId.Value, out var channel))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IReadOnlyList<ScanProgressEvent> snapshot;
|
||||
lock (channel)
|
||||
{
|
||||
snapshot = channel.Snapshot();
|
||||
}
|
||||
|
||||
foreach (var progressEvent in snapshot)
|
||||
{
|
||||
yield return progressEvent;
|
||||
}
|
||||
|
||||
var reader = channel.Reader;
|
||||
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (reader.TryRead(out var progressEvent))
|
||||
{
|
||||
yield return progressEvent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
@@ -19,30 +18,7 @@ public sealed record DeterminismReport(
|
||||
double ThresholdOverall,
|
||||
double ThresholdImage,
|
||||
IReadOnlyList<DeterminismImageReport> Images)
|
||||
{
|
||||
public static DeterminismReport FromHarness(Harness.DeterminismReport harnessReport,
|
||||
string release,
|
||||
string platform,
|
||||
string? policySha = null,
|
||||
string? feedsSha = null,
|
||||
string? scannerSha = null,
|
||||
string version = "1")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(harnessReport);
|
||||
|
||||
return new DeterminismReport(
|
||||
Version: version,
|
||||
Release: release,
|
||||
Platform: platform,
|
||||
PolicySha: policySha,
|
||||
FeedsSha: feedsSha,
|
||||
ScannerSha: scannerSha,
|
||||
OverallScore: harnessReport.OverallScore,
|
||||
ThresholdOverall: harnessReport.OverallThreshold,
|
||||
ThresholdImage: harnessReport.ImageThreshold,
|
||||
Images: harnessReport.Images.Select(DeterminismImageReport.FromHarness).ToList());
|
||||
}
|
||||
}
|
||||
;
|
||||
|
||||
public sealed record DeterminismImageReport(
|
||||
string Image,
|
||||
@@ -50,30 +26,9 @@ public sealed record DeterminismImageReport(
|
||||
int Identical,
|
||||
double Score,
|
||||
IReadOnlyDictionary<string, string> ArtifactHashes,
|
||||
IReadOnlyList<DeterminismRunReport> RunsDetail)
|
||||
{
|
||||
public static DeterminismImageReport FromHarness(Harness.DeterminismImageReport report)
|
||||
{
|
||||
return new DeterminismImageReport(
|
||||
Image: report.ImageDigest,
|
||||
Runs: report.Runs,
|
||||
Identical: report.Identical,
|
||||
Score: report.Score,
|
||||
ArtifactHashes: report.BaselineHashes,
|
||||
RunsDetail: report.RunReports.Select(DeterminismRunReport.FromHarness).ToList());
|
||||
}
|
||||
}
|
||||
IReadOnlyList<DeterminismRunReport> RunsDetail);
|
||||
|
||||
public sealed record DeterminismRunReport(
|
||||
int RunIndex,
|
||||
IReadOnlyDictionary<string, string> ArtifactHashes,
|
||||
IReadOnlyList<string> NonDeterministic)
|
||||
{
|
||||
public static DeterminismRunReport FromHarness(Harness.DeterminismRunReport report)
|
||||
{
|
||||
return new DeterminismRunReport(
|
||||
RunIndex: report.RunIndex,
|
||||
ArtifactHashes: report.ArtifactHashes,
|
||||
NonDeterministic: report.NonDeterministicArtifacts);
|
||||
}
|
||||
}
|
||||
IReadOnlyList<string> NonDeterministic);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
public static class ScannerWorkerInstrumentation
|
||||
{
|
||||
public const string ActivitySourceName = "StellaOps.Scanner.Worker.Job";
|
||||
|
||||
public const string MeterName = "StellaOps.Scanner.Worker";
|
||||
|
||||
public static ActivitySource ActivitySource { get; } = new(ActivitySourceName);
|
||||
|
||||
public static Meter Meter { get; } = new(MeterName, version: "1.0.0");
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
public static class ScannerWorkerInstrumentation
|
||||
{
|
||||
public const string ActivitySourceName = "StellaOps.Scanner.Worker.Job";
|
||||
|
||||
public const string MeterName = "StellaOps.Scanner.Worker";
|
||||
|
||||
public static ActivitySource ActivitySource { get; } = new(ActivitySourceName);
|
||||
|
||||
public static Meter Meter { get; } = new(MeterName, version: "1.0.0");
|
||||
}
|
||||
|
||||
@@ -3,18 +3,20 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
public sealed class ScannerWorkerMetrics
|
||||
{
|
||||
private readonly Histogram<double> _queueLatencyMs;
|
||||
private readonly Histogram<double> _jobDurationMs;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
public sealed class ScannerWorkerMetrics
|
||||
{
|
||||
private readonly Histogram<double> _queueLatencyMs;
|
||||
private readonly Histogram<double> _jobDurationMs;
|
||||
private readonly Histogram<double> _stageDurationMs;
|
||||
private readonly Counter<long> _jobsCompleted;
|
||||
private readonly Counter<long> _jobsFailed;
|
||||
private readonly Counter<long> _languageCacheHits;
|
||||
private readonly Counter<long> _languageCacheMisses;
|
||||
private readonly Counter<long> _osCacheHits;
|
||||
private readonly Counter<long> _osCacheMisses;
|
||||
private readonly Counter<long> _registrySecretRequests;
|
||||
private readonly Histogram<double> _registrySecretTtlSeconds;
|
||||
private readonly Counter<long> _surfaceManifestsPublished;
|
||||
@@ -22,21 +24,21 @@ public sealed class ScannerWorkerMetrics
|
||||
private readonly Counter<long> _surfaceManifestFailures;
|
||||
private readonly Counter<long> _surfacePayloadPersisted;
|
||||
private readonly Histogram<double> _surfaceManifestPublishDurationMs;
|
||||
|
||||
public ScannerWorkerMetrics()
|
||||
{
|
||||
_queueLatencyMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_queue_latency_ms",
|
||||
unit: "ms",
|
||||
description: "Time from job enqueue to lease acquisition.");
|
||||
_jobDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_job_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Total processing duration per job.");
|
||||
_stageDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_stage_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Stage execution duration per job.");
|
||||
|
||||
public ScannerWorkerMetrics()
|
||||
{
|
||||
_queueLatencyMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_queue_latency_ms",
|
||||
unit: "ms",
|
||||
description: "Time from job enqueue to lease acquisition.");
|
||||
_jobDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_job_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Total processing duration per job.");
|
||||
_stageDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_stage_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Stage execution duration per job.");
|
||||
_jobsCompleted = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_jobs_completed_total",
|
||||
description: "Number of successfully completed scan jobs.");
|
||||
@@ -49,6 +51,12 @@ public sealed class ScannerWorkerMetrics
|
||||
_languageCacheMisses = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_language_cache_misses_total",
|
||||
description: "Number of language analyzer cache misses encountered by the worker.");
|
||||
_osCacheHits = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_os_cache_hits_total",
|
||||
description: "Number of OS analyzer cache hits encountered by the worker.");
|
||||
_osCacheMisses = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_os_cache_misses_total",
|
||||
description: "Number of OS analyzer cache misses encountered by the worker.");
|
||||
_registrySecretRequests = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_registry_secret_requests_total",
|
||||
description: "Number of registry secret resolution attempts performed by the worker.");
|
||||
@@ -72,28 +80,28 @@ public sealed class ScannerWorkerMetrics
|
||||
"scanner_worker_surface_manifest_publish_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration in milliseconds to persist and publish surface manifests.");
|
||||
}
|
||||
|
||||
public void RecordQueueLatency(ScanJobContext context, TimeSpan latency)
|
||||
{
|
||||
if (latency <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_queueLatencyMs.Record(latency.TotalMilliseconds, CreateTags(context));
|
||||
}
|
||||
|
||||
public void RecordJobDuration(ScanJobContext context, TimeSpan duration)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_jobDurationMs.Record(duration.TotalMilliseconds, CreateTags(context));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void RecordQueueLatency(ScanJobContext context, TimeSpan latency)
|
||||
{
|
||||
if (latency <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_queueLatencyMs.Record(latency.TotalMilliseconds, CreateTags(context));
|
||||
}
|
||||
|
||||
public void RecordJobDuration(ScanJobContext context, TimeSpan duration)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_jobDurationMs.Record(duration.TotalMilliseconds, CreateTags(context));
|
||||
}
|
||||
|
||||
public void RecordStageDuration(ScanJobContext context, string stage, TimeSpan duration)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
@@ -103,12 +111,12 @@ public sealed class ScannerWorkerMetrics
|
||||
|
||||
_stageDurationMs.Record(duration.TotalMilliseconds, CreateTags(context, stage: stage));
|
||||
}
|
||||
|
||||
public void IncrementJobCompleted(ScanJobContext context)
|
||||
{
|
||||
_jobsCompleted.Add(1, CreateTags(context));
|
||||
}
|
||||
|
||||
|
||||
public void IncrementJobCompleted(ScanJobContext context)
|
||||
{
|
||||
_jobsCompleted.Add(1, CreateTags(context));
|
||||
}
|
||||
|
||||
public void IncrementJobFailed(ScanJobContext context, string failureReason)
|
||||
{
|
||||
_jobsFailed.Add(1, CreateTags(context, failureReason: failureReason));
|
||||
@@ -124,6 +132,16 @@ public sealed class ScannerWorkerMetrics
|
||||
_languageCacheMisses.Add(1, CreateTags(context, analyzerId: analyzerId));
|
||||
}
|
||||
|
||||
public void RecordOsCacheHit(ScanJobContext context, string analyzerId)
|
||||
{
|
||||
_osCacheHits.Add(1, CreateTags(context, analyzerId: analyzerId));
|
||||
}
|
||||
|
||||
public void RecordOsCacheMiss(ScanJobContext context, string analyzerId)
|
||||
{
|
||||
_osCacheMisses.Add(1, CreateTags(context, analyzerId: analyzerId));
|
||||
}
|
||||
|
||||
public void RecordRegistrySecretResolved(
|
||||
ScanJobContext context,
|
||||
string secretName,
|
||||
@@ -253,18 +271,18 @@ public sealed class ScannerWorkerMetrics
|
||||
new("scan.id", context.ScanId),
|
||||
new("attempt", context.Lease.Attempt),
|
||||
};
|
||||
|
||||
if (context.Lease.Metadata.TryGetValue("queue", out var queueName) && !string.IsNullOrWhiteSpace(queueName))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("queue", queueName));
|
||||
}
|
||||
|
||||
if (context.Lease.Metadata.TryGetValue("job.kind", out var jobKind) && !string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("job.kind", jobKind));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stage))
|
||||
|
||||
if (context.Lease.Metadata.TryGetValue("queue", out var queueName) && !string.IsNullOrWhiteSpace(queueName))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("queue", queueName));
|
||||
}
|
||||
|
||||
if (context.Lease.Metadata.TryGetValue("job.kind", out var jobKind) && !string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("job.kind", jobKind));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stage))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("stage", stage));
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
public static void ConfigureScannerWorkerTelemetry(this IHostApplicationBuilder builder, ScannerWorkerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var telemetry = options.Telemetry;
|
||||
if (!telemetry.EnableTelemetry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var openTelemetry = builder.Services.AddOpenTelemetry();
|
||||
|
||||
openTelemetry.ConfigureResource(resource =>
|
||||
{
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
resource.AddService(telemetry.ServiceName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
|
||||
resource.AddAttributes(new[]
|
||||
{
|
||||
new KeyValuePair<string, object>("deployment.environment", builder.Environment.EnvironmentName),
|
||||
});
|
||||
|
||||
foreach (var kvp in telemetry.ResourceAttributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key) || kvp.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.AddAttributes(new[] { new KeyValuePair<string, object>(kvp.Key, kvp.Value) });
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.EnableTracing)
|
||||
{
|
||||
openTelemetry.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(ScannerWorkerInstrumentation.ActivitySourceName);
|
||||
ConfigureExporter(tracing, telemetry);
|
||||
});
|
||||
}
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
public static void ConfigureScannerWorkerTelemetry(this IHostApplicationBuilder builder, ScannerWorkerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var telemetry = options.Telemetry;
|
||||
if (!telemetry.EnableTelemetry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var openTelemetry = builder.Services.AddOpenTelemetry();
|
||||
|
||||
openTelemetry.ConfigureResource(resource =>
|
||||
{
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
resource.AddService(telemetry.ServiceName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
|
||||
resource.AddAttributes(new[]
|
||||
{
|
||||
new KeyValuePair<string, object>("deployment.environment", builder.Environment.EnvironmentName),
|
||||
});
|
||||
|
||||
foreach (var kvp in telemetry.ResourceAttributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key) || kvp.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.AddAttributes(new[] { new KeyValuePair<string, object>(kvp.Key, kvp.Value) });
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.EnableTracing)
|
||||
{
|
||||
openTelemetry.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(ScannerWorkerInstrumentation.ActivitySourceName);
|
||||
ConfigureExporter(tracing, telemetry);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.EnableMetrics)
|
||||
{
|
||||
openTelemetry.WithMetrics(metrics =>
|
||||
@@ -68,38 +68,38 @@ public static class TelemetryExtensions
|
||||
|
||||
ConfigureExporter(metrics, telemetry);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureExporter(TracerProviderBuilder tracing, ScannerWorkerOptions.TelemetryOptions telemetry)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
tracing.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.ExportConsole || string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureExporter(MeterProviderBuilder metrics, ScannerWorkerOptions.TelemetryOptions telemetry)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
metrics.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.ExportConsole || string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureExporter(TracerProviderBuilder tracing, ScannerWorkerOptions.TelemetryOptions telemetry)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
tracing.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.ExportConsole || string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureExporter(MeterProviderBuilder metrics, ScannerWorkerOptions.TelemetryOptions telemetry)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
metrics.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.ExportConsole || string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Hosting;
|
||||
|
||||
public sealed partial class ScannerWorkerHostedService : BackgroundService
|
||||
{
|
||||
private readonly IScanJobSource _jobSource;
|
||||
private readonly ScanJobProcessor _processor;
|
||||
private readonly LeaseHeartbeatService _heartbeatService;
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
|
||||
private readonly ILogger<ScannerWorkerHostedService> _logger;
|
||||
private readonly IDelayScheduler _delayScheduler;
|
||||
|
||||
public ScannerWorkerHostedService(
|
||||
IScanJobSource jobSource,
|
||||
ScanJobProcessor processor,
|
||||
LeaseHeartbeatService heartbeatService,
|
||||
ScannerWorkerMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
IDelayScheduler delayScheduler,
|
||||
IOptionsMonitor<ScannerWorkerOptions> options,
|
||||
ILogger<ScannerWorkerHostedService> logger)
|
||||
{
|
||||
_jobSource = jobSource ?? throw new ArgumentNullException(nameof(jobSource));
|
||||
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
|
||||
_heartbeatService = heartbeatService ?? throw new ArgumentNullException(nameof(heartbeatService));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_delayScheduler = delayScheduler ?? throw new ArgumentNullException(nameof(delayScheduler));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var runningJobs = new HashSet<Task>();
|
||||
var delayStrategy = new PollDelayStrategy(_options.CurrentValue.Polling);
|
||||
|
||||
WorkerStarted(_logger);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
runningJobs.RemoveWhere(static task => task.IsCompleted);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
if (runningJobs.Count >= options.MaxConcurrentJobs)
|
||||
{
|
||||
var completed = await Task.WhenAny(runningJobs).ConfigureAwait(false);
|
||||
runningJobs.Remove(completed);
|
||||
continue;
|
||||
}
|
||||
|
||||
IScanJobLease? lease = null;
|
||||
try
|
||||
{
|
||||
lease = await _jobSource.TryAcquireAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Scanner worker failed to acquire job lease; backing off.");
|
||||
}
|
||||
|
||||
if (lease is null)
|
||||
{
|
||||
var delay = delayStrategy.NextDelay();
|
||||
await _delayScheduler.DelayAsync(delay, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
delayStrategy.Reset();
|
||||
runningJobs.Add(RunJobAsync(lease, stoppingToken));
|
||||
}
|
||||
|
||||
if (runningJobs.Count > 0)
|
||||
{
|
||||
await Task.WhenAll(runningJobs).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
WorkerStopping(_logger);
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(IScanJobLease lease, CancellationToken stoppingToken)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var jobStart = _timeProvider.GetUtcNow();
|
||||
var queueLatency = jobStart - lease.EnqueuedAtUtc;
|
||||
var jobCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
var jobToken = jobCts.Token;
|
||||
var context = new ScanJobContext(lease, _timeProvider, jobStart, jobToken);
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Hosting;
|
||||
|
||||
public sealed partial class ScannerWorkerHostedService : BackgroundService
|
||||
{
|
||||
private readonly IScanJobSource _jobSource;
|
||||
private readonly ScanJobProcessor _processor;
|
||||
private readonly LeaseHeartbeatService _heartbeatService;
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DeterministicRandomService _randomService;
|
||||
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
|
||||
private readonly ILogger<ScannerWorkerHostedService> _logger;
|
||||
private readonly IDelayScheduler _delayScheduler;
|
||||
|
||||
public ScannerWorkerHostedService(
|
||||
IScanJobSource jobSource,
|
||||
ScanJobProcessor processor,
|
||||
LeaseHeartbeatService heartbeatService,
|
||||
ScannerWorkerMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
DeterministicRandomService randomService,
|
||||
IDelayScheduler delayScheduler,
|
||||
IOptionsMonitor<ScannerWorkerOptions> options,
|
||||
ILogger<ScannerWorkerHostedService> logger)
|
||||
{
|
||||
_jobSource = jobSource ?? throw new ArgumentNullException(nameof(jobSource));
|
||||
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
|
||||
_heartbeatService = heartbeatService ?? throw new ArgumentNullException(nameof(heartbeatService));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_randomService = randomService ?? throw new ArgumentNullException(nameof(randomService));
|
||||
_delayScheduler = delayScheduler ?? throw new ArgumentNullException(nameof(delayScheduler));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var runningJobs = new HashSet<Task>();
|
||||
var delayStrategy = new PollDelayStrategy(_options.CurrentValue.Polling, _randomService);
|
||||
|
||||
WorkerStarted(_logger);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
runningJobs.RemoveWhere(static task => task.IsCompleted);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
if (runningJobs.Count >= options.MaxConcurrentJobs)
|
||||
{
|
||||
var completed = await Task.WhenAny(runningJobs).ConfigureAwait(false);
|
||||
runningJobs.Remove(completed);
|
||||
continue;
|
||||
}
|
||||
|
||||
IScanJobLease? lease = null;
|
||||
try
|
||||
{
|
||||
lease = await _jobSource.TryAcquireAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Scanner worker failed to acquire job lease; backing off.");
|
||||
}
|
||||
|
||||
if (lease is null)
|
||||
{
|
||||
var delay = delayStrategy.NextDelay();
|
||||
await _delayScheduler.DelayAsync(delay, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
delayStrategy.Reset();
|
||||
runningJobs.Add(RunJobAsync(lease, stoppingToken));
|
||||
}
|
||||
|
||||
if (runningJobs.Count > 0)
|
||||
{
|
||||
await Task.WhenAll(runningJobs).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
WorkerStopping(_logger);
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(IScanJobLease lease, CancellationToken stoppingToken)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var jobStart = _timeProvider.GetUtcNow();
|
||||
var queueLatency = jobStart - lease.EnqueuedAtUtc;
|
||||
var jobCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
var jobToken = jobCts.Token;
|
||||
var context = new ScanJobContext(lease, _timeProvider, jobStart, jobToken);
|
||||
|
||||
_metrics.RecordQueueLatency(context, queueLatency);
|
||||
JobAcquired(_logger, lease.JobId, lease.ScanId, lease.Attempt, queueLatency.TotalMilliseconds);
|
||||
|
||||
@@ -118,85 +121,85 @@ public sealed partial class ScannerWorkerHostedService : BackgroundService
|
||||
await lease.CompleteAsync(stoppingToken).ConfigureAwait(false);
|
||||
var duration = _timeProvider.GetUtcNow() - jobStart;
|
||||
_metrics.RecordJobDuration(context, duration);
|
||||
_metrics.IncrementJobCompleted(context);
|
||||
JobCompleted(_logger, lease.JobId, lease.ScanId, duration.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
processingException = null;
|
||||
await lease.AbandonAsync("host-stopping", CancellationToken.None).ConfigureAwait(false);
|
||||
JobAbandoned(_logger, lease.JobId, lease.ScanId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
processingException = ex;
|
||||
var duration = _timeProvider.GetUtcNow() - jobStart;
|
||||
_metrics.RecordJobDuration(context, duration);
|
||||
|
||||
var reason = ex.GetType().Name;
|
||||
var maxAttempts = options.Queue.MaxAttempts;
|
||||
if (lease.Attempt >= maxAttempts)
|
||||
{
|
||||
await lease.PoisonAsync(reason, CancellationToken.None).ConfigureAwait(false);
|
||||
_metrics.IncrementJobFailed(context, reason);
|
||||
JobPoisoned(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
await lease.AbandonAsync(reason, CancellationToken.None).ConfigureAwait(false);
|
||||
JobAbandonedWithError(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
jobCts.Cancel();
|
||||
try
|
||||
{
|
||||
await heartbeatTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (processingException is null && ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Heartbeat loop ended with an exception for job {JobId}.", lease.JobId);
|
||||
}
|
||||
|
||||
await lease.DisposeAsync().ConfigureAwait(false);
|
||||
jobCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "Scanner worker host started.")]
|
||||
private static partial void WorkerStarted(ILogger logger);
|
||||
|
||||
[LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "Scanner worker host stopping.")]
|
||||
private static partial void WorkerStopping(ILogger logger);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2002,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Leased job {JobId} (scan {ScanId}) attempt {Attempt}; queue latency {LatencyMs:F0} ms.")]
|
||||
private static partial void JobAcquired(ILogger logger, string jobId, string scanId, int attempt, double latencyMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2003,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Job {JobId} (scan {ScanId}) completed in {DurationMs:F0} ms.")]
|
||||
private static partial void JobCompleted(ILogger logger, string jobId, string scanId, double durationMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2004,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Job {JobId} (scan {ScanId}) abandoned due to host shutdown.")]
|
||||
private static partial void JobAbandoned(ILogger logger, string jobId, string scanId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2005,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Job {JobId} (scan {ScanId}) attempt {Attempt}/{MaxAttempts} abandoned after failure; job will be retried.")]
|
||||
private static partial void JobAbandonedWithError(ILogger logger, string jobId, string scanId, int attempt, int maxAttempts, Exception exception);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2006,
|
||||
Level = LogLevel.Error,
|
||||
Message = "Job {JobId} (scan {ScanId}) attempt {Attempt}/{MaxAttempts} exceeded retry budget; quarantining job.")]
|
||||
private static partial void JobPoisoned(ILogger logger, string jobId, string scanId, int attempt, int maxAttempts, Exception exception);
|
||||
}
|
||||
_metrics.IncrementJobCompleted(context);
|
||||
JobCompleted(_logger, lease.JobId, lease.ScanId, duration.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
processingException = null;
|
||||
await lease.AbandonAsync("host-stopping", CancellationToken.None).ConfigureAwait(false);
|
||||
JobAbandoned(_logger, lease.JobId, lease.ScanId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
processingException = ex;
|
||||
var duration = _timeProvider.GetUtcNow() - jobStart;
|
||||
_metrics.RecordJobDuration(context, duration);
|
||||
|
||||
var reason = ex.GetType().Name;
|
||||
var maxAttempts = options.Queue.MaxAttempts;
|
||||
if (lease.Attempt >= maxAttempts)
|
||||
{
|
||||
await lease.PoisonAsync(reason, CancellationToken.None).ConfigureAwait(false);
|
||||
_metrics.IncrementJobFailed(context, reason);
|
||||
JobPoisoned(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
await lease.AbandonAsync(reason, CancellationToken.None).ConfigureAwait(false);
|
||||
JobAbandonedWithError(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
jobCts.Cancel();
|
||||
try
|
||||
{
|
||||
await heartbeatTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (processingException is null && ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Heartbeat loop ended with an exception for job {JobId}.", lease.JobId);
|
||||
}
|
||||
|
||||
await lease.DisposeAsync().ConfigureAwait(false);
|
||||
jobCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "Scanner worker host started.")]
|
||||
private static partial void WorkerStarted(ILogger logger);
|
||||
|
||||
[LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "Scanner worker host stopping.")]
|
||||
private static partial void WorkerStopping(ILogger logger);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2002,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Leased job {JobId} (scan {ScanId}) attempt {Attempt}; queue latency {LatencyMs:F0} ms.")]
|
||||
private static partial void JobAcquired(ILogger logger, string jobId, string scanId, int attempt, double latencyMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2003,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Job {JobId} (scan {ScanId}) completed in {DurationMs:F0} ms.")]
|
||||
private static partial void JobCompleted(ILogger logger, string jobId, string scanId, double durationMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2004,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Job {JobId} (scan {ScanId}) abandoned due to host shutdown.")]
|
||||
private static partial void JobAbandoned(ILogger logger, string jobId, string scanId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2005,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Job {JobId} (scan {ScanId}) attempt {Attempt}/{MaxAttempts} abandoned after failure; job will be retried.")]
|
||||
private static partial void JobAbandonedWithError(ILogger logger, string jobId, string scanId, int attempt, int maxAttempts, Exception exception);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2006,
|
||||
Level = LogLevel.Error,
|
||||
Message = "Job {JobId} (scan {ScanId}) attempt {Attempt}/{MaxAttempts} exceeded retry budget; quarantining job.")]
|
||||
private static partial void JobPoisoned(ILogger logger, string jobId, string scanId, int attempt, int maxAttempts, Exception exception);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,19 @@ using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
public sealed class ScannerWorkerOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:Worker";
|
||||
|
||||
public int MaxConcurrentJobs { get; set; } = 2;
|
||||
|
||||
public QueueOptions Queue { get; } = new();
|
||||
|
||||
public PollingOptions Polling { get; } = new();
|
||||
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
public sealed class ScannerWorkerOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:Worker";
|
||||
|
||||
public int MaxConcurrentJobs { get; set; } = 2;
|
||||
|
||||
public QueueOptions Queue { get; } = new();
|
||||
|
||||
public PollingOptions Polling { get; } = new();
|
||||
|
||||
public AuthorityOptions Authority { get; } = new();
|
||||
|
||||
public TelemetryOptions Telemetry { get; } = new();
|
||||
@@ -31,121 +31,121 @@ public sealed class ScannerWorkerOptions
|
||||
public SigningOptions Signing { get; } = new();
|
||||
|
||||
public DeterminismOptions Determinism { get; } = new();
|
||||
|
||||
public sealed class QueueOptions
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
|
||||
public double HeartbeatSafetyFactor { get; set; } = 3.0;
|
||||
|
||||
public int MaxHeartbeatJitterMilliseconds { get; set; } = 750;
|
||||
|
||||
public IReadOnlyList<TimeSpan> HeartbeatRetryDelays => _heartbeatRetryDelays;
|
||||
|
||||
public TimeSpan MinHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan MaxHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void SetHeartbeatRetryDelays(IEnumerable<TimeSpan> delays)
|
||||
{
|
||||
_heartbeatRetryDelays = NormalizeDelays(delays);
|
||||
}
|
||||
|
||||
internal IReadOnlyList<TimeSpan> NormalizedHeartbeatRetryDelays => _heartbeatRetryDelays;
|
||||
|
||||
private static IReadOnlyList<TimeSpan> NormalizeDelays(IEnumerable<TimeSpan> delays)
|
||||
{
|
||||
var buffer = new List<TimeSpan>();
|
||||
foreach (var delay in delays)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.Add(delay);
|
||||
}
|
||||
|
||||
buffer.Sort();
|
||||
return new ReadOnlyCollection<TimeSpan>(buffer);
|
||||
}
|
||||
|
||||
private IReadOnlyList<TimeSpan> _heartbeatRetryDelays = new ReadOnlyCollection<TimeSpan>(new TimeSpan[]
|
||||
{
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(10),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class PollingOptions
|
||||
{
|
||||
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
}
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 20;
|
||||
|
||||
public int TokenClockSkewSeconds { get; set; } = 30;
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string> { "scanner.scan" };
|
||||
|
||||
public ResilienceOptions Resilience { get; } = new();
|
||||
}
|
||||
|
||||
public sealed class ResilienceOptions
|
||||
{
|
||||
public bool? EnableRetries { get; set; }
|
||||
|
||||
public IList<TimeSpan> RetryDelays { get; } = new List<TimeSpan>
|
||||
{
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
public bool? AllowOfflineCacheFallback { get; set; }
|
||||
|
||||
public TimeSpan? OfflineCacheTolerance { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
{
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
public bool EnableTelemetry { get; set; } = true;
|
||||
|
||||
public bool EnableTracing { get; set; }
|
||||
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
|
||||
public string ServiceName { get; set; } = "stellaops-scanner-worker";
|
||||
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
|
||||
public bool ExportConsole { get; set; }
|
||||
|
||||
public IDictionary<string, string?> ResourceAttributes { get; } = new ConcurrentDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
public sealed class QueueOptions
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
|
||||
public double HeartbeatSafetyFactor { get; set; } = 3.0;
|
||||
|
||||
public int MaxHeartbeatJitterMilliseconds { get; set; } = 750;
|
||||
|
||||
public IReadOnlyList<TimeSpan> HeartbeatRetryDelays => _heartbeatRetryDelays;
|
||||
|
||||
public TimeSpan MinHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan MaxHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void SetHeartbeatRetryDelays(IEnumerable<TimeSpan> delays)
|
||||
{
|
||||
_heartbeatRetryDelays = NormalizeDelays(delays);
|
||||
}
|
||||
|
||||
internal IReadOnlyList<TimeSpan> NormalizedHeartbeatRetryDelays => _heartbeatRetryDelays;
|
||||
|
||||
private static IReadOnlyList<TimeSpan> NormalizeDelays(IEnumerable<TimeSpan> delays)
|
||||
{
|
||||
var buffer = new List<TimeSpan>();
|
||||
foreach (var delay in delays)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.Add(delay);
|
||||
}
|
||||
|
||||
buffer.Sort();
|
||||
return new ReadOnlyCollection<TimeSpan>(buffer);
|
||||
}
|
||||
|
||||
private IReadOnlyList<TimeSpan> _heartbeatRetryDelays = new ReadOnlyCollection<TimeSpan>(new TimeSpan[]
|
||||
{
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(10),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class PollingOptions
|
||||
{
|
||||
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
}
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 20;
|
||||
|
||||
public int TokenClockSkewSeconds { get; set; } = 30;
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string> { "scanner.scan" };
|
||||
|
||||
public ResilienceOptions Resilience { get; } = new();
|
||||
}
|
||||
|
||||
public sealed class ResilienceOptions
|
||||
{
|
||||
public bool? EnableRetries { get; set; }
|
||||
|
||||
public IList<TimeSpan> RetryDelays { get; } = new List<TimeSpan>
|
||||
{
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
public bool? AllowOfflineCacheFallback { get; set; }
|
||||
|
||||
public TimeSpan? OfflineCacheTolerance { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
{
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
public bool EnableTelemetry { get; set; } = true;
|
||||
|
||||
public bool EnableTracing { get; set; }
|
||||
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
|
||||
public string ServiceName { get; set; } = "stellaops-scanner-worker";
|
||||
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
|
||||
public bool ExportConsole { get; set; }
|
||||
|
||||
public IDictionary<string, string?> ResourceAttributes { get; } = new ConcurrentDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class ShutdownOptions
|
||||
{
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -3,17 +3,17 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWorkerOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, ScannerWorkerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWorkerOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, ScannerWorkerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
if (options.MaxConcurrentJobs <= 0)
|
||||
{
|
||||
failures.Add("Scanner.Worker:MaxConcurrentJobs must be greater than zero.");
|
||||
@@ -31,65 +31,65 @@ public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWork
|
||||
{
|
||||
failures.Add("Scanner.Worker:Queue:HeartbeatSafetyFactor must be at least 3.");
|
||||
}
|
||||
|
||||
if (options.Queue.MaxAttempts <= 0)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Queue:MaxAttempts must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Queue.MinHeartbeatInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Queue:MinHeartbeatInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Queue.MaxHeartbeatInterval <= options.Queue.MinHeartbeatInterval)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Queue:MaxHeartbeatInterval must be greater than MinHeartbeatInterval.");
|
||||
}
|
||||
|
||||
if (options.Polling.InitialDelay <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Polling:InitialDelay must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Polling.MaxDelay < options.Polling.InitialDelay)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Polling:MaxDelay must be greater than or equal to InitialDelay.");
|
||||
}
|
||||
|
||||
if (options.Polling.JitterRatio is < 0 or > 1)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Polling:JitterRatio must be between 0 and 1.");
|
||||
}
|
||||
|
||||
if (options.Authority.Enabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Issuer))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority requires Issuer when Enabled is true.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.ClientId))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority requires ClientId when Enabled is true.");
|
||||
}
|
||||
|
||||
if (options.Authority.BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority:BackchannelTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Authority.TokenClockSkewSeconds < 0)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority:TokenClockSkewSeconds cannot be negative.");
|
||||
}
|
||||
|
||||
if (options.Authority.Resilience.RetryDelays.Any(delay => delay <= TimeSpan.Zero))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority:Resilience:RetryDelays must be positive durations.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (options.Queue.MaxAttempts <= 0)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Queue:MaxAttempts must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Queue.MinHeartbeatInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Queue:MinHeartbeatInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Queue.MaxHeartbeatInterval <= options.Queue.MinHeartbeatInterval)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Queue:MaxHeartbeatInterval must be greater than MinHeartbeatInterval.");
|
||||
}
|
||||
|
||||
if (options.Polling.InitialDelay <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Polling:InitialDelay must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Polling.MaxDelay < options.Polling.InitialDelay)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Polling:MaxDelay must be greater than or equal to InitialDelay.");
|
||||
}
|
||||
|
||||
if (options.Polling.JitterRatio is < 0 or > 1)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Polling:JitterRatio must be between 0 and 1.");
|
||||
}
|
||||
|
||||
if (options.Authority.Enabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Issuer))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority requires Issuer when Enabled is true.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.ClientId))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority requires ClientId when Enabled is true.");
|
||||
}
|
||||
|
||||
if (options.Authority.BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority:BackchannelTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Authority.TokenClockSkewSeconds < 0)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority:TokenClockSkewSeconds cannot be negative.");
|
||||
}
|
||||
|
||||
if (options.Authority.Resilience.RetryDelays.Any(delay => delay <= TimeSpan.Zero))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Authority:Resilience:RetryDelays must be positive durations.");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Shutdown.Timeout < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Shutdown:Timeout must be at least 5 seconds to allow lease completion.");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class AnalyzerStageExecutor : IScanStageExecutor
|
||||
{
|
||||
private readonly IScanAnalyzerDispatcher _dispatcher;
|
||||
|
||||
@@ -13,6 +13,7 @@ using StellaOps.Scanner.Analyzers.Lang.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
||||
using StellaOps.Scanner.Analyzers.OS;
|
||||
using StellaOps.Scanner.Analyzers.OS.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Internal;
|
||||
using StellaOps.Scanner.Analyzers.OS.Mapping;
|
||||
using StellaOps.Scanner.Analyzers.OS.Plugin;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
@@ -126,6 +127,9 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
|
||||
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cache = services.GetRequiredService<ISurfaceCache>();
|
||||
var cacheAdapter = new OsAnalyzerSurfaceCache(cache, surfaceEnvironment.Settings.Tenant);
|
||||
|
||||
foreach (var analyzer in analyzers)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -135,7 +139,46 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
|
||||
try
|
||||
{
|
||||
var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
|
||||
string? fingerprint = null;
|
||||
try
|
||||
{
|
||||
fingerprint = OsRootfsFingerprint.TryCompute(analyzer.AnalyzerId, rootfsPath, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Failed to compute rootfs fingerprint for OS analyzer {AnalyzerId} (job {JobId}); bypassing cache.",
|
||||
analyzer.AnalyzerId,
|
||||
context.JobId);
|
||||
}
|
||||
|
||||
OSPackageAnalyzerResult result;
|
||||
if (fingerprint is not null)
|
||||
{
|
||||
var cacheEntry = await cacheAdapter.GetOrCreateEntryAsync(
|
||||
_logger,
|
||||
analyzer.AnalyzerId,
|
||||
fingerprint,
|
||||
token => analyzer.AnalyzeAsync(analyzerContext, token),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
result = cacheEntry.Result;
|
||||
if (cacheEntry.IsHit)
|
||||
{
|
||||
_metrics.RecordOsCacheHit(context, analyzer.AnalyzerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_metrics.RecordOsCacheMiss(context, analyzer.AnalyzerId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -3,7 +3,7 @@ using StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
internal sealed class DeterministicRandomService
|
||||
public sealed class DeterministicRandomService
|
||||
{
|
||||
private readonly IDeterministicRandomProvider _provider;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IDelayScheduler
|
||||
{
|
||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IDelayScheduler
|
||||
{
|
||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IEntryTraceExecutionService
|
||||
{
|
||||
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IEntryTraceExecutionService
|
||||
{
|
||||
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanAnalyzerDispatcher
|
||||
{
|
||||
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NullScanAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
{
|
||||
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanAnalyzerDispatcher
|
||||
{
|
||||
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NullScanAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
{
|
||||
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanJobLease : IAsyncDisposable
|
||||
{
|
||||
string JobId { get; }
|
||||
|
||||
string ScanId { get; }
|
||||
|
||||
int Attempt { get; }
|
||||
|
||||
DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
TimeSpan LeaseDuration { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
ValueTask RenewAsync(CancellationToken cancellationToken);
|
||||
|
||||
ValueTask CompleteAsync(CancellationToken cancellationToken);
|
||||
|
||||
ValueTask AbandonAsync(string reason, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask PoisonAsync(string reason, CancellationToken cancellationToken);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanJobLease : IAsyncDisposable
|
||||
{
|
||||
string JobId { get; }
|
||||
|
||||
string ScanId { get; }
|
||||
|
||||
int Attempt { get; }
|
||||
|
||||
DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
TimeSpan LeaseDuration { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
ValueTask RenewAsync(CancellationToken cancellationToken);
|
||||
|
||||
ValueTask CompleteAsync(CancellationToken cancellationToken);
|
||||
|
||||
ValueTask AbandonAsync(string reason, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask PoisonAsync(string reason, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanJobSource
|
||||
{
|
||||
Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanJobSource
|
||||
{
|
||||
Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanStageExecutor
|
||||
{
|
||||
string StageName { get; }
|
||||
|
||||
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public interface IScanStageExecutor
|
||||
{
|
||||
string StageName { get; }
|
||||
|
||||
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class LeaseHeartbeatService
|
||||
{
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class LeaseHeartbeatService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
|
||||
private readonly IDelayScheduler _delayScheduler;
|
||||
@@ -28,7 +29,7 @@ public sealed class LeaseHeartbeatService
|
||||
_randomProvider = randomProvider ?? throw new ArgumentNullException(nameof(randomProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
|
||||
public async Task RunAsync(IScanJobLease lease, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lease);
|
||||
@@ -45,27 +46,27 @@ public sealed class LeaseHeartbeatService
|
||||
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (await TryRenewAsync(options, lease, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogError(
|
||||
"Job {JobId} (scan {ScanId}) lease renewal exhausted retries; cancelling processing.",
|
||||
lease.JobId,
|
||||
lease.ScanId);
|
||||
throw new InvalidOperationException("Lease renewal retries exhausted.");
|
||||
}
|
||||
}
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (await TryRenewAsync(options, lease, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogError(
|
||||
"Job {JobId} (scan {ScanId}) lease renewal exhausted retries; cancelling processing.",
|
||||
lease.JobId,
|
||||
lease.ScanId);
|
||||
throw new InvalidOperationException("Lease renewal retries exhausted.");
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan ComputeInterval(ScannerWorkerOptions options, IScanJobLease lease)
|
||||
{
|
||||
@@ -77,9 +78,9 @@ public sealed class LeaseHeartbeatService
|
||||
recommended = options.Queue.MinHeartbeatInterval;
|
||||
}
|
||||
else if (recommended > options.Queue.MaxHeartbeatInterval)
|
||||
{
|
||||
recommended = options.Queue.MaxHeartbeatInterval;
|
||||
}
|
||||
{
|
||||
recommended = options.Queue.MaxHeartbeatInterval;
|
||||
}
|
||||
|
||||
return recommended;
|
||||
}
|
||||
@@ -108,55 +109,55 @@ public sealed class LeaseHeartbeatService
|
||||
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Job {JobId} (scan {ScanId}) heartbeat failed; retrying.",
|
||||
lease.JobId,
|
||||
lease.ScanId);
|
||||
}
|
||||
|
||||
foreach (var delay in options.Queue.NormalizedHeartbeatRetryDelays)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Job {JobId} (scan {ScanId}) heartbeat retry failed; will retry after {Delay}.",
|
||||
lease.JobId,
|
||||
lease.ScanId,
|
||||
delay);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Job {JobId} (scan {ScanId}) heartbeat failed; retrying.",
|
||||
lease.JobId,
|
||||
lease.ScanId);
|
||||
}
|
||||
|
||||
foreach (var delay in options.Queue.NormalizedHeartbeatRetryDelays)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Job {JobId} (scan {ScanId}) heartbeat retry failed; will retry after {Delay}.",
|
||||
lease.JobId,
|
||||
lease.ScanId,
|
||||
delay);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class NoOpStageExecutor : IScanStageExecutor
|
||||
{
|
||||
public NoOpStageExecutor(string stageName)
|
||||
{
|
||||
StageName = stageName ?? throw new ArgumentNullException(nameof(stageName));
|
||||
}
|
||||
|
||||
public string StageName { get; }
|
||||
|
||||
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class NoOpStageExecutor : IScanStageExecutor
|
||||
{
|
||||
public NoOpStageExecutor(string stageName)
|
||||
{
|
||||
StageName = stageName ?? throw new ArgumentNullException(nameof(stageName));
|
||||
}
|
||||
|
||||
public string StageName { get; }
|
||||
|
||||
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class NullScanJobSource : IScanJobSource
|
||||
{
|
||||
private readonly ILogger<NullScanJobSource> _logger;
|
||||
private int _logged;
|
||||
|
||||
public NullScanJobSource(ILogger<NullScanJobSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _logged, 1) == 0)
|
||||
{
|
||||
_logger.LogWarning("No queue provider registered. Scanner worker will idle until a queue adapter is configured.");
|
||||
}
|
||||
|
||||
return Task.FromResult<IScanJobLease?>(null);
|
||||
}
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class NullScanJobSource : IScanJobSource
|
||||
{
|
||||
private readonly ILogger<NullScanJobSource> _logger;
|
||||
private int _logged;
|
||||
|
||||
public NullScanJobSource(ILogger<NullScanJobSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _logged, 1) == 0)
|
||||
{
|
||||
_logger.LogWarning("No queue provider registered. Scanner worker will idle until a queue adapter is configured.");
|
||||
}
|
||||
|
||||
return Task.FromResult<IScanJobLease?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class PollDelayStrategy
|
||||
{
|
||||
using System;
|
||||
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class PollDelayStrategy
|
||||
{
|
||||
private readonly ScannerWorkerOptions.PollingOptions _options;
|
||||
private readonly DeterministicRandomService _randomService;
|
||||
private TimeSpan _currentDelay;
|
||||
@@ -15,35 +15,35 @@ public sealed class PollDelayStrategy
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_randomService = randomService ?? throw new ArgumentNullException(nameof(randomService));
|
||||
}
|
||||
|
||||
public TimeSpan NextDelay()
|
||||
{
|
||||
if (_currentDelay == TimeSpan.Zero)
|
||||
{
|
||||
_currentDelay = _options.InitialDelay;
|
||||
return ApplyJitter(_currentDelay);
|
||||
}
|
||||
|
||||
var doubled = _currentDelay + _currentDelay;
|
||||
_currentDelay = doubled < _options.MaxDelay ? doubled : _options.MaxDelay;
|
||||
return ApplyJitter(_currentDelay);
|
||||
}
|
||||
|
||||
public void Reset() => _currentDelay = TimeSpan.Zero;
|
||||
|
||||
private TimeSpan ApplyJitter(TimeSpan duration)
|
||||
{
|
||||
if (_options.JitterRatio <= 0)
|
||||
{
|
||||
return duration;
|
||||
}
|
||||
|
||||
var maxOffset = duration.TotalMilliseconds * _options.JitterRatio;
|
||||
if (maxOffset <= 0)
|
||||
{
|
||||
return duration;
|
||||
}
|
||||
|
||||
|
||||
public TimeSpan NextDelay()
|
||||
{
|
||||
if (_currentDelay == TimeSpan.Zero)
|
||||
{
|
||||
_currentDelay = _options.InitialDelay;
|
||||
return ApplyJitter(_currentDelay);
|
||||
}
|
||||
|
||||
var doubled = _currentDelay + _currentDelay;
|
||||
_currentDelay = doubled < _options.MaxDelay ? doubled : _options.MaxDelay;
|
||||
return ApplyJitter(_currentDelay);
|
||||
}
|
||||
|
||||
public void Reset() => _currentDelay = TimeSpan.Zero;
|
||||
|
||||
private TimeSpan ApplyJitter(TimeSpan duration)
|
||||
{
|
||||
if (_options.JitterRatio <= 0)
|
||||
{
|
||||
return duration;
|
||||
}
|
||||
|
||||
var maxOffset = duration.TotalMilliseconds * _options.JitterRatio;
|
||||
if (maxOffset <= 0)
|
||||
{
|
||||
return duration;
|
||||
}
|
||||
|
||||
var rng = _randomService.Create();
|
||||
var offset = (rng.NextDouble() * 2.0 - 1.0) * maxOffset;
|
||||
var adjustedMs = Math.Max(0, duration.TotalMilliseconds + offset);
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace StellaOps.Scanner.Worker.Processing.Replay;
|
||||
/// Fetches a sealed replay bundle from the configured object store, verifies its SHA-256 hash,
|
||||
/// and returns a local file path for downstream analyzers.
|
||||
/// </summary>
|
||||
internal sealed class ReplayBundleFetcher
|
||||
public sealed class ReplayBundleFetcher
|
||||
{
|
||||
private readonly IArtifactObjectStore _objectStore;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class ScanJobContext
|
||||
{
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class ScanJobContext
|
||||
{
|
||||
public ScanJobContext(IScanJobLease lease, TimeProvider timeProvider, DateTimeOffset startUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
Lease = lease ?? throw new ArgumentNullException(nameof(lease));
|
||||
@@ -14,13 +14,13 @@ public sealed class ScanJobContext
|
||||
CancellationToken = cancellationToken;
|
||||
Analysis = new ScanAnalysisStore();
|
||||
}
|
||||
|
||||
public IScanJobLease Lease { get; }
|
||||
|
||||
public TimeProvider TimeProvider { get; }
|
||||
|
||||
public DateTimeOffset StartUtc { get; }
|
||||
|
||||
|
||||
public IScanJobLease Lease { get; }
|
||||
|
||||
public TimeProvider TimeProvider { get; }
|
||||
|
||||
public DateTimeOffset StartUtc { get; }
|
||||
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
||||
public string JobId => Lease.JobId;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class ScanJobProcessor
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IScanStageExecutor> _executors;
|
||||
@@ -26,36 +27,36 @@ public sealed class ScanJobProcessor
|
||||
_reachabilityPublisher = reachabilityPublisher ?? throw new ArgumentNullException(nameof(reachabilityPublisher));
|
||||
_replayBundleFetcher = replayBundleFetcher ?? throw new ArgumentNullException(nameof(replayBundleFetcher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var map = new Dictionary<string, IScanStageExecutor>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var executor in executors ?? Array.Empty<IScanStageExecutor>())
|
||||
{
|
||||
if (executor is null || string.IsNullOrWhiteSpace(executor.StageName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map[executor.StageName] = executor;
|
||||
}
|
||||
|
||||
foreach (var stage in ScanStageNames.Ordered)
|
||||
{
|
||||
if (map.ContainsKey(stage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map[stage] = new NoOpStageExecutor(stage);
|
||||
_logger.LogDebug("No executor registered for stage {Stage}; using no-op placeholder.", stage);
|
||||
}
|
||||
|
||||
_executors = map;
|
||||
}
|
||||
|
||||
|
||||
var map = new Dictionary<string, IScanStageExecutor>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var executor in executors ?? Array.Empty<IScanStageExecutor>())
|
||||
{
|
||||
if (executor is null || string.IsNullOrWhiteSpace(executor.StageName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map[executor.StageName] = executor;
|
||||
}
|
||||
|
||||
foreach (var stage in ScanStageNames.Ordered)
|
||||
{
|
||||
if (map.ContainsKey(stage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map[stage] = new NoOpStageExecutor(stage);
|
||||
_logger.LogDebug("No executor registered for stage {Stage}; using no-op placeholder.", stage);
|
||||
}
|
||||
|
||||
_executors = map;
|
||||
}
|
||||
|
||||
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
await EnsureReplayBundleFetchedAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureReplayBundleFetchedAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var stage in ScanStageNames.Ordered)
|
||||
{
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed partial class ScanProgressReporter
|
||||
{
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly ILogger<ScanProgressReporter> _logger;
|
||||
|
||||
public ScanProgressReporter(ScannerWorkerMetrics metrics, ILogger<ScanProgressReporter> logger)
|
||||
{
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask ExecuteStageAsync(
|
||||
ScanJobContext context,
|
||||
string stageName,
|
||||
Func<ScanJobContext, CancellationToken, ValueTask> stageWork,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stageName);
|
||||
ArgumentNullException.ThrowIfNull(stageWork);
|
||||
|
||||
StageStarting(_logger, context.JobId, context.ScanId, stageName, context.Lease.Attempt);
|
||||
|
||||
var start = context.TimeProvider.GetUtcNow();
|
||||
using var activity = ScannerWorkerInstrumentation.ActivitySource.StartActivity(
|
||||
$"scanner.worker.{stageName}",
|
||||
ActivityKind.Internal);
|
||||
|
||||
activity?.SetTag("scanner.worker.job_id", context.JobId);
|
||||
activity?.SetTag("scanner.worker.scan_id", context.ScanId);
|
||||
activity?.SetTag("scanner.worker.stage", stageName);
|
||||
|
||||
try
|
||||
{
|
||||
await stageWork(context, cancellationToken).ConfigureAwait(false);
|
||||
var duration = context.TimeProvider.GetUtcNow() - start;
|
||||
_metrics.RecordStageDuration(context, stageName, duration);
|
||||
StageCompleted(_logger, context.JobId, context.ScanId, stageName, duration.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
StageCancelled(_logger, context.JobId, context.ScanId, stageName);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = context.TimeProvider.GetUtcNow() - start;
|
||||
_metrics.RecordStageDuration(context, stageName, duration);
|
||||
StageFailed(_logger, context.JobId, context.ScanId, stageName, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1000,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Job {JobId} (scan {ScanId}) entering stage {Stage} (attempt {Attempt}).")]
|
||||
private static partial void StageStarting(ILogger logger, string jobId, string scanId, string stage, int attempt);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1001,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Job {JobId} (scan {ScanId}) finished stage {Stage} in {ElapsedMs:F0} ms.")]
|
||||
private static partial void StageCompleted(ILogger logger, string jobId, string scanId, string stage, double elapsedMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1002,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Job {JobId} (scan {ScanId}) stage {Stage} cancelled by request.")]
|
||||
private static partial void StageCancelled(ILogger logger, string jobId, string scanId, string stage);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1003,
|
||||
Level = LogLevel.Error,
|
||||
Message = "Job {JobId} (scan {ScanId}) stage {Stage} failed.")]
|
||||
private static partial void StageFailed(ILogger logger, string jobId, string scanId, string stage, Exception exception);
|
||||
}
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed partial class ScanProgressReporter
|
||||
{
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly ILogger<ScanProgressReporter> _logger;
|
||||
|
||||
public ScanProgressReporter(ScannerWorkerMetrics metrics, ILogger<ScanProgressReporter> logger)
|
||||
{
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask ExecuteStageAsync(
|
||||
ScanJobContext context,
|
||||
string stageName,
|
||||
Func<ScanJobContext, CancellationToken, ValueTask> stageWork,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stageName);
|
||||
ArgumentNullException.ThrowIfNull(stageWork);
|
||||
|
||||
StageStarting(_logger, context.JobId, context.ScanId, stageName, context.Lease.Attempt);
|
||||
|
||||
var start = context.TimeProvider.GetUtcNow();
|
||||
using var activity = ScannerWorkerInstrumentation.ActivitySource.StartActivity(
|
||||
$"scanner.worker.{stageName}",
|
||||
ActivityKind.Internal);
|
||||
|
||||
activity?.SetTag("scanner.worker.job_id", context.JobId);
|
||||
activity?.SetTag("scanner.worker.scan_id", context.ScanId);
|
||||
activity?.SetTag("scanner.worker.stage", stageName);
|
||||
|
||||
try
|
||||
{
|
||||
await stageWork(context, cancellationToken).ConfigureAwait(false);
|
||||
var duration = context.TimeProvider.GetUtcNow() - start;
|
||||
_metrics.RecordStageDuration(context, stageName, duration);
|
||||
StageCompleted(_logger, context.JobId, context.ScanId, stageName, duration.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
StageCancelled(_logger, context.JobId, context.ScanId, stageName);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = context.TimeProvider.GetUtcNow() - start;
|
||||
_metrics.RecordStageDuration(context, stageName, duration);
|
||||
StageFailed(_logger, context.JobId, context.ScanId, stageName, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1000,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Job {JobId} (scan {ScanId}) entering stage {Stage} (attempt {Attempt}).")]
|
||||
private static partial void StageStarting(ILogger logger, string jobId, string scanId, string stage, int attempt);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1001,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Job {JobId} (scan {ScanId}) finished stage {Stage} in {ElapsedMs:F0} ms.")]
|
||||
private static partial void StageCompleted(ILogger logger, string jobId, string scanId, string stage, double elapsedMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1002,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Job {JobId} (scan {ScanId}) stage {Stage} cancelled by request.")]
|
||||
private static partial void StageCancelled(ILogger logger, string jobId, string scanId, string stage);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1003,
|
||||
Level = LogLevel.Error,
|
||||
Message = "Job {JobId} (scan {ScanId}) stage {Stage} failed.")]
|
||||
private static partial void StageFailed(ILogger logger, string jobId, string scanId, string stage, Exception exception);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public static class ScanStageNames
|
||||
{
|
||||
public const string IngestReplay = "ingest-replay";
|
||||
|
||||
@@ -129,30 +129,14 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner
|
||||
try
|
||||
{
|
||||
var tenant = environment?.Settings.Tenant ?? "default";
|
||||
var request = new SurfaceSecretRequest(tenant, component: "scanner-worker", secretType: "attestation", name: "dsse-signing");
|
||||
var handle = provider.TryGetSecret(request, CancellationToken.None);
|
||||
if (handle is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (handle.Secret.TryGetProperty("privateKeyPem", out var privateKeyPem) && privateKeyPem.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var pem = privateKeyPem.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(pem))
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(pem);
|
||||
}
|
||||
}
|
||||
|
||||
if (handle.Secret.TryGetProperty("token", out var token) && token.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = token.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
}
|
||||
var request = new SurfaceSecretRequest(
|
||||
Tenant: tenant,
|
||||
Component: "scanner-worker",
|
||||
SecretType: "attestation",
|
||||
Name: "dsse-signing");
|
||||
using var handle = provider.GetAsync(request, CancellationToken.None).GetAwaiter().GetResult();
|
||||
var bytes = handle.AsBytes();
|
||||
return bytes.IsEmpty ? null : bytes.Span.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -85,11 +85,6 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
await PersistRubyPackagesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await PersistBunPackagesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var determinismPayloads = BuildDeterminismPayloads(context, payloads, out var merkleRoot);
|
||||
if (determinismPayloads is not null && determinismPayloads.Count > 0)
|
||||
{
|
||||
payloads.AddRange(determinismPayloads);
|
||||
}
|
||||
if (payloads.Count == 0)
|
||||
{
|
||||
_metrics.RecordSurfaceManifestSkipped(context);
|
||||
@@ -97,6 +92,12 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
return;
|
||||
}
|
||||
|
||||
var determinismPayloads = BuildDeterminismPayloads(context, payloads, out var merkleRoot);
|
||||
if (determinismPayloads is not null && determinismPayloads.Count > 0)
|
||||
{
|
||||
payloads.AddRange(determinismPayloads);
|
||||
}
|
||||
|
||||
var tenant = _surfaceEnvironment.Settings?.Tenant ?? string.Empty;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
@@ -249,12 +250,6 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
}));
|
||||
}
|
||||
|
||||
var determinismPayload = BuildDeterminismPayload(context, payloads);
|
||||
if (determinismPayload is not null)
|
||||
{
|
||||
payloads.Add(determinismPayload);
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
|
||||
@@ -326,7 +321,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
}));
|
||||
|
||||
// Attach DSSE envelope for layer fragments when present.
|
||||
foreach (var fragmentPayload in payloadList.Where(p => p.Kind == "layer.fragments"))
|
||||
foreach (var fragmentPayload in payloadList.Where(p => p.Kind == "layer.fragments").ToArray())
|
||||
{
|
||||
var dsse = _dsseSigner.SignAsync(
|
||||
payloadType: fragmentPayload.MediaType,
|
||||
@@ -362,10 +357,9 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
return payloadList.Skip(payloads.Count()).ToList();
|
||||
}
|
||||
|
||||
private static (Dictionary<string, string> Hashes, byte[] RecipeBytes, string RecipeSha256) BuildCompositionRecipe(IEnumerable<SurfaceManifestPayload> payloads)
|
||||
private (Dictionary<string, string> Hashes, byte[] RecipeBytes, string RecipeSha256) BuildCompositionRecipe(IEnumerable<SurfaceManifestPayload> payloads)
|
||||
{
|
||||
var map = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
foreach (var payload in payloads.OrderBy(p => p.Kind, StringComparer.Ordinal))
|
||||
{
|
||||
@@ -381,8 +375,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
|
||||
var recipeJson = JsonSerializer.Serialize(recipe, JsonOptions);
|
||||
var recipeBytes = Encoding.UTF8.GetBytes(recipeJson);
|
||||
var rootHash = sha.ComputeHash(recipeBytes);
|
||||
var merkleRoot = Convert.ToHexString(rootHash).ToLowerInvariant();
|
||||
var merkleRoot = _hash.ComputeHashHex(recipeBytes, HashAlgorithms.Sha256);
|
||||
|
||||
return (new Dictionary<string, string>(map, StringComparer.OrdinalIgnoreCase), recipeBytes, merkleRoot);
|
||||
}
|
||||
@@ -459,10 +452,10 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
|
||||
if (bestPlan is not null)
|
||||
{
|
||||
metadata["best_terminal"] = bestPlan.Value.TerminalPath;
|
||||
metadata["best_confidence"] = bestPlan.Value.Confidence.ToString("F4", CultureInfoInvariant);
|
||||
metadata["best_user"] = bestPlan.Value.User;
|
||||
metadata["best_workdir"] = bestPlan.Value.WorkingDirectory;
|
||||
metadata["best_terminal"] = bestPlan.TerminalPath;
|
||||
metadata["best_confidence"] = bestPlan.Confidence.ToString("F4", CultureInfoInvariant);
|
||||
metadata["best_user"] = bestPlan.User;
|
||||
metadata["best_workdir"] = bestPlan.WorkingDirectory;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class SystemDelayScheduler : IDelayScheduler
|
||||
{
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class SystemDelayScheduler : IDelayScheduler
|
||||
{
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Worker.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Worker.Tests")]
|
||||
|
||||
6
src/Scanner/StellaOps.Scanner.Worker/TASKS.md
Normal file
6
src/Scanner/StellaOps.Scanner.Worker/TASKS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Scanner Worker Tasks (Sprint 0409.0001.0001)
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| SCAN-NL-0409-002 | DONE | OS analyzer surface-cache wiring + hit/miss metrics + worker tests updated to current APIs. | 2025-12-12 |
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using System;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
|
||||
|
||||
public sealed class DotNetAnalyzerPlugin : ILanguageAnalyzerPlugin
|
||||
{
|
||||
public string Name => "StellaOps.Scanner.Analyzers.Lang.DotNet";
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||
|
||||
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return new DotNetLanguageAnalyzer();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
|
||||
|
||||
public sealed class DotNetAnalyzerPlugin : ILanguageAnalyzerPlugin
|
||||
{
|
||||
public string Name => "StellaOps.Scanner.Analyzers.Lang.DotNet";
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||
|
||||
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return new DotNetLanguageAnalyzer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
|
||||
|
||||
public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
public string Id => "dotnet";
|
||||
|
||||
public string DisplayName => ".NET Analyzer (preview)";
|
||||
|
||||
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var package in packages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: package.Metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: package.UsedByEntrypoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
|
||||
|
||||
public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
public string Id => "dotnet";
|
||||
|
||||
public string DisplayName => ".NET Analyzer (preview)";
|
||||
|
||||
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var package in packages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: package.Metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: package.UsedByEntrypoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Globalization;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
|
||||
global using StellaOps.Scanner.Analyzers.Lang;
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Globalization;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
|
||||
global using StellaOps.Scanner.Analyzers.Lang;
|
||||
|
||||
@@ -1,172 +1,172 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
internal sealed class DotNetDepsFile
|
||||
{
|
||||
private DotNetDepsFile(string relativePath, IReadOnlyDictionary<string, DotNetLibrary> libraries)
|
||||
{
|
||||
RelativePath = relativePath;
|
||||
Libraries = libraries;
|
||||
}
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, DotNetLibrary> Libraries { get; }
|
||||
|
||||
public static DotNetDepsFile? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var libraries = ParseLibraries(root, cancellationToken);
|
||||
if (libraries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PopulateTargets(root, libraries, cancellationToken);
|
||||
return new DotNetDepsFile(relativePath, libraries);
|
||||
}
|
||||
|
||||
private static Dictionary<string, DotNetLibrary> ParseLibraries(JsonElement root, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<string, DotNetLibrary>(StringComparer.Ordinal);
|
||||
|
||||
if (!root.TryGetProperty("libraries", out var librariesElement) || librariesElement.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var property in librariesElement.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (DotNetLibrary.TryCreate(property.Name, property.Value, out var library))
|
||||
{
|
||||
result[property.Name] = library;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void PopulateTargets(JsonElement root, IDictionary<string, DotNetLibrary> libraries, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!root.TryGetProperty("targets", out var targetsElement) || targetsElement.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var targetProperty in targetsElement.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (tfm, rid) = ParseTargetKey(targetProperty.Name);
|
||||
if (targetProperty.Value.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var libraryProperty in targetProperty.Value.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!libraries.TryGetValue(libraryProperty.Name, out var library))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tfm))
|
||||
{
|
||||
library.AddTargetFramework(tfm);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(rid))
|
||||
{
|
||||
library.AddRuntimeIdentifier(rid);
|
||||
}
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
internal sealed class DotNetDepsFile
|
||||
{
|
||||
private DotNetDepsFile(string relativePath, IReadOnlyDictionary<string, DotNetLibrary> libraries)
|
||||
{
|
||||
RelativePath = relativePath;
|
||||
Libraries = libraries;
|
||||
}
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, DotNetLibrary> Libraries { get; }
|
||||
|
||||
public static DotNetDepsFile? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var libraries = ParseLibraries(root, cancellationToken);
|
||||
if (libraries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PopulateTargets(root, libraries, cancellationToken);
|
||||
return new DotNetDepsFile(relativePath, libraries);
|
||||
}
|
||||
|
||||
private static Dictionary<string, DotNetLibrary> ParseLibraries(JsonElement root, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<string, DotNetLibrary>(StringComparer.Ordinal);
|
||||
|
||||
if (!root.TryGetProperty("libraries", out var librariesElement) || librariesElement.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var property in librariesElement.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (DotNetLibrary.TryCreate(property.Name, property.Value, out var library))
|
||||
{
|
||||
result[property.Name] = library;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void PopulateTargets(JsonElement root, IDictionary<string, DotNetLibrary> libraries, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!root.TryGetProperty("targets", out var targetsElement) || targetsElement.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var targetProperty in targetsElement.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (tfm, rid) = ParseTargetKey(targetProperty.Name);
|
||||
if (targetProperty.Value.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var libraryProperty in targetProperty.Value.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!libraries.TryGetValue(libraryProperty.Name, out var library))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tfm))
|
||||
{
|
||||
library.AddTargetFramework(tfm);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(rid))
|
||||
{
|
||||
library.AddRuntimeIdentifier(rid);
|
||||
}
|
||||
|
||||
library.MergeTargetMetadata(libraryProperty.Value, tfm, rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (string tfm, string? rid) ParseTargetKey(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return (string.Empty, null);
|
||||
}
|
||||
|
||||
var separatorIndex = value.IndexOf('/');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
return (value.Trim(), null);
|
||||
}
|
||||
|
||||
var tfm = value[..separatorIndex].Trim();
|
||||
var rid = value[(separatorIndex + 1)..].Trim();
|
||||
return (tfm, string.IsNullOrEmpty(rid) ? null : rid);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DotNetLibrary
|
||||
{
|
||||
private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (string tfm, string? rid) ParseTargetKey(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return (string.Empty, null);
|
||||
}
|
||||
|
||||
var separatorIndex = value.IndexOf('/');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
return (value.Trim(), null);
|
||||
}
|
||||
|
||||
var tfm = value[..separatorIndex].Trim();
|
||||
var rid = value[(separatorIndex + 1)..].Trim();
|
||||
return (tfm, string.IsNullOrEmpty(rid) ? null : rid);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DotNetLibrary
|
||||
{
|
||||
private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal);
|
||||
private readonly List<DotNetLibraryAsset> _runtimeAssets = new();
|
||||
private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal);
|
||||
|
||||
private DotNetLibrary(
|
||||
string key,
|
||||
string id,
|
||||
string version,
|
||||
string type,
|
||||
bool? serviceable,
|
||||
string? sha512,
|
||||
string? path,
|
||||
string? hashPath)
|
||||
{
|
||||
Key = key;
|
||||
Id = id;
|
||||
Version = version;
|
||||
Type = type;
|
||||
Serviceable = serviceable;
|
||||
Sha512 = NormalizeValue(sha512);
|
||||
PackagePath = NormalizePath(path);
|
||||
HashPath = NormalizePath(hashPath);
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public bool? Serviceable { get; }
|
||||
|
||||
public string? Sha512 { get; }
|
||||
|
||||
public string? PackagePath { get; }
|
||||
|
||||
public string? HashPath { get; }
|
||||
|
||||
public bool IsPackage => string.Equals(Type, "package", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal);
|
||||
|
||||
private DotNetLibrary(
|
||||
string key,
|
||||
string id,
|
||||
string version,
|
||||
string type,
|
||||
bool? serviceable,
|
||||
string? sha512,
|
||||
string? path,
|
||||
string? hashPath)
|
||||
{
|
||||
Key = key;
|
||||
Id = id;
|
||||
Version = version;
|
||||
Type = type;
|
||||
Serviceable = serviceable;
|
||||
Sha512 = NormalizeValue(sha512);
|
||||
PackagePath = NormalizePath(path);
|
||||
HashPath = NormalizePath(hashPath);
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public bool? Serviceable { get; }
|
||||
|
||||
public string? Sha512 { get; }
|
||||
|
||||
public string? PackagePath { get; }
|
||||
|
||||
public string? HashPath { get; }
|
||||
|
||||
public bool IsPackage => string.Equals(Type, "package", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyCollection<string> Dependencies => _dependencies;
|
||||
|
||||
public IReadOnlyCollection<string> TargetFrameworks => _targetFrameworks;
|
||||
@@ -174,65 +174,65 @@ internal sealed class DotNetLibrary
|
||||
public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers;
|
||||
|
||||
public IReadOnlyCollection<DotNetLibraryAsset> RuntimeAssets => _runtimeAssets;
|
||||
|
||||
public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library)
|
||||
{
|
||||
library = null;
|
||||
if (!TrySplitNameAndVersion(key, out var id, out var version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var type = element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String
|
||||
? typeElement.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
bool? serviceable = null;
|
||||
if (element.TryGetProperty("serviceable", out var serviceableElement))
|
||||
{
|
||||
if (serviceableElement.ValueKind is JsonValueKind.True)
|
||||
{
|
||||
serviceable = true;
|
||||
}
|
||||
else if (serviceableElement.ValueKind is JsonValueKind.False)
|
||||
{
|
||||
serviceable = false;
|
||||
}
|
||||
}
|
||||
|
||||
var sha512 = element.TryGetProperty("sha512", out var sha512Element) && sha512Element.ValueKind == JsonValueKind.String
|
||||
? sha512Element.GetString()
|
||||
: null;
|
||||
|
||||
var path = element.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String
|
||||
? pathElement.GetString()
|
||||
: null;
|
||||
|
||||
var hashPath = element.TryGetProperty("hashPath", out var hashElement) && hashElement.ValueKind == JsonValueKind.String
|
||||
? hashElement.GetString()
|
||||
: null;
|
||||
|
||||
library = new DotNetLibrary(key, id, version, type, serviceable, sha512, path, hashPath);
|
||||
library.MergeLibraryMetadata(element);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void AddTargetFramework(string tfm)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tfm))
|
||||
{
|
||||
_targetFrameworks.Add(tfm);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddRuntimeIdentifier(string rid)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(rid))
|
||||
{
|
||||
_runtimeIdentifiers.Add(rid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library)
|
||||
{
|
||||
library = null;
|
||||
if (!TrySplitNameAndVersion(key, out var id, out var version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var type = element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String
|
||||
? typeElement.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
bool? serviceable = null;
|
||||
if (element.TryGetProperty("serviceable", out var serviceableElement))
|
||||
{
|
||||
if (serviceableElement.ValueKind is JsonValueKind.True)
|
||||
{
|
||||
serviceable = true;
|
||||
}
|
||||
else if (serviceableElement.ValueKind is JsonValueKind.False)
|
||||
{
|
||||
serviceable = false;
|
||||
}
|
||||
}
|
||||
|
||||
var sha512 = element.TryGetProperty("sha512", out var sha512Element) && sha512Element.ValueKind == JsonValueKind.String
|
||||
? sha512Element.GetString()
|
||||
: null;
|
||||
|
||||
var path = element.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String
|
||||
? pathElement.GetString()
|
||||
: null;
|
||||
|
||||
var hashPath = element.TryGetProperty("hashPath", out var hashElement) && hashElement.ValueKind == JsonValueKind.String
|
||||
? hashElement.GetString()
|
||||
: null;
|
||||
|
||||
library = new DotNetLibrary(key, id, version, type, serviceable, sha512, path, hashPath);
|
||||
library.MergeLibraryMetadata(element);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void AddTargetFramework(string tfm)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tfm))
|
||||
{
|
||||
_targetFrameworks.Add(tfm);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddRuntimeIdentifier(string rid)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(rid))
|
||||
{
|
||||
_runtimeIdentifiers.Add(rid);
|
||||
}
|
||||
}
|
||||
|
||||
public void MergeTargetMetadata(JsonElement element, string? tfm, string? rid)
|
||||
{
|
||||
if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object)
|
||||
@@ -245,7 +245,7 @@ internal sealed class DotNetLibrary
|
||||
|
||||
MergeRuntimeAssets(element, tfm, rid);
|
||||
}
|
||||
|
||||
|
||||
public void MergeLibraryMetadata(JsonElement element)
|
||||
{
|
||||
if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object)
|
||||
@@ -296,66 +296,66 @@ internal sealed class DotNetLibrary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddDependency(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dependencyId = name;
|
||||
if (TrySplitNameAndVersion(name, out var parsedName, out _))
|
||||
{
|
||||
dependencyId = parsedName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dependencyId))
|
||||
{
|
||||
_dependencies.Add(dependencyId);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySplitNameAndVersion(string key, out string name, out string version)
|
||||
{
|
||||
name = string.Empty;
|
||||
version = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var separatorIndex = key.LastIndexOf('/');
|
||||
if (separatorIndex <= 0 || separatorIndex >= key.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
name = key[..separatorIndex].Trim();
|
||||
version = key[(separatorIndex + 1)..].Trim();
|
||||
return name.Length > 0 && version.Length > 0;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string? NormalizeValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private void AddDependency(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dependencyId = name;
|
||||
if (TrySplitNameAndVersion(name, out var parsedName, out _))
|
||||
{
|
||||
dependencyId = parsedName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dependencyId))
|
||||
{
|
||||
_dependencies.Add(dependencyId);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySplitNameAndVersion(string key, out string name, out string version)
|
||||
{
|
||||
name = string.Empty;
|
||||
version = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var separatorIndex = key.LastIndexOf('/');
|
||||
if (separatorIndex <= 0 || separatorIndex >= key.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
name = key[..separatorIndex].Trim();
|
||||
version = key[(separatorIndex + 1)..].Trim();
|
||||
return name.Length > 0 && version.Length > 0;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string? NormalizeValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
internal enum DotNetLibraryAssetKind
|
||||
|
||||
@@ -1,332 +1,332 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security;
|
||||
using System.Xml;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
internal static class DotNetFileMetadataCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<string>> Sha256Cache = new();
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<AssemblyName>> AssemblyCache = new();
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<FileVersionInfo>> VersionCache = new();
|
||||
|
||||
public static bool TryGetSha256(string path, out string? sha256)
|
||||
=> TryGet(path, Sha256Cache, ComputeSha256, out sha256);
|
||||
|
||||
public static bool TryGetAssemblyName(string path, out AssemblyName? assemblyName)
|
||||
=> TryGet(path, AssemblyCache, TryReadAssemblyName, out assemblyName);
|
||||
|
||||
public static bool TryGetFileVersionInfo(string path, out FileVersionInfo? versionInfo)
|
||||
=> TryGet(path, VersionCache, TryReadFileVersionInfo, out versionInfo);
|
||||
|
||||
private static bool TryGet<T>(string path, ConcurrentDictionary<DotNetFileCacheKey, Optional<T>> cache, Func<string, T?> resolver, out T? value)
|
||||
where T : class
|
||||
{
|
||||
value = null;
|
||||
|
||||
DotNetFileCacheKey key;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new DotNetFileCacheKey(info.FullName, info.Length, info.LastWriteTimeUtc.Ticks);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var optional = cache.GetOrAdd(key, static (cacheKey, state) => CreateOptional(cacheKey.Path, state.resolver), (resolver, path));
|
||||
if (!optional.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = optional.Value;
|
||||
return value is not null;
|
||||
}
|
||||
|
||||
private static Optional<T> CreateOptional<T>(string path, Func<string, T?> resolver) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = resolver(path);
|
||||
return Optional<T>.From(value);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (FileLoadException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static AssemblyName? TryReadAssemblyName(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return AssemblyName.GetAssemblyName(path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (FileLoadException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static FileVersionInfo? TryReadFileVersionInfo(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return FileVersionInfo.GetVersionInfo(path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DotNetLicenseCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<DotNetLicenseInfo>> Licenses = new();
|
||||
|
||||
public static bool TryGetLicenseInfo(string nuspecPath, out DotNetLicenseInfo? info)
|
||||
{
|
||||
info = null;
|
||||
|
||||
DotNetFileCacheKey key;
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(nuspecPath);
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new DotNetFileCacheKey(fileInfo.FullName, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var optional = Licenses.GetOrAdd(key, static (cacheKey, path) => CreateOptional(path), nuspecPath);
|
||||
if (!optional.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info = optional.Value;
|
||||
return info is not null;
|
||||
}
|
||||
|
||||
private static Optional<DotNetLicenseInfo> CreateOptional(string nuspecPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = Parse(nuspecPath);
|
||||
return Optional<DotNetLicenseInfo>.From(info);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static DotNetLicenseInfo? Parse(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = XmlReader.Create(stream, new XmlReaderSettings
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Ignore,
|
||||
IgnoreComments = true,
|
||||
IgnoreWhitespace = true,
|
||||
});
|
||||
|
||||
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var urls = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.NodeType != XmlNodeType.Element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(reader.LocalName, "license", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var type = reader.GetAttribute("type");
|
||||
var value = reader.ReadElementContentAsString()?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(type, "expression", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
expressions.Add(value);
|
||||
}
|
||||
else if (string.Equals(type, "file", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
files.Add(NormalizeLicensePath(value));
|
||||
}
|
||||
else
|
||||
{
|
||||
expressions.Add(value);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(reader.LocalName, "licenseUrl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = reader.ReadElementContentAsString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
urls.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expressions.Count == 0 && files.Count == 0 && urls.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DotNetLicenseInfo(
|
||||
expressions.ToArray(),
|
||||
files.ToArray(),
|
||||
urls.ToArray());
|
||||
}
|
||||
|
||||
private static string NormalizeLicensePath(string value)
|
||||
=> value.Replace('\\', '/').Trim();
|
||||
}
|
||||
|
||||
internal sealed record DotNetLicenseInfo(
|
||||
IReadOnlyList<string> Expressions,
|
||||
IReadOnlyList<string> Files,
|
||||
IReadOnlyList<string> Urls);
|
||||
|
||||
internal readonly record struct DotNetFileCacheKey(string Path, long Length, long LastWriteTicks)
|
||||
{
|
||||
private readonly string _normalizedPath = OperatingSystem.IsWindows()
|
||||
? Path.ToLowerInvariant()
|
||||
: Path;
|
||||
|
||||
public bool Equals(DotNetFileCacheKey other)
|
||||
=> Length == other.Length
|
||||
&& LastWriteTicks == other.LastWriteTicks
|
||||
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
|
||||
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
|
||||
}
|
||||
|
||||
internal readonly struct Optional<T> where T : class
|
||||
{
|
||||
private Optional(bool hasValue, T? value)
|
||||
{
|
||||
HasValue = hasValue;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public bool HasValue { get; }
|
||||
|
||||
public T? Value { get; }
|
||||
|
||||
public static Optional<T> From(T? value)
|
||||
=> value is null ? None : new Optional<T>(true, value);
|
||||
|
||||
public static Optional<T> None => default;
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security;
|
||||
using System.Xml;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
internal static class DotNetFileMetadataCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<string>> Sha256Cache = new();
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<AssemblyName>> AssemblyCache = new();
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<FileVersionInfo>> VersionCache = new();
|
||||
|
||||
public static bool TryGetSha256(string path, out string? sha256)
|
||||
=> TryGet(path, Sha256Cache, ComputeSha256, out sha256);
|
||||
|
||||
public static bool TryGetAssemblyName(string path, out AssemblyName? assemblyName)
|
||||
=> TryGet(path, AssemblyCache, TryReadAssemblyName, out assemblyName);
|
||||
|
||||
public static bool TryGetFileVersionInfo(string path, out FileVersionInfo? versionInfo)
|
||||
=> TryGet(path, VersionCache, TryReadFileVersionInfo, out versionInfo);
|
||||
|
||||
private static bool TryGet<T>(string path, ConcurrentDictionary<DotNetFileCacheKey, Optional<T>> cache, Func<string, T?> resolver, out T? value)
|
||||
where T : class
|
||||
{
|
||||
value = null;
|
||||
|
||||
DotNetFileCacheKey key;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new DotNetFileCacheKey(info.FullName, info.Length, info.LastWriteTimeUtc.Ticks);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var optional = cache.GetOrAdd(key, static (cacheKey, state) => CreateOptional(cacheKey.Path, state.resolver), (resolver, path));
|
||||
if (!optional.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = optional.Value;
|
||||
return value is not null;
|
||||
}
|
||||
|
||||
private static Optional<T> CreateOptional<T>(string path, Func<string, T?> resolver) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = resolver(path);
|
||||
return Optional<T>.From(value);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (FileLoadException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Optional<T>.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static AssemblyName? TryReadAssemblyName(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return AssemblyName.GetAssemblyName(path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (FileLoadException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static FileVersionInfo? TryReadFileVersionInfo(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return FileVersionInfo.GetVersionInfo(path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DotNetLicenseCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<DotNetLicenseInfo>> Licenses = new();
|
||||
|
||||
public static bool TryGetLicenseInfo(string nuspecPath, out DotNetLicenseInfo? info)
|
||||
{
|
||||
info = null;
|
||||
|
||||
DotNetFileCacheKey key;
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(nuspecPath);
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new DotNetFileCacheKey(fileInfo.FullName, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var optional = Licenses.GetOrAdd(key, static (cacheKey, path) => CreateOptional(path), nuspecPath);
|
||||
if (!optional.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info = optional.Value;
|
||||
return info is not null;
|
||||
}
|
||||
|
||||
private static Optional<DotNetLicenseInfo> CreateOptional(string nuspecPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = Parse(nuspecPath);
|
||||
return Optional<DotNetLicenseInfo>.From(info);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
return Optional<DotNetLicenseInfo>.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static DotNetLicenseInfo? Parse(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = XmlReader.Create(stream, new XmlReaderSettings
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Ignore,
|
||||
IgnoreComments = true,
|
||||
IgnoreWhitespace = true,
|
||||
});
|
||||
|
||||
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var urls = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.NodeType != XmlNodeType.Element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(reader.LocalName, "license", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var type = reader.GetAttribute("type");
|
||||
var value = reader.ReadElementContentAsString()?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(type, "expression", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
expressions.Add(value);
|
||||
}
|
||||
else if (string.Equals(type, "file", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
files.Add(NormalizeLicensePath(value));
|
||||
}
|
||||
else
|
||||
{
|
||||
expressions.Add(value);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(reader.LocalName, "licenseUrl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = reader.ReadElementContentAsString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
urls.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expressions.Count == 0 && files.Count == 0 && urls.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DotNetLicenseInfo(
|
||||
expressions.ToArray(),
|
||||
files.ToArray(),
|
||||
urls.ToArray());
|
||||
}
|
||||
|
||||
private static string NormalizeLicensePath(string value)
|
||||
=> value.Replace('\\', '/').Trim();
|
||||
}
|
||||
|
||||
internal sealed record DotNetLicenseInfo(
|
||||
IReadOnlyList<string> Expressions,
|
||||
IReadOnlyList<string> Files,
|
||||
IReadOnlyList<string> Urls);
|
||||
|
||||
internal readonly record struct DotNetFileCacheKey(string Path, long Length, long LastWriteTicks)
|
||||
{
|
||||
private readonly string _normalizedPath = OperatingSystem.IsWindows()
|
||||
? Path.ToLowerInvariant()
|
||||
: Path;
|
||||
|
||||
public bool Equals(DotNetFileCacheKey other)
|
||||
=> Length == other.Length
|
||||
&& LastWriteTicks == other.LastWriteTicks
|
||||
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
|
||||
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
|
||||
}
|
||||
|
||||
internal readonly struct Optional<T> where T : class
|
||||
{
|
||||
private Optional(bool hasValue, T? value)
|
||||
{
|
||||
HasValue = hasValue;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public bool HasValue { get; }
|
||||
|
||||
public T? Value { get; }
|
||||
|
||||
public static Optional<T> From(T? value)
|
||||
=> value is null ? None : new Optional<T>(true, value);
|
||||
|
||||
public static Optional<T> None => default;
|
||||
}
|
||||
|
||||
@@ -1,158 +1,158 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
internal sealed class DotNetRuntimeConfig
|
||||
{
|
||||
private DotNetRuntimeConfig(
|
||||
string relativePath,
|
||||
IReadOnlyCollection<string> tfms,
|
||||
IReadOnlyCollection<string> frameworks,
|
||||
IReadOnlyCollection<RuntimeGraphEntry> runtimeGraph)
|
||||
{
|
||||
RelativePath = relativePath;
|
||||
Tfms = tfms;
|
||||
Frameworks = frameworks;
|
||||
RuntimeGraph = runtimeGraph;
|
||||
}
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public IReadOnlyCollection<string> Tfms { get; }
|
||||
|
||||
public IReadOnlyCollection<string> Frameworks { get; }
|
||||
|
||||
public IReadOnlyCollection<RuntimeGraphEntry> RuntimeGraph { get; }
|
||||
|
||||
public static DotNetRuntimeConfig? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("runtimeOptions", out var runtimeOptions) || runtimeOptions.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tfms = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var frameworks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var runtimeGraph = new List<RuntimeGraphEntry>();
|
||||
|
||||
if (runtimeOptions.TryGetProperty("tfm", out var tfmElement) && tfmElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
AddIfPresent(tfms, tfmElement.GetString());
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("framework", out var frameworkElement) && frameworkElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var frameworkId = FormatFramework(frameworkElement);
|
||||
AddIfPresent(frameworks, frameworkId);
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("frameworks", out var frameworksElement) && frameworksElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in frameworksElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var frameworkId = FormatFramework(item);
|
||||
AddIfPresent(frameworks, frameworkId);
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("includedFrameworks", out var includedElement) && includedElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in includedElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var frameworkId = FormatFramework(item);
|
||||
AddIfPresent(frameworks, frameworkId);
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("runtimeGraph", out var runtimeGraphElement) &&
|
||||
runtimeGraphElement.ValueKind == JsonValueKind.Object &&
|
||||
runtimeGraphElement.TryGetProperty("runtimes", out var runtimesElement) &&
|
||||
runtimesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var ridProperty in runtimesElement.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ridProperty.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fallbacks = new List<string>();
|
||||
if (ridProperty.Value.ValueKind == JsonValueKind.Object &&
|
||||
ridProperty.Value.TryGetProperty("fallbacks", out var fallbacksElement) &&
|
||||
fallbacksElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var fallback in fallbacksElement.EnumerateArray())
|
||||
{
|
||||
if (fallback.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var fallbackValue = fallback.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(fallbackValue))
|
||||
{
|
||||
fallbacks.Add(fallbackValue.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtimeGraph.Add(new RuntimeGraphEntry(ridProperty.Name.Trim(), fallbacks));
|
||||
}
|
||||
}
|
||||
|
||||
return new DotNetRuntimeConfig(
|
||||
relativePath,
|
||||
tfms.ToArray(),
|
||||
frameworks.ToArray(),
|
||||
runtimeGraph);
|
||||
}
|
||||
|
||||
private static void AddIfPresent(ISet<string> set, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
set.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FormatFramework(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String
|
||||
? nameElement.GetString()
|
||||
: null;
|
||||
|
||||
var version = element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String
|
||||
? versionElement.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return name.Trim();
|
||||
}
|
||||
|
||||
return $"{name.Trim()}@{version.Trim()}";
|
||||
}
|
||||
|
||||
internal sealed record RuntimeGraphEntry(string Rid, IReadOnlyList<string> Fallbacks);
|
||||
}
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
internal sealed class DotNetRuntimeConfig
|
||||
{
|
||||
private DotNetRuntimeConfig(
|
||||
string relativePath,
|
||||
IReadOnlyCollection<string> tfms,
|
||||
IReadOnlyCollection<string> frameworks,
|
||||
IReadOnlyCollection<RuntimeGraphEntry> runtimeGraph)
|
||||
{
|
||||
RelativePath = relativePath;
|
||||
Tfms = tfms;
|
||||
Frameworks = frameworks;
|
||||
RuntimeGraph = runtimeGraph;
|
||||
}
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public IReadOnlyCollection<string> Tfms { get; }
|
||||
|
||||
public IReadOnlyCollection<string> Frameworks { get; }
|
||||
|
||||
public IReadOnlyCollection<RuntimeGraphEntry> RuntimeGraph { get; }
|
||||
|
||||
public static DotNetRuntimeConfig? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("runtimeOptions", out var runtimeOptions) || runtimeOptions.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tfms = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var frameworks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var runtimeGraph = new List<RuntimeGraphEntry>();
|
||||
|
||||
if (runtimeOptions.TryGetProperty("tfm", out var tfmElement) && tfmElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
AddIfPresent(tfms, tfmElement.GetString());
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("framework", out var frameworkElement) && frameworkElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var frameworkId = FormatFramework(frameworkElement);
|
||||
AddIfPresent(frameworks, frameworkId);
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("frameworks", out var frameworksElement) && frameworksElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in frameworksElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var frameworkId = FormatFramework(item);
|
||||
AddIfPresent(frameworks, frameworkId);
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("includedFrameworks", out var includedElement) && includedElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in includedElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var frameworkId = FormatFramework(item);
|
||||
AddIfPresent(frameworks, frameworkId);
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeOptions.TryGetProperty("runtimeGraph", out var runtimeGraphElement) &&
|
||||
runtimeGraphElement.ValueKind == JsonValueKind.Object &&
|
||||
runtimeGraphElement.TryGetProperty("runtimes", out var runtimesElement) &&
|
||||
runtimesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var ridProperty in runtimesElement.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ridProperty.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fallbacks = new List<string>();
|
||||
if (ridProperty.Value.ValueKind == JsonValueKind.Object &&
|
||||
ridProperty.Value.TryGetProperty("fallbacks", out var fallbacksElement) &&
|
||||
fallbacksElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var fallback in fallbacksElement.EnumerateArray())
|
||||
{
|
||||
if (fallback.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var fallbackValue = fallback.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(fallbackValue))
|
||||
{
|
||||
fallbacks.Add(fallbackValue.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtimeGraph.Add(new RuntimeGraphEntry(ridProperty.Name.Trim(), fallbacks));
|
||||
}
|
||||
}
|
||||
|
||||
return new DotNetRuntimeConfig(
|
||||
relativePath,
|
||||
tfms.ToArray(),
|
||||
frameworks.ToArray(),
|
||||
runtimeGraph);
|
||||
}
|
||||
|
||||
private static void AddIfPresent(ISet<string> set, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
set.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FormatFramework(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String
|
||||
? nameElement.GetString()
|
||||
: null;
|
||||
|
||||
var version = element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String
|
||||
? versionElement.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return name.Trim();
|
||||
}
|
||||
|
||||
return $"{name.Trim()}@{version.Trim()}";
|
||||
}
|
||||
|
||||
internal sealed record RuntimeGraphEntry(string Rid, IReadOnlyList<string> Fallbacks);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user