feat: Initialize Zastava Webhook service with TLS and Authority authentication
- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
		@@ -1,5 +1,6 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Collections.ObjectModel;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Net.Http;
 | 
			
		||||
@@ -25,6 +26,8 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
 | 
			
		||||
{
 | 
			
		||||
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
 | 
			
		||||
    private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
 | 
			
		||||
    private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
 | 
			
		||||
        new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
 | 
			
		||||
 | 
			
		||||
    private readonly HttpClient _httpClient;
 | 
			
		||||
    private readonly StellaOpsCliOptions _options;
 | 
			
		||||
@@ -266,6 +269,208 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
 | 
			
		||||
        return new ExcititorOperationResult(false, failure, null, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        EnsureBackendConfigured();
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(exportId))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Export id must be provided.", nameof(exportId));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(destinationPath))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Destination path must be provided.", nameof(destinationPath));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var fullPath = Path.GetFullPath(destinationPath);
 | 
			
		||||
        var directory = Path.GetDirectoryName(fullPath);
 | 
			
		||||
        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
 | 
			
		||||
        {
 | 
			
		||||
            Directory.CreateDirectory(directory);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalizedAlgorithm = string.IsNullOrWhiteSpace(expectedDigestAlgorithm)
 | 
			
		||||
            ? null
 | 
			
		||||
            : expectedDigestAlgorithm.Trim();
 | 
			
		||||
        var normalizedDigest = NormalizeExpectedDigest(expectedDigest);
 | 
			
		||||
 | 
			
		||||
        if (File.Exists(fullPath)
 | 
			
		||||
            && string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase)
 | 
			
		||||
            && !string.IsNullOrWhiteSpace(normalizedDigest))
 | 
			
		||||
        {
 | 
			
		||||
            var existingDigest = await ComputeSha256Async(fullPath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (string.Equals(existingDigest, normalizedDigest, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                var info = new FileInfo(fullPath);
 | 
			
		||||
                _logger.LogDebug("Export {ExportId} already present at {Path}; digest matches.", exportId, fullPath);
 | 
			
		||||
                return new ExcititorExportDownloadResult(fullPath, info.Length, true);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var encodedId = Uri.EscapeDataString(exportId);
 | 
			
		||||
        using var request = CreateRequest(HttpMethod.Get, $"excititor/export/{encodedId}/download");
 | 
			
		||||
        await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        var tempPath = fullPath + ".tmp";
 | 
			
		||||
        if (File.Exists(tempPath))
 | 
			
		||||
        {
 | 
			
		||||
            File.Delete(tempPath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        using (var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false))
 | 
			
		||||
        {
 | 
			
		||||
            if (!response.IsSuccessStatusCode)
 | 
			
		||||
            {
 | 
			
		||||
                var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                throw new InvalidOperationException(failure);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await using (var fileStream = File.Create(tempPath))
 | 
			
		||||
            {
 | 
			
		||||
                await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(normalizedAlgorithm) && !string.IsNullOrWhiteSpace(normalizedDigest))
 | 
			
		||||
        {
 | 
			
		||||
            if (string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                var computed = await ComputeSha256Async(tempPath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                if (!string.Equals(computed, normalizedDigest, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
                {
 | 
			
		||||
                    File.Delete(tempPath);
 | 
			
		||||
                    throw new InvalidOperationException($"Export digest mismatch. Expected sha256:{normalizedDigest}, computed sha256:{computed}.");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogWarning("Export digest verification skipped. Unsupported algorithm {Algorithm}.", normalizedAlgorithm);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (File.Exists(fullPath))
 | 
			
		||||
        {
 | 
			
		||||
            File.Delete(fullPath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        File.Move(tempPath, fullPath);
 | 
			
		||||
 | 
			
		||||
        var downloaded = new FileInfo(fullPath);
 | 
			
		||||
        return new ExcititorExportDownloadResult(fullPath, downloaded.Length, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        EnsureBackendConfigured();
 | 
			
		||||
 | 
			
		||||
        if (request is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentNullException(nameof(request));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var images = NormalizeImages(request.Images);
 | 
			
		||||
        if (images.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("At least one image digest must be provided.", nameof(request));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var payload = new RuntimePolicyEvaluationRequestDocument
 | 
			
		||||
        {
 | 
			
		||||
            Namespace = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim(),
 | 
			
		||||
            Images = images
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (request.Labels.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            payload.Labels = new Dictionary<string, string>(StringComparer.Ordinal);
 | 
			
		||||
            foreach (var label in request.Labels)
 | 
			
		||||
            {
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(label.Key))
 | 
			
		||||
                {
 | 
			
		||||
                    payload.Labels[label.Key] = label.Value ?? string.Empty;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        using var message = CreateRequest(HttpMethod.Post, "api/scanner/policy/runtime");
 | 
			
		||||
        await AuthorizeRequestAsync(message, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        message.Content = JsonContent.Create(payload, options: SerializerOptions);
 | 
			
		||||
 | 
			
		||||
        using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException(failure);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        RuntimePolicyEvaluationResponseDocument? document;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            document = await response.Content.ReadFromJsonAsync<RuntimePolicyEvaluationResponseDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (JsonException ex)
 | 
			
		||||
        {
 | 
			
		||||
            var raw = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException($"Failed to parse runtime policy response. {ex.Message}", ex)
 | 
			
		||||
            {
 | 
			
		||||
                Data = { ["payload"] = raw }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (document is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Runtime policy response was empty.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
 | 
			
		||||
        if (document.Results is not null)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var kvp in document.Results)
 | 
			
		||||
            {
 | 
			
		||||
                var image = kvp.Key;
 | 
			
		||||
                var decision = kvp.Value;
 | 
			
		||||
                if (string.IsNullOrWhiteSpace(image) || decision is null)
 | 
			
		||||
                {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                var verdict = string.IsNullOrWhiteSpace(decision.PolicyVerdict)
 | 
			
		||||
                    ? "unknown"
 | 
			
		||||
                    : decision.PolicyVerdict!.Trim();
 | 
			
		||||
 | 
			
		||||
                var reasons = ExtractReasons(decision.Reasons);
 | 
			
		||||
                var metadata = ExtractExtensionMetadata(decision.ExtensionData);
 | 
			
		||||
 | 
			
		||||
                RuntimePolicyRekorReference? rekor = null;
 | 
			
		||||
                if (decision.Rekor is not null &&
 | 
			
		||||
                    (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url)))
 | 
			
		||||
                {
 | 
			
		||||
                    rekor = new RuntimePolicyRekorReference(
 | 
			
		||||
                        NormalizeOptionalString(decision.Rekor.Uuid),
 | 
			
		||||
                        NormalizeOptionalString(decision.Rekor.Url));
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                decisions[image] = new RuntimePolicyImageDecision(
 | 
			
		||||
                    verdict,
 | 
			
		||||
                    decision.Signed,
 | 
			
		||||
                    decision.HasSbom,
 | 
			
		||||
                    reasons,
 | 
			
		||||
                    rekor,
 | 
			
		||||
                    metadata);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var decisionsView = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions);
 | 
			
		||||
 | 
			
		||||
        return new RuntimePolicyEvaluationResult(
 | 
			
		||||
            document.TtlSeconds ?? 0,
 | 
			
		||||
            document.ExpiresAtUtc?.ToUniversalTime(),
 | 
			
		||||
            string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
 | 
			
		||||
            decisionsView);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        EnsureBackendConfigured();
 | 
			
		||||
@@ -324,7 +529,96 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
 | 
			
		||||
 | 
			
		||||
        return list;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    private static List<string> NormalizeImages(IReadOnlyList<string> images)
 | 
			
		||||
    {
 | 
			
		||||
        var normalized = new List<string>();
 | 
			
		||||
        if (images is null)
 | 
			
		||||
        {
 | 
			
		||||
            return normalized;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var seen = new HashSet<string>(StringComparer.Ordinal);
 | 
			
		||||
        foreach (var entry in images)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(entry))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var trimmed = entry.Trim();
 | 
			
		||||
            if (seen.Add(trimmed))
 | 
			
		||||
            {
 | 
			
		||||
                normalized.Add(trimmed);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return normalized;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<string> ExtractReasons(List<string>? reasons)
 | 
			
		||||
    {
 | 
			
		||||
        if (reasons is null || reasons.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<string>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var list = new List<string>();
 | 
			
		||||
        foreach (var reason in reasons)
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(reason))
 | 
			
		||||
            {
 | 
			
		||||
                list.Add(reason.Trim());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return list.Count == 0 ? Array.Empty<string>() : list;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyDictionary<string, object?> ExtractExtensionMetadata(Dictionary<string, JsonElement>? extensionData)
 | 
			
		||||
    {
 | 
			
		||||
        if (extensionData is null || extensionData.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyMetadata;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var metadata = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
        foreach (var kvp in extensionData)
 | 
			
		||||
        {
 | 
			
		||||
            var value = ConvertJsonElementToObject(kvp.Value);
 | 
			
		||||
            if (value is not null)
 | 
			
		||||
            {
 | 
			
		||||
                metadata[kvp.Key] = value;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (metadata.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyMetadata;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new ReadOnlyDictionary<string, object?>(metadata);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static object? ConvertJsonElementToObject(JsonElement element)
 | 
			
		||||
    {
 | 
			
		||||
        return element.ValueKind switch
 | 
			
		||||
        {
 | 
			
		||||
            JsonValueKind.String => element.GetString(),
 | 
			
		||||
            JsonValueKind.True => true,
 | 
			
		||||
            JsonValueKind.False => false,
 | 
			
		||||
            JsonValueKind.Number when element.TryGetInt64(out var integer) => integer,
 | 
			
		||||
            JsonValueKind.Number when element.TryGetDouble(out var @double) => @double,
 | 
			
		||||
            JsonValueKind.Null or JsonValueKind.Undefined => null,
 | 
			
		||||
            _ => element.GetRawText()
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? NormalizeOptionalString(string? value)
 | 
			
		||||
    {
 | 
			
		||||
        return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
 | 
			
		||||
    {
 | 
			
		||||
        if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
 | 
			
		||||
@@ -596,12 +890,25 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<string> ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        string digestHex;
 | 
			
		||||
        await using (var stream = File.OpenRead(filePath))
 | 
			
		||||
        {
 | 
			
		||||
            var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    private static string? NormalizeExpectedDigest(string? digest)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(digest))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var trimmed = digest.Trim();
 | 
			
		||||
        return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
 | 
			
		||||
            ? trimmed[7..]
 | 
			
		||||
            : trimmed;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<string> ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        string digestHex;
 | 
			
		||||
        await using (var stream = File.OpenRead(filePath))
 | 
			
		||||
        {
 | 
			
		||||
            var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            digestHex = Convert.ToHexString(hash).ToLowerInvariant();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -619,18 +926,25 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
 | 
			
		||||
            _logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return digestHex;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string NormalizeDigest(string digest)
 | 
			
		||||
    {
 | 
			
		||||
        if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return digest[7..];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return digest;
 | 
			
		||||
    }
 | 
			
		||||
        return digestHex;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string NormalizeDigest(string digest)
 | 
			
		||||
    {
 | 
			
		||||
        if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return digest[7..];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return digest;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var stream = File.OpenRead(filePath);
 | 
			
		||||
        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)
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user