Add channel test providers for Email, Slack, Teams, and Webhook
- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
@@ -19,9 +19,9 @@ using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -48,34 +48,34 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
@@ -83,55 +83,55 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -194,46 +194,46 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return new JobTriggerResult(false, failureMessage, null, null);
|
||||
}
|
||||
@@ -443,19 +443,24 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
var reasons = ExtractReasons(decision.Reasons);
|
||||
var metadata = ExtractExtensionMetadata(decision.ExtensionData);
|
||||
|
||||
var hasSbom = decision.HasSbomReferrers ?? decision.HasSbomLegacy;
|
||||
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
if (decision.Rekor is not null &&
|
||||
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url)))
|
||||
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) ||
|
||||
!string.IsNullOrWhiteSpace(decision.Rekor.Url) ||
|
||||
decision.Rekor.Verified.HasValue))
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
NormalizeOptionalString(decision.Rekor.Uuid),
|
||||
NormalizeOptionalString(decision.Rekor.Url));
|
||||
NormalizeOptionalString(decision.Rekor.Url),
|
||||
decision.Rekor.Verified);
|
||||
}
|
||||
|
||||
decisions[image] = new RuntimePolicyImageDecision(
|
||||
verdict,
|
||||
decision.Signed,
|
||||
decision.HasSbom,
|
||||
hasSbom,
|
||||
reasons,
|
||||
rekor,
|
||||
metadata);
|
||||
@@ -624,15 +629,15 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid request URI '{relativeUri}'.");
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
return new HttpRequestMessage(method, requestUri);
|
||||
@@ -820,76 +825,76 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
if (_httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeExpectedDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
@@ -909,23 +914,23 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
await using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
return digestHex;
|
||||
}
|
||||
|
||||
@@ -945,71 +950,71 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response)
|
||||
{
|
||||
var metadata = new
|
||||
{
|
||||
channel,
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ internal sealed record RuntimePolicyEvaluationResult(
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbom,
|
||||
bool? HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url);
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
@@ -42,8 +42,12 @@ internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool? HasSbomReferrers { get; set; }
|
||||
|
||||
// Legacy field kept for pre-contract-sync services.
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbom { get; set; }
|
||||
public bool? HasSbomLegacy { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
@@ -62,4 +66,7 @@ internal sealed class RuntimePolicyRekorDocument
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user