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.ObjectModel; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Security.Cryptography; | ||||
| @@ -319,6 +320,61 @@ public sealed class CommandHandlersTests | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorExportAsync_DownloadsWhenOutputProvided() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|             const string manifestJson = """ | ||||
|             { | ||||
|               "exportId": "exports/20251019T101530Z/abcdef1234567890", | ||||
|               "format": "openvex", | ||||
|               "createdAt": "2025-10-19T10:15:30Z", | ||||
|               "artifact": { "algorithm": "sha256", "digest": "abcdef1234567890" }, | ||||
|               "fromCache": false, | ||||
|               "sizeBytes": 2048, | ||||
|               "attestation": { | ||||
|                 "rekor": { | ||||
|                   "location": "https://rekor.example/api/v1/log/entries/123", | ||||
|                   "logIndex": "123" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|             """; | ||||
|  | ||||
|             backend.ExcititorResult = new ExcititorOperationResult(true, "ok", null, JsonDocument.Parse(manifestJson).RootElement.Clone()); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|             var outputPath = Path.Combine(tempDir.Path, "export.json"); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorExportAsync( | ||||
|                 provider, | ||||
|                 format: "openvex", | ||||
|                 delta: false, | ||||
|                 scope: null, | ||||
|                 since: null, | ||||
|                 provider: null, | ||||
|                 outputPath: outputPath, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Single(backend.ExportDownloads); | ||||
|             var request = backend.ExportDownloads[0]; | ||||
|             Assert.Equal("exports/20251019T101530Z/abcdef1234567890", request.ExportId); | ||||
|             Assert.Equal(Path.GetFullPath(outputPath), request.DestinationPath); | ||||
|             Assert.Equal("sha256", request.Algorithm); | ||||
|             Assert.Equal("abcdef1234567890", request.Digest); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Theory] | ||||
|     [InlineData(null)] | ||||
|     [InlineData("default")] | ||||
| @@ -624,6 +680,7 @@ public sealed class CommandHandlersTests | ||||
|         public string? LastExcititorRoute { get; private set; } | ||||
|         public HttpMethod? LastExcititorMethod { get; private set; } | ||||
|         public object? LastExcititorPayload { get; private set; } | ||||
|         public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new(); | ||||
|         public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null); | ||||
|         public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>(); | ||||
|  | ||||
| @@ -650,8 +707,29 @@ public sealed class CommandHandlersTests | ||||
|             return Task.FromResult(ExcititorResult ?? new ExcititorOperationResult(true, "ok", null, null)); | ||||
|         } | ||||
|  | ||||
|         public Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var fullPath = Path.GetFullPath(destinationPath); | ||||
|             var directory = Path.GetDirectoryName(fullPath); | ||||
|             if (!string.IsNullOrEmpty(directory)) | ||||
|             { | ||||
|                 Directory.CreateDirectory(directory); | ||||
|             } | ||||
|  | ||||
|             File.WriteAllText(fullPath, "{}"); | ||||
|             var info = new FileInfo(fullPath); | ||||
|             ExportDownloads.Add((exportId, fullPath, expectedDigestAlgorithm, expectedDigest)); | ||||
|             return Task.FromResult(new ExcititorExportDownloadResult(fullPath, info.Length, false)); | ||||
|         } | ||||
|  | ||||
|         public Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken) | ||||
|             => Task.FromResult(ProviderSummaries); | ||||
|  | ||||
|         public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var empty = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(new Dictionary<string, RuntimePolicyImageDecision>()); | ||||
|             return Task.FromResult(new RuntimePolicyEvaluationResult(0, null, null, empty)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class StubExecutor : IScannerExecutor | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Json; | ||||
| using System.Security.Cryptography; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Json; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| @@ -375,6 +377,107 @@ public sealed class BackendOperationsClientTests | ||||
|         Assert.True(tokenClient.Requests > 0); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task EvaluateRuntimePolicyAsync_ParsesDecisionPayload() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             Assert.Equal(HttpMethod.Post, request.Method); | ||||
|             Assert.Equal("/api/scanner/policy/runtime", request.RequestUri!.AbsolutePath); | ||||
|  | ||||
|             var body = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); | ||||
|             using var document = JsonDocument.Parse(body); | ||||
|             var root = document.RootElement; | ||||
|             Assert.Equal("prod", root.GetProperty("namespace").GetString()); | ||||
|             Assert.Equal("payments", root.GetProperty("labels").GetProperty("app").GetString()); | ||||
|             var images = root.GetProperty("images"); | ||||
|             Assert.Equal(2, images.GetArrayLength()); | ||||
|             Assert.Equal("ghcr.io/app@sha256:abc", images[0].GetString()); | ||||
|             Assert.Equal("ghcr.io/api@sha256:def", images[1].GetString()); | ||||
|  | ||||
|             var responseJson = @"{ | ||||
|   ""ttlSeconds"": 120, | ||||
|   ""policyRevision"": ""rev-123"", | ||||
|   ""expiresAtUtc"": ""2025-10-19T12:34:56Z"", | ||||
|   ""results"": { | ||||
|     ""ghcr.io/app@sha256:abc"": { | ||||
|       ""policyVerdict"": ""pass"", | ||||
|       ""signed"": true, | ||||
|       ""hasSbom"": true, | ||||
|       ""reasons"": [], | ||||
|       ""rekor"": { ""uuid"": ""uuid-1"", ""url"": ""https://rekor.example/uuid-1"" }, | ||||
|       ""confidence"": 0.87, | ||||
|       ""quiet"": false, | ||||
|       ""metadata"": { ""note"": ""cached"" } | ||||
|     }, | ||||
|     ""ghcr.io/api@sha256:def"": { | ||||
|       ""policyVerdict"": ""fail"", | ||||
|       ""signed"": false, | ||||
|       ""hasSbom"": false, | ||||
|       ""reasons"": [""unsigned"", ""missing sbom""] | ||||
|     } | ||||
|   } | ||||
| }"; | ||||
|  | ||||
|             return new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://scanner.example/") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://scanner.example/" | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var labels = new ReadOnlyDictionary<string, string>(new Dictionary<string, string> { ["app"] = "payments" }); | ||||
|         var imagesList = new ReadOnlyCollection<string>(new List<string> | ||||
|         { | ||||
|             "ghcr.io/app@sha256:abc", | ||||
|             "ghcr.io/app@sha256:abc", | ||||
|             "ghcr.io/api@sha256:def" | ||||
|         }); | ||||
|         var requestModel = new RuntimePolicyEvaluationRequest("prod", labels, imagesList); | ||||
|  | ||||
|         var result = await client.EvaluateRuntimePolicyAsync(requestModel, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(120, result.TtlSeconds); | ||||
|         Assert.Equal("rev-123", result.PolicyRevision); | ||||
|         Assert.Equal(DateTimeOffset.Parse("2025-10-19T12:34:56Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), result.ExpiresAtUtc); | ||||
|         Assert.Equal(2, result.Decisions.Count); | ||||
|  | ||||
|         var primary = result.Decisions["ghcr.io/app@sha256:abc"]; | ||||
|         Assert.Equal("pass", primary.PolicyVerdict); | ||||
|         Assert.True(primary.Signed); | ||||
|         Assert.True(primary.HasSbom); | ||||
|         Assert.Empty(primary.Reasons); | ||||
|         Assert.NotNull(primary.Rekor); | ||||
|         Assert.Equal("uuid-1", primary.Rekor!.Uuid); | ||||
|         Assert.Equal("https://rekor.example/uuid-1", primary.Rekor.Url); | ||||
|         Assert.Equal(0.87, Assert.IsType<double>(primary.AdditionalProperties["confidence"]), 3); | ||||
|         Assert.False(Assert.IsType<bool>(primary.AdditionalProperties["quiet"])); | ||||
|         var metadataJson = Assert.IsType<string>(primary.AdditionalProperties["metadata"]); | ||||
|         using var metadataDocument = JsonDocument.Parse(metadataJson); | ||||
|         Assert.Equal("cached", metadataDocument.RootElement.GetProperty("note").GetString()); | ||||
|  | ||||
|         var secondary = result.Decisions["ghcr.io/api@sha256:def"]; | ||||
|         Assert.Equal("fail", secondary.PolicyVerdict); | ||||
|         Assert.False(secondary.Signed); | ||||
|         Assert.False(secondary.HasSbom); | ||||
|         Assert.Collection(secondary.Reasons, | ||||
|             item => Assert.Equal("unsigned", item), | ||||
|             item => Assert.Equal("missing sbom", item)); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubTokenClient : IStellaOpsTokenClient | ||||
|     { | ||||
|         private readonly StellaOpsTokenResult _tokenResult; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user