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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorExportDownloadResult(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
bool FromCache);
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user