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:
master
2025-10-19 18:36:22 +03:00
parent 2062da7a8b
commit d099a90f9b
966 changed files with 91038 additions and 1850 deletions

View File

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

View File

@@ -17,5 +17,9 @@ internal interface IBackendOperationsClient
Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken);
Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken);
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Cli.Services.Models;
internal sealed record ExcititorExportDownloadResult(
string Path,
long SizeBytes,
bool FromCache);

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
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> Decisions);
internal sealed record RuntimePolicyImageDecision(
string PolicyVerdict,
bool? Signed,
bool? HasSbom,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IReadOnlyDictionary<string, object?> AdditionalProperties);
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url);

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class RuntimePolicyEvaluationRequestDocument
{
[JsonPropertyName("namespace")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Namespace { get; set; }
[JsonPropertyName("labels")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Labels { get; set; }
[JsonPropertyName("images")]
public List<string> Images { get; set; } = new();
}
internal sealed class RuntimePolicyEvaluationResponseDocument
{
[JsonPropertyName("ttlSeconds")]
public int? TtlSeconds { get; set; }
[JsonPropertyName("expiresAtUtc")]
public DateTimeOffset? ExpiresAtUtc { get; set; }
[JsonPropertyName("policyRevision")]
public string? PolicyRevision { get; set; }
[JsonPropertyName("results")]
public Dictionary<string, RuntimePolicyEvaluationImageDocument>? Results { get; set; }
}
internal sealed class RuntimePolicyEvaluationImageDocument
{
[JsonPropertyName("policyVerdict")]
public string? PolicyVerdict { get; set; }
[JsonPropertyName("signed")]
public bool? Signed { get; set; }
[JsonPropertyName("hasSbom")]
public bool? HasSbom { get; set; }
[JsonPropertyName("reasons")]
public List<string>? Reasons { get; set; }
[JsonPropertyName("rekor")]
public RuntimePolicyRekorDocument? Rekor { get; set; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
}
internal sealed class RuntimePolicyRekorDocument
{
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
}