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);
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user