FUll implementation plan (first draft)
This commit is contained in:
		| @@ -1,6 +1,7 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| @@ -211,6 +212,113 @@ public sealed class CommandHandlersTests | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorInitAsync_CallsBackend() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "accepted", null, null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorInitAsync( | ||||
|                 provider, | ||||
|                 new[] { "redhat" }, | ||||
|                 resume: true, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal("init", backend.LastExcititorRoute); | ||||
|             Assert.Equal(HttpMethod.Post, backend.LastExcititorMethod); | ||||
|             var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload); | ||||
|             Assert.Equal(true, payload["resume"]); | ||||
|             var providers = Assert.IsAssignableFrom<IEnumerable<string>>(payload["providers"]!); | ||||
|             Assert.Contains("redhat", providers, StringComparer.OrdinalIgnoreCase); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorListProvidersAsync_WritesOutput() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) | ||||
|             { | ||||
|                 ProviderSummaries = new[] | ||||
|                 { | ||||
|                     new ExcititorProviderSummary("redhat", "distro", "Red Hat", "vendor", true, DateTimeOffset.UtcNow) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|             await CommandHandlers.HandleExcititorListProvidersAsync(provider, includeDisabled: false, verbose: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorVerifyAsync_FailsWithoutArguments() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorVerifyAsync(provider, null, null, null, verbose: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(1, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorVerifyAsync_AttachesAttestationFile() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempFile = new TempFile("attestation.json", Encoding.UTF8.GetBytes("{\"ok\":true}")); | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorVerifyAsync( | ||||
|                 provider, | ||||
|                 exportId: "export-123", | ||||
|                 digest: "sha256:abc", | ||||
|                 attestationPath: tempFile.Path, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal("verify", backend.LastExcititorRoute); | ||||
|             var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload); | ||||
|             Assert.Equal("export-123", payload["exportId"]); | ||||
|             Assert.Equal("sha256:abc", payload["digest"]); | ||||
|             var attestation = Assert.IsAssignableFrom<IDictionary<string, object?>>(payload["attestation"]!); | ||||
|             Assert.Equal(Path.GetFileName(tempFile.Path), attestation["fileName"]); | ||||
|             Assert.NotNull(attestation["base64"]); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Theory] | ||||
|     [InlineData(null)] | ||||
|     [InlineData("default")] | ||||
| @@ -502,33 +610,49 @@ public sealed class CommandHandlersTests | ||||
|         return new StubExecutor(new ScannerExecutionResult(0, tempResultsFile, tempMetadataFile)); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubBackendClient : IBackendOperationsClient | ||||
|     { | ||||
|         private readonly JobTriggerResult _result; | ||||
|  | ||||
|         public StubBackendClient(JobTriggerResult result) | ||||
|         { | ||||
|             _result = result; | ||||
|         } | ||||
|  | ||||
|         public string? LastJobKind { get; private set; } | ||||
|         public string? LastUploadPath { get; private set; } | ||||
|  | ||||
|         public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastUploadPath = filePath; | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastJobKind = jobKind; | ||||
|             return Task.FromResult(_result); | ||||
|         } | ||||
|     } | ||||
|     private sealed class StubBackendClient : IBackendOperationsClient | ||||
|     { | ||||
|         private readonly JobTriggerResult _jobResult; | ||||
|  | ||||
|         public StubBackendClient(JobTriggerResult result) | ||||
|         { | ||||
|             _jobResult = result; | ||||
|         } | ||||
|  | ||||
|         public string? LastJobKind { get; private set; } | ||||
|         public string? LastUploadPath { get; private set; } | ||||
|         public string? LastExcititorRoute { get; private set; } | ||||
|         public HttpMethod? LastExcititorMethod { get; private set; } | ||||
|         public object? LastExcititorPayload { get; private set; } | ||||
|         public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null); | ||||
|         public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>(); | ||||
|  | ||||
|         public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastUploadPath = filePath; | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastJobKind = jobKind; | ||||
|             return Task.FromResult(_jobResult); | ||||
|         } | ||||
|  | ||||
|         public Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastExcititorRoute = route; | ||||
|             LastExcititorMethod = method; | ||||
|             LastExcititorPayload = payload; | ||||
|             return Task.FromResult(ExcititorResult ?? new ExcititorOperationResult(true, "ok", null, null)); | ||||
|         } | ||||
|  | ||||
|         public Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken) | ||||
|             => Task.FromResult(ProviderSummaries); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubExecutor : IScannerExecutor | ||||
|     { | ||||
|   | ||||
| @@ -1,14 +1,15 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Testing; | ||||
|  | ||||
| internal sealed class TempDirectory : IDisposable | ||||
| { | ||||
| internal sealed class TempDirectory : IDisposable | ||||
| { | ||||
|     public TempDirectory() | ||||
|     { | ||||
|         Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}"); | ||||
| @@ -31,7 +32,41 @@ internal sealed class TempDirectory : IDisposable | ||||
|             // ignored | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|  | ||||
| internal sealed class TempFile : IDisposable | ||||
| { | ||||
|     public TempFile(string fileName, byte[] contents) | ||||
|     { | ||||
|         var directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-file-{Guid.NewGuid():N}"); | ||||
|         Directory.CreateDirectory(directory); | ||||
|         Path = System.IO.Path.Combine(directory, fileName); | ||||
|         File.WriteAllBytes(Path, contents); | ||||
|     } | ||||
|  | ||||
|     public string Path { get; } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             if (File.Exists(Path)) | ||||
|             { | ||||
|                 File.Delete(Path); | ||||
|             } | ||||
|  | ||||
|             var directory = System.IO.Path.GetDirectoryName(Path); | ||||
|             if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) | ||||
|             { | ||||
|                 Directory.Delete(directory, recursive: true); | ||||
|             } | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             // ignored intentionally | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class StubHttpMessageHandler : HttpMessageHandler | ||||
| { | ||||
|   | ||||
| @@ -24,6 +24,7 @@ internal static class CommandFactory | ||||
|         root.Add(BuildScannerCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildConfigCommand(options)); | ||||
|  | ||||
| @@ -220,10 +221,191 @@ internal static class CommandFactory | ||||
|  | ||||
|         db.Add(fetch); | ||||
|         db.Add(merge); | ||||
|         db.Add(export); | ||||
|         db.Add(export); | ||||
|         return db; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows."); | ||||
|  | ||||
|         var init = new Command("init", "Initialize Excititor ingest state."); | ||||
|         var initProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to initialize.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var resumeOption = new Option<bool>("--resume") | ||||
|         { | ||||
|             Description = "Resume ingest from the last persisted checkpoint instead of starting fresh." | ||||
|         }; | ||||
|         init.Add(initProviders); | ||||
|         init.Add(resumeOption); | ||||
|         init.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>(); | ||||
|             var resume = parseResult.GetValue(resumeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var pull = new Command("pull", "Trigger Excititor ingest for configured providers."); | ||||
|         var pullProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to ingest.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var sinceOption = new Option<DateTimeOffset?>("--since") | ||||
|         { | ||||
|             Description = "Optional ISO-8601 timestamp to begin the ingest window." | ||||
|         }; | ||||
|         var windowOption = new Option<TimeSpan?>("--window") | ||||
|         { | ||||
|             Description = "Optional window duration (e.g. 24:00:00)." | ||||
|         }; | ||||
|         var forceOption = new Option<bool>("--force") | ||||
|         { | ||||
|             Description = "Force ingestion even if the backend reports no pending work." | ||||
|         }; | ||||
|         pull.Add(pullProviders); | ||||
|         pull.Add(sinceOption); | ||||
|         pull.Add(windowOption); | ||||
|         pull.Add(forceOption); | ||||
|         pull.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>(); | ||||
|             var since = parseResult.GetValue(sinceOption); | ||||
|             var window = parseResult.GetValue(windowOption); | ||||
|             var force = parseResult.GetValue(forceOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token."); | ||||
|         var resumeProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to resume.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var checkpointOption = new Option<string?>("--checkpoint") | ||||
|         { | ||||
|             Description = "Optional checkpoint identifier to resume from." | ||||
|         }; | ||||
|         resume.Add(resumeProviders); | ||||
|         resume.Add(checkpointOption); | ||||
|         resume.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>(); | ||||
|             var checkpoint = parseResult.GetValue(checkpointOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var list = new Command("list-providers", "List Excititor providers and their ingest status."); | ||||
|         var includeDisabledOption = new Option<bool>("--include-disabled") | ||||
|         { | ||||
|             Description = "Include disabled providers in the listing." | ||||
|         }; | ||||
|         list.Add(includeDisabledOption); | ||||
|         list.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var includeDisabled = parseResult.GetValue(includeDisabledOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var export = new Command("export", "Trigger Excititor export generation."); | ||||
|         var formatOption = new Option<string>("--format") | ||||
|         { | ||||
|             Description = "Export format (e.g. openvex, json)." | ||||
|         }; | ||||
|         var exportDeltaOption = new Option<bool>("--delta") | ||||
|         { | ||||
|             Description = "Request a delta export when supported." | ||||
|         }; | ||||
|         var exportScopeOption = new Option<string?>("--scope") | ||||
|         { | ||||
|             Description = "Optional policy scope or tenant identifier." | ||||
|         }; | ||||
|         var exportSinceOption = new Option<DateTimeOffset?>("--since") | ||||
|         { | ||||
|             Description = "Optional ISO-8601 timestamp to restrict export contents." | ||||
|         }; | ||||
|         var exportProviderOption = new Option<string?>("--provider") | ||||
|         { | ||||
|             Description = "Optional provider identifier when requesting targeted exports." | ||||
|         }; | ||||
|         export.Add(formatOption); | ||||
|         export.Add(exportDeltaOption); | ||||
|         export.Add(exportScopeOption); | ||||
|         export.Add(exportSinceOption); | ||||
|         export.Add(exportProviderOption); | ||||
|         export.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var format = parseResult.GetValue(formatOption) ?? "openvex"; | ||||
|             var delta = parseResult.GetValue(exportDeltaOption); | ||||
|             var scope = parseResult.GetValue(exportScopeOption); | ||||
|             var since = parseResult.GetValue(exportSinceOption); | ||||
|             var provider = parseResult.GetValue(exportProviderOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var verify = new Command("verify", "Verify Excititor exports or attestations."); | ||||
|         var exportIdOption = new Option<string?>("--export-id") | ||||
|         { | ||||
|             Description = "Export identifier to verify." | ||||
|         }; | ||||
|         var digestOption = new Option<string?>("--digest") | ||||
|         { | ||||
|             Description = "Expected digest for the export or attestation." | ||||
|         }; | ||||
|         var attestationOption = new Option<string?>("--attestation") | ||||
|         { | ||||
|             Description = "Path to a local attestation file to verify (base64 content will be uploaded)." | ||||
|         }; | ||||
|         verify.Add(exportIdOption); | ||||
|         verify.Add(digestOption); | ||||
|         verify.Add(attestationOption); | ||||
|         verify.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var exportId = parseResult.GetValue(exportIdOption); | ||||
|             var digest = parseResult.GetValue(digestOption); | ||||
|             var attestation = parseResult.GetValue(attestationOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories."); | ||||
|         var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to reconcile.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var maxAgeOption = new Option<TimeSpan?>("--max-age") | ||||
|         { | ||||
|             Description = "Optional maximum age window (e.g. 7.00:00:00)." | ||||
|         }; | ||||
|         reconcile.Add(reconcileProviders); | ||||
|         reconcile.Add(maxAgeOption); | ||||
|         reconcile.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>(); | ||||
|             var maxAge = parseResult.GetValue(maxAgeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         excititor.Add(init); | ||||
|         excititor.Add(pull); | ||||
|         excititor.Add(resume); | ||||
|         excititor.Add(list); | ||||
|         excititor.Add(export); | ||||
|         excititor.Add(verify); | ||||
|         excititor.Add(reconcile); | ||||
|         return excititor; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var auth = new Command("auth", "Manage authentication with StellaOps Authority."); | ||||
|   | ||||
| @@ -4,6 +4,8 @@ using System.Collections.Generic; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net.Http; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using System.Text; | ||||
| @@ -340,6 +342,310 @@ internal static class CommandHandlers | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static Task HandleExcititorInitAsync( | ||||
|         IServiceProvider services, | ||||
|         IReadOnlyList<string> providers, | ||||
|         bool resume, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var normalizedProviders = NormalizeProviders(providers); | ||||
|         var payload = new Dictionary<string, object?>(StringComparer.Ordinal); | ||||
|         if (normalizedProviders.Count > 0) | ||||
|         { | ||||
|             payload["providers"] = normalizedProviders; | ||||
|         } | ||||
|         if (resume) | ||||
|         { | ||||
|             payload["resume"] = true; | ||||
|         } | ||||
|  | ||||
|         return ExecuteExcititorCommandAsync( | ||||
|             services, | ||||
|             commandName: "excititor init", | ||||
|             verbose, | ||||
|             new Dictionary<string, object?> | ||||
|             { | ||||
|                 ["providers"] = normalizedProviders.Count, | ||||
|                 ["resume"] = resume | ||||
|             }, | ||||
|             client => client.ExecuteExcititorOperationAsync("init", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     public static Task HandleExcititorPullAsync( | ||||
|         IServiceProvider services, | ||||
|         IReadOnlyList<string> providers, | ||||
|         DateTimeOffset? since, | ||||
|         TimeSpan? window, | ||||
|         bool force, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var normalizedProviders = NormalizeProviders(providers); | ||||
|         var payload = new Dictionary<string, object?>(StringComparer.Ordinal); | ||||
|         if (normalizedProviders.Count > 0) | ||||
|         { | ||||
|             payload["providers"] = normalizedProviders; | ||||
|         } | ||||
|         if (since.HasValue) | ||||
|         { | ||||
|             payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); | ||||
|         } | ||||
|         if (window.HasValue) | ||||
|         { | ||||
|             payload["window"] = window.Value.ToString("c", CultureInfo.InvariantCulture); | ||||
|         } | ||||
|         if (force) | ||||
|         { | ||||
|             payload["force"] = true; | ||||
|         } | ||||
|  | ||||
|         return ExecuteExcititorCommandAsync( | ||||
|             services, | ||||
|             commandName: "excititor pull", | ||||
|             verbose, | ||||
|             new Dictionary<string, object?> | ||||
|             { | ||||
|                 ["providers"] = normalizedProviders.Count, | ||||
|                 ["force"] = force, | ||||
|                 ["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), | ||||
|                 ["window"] = window?.ToString("c", CultureInfo.InvariantCulture) | ||||
|             }, | ||||
|             client => client.ExecuteExcititorOperationAsync("ingest/run", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     public static Task HandleExcititorResumeAsync( | ||||
|         IServiceProvider services, | ||||
|         IReadOnlyList<string> providers, | ||||
|         string? checkpoint, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var normalizedProviders = NormalizeProviders(providers); | ||||
|         var payload = new Dictionary<string, object?>(StringComparer.Ordinal); | ||||
|         if (normalizedProviders.Count > 0) | ||||
|         { | ||||
|             payload["providers"] = normalizedProviders; | ||||
|         } | ||||
|         if (!string.IsNullOrWhiteSpace(checkpoint)) | ||||
|         { | ||||
|             payload["checkpoint"] = checkpoint.Trim(); | ||||
|         } | ||||
|  | ||||
|         return ExecuteExcititorCommandAsync( | ||||
|             services, | ||||
|             commandName: "excititor resume", | ||||
|             verbose, | ||||
|             new Dictionary<string, object?> | ||||
|             { | ||||
|                 ["providers"] = normalizedProviders.Count, | ||||
|                 ["checkpoint"] = checkpoint | ||||
|             }, | ||||
|             client => client.ExecuteExcititorOperationAsync("ingest/resume", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     public static async Task HandleExcititorListProvidersAsync( | ||||
|         IServiceProvider services, | ||||
|         bool includeDisabled, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         await using var scope = services.CreateAsyncScope(); | ||||
|         var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>(); | ||||
|         var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-list-providers"); | ||||
|         var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>(); | ||||
|         var previousLevel = verbosity.MinimumLevel; | ||||
|         verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; | ||||
|         using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.list-providers", ActivityKind.Client); | ||||
|         activity?.SetTag("stellaops.cli.command", "excititor list-providers"); | ||||
|         activity?.SetTag("stellaops.cli.include_disabled", includeDisabled); | ||||
|         using var duration = CliMetrics.MeasureCommandDuration("excititor list-providers"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var providers = await client.GetExcititorProvidersAsync(includeDisabled, cancellationToken).ConfigureAwait(false); | ||||
|             Environment.ExitCode = 0; | ||||
|             logger.LogInformation("Providers returned: {Count}", providers.Count); | ||||
|  | ||||
|             if (providers.Count > 0) | ||||
|             { | ||||
|                 if (AnsiConsole.Profile.Capabilities.Interactive) | ||||
|                 { | ||||
|                     var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Trust", "Enabled", "Last Ingested"); | ||||
|                     foreach (var provider in providers) | ||||
|                     { | ||||
|                         table.AddRow( | ||||
|                             provider.Id, | ||||
|                             provider.Kind, | ||||
|                             string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier, | ||||
|                             provider.Enabled ? "yes" : "no", | ||||
|                             provider.LastIngestedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown"); | ||||
|                     } | ||||
|  | ||||
|                     AnsiConsole.Write(table); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     foreach (var provider in providers) | ||||
|                     { | ||||
|                         logger.LogInformation("{ProviderId} [{Kind}] Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}", | ||||
|                             provider.Id, | ||||
|                             provider.Kind, | ||||
|                             provider.Enabled ? "yes" : "no", | ||||
|                             string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier, | ||||
|                             provider.LastIngestedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Failed to list Excititor providers."); | ||||
|             Environment.ExitCode = 1; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             verbosity.MinimumLevel = previousLevel; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static Task HandleExcititorExportAsync( | ||||
|         IServiceProvider services, | ||||
|         string format, | ||||
|         bool delta, | ||||
|         string? scope, | ||||
|         DateTimeOffset? since, | ||||
|         string? provider, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var payload = new Dictionary<string, object?>(StringComparer.Ordinal) | ||||
|         { | ||||
|             ["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(), | ||||
|             ["delta"] = delta | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(scope)) | ||||
|         { | ||||
|             payload["scope"] = scope.Trim(); | ||||
|         } | ||||
|         if (since.HasValue) | ||||
|         { | ||||
|             payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); | ||||
|         } | ||||
|         if (!string.IsNullOrWhiteSpace(provider)) | ||||
|         { | ||||
|             payload["provider"] = provider.Trim(); | ||||
|         } | ||||
|  | ||||
|         return ExecuteExcititorCommandAsync( | ||||
|             services, | ||||
|             commandName: "excititor export", | ||||
|             verbose, | ||||
|             new Dictionary<string, object?> | ||||
|             { | ||||
|                 ["format"] = payload["format"], | ||||
|                 ["delta"] = delta, | ||||
|                 ["scope"] = scope, | ||||
|                 ["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), | ||||
|                 ["provider"] = provider | ||||
|             }, | ||||
|             client => client.ExecuteExcititorOperationAsync("export", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     public static Task HandleExcititorVerifyAsync( | ||||
|         IServiceProvider services, | ||||
|         string? exportId, | ||||
|         string? digest, | ||||
|         string? attestationPath, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(exportId) && string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(attestationPath)) | ||||
|         { | ||||
|             var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify"); | ||||
|             logger.LogError("At least one of --export-id, --digest, or --attestation must be provided."); | ||||
|             Environment.ExitCode = 1; | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         var payload = new Dictionary<string, object?>(StringComparer.Ordinal); | ||||
|         if (!string.IsNullOrWhiteSpace(exportId)) | ||||
|         { | ||||
|             payload["exportId"] = exportId.Trim(); | ||||
|         } | ||||
|         if (!string.IsNullOrWhiteSpace(digest)) | ||||
|         { | ||||
|             payload["digest"] = digest.Trim(); | ||||
|         } | ||||
|         if (!string.IsNullOrWhiteSpace(attestationPath)) | ||||
|         { | ||||
|             var fullPath = Path.GetFullPath(attestationPath); | ||||
|             if (!File.Exists(fullPath)) | ||||
|             { | ||||
|                 var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify"); | ||||
|                 logger.LogError("Attestation file not found at {Path}.", fullPath); | ||||
|                 Environment.ExitCode = 1; | ||||
|                 return Task.CompletedTask; | ||||
|             } | ||||
|  | ||||
|             var bytes = File.ReadAllBytes(fullPath); | ||||
|             payload["attestation"] = new Dictionary<string, object?>(StringComparer.Ordinal) | ||||
|             { | ||||
|                 ["fileName"] = Path.GetFileName(fullPath), | ||||
|                 ["base64"] = Convert.ToBase64String(bytes) | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         return ExecuteExcititorCommandAsync( | ||||
|             services, | ||||
|             commandName: "excititor verify", | ||||
|             verbose, | ||||
|             new Dictionary<string, object?> | ||||
|             { | ||||
|                 ["export_id"] = exportId, | ||||
|                 ["digest"] = digest, | ||||
|                 ["attestation_path"] = attestationPath | ||||
|             }, | ||||
|             client => client.ExecuteExcititorOperationAsync("verify", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     public static Task HandleExcititorReconcileAsync( | ||||
|         IServiceProvider services, | ||||
|         IReadOnlyList<string> providers, | ||||
|         TimeSpan? maxAge, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var normalizedProviders = NormalizeProviders(providers); | ||||
|         var payload = new Dictionary<string, object?>(StringComparer.Ordinal); | ||||
|         if (normalizedProviders.Count > 0) | ||||
|         { | ||||
|             payload["providers"] = normalizedProviders; | ||||
|         } | ||||
|         if (maxAge.HasValue) | ||||
|         { | ||||
|             payload["maxAge"] = maxAge.Value.ToString("c", CultureInfo.InvariantCulture); | ||||
|         } | ||||
|  | ||||
|         return ExecuteExcititorCommandAsync( | ||||
|             services, | ||||
|             commandName: "excititor reconcile", | ||||
|             verbose, | ||||
|             new Dictionary<string, object?> | ||||
|             { | ||||
|                 ["providers"] = normalizedProviders.Count, | ||||
|                 ["max_age"] = maxAge?.ToString("c", CultureInfo.InvariantCulture) | ||||
|             }, | ||||
|             client => client.ExecuteExcititorOperationAsync("reconcile", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     public static async Task HandleAuthLoginAsync( | ||||
|         IServiceProvider services, | ||||
|         StellaOpsCliOptions options, | ||||
| @@ -1111,12 +1417,109 @@ internal static class CommandHandlers | ||||
|         "jti" | ||||
|     }; | ||||
|  | ||||
|     private static async Task ExecuteExcititorCommandAsync( | ||||
|         IServiceProvider services, | ||||
|         string commandName, | ||||
|         bool verbose, | ||||
|         IDictionary<string, object?>? activityTags, | ||||
|         Func<IBackendOperationsClient, Task<ExcititorOperationResult>> operation, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         await using var scope = services.CreateAsyncScope(); | ||||
|         var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>(); | ||||
|         var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(commandName.Replace(' ', '-')); | ||||
|         var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>(); | ||||
|         var previousLevel = verbosity.MinimumLevel; | ||||
|         verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; | ||||
|         using var activity = CliActivitySource.Instance.StartActivity($"cli.{commandName.Replace(' ', '.')}" , ActivityKind.Client); | ||||
|         activity?.SetTag("stellaops.cli.command", commandName); | ||||
|         if (activityTags is not null) | ||||
|         { | ||||
|             foreach (var tag in activityTags) | ||||
|             { | ||||
|                 activity?.SetTag(tag.Key, tag.Value); | ||||
|             } | ||||
|         } | ||||
|         using var duration = CliMetrics.MeasureCommandDuration(commandName); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var result = await operation(client).ConfigureAwait(false); | ||||
|             if (result.Success) | ||||
|             { | ||||
|                 if (!string.IsNullOrWhiteSpace(result.Message)) | ||||
|                 { | ||||
|                     logger.LogInformation(result.Message); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     logger.LogInformation("Operation completed successfully."); | ||||
|                 } | ||||
|  | ||||
|                 if (!string.IsNullOrWhiteSpace(result.Location)) | ||||
|                 { | ||||
|                     logger.LogInformation("Location: {Location}", result.Location); | ||||
|                 } | ||||
|  | ||||
|                 if (result.Payload is JsonElement payload && payload.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null) | ||||
|                 { | ||||
|                     logger.LogDebug("Response payload: {Payload}", payload.ToString()); | ||||
|                 } | ||||
|  | ||||
|                 Environment.ExitCode = 0; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Operation failed." : result.Message); | ||||
|                 Environment.ExitCode = 1; | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Excititor operation failed."); | ||||
|             Environment.ExitCode = 1; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             verbosity.MinimumLevel = previousLevel; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers) | ||||
|     { | ||||
|         if (providers is null || providers.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         var list = new List<string>(); | ||||
|         foreach (var provider in providers) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(provider)) | ||||
|             { | ||||
|                 list.Add(provider.Trim()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return list.Count == 0 ? Array.Empty<string>() : list; | ||||
|     } | ||||
|  | ||||
|     private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source) | ||||
|     { | ||||
|         foreach (var key in source.Where(kvp => kvp.Value is null).Select(kvp => kvp.Key).ToList()) | ||||
|         { | ||||
|             source.Remove(key); | ||||
|         } | ||||
|  | ||||
|         return source; | ||||
|     } | ||||
|  | ||||
|     private static async Task TriggerJobAsync( | ||||
|         IBackendOperationsClient client, | ||||
|         ILogger logger, | ||||
|         string jobKind, | ||||
|         IDictionary<string, object?> parameters, | ||||
|         CancellationToken cancellationToken) | ||||
|         IDictionary<string, object?> parameters, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false); | ||||
|         if (result.Success) | ||||
|   | ||||
| @@ -231,9 +231,99 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient | ||||
|             return new JobTriggerResult(true, "Accepted", location, run); | ||||
|         } | ||||
|  | ||||
|         var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); | ||||
|         return new JobTriggerResult(false, failureMessage, null, null); | ||||
|     } | ||||
|         var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); | ||||
|         return new JobTriggerResult(false, failureMessage, null, null); | ||||
|     } | ||||
|  | ||||
|     public async Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken) | ||||
|     { | ||||
|         EnsureBackendConfigured(); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(route)) | ||||
|         { | ||||
|             throw new ArgumentException("Route must be provided.", nameof(route)); | ||||
|         } | ||||
|  | ||||
|         var relative = route.TrimStart('/'); | ||||
|         using var request = CreateRequest(method, $"excititor/{relative}"); | ||||
|  | ||||
|         if (payload is not null && method != HttpMethod.Get && method != HttpMethod.Delete) | ||||
|         { | ||||
|             request.Content = JsonContent.Create(payload, options: SerializerOptions); | ||||
|         } | ||||
|  | ||||
|         await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (response.IsSuccessStatusCode) | ||||
|         { | ||||
|             var (message, payloadElement) = await ExtractExcititorResponseAsync(response, cancellationToken).ConfigureAwait(false); | ||||
|             var location = response.Headers.Location?.ToString(); | ||||
|             return new ExcititorOperationResult(true, message, location, payloadElement); | ||||
|         } | ||||
|  | ||||
|         var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); | ||||
|         return new ExcititorOperationResult(false, failure, null, null); | ||||
|     } | ||||
|  | ||||
|     public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken) | ||||
|     { | ||||
|         EnsureBackendConfigured(); | ||||
|  | ||||
|         var query = includeDisabled ? "?includeDisabled=true" : string.Empty; | ||||
|         using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}"); | ||||
|         await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); | ||||
|             throw new InvalidOperationException(failure); | ||||
|         } | ||||
|  | ||||
|         if (response.Content is null || response.Content.Headers.ContentLength is 0) | ||||
|         { | ||||
|             return Array.Empty<ExcititorProviderSummary>(); | ||||
|         } | ||||
|  | ||||
|         await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         if (stream is null || stream.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<ExcititorProviderSummary>(); | ||||
|         } | ||||
|  | ||||
|         using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|         var root = document.RootElement; | ||||
|         if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("providers", out var providersProperty)) | ||||
|         { | ||||
|             root = providersProperty; | ||||
|         } | ||||
|  | ||||
|         if (root.ValueKind != JsonValueKind.Array) | ||||
|         { | ||||
|             return Array.Empty<ExcititorProviderSummary>(); | ||||
|         } | ||||
|  | ||||
|         var list = new List<ExcititorProviderSummary>(); | ||||
|         foreach (var item in root.EnumerateArray()) | ||||
|         { | ||||
|             var id = GetStringProperty(item, "id") ?? string.Empty; | ||||
|             if (string.IsNullOrWhiteSpace(id)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var kind = GetStringProperty(item, "kind") ?? "unknown"; | ||||
|             var displayName = GetStringProperty(item, "displayName") ?? id; | ||||
|             var trustTier = GetStringProperty(item, "trustTier") ?? string.Empty; | ||||
|             var enabled = GetBooleanProperty(item, "enabled", defaultValue: true); | ||||
|             var lastIngested = GetDateTimeOffsetProperty(item, "lastIngestedAt"); | ||||
|  | ||||
|             list.Add(new ExcititorProviderSummary(id, kind, displayName, trustTier, enabled, lastIngested)); | ||||
|         } | ||||
|  | ||||
|         return list; | ||||
|     } | ||||
|  | ||||
|     private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri) | ||||
|     { | ||||
| @@ -328,10 +418,114 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<(string Message, JsonElement? Payload)> ExtractExcititorResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (response.Content is null || response.Content.Headers.ContentLength is 0) | ||||
|         { | ||||
|             return ($"HTTP {(int)response.StatusCode}", null); | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|             if (stream is null || stream.Length == 0) | ||||
|             { | ||||
|                 return ($"HTTP {(int)response.StatusCode}", null); | ||||
|             } | ||||
|  | ||||
|             using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|             var root = document.RootElement.Clone(); | ||||
|             string? message = null; | ||||
|             if (root.ValueKind == JsonValueKind.Object) | ||||
|             { | ||||
|                 message = GetStringProperty(root, "message") ?? GetStringProperty(root, "status"); | ||||
|             } | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(message)) | ||||
|             { | ||||
|                 message = root.ValueKind == JsonValueKind.Object || root.ValueKind == JsonValueKind.Array | ||||
|                     ? root.ToString() | ||||
|                     : root.GetRawText(); | ||||
|             } | ||||
|  | ||||
|             return (message ?? $"HTTP {(int)response.StatusCode}", root); | ||||
|         } | ||||
|         catch (JsonException) | ||||
|         { | ||||
|             var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|             return (string.IsNullOrWhiteSpace(text) ? $"HTTP {(int)response.StatusCode}" : text.Trim(), null); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property) | ||||
|     { | ||||
|         if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (element.ValueKind == JsonValueKind.Object) | ||||
|         { | ||||
|             foreach (var candidate in element.EnumerateObject()) | ||||
|             { | ||||
|                 if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     property = candidate.Value; | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         property = default; | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static string? GetStringProperty(JsonElement element, string propertyName) | ||||
|     { | ||||
|         if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) | ||||
|         { | ||||
|             if (property.ValueKind == JsonValueKind.String) | ||||
|             { | ||||
|                 return property.GetString(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static bool GetBooleanProperty(JsonElement element, string propertyName, bool defaultValue) | ||||
|     { | ||||
|         if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) | ||||
|         { | ||||
|             return property.ValueKind switch | ||||
|             { | ||||
|                 JsonValueKind.True => true, | ||||
|                 JsonValueKind.False => false, | ||||
|                 JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed, | ||||
|                 _ => defaultValue | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         return defaultValue; | ||||
|     } | ||||
|  | ||||
|     private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName) | ||||
|     { | ||||
|         if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String) | ||||
|         { | ||||
|             if (DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) | ||||
|             { | ||||
|                 return parsed.ToUniversalTime(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private void EnsureBackendConfigured() | ||||
|     { | ||||
|         if (_httpClient.BaseAddress is null) | ||||
|         { | ||||
|         { | ||||
|             throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings."); | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Services.Models; | ||||
| using System.Collections.Generic; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Services.Models; | ||||
|  | ||||
| namespace StellaOps.Cli.Services; | ||||
|  | ||||
| internal interface IBackendOperationsClient | ||||
| { | ||||
|     Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken); | ||||
|  | ||||
|     Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken); | ||||
|  | ||||
|     Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken); | ||||
| } | ||||
|     Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken); | ||||
|  | ||||
|     Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken); | ||||
|  | ||||
|     Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken); | ||||
|  | ||||
|     Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken); | ||||
|  | ||||
|     Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken); | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,9 @@ | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Cli.Services.Models; | ||||
|  | ||||
| internal sealed record ExcititorOperationResult( | ||||
|     bool Success, | ||||
|     string Message, | ||||
|     string? Location, | ||||
|     JsonElement? Payload); | ||||
| @@ -0,0 +1,11 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Cli.Services.Models; | ||||
|  | ||||
| internal sealed record ExcititorProviderSummary( | ||||
|     string Id, | ||||
|     string Kind, | ||||
|     string DisplayName, | ||||
|     string TrustTier, | ||||
|     bool Enabled, | ||||
|     DateTimeOffset? LastIngestedAt); | ||||
| @@ -14,6 +14,9 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md | ||||
| |Expose auth client resilience settings|DevEx/CLI|Auth libraries LIB5|**DONE (2025-10-10)** – CLI options now bind resilience knobs, `AddStellaOpsAuthClient` honours them, and tests cover env overrides.| | ||||
| |Document advanced Authority tuning|Docs/CLI|Expose auth client resilience settings|**DONE (2025-10-10)** – docs/09 and docs/10 describe retry/offline settings with env examples and point to the integration guide.| | ||||
| |Surface password policy diagnostics in CLI output|DevEx/CLI, Security Guild|AUTHSEC-CRYPTO-02-004|**DONE (2025-10-15)** – CLI startup runs the Authority plug-in analyzer, logs weakened password policy warnings with manifest paths, added unit tests (`dotnet test src/StellaOps.Cli.Tests`) and updated docs/09 with remediation guidance.| | ||||
| |EXCITITOR-CLI-01-001 – Add `excititor` command group|DevEx/CLI|EXCITITOR-WEB-01-001|TODO – Introduce `excititor` verb hierarchy (init/pull/resume/list-providers/export/verify/reconcile) forwarding to WebService with token auth and consistent exit codes.| | ||||
| |EXCITITOR-CLI-01-001 – Add `excititor` command group|DevEx/CLI|EXCITITOR-WEB-01-001|DONE (2025-10-18) – Introduced `excititor` verbs (init/pull/resume/list-providers/export/verify/reconcile) with token-auth backend calls, provenance-friendly logging, and regression coverage.| | ||||
| |EXCITITOR-CLI-01-002 – Export download & attestation UX|DevEx/CLI|EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001|TODO – Display export metadata (sha256, size, Rekor link), support optional artifact download path, and handle cache hits gracefully.| | ||||
| |EXCITITOR-CLI-01-003 – CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|TODO – Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.| | ||||
| |CLI-RUNTIME-13-005 – Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|TODO – Add `runtime policy test` and related verbs to query `/policy/runtime`, display verdicts/TTL/reasons, and support batch inputs.| | ||||
| |CLI-OFFLINE-13-006 – Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|TODO – Implement `offline kit pull/import/status` commands with integrity checks, resumable downloads, and doc updates.| | ||||
| |CLI-PLUGIN-13-007 – Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO – Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).| | ||||
|   | ||||
| @@ -0,0 +1,7 @@ | ||||
| # StellaOps Mirror Connector Task Board (Sprint 8) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | FEEDCONN-STELLA-08-001 | TODO | BE-Conn-Stella | CONCELIER-EXPORT-08-201 | Implement Concelier mirror fetcher hitting `https://<domain>.stella-ops.org/concelier/exports/index.json`, verify signatures/digests, and persist raw documents with provenance. | Fetch job downloads mirror manifest, verifies digest/signature, stores raw docs with tests covering happy-path + tampered manifest. | | ||||
| | FEEDCONN-STELLA-08-002 | TODO | BE-Conn-Stella | FEEDCONN-STELLA-08-001 | Map mirror payloads into canonical advisory DTOs with provenance referencing mirror domain + original source metadata. | Mapper produces advisories/aliases/affected with mirror provenance; fixtures assert canonical parity with upstream JSON exporters. | | ||||
| | FEEDCONN-STELLA-08-003 | TODO | BE-Conn-Stella | FEEDCONN-STELLA-08-002 | Add incremental cursor + resume support (per-export fingerprint) and document configuration for downstream Concelier instances. | Connector resumes from last export, handles deletion/delta cases, docs updated with config sample; integration test covers resume + new export scenario. | | ||||
| @@ -18,3 +18,4 @@ | ||||
| |Reference normalization & freshness instrumentation cleanup|BE-Core, QA|Models|DONE (2025-10-15) – reference keys normalized, freshness overrides applied to union fields, and new tests assert decision logging.| | ||||
| |FEEDCORE-ENGINE-07-001 – Advisory event log & asOf queries|Team Core Engine & Storage Analytics|FEEDSTORAGE-DATA-07-001|TODO – Introduce immutable advisory statement events, expose `asOf` query surface for merge/export pipelines, and document determinism guarantees for replay.| | ||||
| |FEEDCORE-ENGINE-07-002 – Noise prior computation service|Team Core Engine & Data Science|FEEDCORE-ENGINE-07-001|TODO – Build rule-based learner capturing false-positive priors per package/env, persist summaries, and expose APIs for Excititor/scan suppressors with reproducible statistics.| | ||||
| |FEEDCORE-ENGINE-07-003 – Unknown state ledger & confidence seeding|Team Core Engine & Storage Analytics|FEEDCORE-ENGINE-07-001|TODO – Persist `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with initial confidence bands, expose query surface for Policy, and add fixtures validating canonical serialization.| | ||||
|   | ||||
| @@ -10,3 +10,4 @@ | ||||
| |Stream advisories during export|BE-Export|Storage.Mongo|DONE – exporter + streaming-only test ensures single enumeration and per-file digest capture.| | ||||
| |Emit export manifest with digest metadata|BE-Export|Exporters|DONE – manifest now includes per-file digests/sizes alongside tree digest.| | ||||
| |Surface new advisory fields (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – JSON exporter validated with new fixtures ensuring description/CWEs/canonical metric are preserved in outputs; `dotnet test src/StellaOps.Concelier.Exporter.Json.Tests` run 2025-10-15 for regression coverage.| | ||||
| |CONCELIER-EXPORT-08-201 – Mirror bundle + domain manifest|Team Concelier Export|FEEDCORE-ENGINE-07-001|TODO – Produce per-domain aggregate bundles (JSON + manifest) with deterministic digests, include upstream source metadata, and publish index consumed by mirror endpoints/tests.|  | ||||
|   | ||||
| @@ -12,3 +12,4 @@ | ||||
| |Streamed package building to avoid large copies|BE-Export|Exporters|DONE – metadata/config now reuse backing arrays and OCI writer streams directly without double buffering.| | ||||
| |Plan incremental/delta exports|BE-Export|Exporters|DONE – state captures per-file manifests, planner schedules delta vs full resets, layer reuse smoke test verifies OCI reuse, and operator guide documents the validation flow.| | ||||
| |Advisory schema parity export (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – exporter/test fixtures updated to handle description/CWEs/canonical metric fields during Trivy DB packaging; `dotnet test src/StellaOps.Concelier.Exporter.TrivyDb.Tests` re-run 2025-10-15 to confirm coverage.| | ||||
| |CONCELIER-EXPORT-08-202 – Mirror-ready Trivy DB bundles|Team Concelier Export|CONCELIER-EXPORT-08-201|TODO – Generate domain-specific Trivy DB archives + metadata manifest, ensure deterministic digests, and document sync process for downstream Concelier nodes.|  | ||||
|   | ||||
| @@ -22,3 +22,4 @@ | ||||
| |Update Concelier operator guide for enforcement cutoff|Docs/Concelier|FSR1 rollout|**DONE (2025-10-12)** – Installation guide emphasises disabling `allowAnonymousFallback` before 2025-12-31 UTC and connects audit signals to the rollout checklist.|  | ||||
| |Rename plugin drop directory to namespaced path|BE-Base|Plugins|**TODO** – Point Concelier source/exporter build outputs to `StellaOps.Concelier.PluginBinaries`, update PluginHost defaults/search patterns to match, ensure Offline Kit packaging/tests expect the new folder, and document migration guidance for operators.|  | ||||
| |Authority resilience adoption|Concelier WebService, Docs|Plumb Authority client resilience options|**BLOCKED (2025-10-10)** – Roll out retry/offline knobs to deployment docs and confirm CLI parity once LIB5 lands; unblock after resilience options wired and tested.|  | ||||
| |CONCELIER-WEB-08-201 – Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|TODO – Add domain-scoped mirror configuration (`*.stella-ops.org`), expose signed export index/download APIs with quota and auth, and document sync workflow for downstream Concelier instances.|  | ||||
|   | ||||
| @@ -0,0 +1,76 @@ | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
| using StellaOps.Excititor.Core; | ||||
| using System.Collections.Generic; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Configuration; | ||||
|  | ||||
| public sealed class OciOpenVexAttestationConnectorOptionsValidatorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Validate_WithValidConfiguration_Succeeds() | ||||
|     { | ||||
|         var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData> | ||||
|         { | ||||
|             ["/offline/registry.example.com/repo/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty), | ||||
|         }); | ||||
|  | ||||
|         var validator = new OciOpenVexAttestationConnectorOptionsValidator(fileSystem); | ||||
|         var options = new OciOpenVexAttestationConnectorOptions | ||||
|         { | ||||
|             AllowHttpRegistries = true, | ||||
|         }; | ||||
|  | ||||
|         options.Images.Add(new OciImageSubscriptionOptions | ||||
|         { | ||||
|             Reference = "registry.example.com/repo/image:latest", | ||||
|             OfflineBundlePath = "/offline/registry.example.com/repo/latest/openvex-attestations.tgz", | ||||
|         }); | ||||
|  | ||||
|         options.Registry.Username = "user"; | ||||
|         options.Registry.Password = "pass"; | ||||
|  | ||||
|         options.Cosign.Mode = CosignCredentialMode.None; | ||||
|  | ||||
|         var errors = new List<string>(); | ||||
|  | ||||
|         validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors); | ||||
|  | ||||
|         errors.Should().BeEmpty(); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Validate_WhenImagesMissing_AddsError() | ||||
|     { | ||||
|         var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem()); | ||||
|         var options = new OciOpenVexAttestationConnectorOptions(); | ||||
|  | ||||
|         var errors = new List<string>(); | ||||
|  | ||||
|         validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors); | ||||
|  | ||||
|         errors.Should().ContainSingle().Which.Should().Contain("At least one OCI image reference must be configured."); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Validate_WhenDigestMalformed_AddsError() | ||||
|     { | ||||
|         var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem()); | ||||
|         var options = new OciOpenVexAttestationConnectorOptions(); | ||||
|         options.Images.Add(new OciImageSubscriptionOptions | ||||
|         { | ||||
|             Reference = "registry.test/repo/image@sha256:not-a-digest", | ||||
|         }); | ||||
|  | ||||
|         var errors = new List<string>(); | ||||
|  | ||||
|         validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors); | ||||
|  | ||||
|         errors.Should().ContainSingle(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,213 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch; | ||||
| using StellaOps.Excititor.Core; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector; | ||||
|  | ||||
| public sealed class OciOpenVexAttestationConnectorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument() | ||||
|     { | ||||
|         var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData> | ||||
|         { | ||||
|             ["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"), | ||||
|         }); | ||||
|  | ||||
|         using var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var httpClient = new HttpClient(new StubHttpMessageHandler()) | ||||
|         { | ||||
|             BaseAddress = new System.Uri("https://registry.example.com/") | ||||
|         }; | ||||
|  | ||||
|         var httpFactory = new SingleClientHttpClientFactory(httpClient); | ||||
|         var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance); | ||||
|         var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance); | ||||
|  | ||||
|         var connector = new OciOpenVexAttestationConnector( | ||||
|             discovery, | ||||
|             fetcher, | ||||
|             NullLogger<OciOpenVexAttestationConnector>.Instance, | ||||
|             TimeProvider.System); | ||||
|  | ||||
|         var settingsValues = ImmutableDictionary<string, string>.Empty | ||||
|             .Add("Images:0:Reference", "registry.example.com/repo/image:latest") | ||||
|             .Add("Images:0:OfflineBundlePath", "/bundles/attestation.json") | ||||
|             .Add("Offline:PreferOffline", "true") | ||||
|             .Add("Offline:AllowNetworkFallback", "false") | ||||
|             .Add("Cosign:Mode", "None"); | ||||
|  | ||||
|         var settings = new VexConnectorSettings(settingsValues); | ||||
|         await connector.ValidateAsync(settings, CancellationToken.None); | ||||
|  | ||||
|         var sink = new CapturingRawSink(); | ||||
|         var verifier = new CapturingSignatureVerifier(); | ||||
|         var context = new VexConnectorContext( | ||||
|             Since: null, | ||||
|             Settings: VexConnectorSettings.Empty, | ||||
|             RawSink: sink, | ||||
|             SignatureVerifier: verifier, | ||||
|             Normalizers: new NoopNormalizerRouter(), | ||||
|             Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider()); | ||||
|  | ||||
|         var documents = new List<VexRawDocument>(); | ||||
|         await foreach (var document in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(document); | ||||
|         } | ||||
|  | ||||
|         documents.Should().HaveCount(1); | ||||
|         sink.Documents.Should().HaveCount(1); | ||||
|         documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation); | ||||
|         documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline"); | ||||
|         documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline"); | ||||
|         documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous"); | ||||
|         verifier.VerifyCalls.Should().Be(1); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance() | ||||
|     { | ||||
|         var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData> | ||||
|         { | ||||
|             ["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"), | ||||
|         }); | ||||
|  | ||||
|         using var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var httpClient = new HttpClient(new StubHttpMessageHandler()) | ||||
|         { | ||||
|             BaseAddress = new System.Uri("https://registry.example.com/") | ||||
|         }; | ||||
|  | ||||
|         var httpFactory = new SingleClientHttpClientFactory(httpClient); | ||||
|         var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance); | ||||
|         var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance); | ||||
|  | ||||
|         var connector = new OciOpenVexAttestationConnector( | ||||
|             discovery, | ||||
|             fetcher, | ||||
|             NullLogger<OciOpenVexAttestationConnector>.Instance, | ||||
|             TimeProvider.System); | ||||
|  | ||||
|         var settingsValues = ImmutableDictionary<string, string>.Empty | ||||
|             .Add("Images:0:Reference", "registry.example.com/repo/image:latest") | ||||
|             .Add("Images:0:OfflineBundlePath", "/bundles/attestation.json") | ||||
|             .Add("Offline:PreferOffline", "true") | ||||
|             .Add("Offline:AllowNetworkFallback", "false") | ||||
|             .Add("Cosign:Mode", "Keyless") | ||||
|             .Add("Cosign:Keyless:Issuer", "https://issuer.example.com") | ||||
|             .Add("Cosign:Keyless:Subject", "subject@example.com"); | ||||
|  | ||||
|         var settings = new VexConnectorSettings(settingsValues); | ||||
|         await connector.ValidateAsync(settings, CancellationToken.None); | ||||
|  | ||||
|         var sink = new CapturingRawSink(); | ||||
|         var verifier = new CapturingSignatureVerifier | ||||
|         { | ||||
|             Result = new VexSignatureMetadata( | ||||
|                 type: "cosign", | ||||
|                 subject: "sig-subject", | ||||
|                 issuer: "sig-issuer", | ||||
|                 keyId: "key-id", | ||||
|                 verifiedAt: DateTimeOffset.UtcNow, | ||||
|                 transparencyLogReference: "rekor://entry/123") | ||||
|         }; | ||||
|  | ||||
|         var context = new VexConnectorContext( | ||||
|             Since: null, | ||||
|             Settings: VexConnectorSettings.Empty, | ||||
|             RawSink: sink, | ||||
|             SignatureVerifier: verifier, | ||||
|             Normalizers: new NoopNormalizerRouter(), | ||||
|             Services: new ServiceCollection().BuildServiceProvider()); | ||||
|  | ||||
|         var documents = new List<VexRawDocument>(); | ||||
|         await foreach (var document in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(document); | ||||
|         } | ||||
|  | ||||
|         documents.Should().HaveCount(1); | ||||
|         var metadata = documents[0].Metadata; | ||||
|         metadata.Should().Contain("vex.signature.type", "cosign"); | ||||
|         metadata.Should().Contain("vex.signature.subject", "sig-subject"); | ||||
|         metadata.Should().Contain("vex.signature.issuer", "sig-issuer"); | ||||
|         metadata.Should().Contain("vex.signature.keyId", "key-id"); | ||||
|         metadata.Should().ContainKey("vex.signature.verifiedAt"); | ||||
|         metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123"); | ||||
|         metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless"); | ||||
|         metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com"); | ||||
|         metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com"); | ||||
|         verifier.VerifyCalls.Should().Be(1); | ||||
|     } | ||||
|  | ||||
|     private sealed class CapturingRawSink : IVexRawDocumentSink | ||||
|     { | ||||
|         public List<VexRawDocument> Documents { get; } = new(); | ||||
|  | ||||
|         public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Documents.Add(document); | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class CapturingSignatureVerifier : IVexSignatureVerifier | ||||
|     { | ||||
|         public int VerifyCalls { get; private set; } | ||||
|  | ||||
|         public VexSignatureMetadata? Result { get; set; } | ||||
|  | ||||
|         public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|         { | ||||
|             VerifyCalls++; | ||||
|             return ValueTask.FromResult(Result); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class NoopNormalizerRouter : IVexNormalizerRouter | ||||
|     { | ||||
|         public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
|  | ||||
|     private sealed class SingleClientHttpClientFactory : IHttpClientFactory | ||||
|     { | ||||
|         private readonly HttpClient _client; | ||||
|  | ||||
|         public SingleClientHttpClientFactory(HttpClient client) | ||||
|         { | ||||
|             _client = client; | ||||
|         } | ||||
|  | ||||
|         public HttpClient CreateClient(string name) => _client; | ||||
|     } | ||||
|  | ||||
|     private sealed class StubHttpMessageHandler : HttpMessageHandler | ||||
|     { | ||||
|         protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) | ||||
|             { | ||||
|                 RequestMessage = request | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,83 @@ | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
| using System.Collections.Generic; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Discovery; | ||||
|  | ||||
| public sealed class OciAttestationDiscoveryServiceTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task LoadAsync_ResolvesOfflinePaths() | ||||
|     { | ||||
|         var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData> | ||||
|         { | ||||
|             ["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty), | ||||
|         }); | ||||
|  | ||||
|         using var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance); | ||||
|  | ||||
|         var options = new OciOpenVexAttestationConnectorOptions | ||||
|         { | ||||
|             AllowHttpRegistries = true, | ||||
|         }; | ||||
|  | ||||
|         options.Images.Add(new OciImageSubscriptionOptions | ||||
|         { | ||||
|             Reference = "registry.example.com/repo/image:latest", | ||||
|         }); | ||||
|  | ||||
|         options.Offline.RootDirectory = "/bundles"; | ||||
|         options.Cosign.Mode = CosignCredentialMode.None; | ||||
|  | ||||
|         var result = await service.LoadAsync(options, CancellationToken.None); | ||||
|  | ||||
|         result.Targets.Should().ContainSingle(); | ||||
|         result.Targets[0].OfflineBundle.Should().NotBeNull(); | ||||
|         var offline = result.Targets[0].OfflineBundle!; | ||||
|         offline.Exists.Should().BeTrue(); | ||||
|         var expectedPath = fileSystem.Path.Combine( | ||||
|             fileSystem.Path.GetFullPath("/bundles"), | ||||
|             "registry.example.com", | ||||
|             "repo", | ||||
|             "image", | ||||
|             "latest", | ||||
|             "openvex-attestations.tgz"); | ||||
|         offline.Path.Should().Be(expectedPath); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task LoadAsync_CachesResults() | ||||
|     { | ||||
|         var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData> | ||||
|         { | ||||
|             ["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty), | ||||
|         }); | ||||
|  | ||||
|         using var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance); | ||||
|  | ||||
|         var options = new OciOpenVexAttestationConnectorOptions | ||||
|         { | ||||
|             AllowHttpRegistries = true, | ||||
|         }; | ||||
|  | ||||
|         options.Images.Add(new OciImageSubscriptionOptions | ||||
|         { | ||||
|             Reference = "registry.example.com/repo/image:latest", | ||||
|         }); | ||||
|  | ||||
|         options.Offline.RootDirectory = "/bundles"; | ||||
|         options.Cosign.Mode = CosignCredentialMode.None; | ||||
|  | ||||
|         var first = await service.LoadAsync(options, CancellationToken.None); | ||||
|         var second = await service.LoadAsync(options, CancellationToken.None); | ||||
|  | ||||
|         ReferenceEquals(first, second).Should().BeTrue(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <WarningsNotAsErrors>NU1903</WarningsNotAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="FluentAssertions" Version="6.12.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,110 @@ | ||||
| using System; | ||||
| using System.IO.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication; | ||||
|  | ||||
| public sealed record CosignKeylessIdentity( | ||||
|     string Issuer, | ||||
|     string Subject, | ||||
|     Uri? FulcioUrl, | ||||
|     Uri? RekorUrl, | ||||
|     string? ClientId, | ||||
|     string? ClientSecret, | ||||
|     string? Audience, | ||||
|     string? IdentityToken); | ||||
|  | ||||
| public sealed record CosignKeyPairIdentity( | ||||
|     string PrivateKeyPath, | ||||
|     string? Password, | ||||
|     string? CertificatePath, | ||||
|     Uri? RekorUrl, | ||||
|     string? FulcioRootPath); | ||||
|  | ||||
| public sealed record OciCosignAuthority( | ||||
|     CosignCredentialMode Mode, | ||||
|     CosignKeylessIdentity? Keyless, | ||||
|     CosignKeyPairIdentity? KeyPair, | ||||
|     bool RequireSignature, | ||||
|     TimeSpan VerifyTimeout); | ||||
|  | ||||
| public static class OciCosignAuthorityFactory | ||||
| { | ||||
|     public static OciCosignAuthority Create(OciCosignVerificationOptions options, IFileSystem? fileSystem = null) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         CosignKeylessIdentity? keyless = null; | ||||
|         CosignKeyPairIdentity? keyPair = null; | ||||
|  | ||||
|         switch (options.Mode) | ||||
|         { | ||||
|             case CosignCredentialMode.None: | ||||
|                 break; | ||||
|  | ||||
|             case CosignCredentialMode.Keyless: | ||||
|                 keyless = CreateKeyless(options.Keyless); | ||||
|                 break; | ||||
|  | ||||
|             case CosignCredentialMode.KeyPair: | ||||
|                 keyPair = CreateKeyPair(options.KeyPair, fileSystem); | ||||
|                 break; | ||||
|  | ||||
|             default: | ||||
|                 throw new InvalidOperationException($"Unsupported Cosign credential mode '{options.Mode}'."); | ||||
|         } | ||||
|  | ||||
|         return new OciCosignAuthority( | ||||
|             Mode: options.Mode, | ||||
|             Keyless: keyless, | ||||
|             KeyPair: keyPair, | ||||
|             RequireSignature: options.RequireSignature, | ||||
|             VerifyTimeout: options.VerifyTimeout); | ||||
|     } | ||||
|  | ||||
|     private static CosignKeylessIdentity CreateKeyless(CosignKeylessOptions options) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         Uri? fulcio = null; | ||||
|         Uri? rekor = null; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(options.FulcioUrl)) | ||||
|         { | ||||
|             fulcio = new Uri(options.FulcioUrl, UriKind.Absolute); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(options.RekorUrl)) | ||||
|         { | ||||
|             rekor = new Uri(options.RekorUrl, UriKind.Absolute); | ||||
|         } | ||||
|  | ||||
|         return new CosignKeylessIdentity( | ||||
|             Issuer: options.Issuer!, | ||||
|             Subject: options.Subject!, | ||||
|             FulcioUrl: fulcio, | ||||
|             RekorUrl: rekor, | ||||
|             ClientId: options.ClientId, | ||||
|             ClientSecret: options.ClientSecret, | ||||
|             Audience: options.Audience, | ||||
|             IdentityToken: options.IdentityToken); | ||||
|     } | ||||
|  | ||||
|     private static CosignKeyPairIdentity CreateKeyPair(CosignKeyPairOptions options, IFileSystem? fileSystem) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         Uri? rekor = null; | ||||
|         if (!string.IsNullOrWhiteSpace(options.RekorUrl)) | ||||
|         { | ||||
|             rekor = new Uri(options.RekorUrl, UriKind.Absolute); | ||||
|         } | ||||
|  | ||||
|         return new CosignKeyPairIdentity( | ||||
|             PrivateKeyPath: options.PrivateKeyPath!, | ||||
|             Password: options.Password, | ||||
|             CertificatePath: options.CertificatePath, | ||||
|             RekorUrl: rekor, | ||||
|             FulcioRootPath: options.FulcioRootPath); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,59 @@ | ||||
| using System; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication; | ||||
|  | ||||
| public enum OciRegistryAuthMode | ||||
| { | ||||
|     Anonymous = 0, | ||||
|     Basic = 1, | ||||
|     IdentityToken = 2, | ||||
|     RefreshToken = 3, | ||||
| } | ||||
|  | ||||
| public sealed record OciRegistryAuthorization( | ||||
|     string? RegistryAuthority, | ||||
|     OciRegistryAuthMode Mode, | ||||
|     string? Username, | ||||
|     string? Password, | ||||
|     string? IdentityToken, | ||||
|     string? RefreshToken, | ||||
|     bool AllowAnonymousFallback) | ||||
| { | ||||
|     public static OciRegistryAuthorization Create(OciRegistryAuthenticationOptions options) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         var mode = OciRegistryAuthMode.Anonymous; | ||||
|         string? username = null; | ||||
|         string? password = null; | ||||
|         string? identityToken = null; | ||||
|         string? refreshToken = null; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(options.IdentityToken)) | ||||
|         { | ||||
|             mode = OciRegistryAuthMode.IdentityToken; | ||||
|             identityToken = options.IdentityToken; | ||||
|         } | ||||
|         else if (!string.IsNullOrWhiteSpace(options.RefreshToken)) | ||||
|         { | ||||
|             mode = OciRegistryAuthMode.RefreshToken; | ||||
|             refreshToken = options.RefreshToken; | ||||
|         } | ||||
|         else if (!string.IsNullOrWhiteSpace(options.Username)) | ||||
|         { | ||||
|             mode = OciRegistryAuthMode.Basic; | ||||
|             username = options.Username; | ||||
|             password = options.Password; | ||||
|         } | ||||
|  | ||||
|         return new OciRegistryAuthorization( | ||||
|             RegistryAuthority: options.RegistryAuthority, | ||||
|             Mode: mode, | ||||
|             Username: username, | ||||
|             Password: password, | ||||
|             IdentityToken: identityToken, | ||||
|             RefreshToken: refreshToken, | ||||
|             AllowAnonymousFallback: options.AllowAnonymousFallback); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,321 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.IO.Abstractions; | ||||
| using System.Linq; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
|  | ||||
| public sealed class OciOpenVexAttestationConnectorOptions | ||||
| { | ||||
|     public const string HttpClientName = "excititor.connector.oci.openvex.attest"; | ||||
|  | ||||
|     public IList<OciImageSubscriptionOptions> Images { get; } = new List<OciImageSubscriptionOptions>(); | ||||
|  | ||||
|     public OciRegistryAuthenticationOptions Registry { get; } = new(); | ||||
|  | ||||
|     public OciCosignVerificationOptions Cosign { get; } = new(); | ||||
|  | ||||
|     public OciOfflineBundleOptions Offline { get; } = new(); | ||||
|  | ||||
|     public TimeSpan DiscoveryCacheDuration { get; set; } = TimeSpan.FromMinutes(15); | ||||
|  | ||||
|     public int MaxParallelResolutions { get; set; } = 4; | ||||
|  | ||||
|     public bool AllowHttpRegistries { get; set; } | ||||
|  | ||||
|     public void Validate(IFileSystem? fileSystem = null) | ||||
|     { | ||||
|         if (Images.Count == 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("At least one OCI image reference must be configured."); | ||||
|         } | ||||
|  | ||||
|         foreach (var image in Images) | ||||
|         { | ||||
|             image.Validate(); | ||||
|         } | ||||
|  | ||||
|         if (MaxParallelResolutions <= 0 || MaxParallelResolutions > 32) | ||||
|         { | ||||
|             throw new InvalidOperationException("MaxParallelResolutions must be between 1 and 32."); | ||||
|         } | ||||
|  | ||||
|         if (DiscoveryCacheDuration <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("DiscoveryCacheDuration must be a positive time span."); | ||||
|         } | ||||
|  | ||||
|         Registry.Validate(); | ||||
|         Cosign.Validate(fileSystem); | ||||
|         Offline.Validate(fileSystem); | ||||
|  | ||||
|         if (!AllowHttpRegistries && Images.Any(i => i.Reference is not null && i.Reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase))) | ||||
|         { | ||||
|             throw new InvalidOperationException("HTTP (non-TLS) registries are disabled. Enable AllowHttpRegistries to permit them."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| public sealed class OciImageSubscriptionOptions | ||||
| { | ||||
|     private OciImageReference? _parsedReference; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets or sets the OCI reference (e.g. registry.example.com/repository:tag or registry.example.com/repository@sha256:abcdef). | ||||
|     /// </summary> | ||||
|     public string? Reference { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional friendly name used in logs when referencing this subscription. | ||||
|     /// </summary> | ||||
|     public string? DisplayName { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional file path for an offline attestation bundle associated with this image. | ||||
|     /// </summary> | ||||
|     public string? OfflineBundlePath { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional override for the expected subject digest. When provided, discovery will verify resolved digests match. | ||||
|     /// </summary> | ||||
|     public string? ExpectedSubjectDigest { get; set; } | ||||
|  | ||||
|     internal OciImageReference? ParsedReference => _parsedReference; | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(Reference)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Image Reference is required for OCI OpenVEX attestation connector."); | ||||
|         } | ||||
|  | ||||
|         _parsedReference = OciImageReferenceParser.Parse(Reference); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(ExpectedSubjectDigest)) | ||||
|         { | ||||
|             if (!ExpectedSubjectDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("ExpectedSubjectDigest must start with 'sha256:'."); | ||||
|             } | ||||
|  | ||||
|             if (ExpectedSubjectDigest.Length != "sha256:".Length + 64) | ||||
|             { | ||||
|                 throw new InvalidOperationException("ExpectedSubjectDigest must contain a 64-character hexadecimal hash."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| public sealed class OciRegistryAuthenticationOptions | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Optional registry authority filter (e.g. registry.example.com:5000). When set it must match image references. | ||||
|     /// </summary> | ||||
|     public string? RegistryAuthority { get; set; } | ||||
|  | ||||
|     public string? Username { get; set; } | ||||
|  | ||||
|     public string? Password { get; set; } | ||||
|  | ||||
|     public string? IdentityToken { get; set; } | ||||
|  | ||||
|     public string? RefreshToken { get; set; } | ||||
|  | ||||
|     public bool AllowAnonymousFallback { get; set; } = true; | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         var hasUser = !string.IsNullOrWhiteSpace(Username); | ||||
|         var hasPassword = !string.IsNullOrWhiteSpace(Password); | ||||
|         var hasIdentityToken = !string.IsNullOrWhiteSpace(IdentityToken); | ||||
|         var hasRefreshToken = !string.IsNullOrWhiteSpace(RefreshToken); | ||||
|  | ||||
|         if (hasIdentityToken && (hasUser || hasPassword)) | ||||
|         { | ||||
|             throw new InvalidOperationException("IdentityToken cannot be combined with Username/Password for OCI registry authentication."); | ||||
|         } | ||||
|  | ||||
|         if (hasRefreshToken && (hasUser || hasPassword)) | ||||
|         { | ||||
|             throw new InvalidOperationException("RefreshToken cannot be combined with Username/Password for OCI registry authentication."); | ||||
|         } | ||||
|  | ||||
|         if (hasUser != hasPassword) | ||||
|         { | ||||
|             throw new InvalidOperationException("Username and Password must be provided together for OCI registry authentication."); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(RegistryAuthority) && RegistryAuthority.Contains('/', StringComparison.Ordinal)) | ||||
|         { | ||||
|             throw new InvalidOperationException("RegistryAuthority must not contain path segments."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| public sealed class OciCosignVerificationOptions | ||||
| { | ||||
|     public CosignCredentialMode Mode { get; set; } = CosignCredentialMode.Keyless; | ||||
|  | ||||
|     public CosignKeylessOptions Keyless { get; } = new(); | ||||
|  | ||||
|     public CosignKeyPairOptions KeyPair { get; } = new(); | ||||
|  | ||||
|     public bool RequireSignature { get; set; } = true; | ||||
|  | ||||
|     public TimeSpan VerifyTimeout { get; set; } = TimeSpan.FromSeconds(30); | ||||
|  | ||||
|     public void Validate(IFileSystem? fileSystem = null) | ||||
|     { | ||||
|         if (VerifyTimeout <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("VerifyTimeout must be a positive time span."); | ||||
|         } | ||||
|  | ||||
|         switch (Mode) | ||||
|         { | ||||
|             case CosignCredentialMode.None: | ||||
|                 break; | ||||
|  | ||||
|             case CosignCredentialMode.Keyless: | ||||
|                 Keyless.Validate(); | ||||
|                 break; | ||||
|  | ||||
|             case CosignCredentialMode.KeyPair: | ||||
|                 KeyPair.Validate(fileSystem); | ||||
|                 break; | ||||
|  | ||||
|             default: | ||||
|                 throw new InvalidOperationException($"Unsupported Cosign credential mode '{Mode}'."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| public enum CosignCredentialMode | ||||
| { | ||||
|     None = 0, | ||||
|     Keyless = 1, | ||||
|     KeyPair = 2, | ||||
| } | ||||
|  | ||||
| public sealed class CosignKeylessOptions | ||||
| { | ||||
|     public string? Issuer { get; set; } | ||||
|  | ||||
|     public string? Subject { get; set; } | ||||
|  | ||||
|     public string? FulcioUrl { get; set; } = "https://fulcio.sigstore.dev"; | ||||
|  | ||||
|     public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev"; | ||||
|  | ||||
|     public string? ClientId { get; set; } | ||||
|  | ||||
|     public string? ClientSecret { get; set; } | ||||
|  | ||||
|     public string? Audience { get; set; } | ||||
|  | ||||
|     public string? IdentityToken { get; set; } | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(Issuer)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cosign keyless Issuer must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(Subject)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cosign keyless Subject must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(FulcioUrl) && !Uri.TryCreate(FulcioUrl, UriKind.Absolute, out var fulcio)) | ||||
|         { | ||||
|             throw new InvalidOperationException("FulcioUrl must be an absolute URI when provided."); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out var rekor)) | ||||
|         { | ||||
|             throw new InvalidOperationException("RekorUrl must be an absolute URI when provided."); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cosign keyless ClientId must be provided when ClientSecret is specified."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| public sealed class CosignKeyPairOptions | ||||
| { | ||||
|     public string? PrivateKeyPath { get; set; } | ||||
|  | ||||
|     public string? Password { get; set; } | ||||
|  | ||||
|     public string? CertificatePath { get; set; } | ||||
|  | ||||
|     public string? RekorUrl { get; set; } | ||||
|  | ||||
|     public string? FulcioRootPath { get; set; } | ||||
|  | ||||
|     public void Validate(IFileSystem? fileSystem = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(PrivateKeyPath)) | ||||
|         { | ||||
|             throw new InvalidOperationException("PrivateKeyPath must be provided for Cosign key pair mode."); | ||||
|         } | ||||
|  | ||||
|         var fs = fileSystem ?? new FileSystem(); | ||||
|         if (!fs.File.Exists(PrivateKeyPath)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Cosign private key file not found: {PrivateKeyPath}"); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(CertificatePath) && !fs.File.Exists(CertificatePath)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Cosign certificate file not found: {CertificatePath}"); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(FulcioRootPath) && !fs.File.Exists(FulcioRootPath)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Cosign Fulcio root file not found: {FulcioRootPath}"); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out _)) | ||||
|         { | ||||
|             throw new InvalidOperationException("RekorUrl must be an absolute URI when provided for Cosign key pair mode."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| public sealed class OciOfflineBundleOptions | ||||
| { | ||||
|     public string? RootDirectory { get; set; } | ||||
|  | ||||
|     public bool PreferOffline { get; set; } | ||||
|  | ||||
|     public bool AllowNetworkFallback { get; set; } = true; | ||||
|  | ||||
|     public string? DefaultBundleFileName { get; set; } = "openvex-attestations.tgz"; | ||||
|  | ||||
|     public bool RequireBundles { get; set; } | ||||
|  | ||||
|     public void Validate(IFileSystem? fileSystem = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(RootDirectory)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var fs = fileSystem ?? new FileSystem(); | ||||
|         if (!fs.Directory.Exists(RootDirectory)) | ||||
|         { | ||||
|             if (PreferOffline || RequireBundles) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Offline bundle root directory '{RootDirectory}' does not exist."); | ||||
|             } | ||||
|  | ||||
|             fs.Directory.CreateDirectory(RootDirectory); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,35 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
|  | ||||
| public sealed class OciOpenVexAttestationConnectorOptionsValidator : IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions> | ||||
| { | ||||
|     private readonly IFileSystem _fileSystem; | ||||
|  | ||||
|     public OciOpenVexAttestationConnectorOptionsValidator(IFileSystem fileSystem) | ||||
|     { | ||||
|         _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); | ||||
|     } | ||||
|  | ||||
|     public void Validate( | ||||
|         VexConnectorDescriptor descriptor, | ||||
|         OciOpenVexAttestationConnectorOptions options, | ||||
|         IList<string> errors) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(descriptor); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|         ArgumentNullException.ThrowIfNull(errors); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             options.Validate(_fileSystem); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             errors.Add(ex.Message); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,52 @@ | ||||
| using System; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
| using StellaOps.Excititor.Core; | ||||
| using System.IO.Abstractions; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection; | ||||
|  | ||||
| public static class OciOpenVexAttestationConnectorServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddOciOpenVexAttestationConnector( | ||||
|         this IServiceCollection services, | ||||
|         Action<OciOpenVexAttestationConnectorOptions>? configure = null) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|  | ||||
|         services.TryAddSingleton<IMemoryCache, MemoryCache>(); | ||||
|         services.TryAddSingleton<IFileSystem, FileSystem>(); | ||||
|  | ||||
|         services.AddOptions<OciOpenVexAttestationConnectorOptions>() | ||||
|             .Configure(options => | ||||
|             { | ||||
|                 configure?.Invoke(options); | ||||
|             }); | ||||
|  | ||||
|         services.AddSingleton<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>, OciOpenVexAttestationConnectorOptionsValidator>(); | ||||
|         services.AddSingleton<OciAttestationDiscoveryService>(); | ||||
|         services.AddSingleton<OciAttestationFetcher>(); | ||||
|         services.AddSingleton<IVexConnector, OciOpenVexAttestationConnector>(); | ||||
|  | ||||
|         services.AddHttpClient(OciOpenVexAttestationConnectorOptions.HttpClientName, client => | ||||
|             { | ||||
|                 client.Timeout = TimeSpan.FromSeconds(30); | ||||
|                 client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/1.0"); | ||||
|                 client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.cncf.openvex.v1+json"); | ||||
|                 client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); | ||||
|             }) | ||||
|             .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler | ||||
|             { | ||||
|                 AutomaticDecompression = DecompressionMethods.All, | ||||
|             }); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| using System.Collections.Immutable; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| public sealed record OciAttestationDiscoveryResult( | ||||
|     ImmutableArray<OciAttestationTarget> Targets, | ||||
|     OciRegistryAuthorization RegistryAuthorization, | ||||
|     OciCosignAuthority CosignAuthority, | ||||
|     bool PreferOffline, | ||||
|     bool AllowNetworkFallback); | ||||
| @@ -0,0 +1,188 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.IO.Abstractions; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| public sealed class OciAttestationDiscoveryService | ||||
| { | ||||
|     private readonly IMemoryCache _memoryCache; | ||||
|     private readonly IFileSystem _fileSystem; | ||||
|     private readonly ILogger<OciAttestationDiscoveryService> _logger; | ||||
|  | ||||
|     public OciAttestationDiscoveryService( | ||||
|         IMemoryCache memoryCache, | ||||
|         IFileSystem fileSystem, | ||||
|         ILogger<OciAttestationDiscoveryService> logger) | ||||
|     { | ||||
|         _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); | ||||
|         _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public Task<OciAttestationDiscoveryResult> LoadAsync( | ||||
|         OciOpenVexAttestationConnectorOptions options, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         var cacheKey = CreateCacheKey(options); | ||||
|         if (_memoryCache.TryGetValue(cacheKey, out OciAttestationDiscoveryResult? cached) && cached is not null) | ||||
|         { | ||||
|             _logger.LogDebug("Using cached OCI attestation discovery result for {ImageCount} images.", cached.Targets.Length); | ||||
|             return Task.FromResult(cached); | ||||
|         } | ||||
|  | ||||
|         var targets = new List<OciAttestationTarget>(options.Images.Count); | ||||
|         foreach (var image in options.Images) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var parsed = image.ParsedReference ?? OciImageReferenceParser.Parse(image.Reference!); | ||||
|             var offlinePath = ResolveOfflinePath(options, image, parsed); | ||||
|  | ||||
|             OciOfflineBundleReference? offline = null; | ||||
|             if (!string.IsNullOrWhiteSpace(offlinePath)) | ||||
|             { | ||||
|                 var fullPath = _fileSystem.Path.GetFullPath(offlinePath!); | ||||
|                 var exists = _fileSystem.File.Exists(fullPath) || _fileSystem.Directory.Exists(fullPath); | ||||
|  | ||||
|                 if (!exists && options.Offline.RequireBundles) | ||||
|                 { | ||||
|                     throw new InvalidOperationException($"Required offline bundle '{fullPath}' for reference '{parsed.Canonical}' was not found."); | ||||
|                 } | ||||
|  | ||||
|                 offline = new OciOfflineBundleReference(fullPath, exists, image.ExpectedSubjectDigest); | ||||
|             } | ||||
|  | ||||
|             targets.Add(new OciAttestationTarget(parsed, image.ExpectedSubjectDigest, offline)); | ||||
|         } | ||||
|  | ||||
|         var authorization = OciRegistryAuthorization.Create(options.Registry); | ||||
|         var cosignAuthority = OciCosignAuthorityFactory.Create(options.Cosign, _fileSystem); | ||||
|  | ||||
|         var result = new OciAttestationDiscoveryResult( | ||||
|             targets.ToImmutableArray(), | ||||
|             authorization, | ||||
|             cosignAuthority, | ||||
|             options.Offline.PreferOffline, | ||||
|             options.Offline.AllowNetworkFallback); | ||||
|  | ||||
|         _memoryCache.Set(cacheKey, result, options.DiscoveryCacheDuration); | ||||
|  | ||||
|         return Task.FromResult(result); | ||||
|     } | ||||
|  | ||||
|     private string? ResolveOfflinePath( | ||||
|         OciOpenVexAttestationConnectorOptions options, | ||||
|         OciImageSubscriptionOptions image, | ||||
|         OciImageReference parsed) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(image.OfflineBundlePath)) | ||||
|         { | ||||
|             return image.OfflineBundlePath; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(options.Offline.RootDirectory)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var root = options.Offline.RootDirectory!; | ||||
|         var segments = new List<string> { SanitizeSegment(parsed.Registry) }; | ||||
|  | ||||
|         var repositoryParts = parsed.Repository.Split('/', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (repositoryParts.Length == 0) | ||||
|         { | ||||
|             segments.Add(SanitizeSegment(parsed.Repository)); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             foreach (var part in repositoryParts) | ||||
|             { | ||||
|                 segments.Add(SanitizeSegment(part)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var versionSegment = parsed.Digest is not null | ||||
|             ? SanitizeSegment(parsed.Digest) | ||||
|             : SanitizeSegment(parsed.Tag ?? "latest"); | ||||
|  | ||||
|         segments.Add(versionSegment); | ||||
|  | ||||
|         var combined = _fileSystem.Path.Combine(new[] { root }.Concat(segments).ToArray()); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(options.Offline.DefaultBundleFileName)) | ||||
|         { | ||||
|             combined = _fileSystem.Path.Combine(combined, options.Offline.DefaultBundleFileName!); | ||||
|         } | ||||
|  | ||||
|         return combined; | ||||
|     } | ||||
|  | ||||
|     private static string SanitizeSegment(string value) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(value)) | ||||
|         { | ||||
|             return "_"; | ||||
|         } | ||||
|  | ||||
|         var builder = new StringBuilder(value.Length); | ||||
|         foreach (var ch in value) | ||||
|         { | ||||
|             if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.') | ||||
|             { | ||||
|                 builder.Append(ch); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 builder.Append('_'); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return builder.Length == 0 ? "_" : builder.ToString(); | ||||
|     } | ||||
|  | ||||
|     private static string CreateCacheKey(OciOpenVexAttestationConnectorOptions options) | ||||
|     { | ||||
|         using var sha = SHA256.Create(); | ||||
|         var builder = new StringBuilder(); | ||||
|         builder.AppendLine("oci-openvex-attest"); | ||||
|         builder.AppendLine(options.MaxParallelResolutions.ToString()); | ||||
|         builder.AppendLine(options.AllowHttpRegistries.ToString()); | ||||
|         builder.AppendLine(options.Offline.PreferOffline.ToString()); | ||||
|         builder.AppendLine(options.Offline.AllowNetworkFallback.ToString()); | ||||
|  | ||||
|         foreach (var image in options.Images) | ||||
|         { | ||||
|             builder.AppendLine(image.Reference ?? string.Empty); | ||||
|             builder.AppendLine(image.ExpectedSubjectDigest ?? string.Empty); | ||||
|             builder.AppendLine(image.OfflineBundlePath ?? string.Empty); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(options.Offline.RootDirectory)) | ||||
|         { | ||||
|             builder.AppendLine(options.Offline.RootDirectory); | ||||
|             builder.AppendLine(options.Offline.DefaultBundleFileName ?? string.Empty); | ||||
|         } | ||||
|  | ||||
|         builder.AppendLine(options.Registry.RegistryAuthority ?? string.Empty); | ||||
|         builder.AppendLine(options.Registry.AllowAnonymousFallback.ToString()); | ||||
|  | ||||
|         var bytes = Encoding.UTF8.GetBytes(builder.ToString()); | ||||
|         var hashBytes = sha.ComputeHash(bytes); | ||||
|         return Convert.ToHexString(hashBytes); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| public sealed record OciAttestationTarget( | ||||
|     OciImageReference Image, | ||||
|     string? ExpectedSubjectDigest, | ||||
|     OciOfflineBundleReference? OfflineBundle); | ||||
| @@ -0,0 +1,27 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| public sealed record OciImageReference(string Registry, string Repository, string? Tag, string? Digest, string OriginalReference, string Scheme = "https") | ||||
| { | ||||
|     public string Canonical => | ||||
|         Digest is not null | ||||
|             ? $"{Registry}/{Repository}@{Digest}" | ||||
|             : Tag is not null | ||||
|                 ? $"{Registry}/{Repository}:{Tag}" | ||||
|                 : $"{Registry}/{Repository}"; | ||||
|  | ||||
|     public bool HasDigest => !string.IsNullOrWhiteSpace(Digest); | ||||
|  | ||||
|     public bool HasTag => !string.IsNullOrWhiteSpace(Tag); | ||||
|  | ||||
|     public OciImageReference WithDigest(string digest) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(digest)) | ||||
|         { | ||||
|             throw new ArgumentException("Digest must be provided.", nameof(digest)); | ||||
|         } | ||||
|  | ||||
|         return this with { Digest = digest }; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,129 @@ | ||||
| using System; | ||||
| using System.Text.RegularExpressions; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| internal static class OciImageReferenceParser | ||||
| { | ||||
|     private static readonly Regex DigestRegex = new(@"^(?<algorithm>[A-Za-z0-9+._-]+):(?<hash>[A-Fa-f0-9]{32,})$", RegexOptions.Compiled | RegexOptions.CultureInvariant); | ||||
|     private static readonly Regex RepositoryRegex = new(@"^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); | ||||
|  | ||||
|     public static OciImageReference Parse(string reference) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(reference)) | ||||
|         { | ||||
|             throw new InvalidOperationException("OCI reference cannot be empty."); | ||||
|         } | ||||
|  | ||||
|         var trimmed = reference.Trim(); | ||||
|         string original = trimmed; | ||||
|  | ||||
|         var scheme = "https"; | ||||
|         if (trimmed.StartsWith("oci://", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             trimmed = trimmed.Substring("oci://".Length); | ||||
|         } | ||||
|  | ||||
|         if (trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             trimmed = trimmed.Substring("https://".Length); | ||||
|             scheme = "https"; | ||||
|         } | ||||
|         else if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             trimmed = trimmed.Substring("http://".Length); | ||||
|             scheme = "http"; | ||||
|         } | ||||
|  | ||||
|         var firstSlash = trimmed.IndexOf('/'); | ||||
|         if (firstSlash <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"OCI reference '{reference}' must include a registry and repository component."); | ||||
|         } | ||||
|  | ||||
|         var registry = trimmed[..firstSlash]; | ||||
|         var remainder = trimmed[(firstSlash + 1)..]; | ||||
|  | ||||
|         if (!LooksLikeRegistry(registry)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"OCI reference '{reference}' is missing an explicit registry component."); | ||||
|         } | ||||
|  | ||||
|         string? digest = null; | ||||
|         string? tag = null; | ||||
|  | ||||
|         var digestIndex = remainder.IndexOf('@'); | ||||
|         if (digestIndex >= 0) | ||||
|         { | ||||
|             digest = remainder[(digestIndex + 1)..]; | ||||
|             remainder = remainder[..digestIndex]; | ||||
|  | ||||
|             if (!DigestRegex.IsMatch(digest)) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Digest segment '{digest}' is not a valid OCI digest."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var tagIndex = remainder.LastIndexOf(':'); | ||||
|         if (tagIndex >= 0) | ||||
|         { | ||||
|             tag = remainder[(tagIndex + 1)..]; | ||||
|             remainder = remainder[..tagIndex]; | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(tag)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("OCI tag segment cannot be empty."); | ||||
|             } | ||||
|  | ||||
|             if (tag.Contains('/', StringComparison.Ordinal)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("OCI tag segment cannot contain '/'."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var repository = remainder; | ||||
|         if (string.IsNullOrWhiteSpace(repository)) | ||||
|         { | ||||
|             throw new InvalidOperationException("OCI repository segment cannot be empty."); | ||||
|         } | ||||
|  | ||||
|         if (!RepositoryRegex.IsMatch(repository)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Repository segment '{repository}' is not valid per OCI distribution rules."); | ||||
|         } | ||||
|  | ||||
|         return new OciImageReference( | ||||
|             Registry: registry, | ||||
|             Repository: repository, | ||||
|             Tag: tag, | ||||
|             Digest: digest, | ||||
|             OriginalReference: original, | ||||
|             Scheme: scheme); | ||||
|     } | ||||
|  | ||||
|     private static bool LooksLikeRegistry(string value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (value.Equals("localhost", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (value.Contains('.', StringComparison.Ordinal) || value.Contains(':', StringComparison.Ordinal)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // IPv4/IPv6 simplified check | ||||
|         if (value.Length >= 3 && char.IsDigit(value[0])) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| public sealed record OciOfflineBundleReference(string Path, bool Exists, string? ExpectedSubjectDigest); | ||||
| @@ -0,0 +1,14 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch; | ||||
|  | ||||
| internal sealed record OciArtifactDescriptor( | ||||
|     [property: JsonPropertyName("digest")] string Digest, | ||||
|     [property: JsonPropertyName("mediaType")] string MediaType, | ||||
|     [property: JsonPropertyName("artifactType")] string? ArtifactType, | ||||
|     [property: JsonPropertyName("size")] long Size, | ||||
|     [property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string>? Annotations); | ||||
|  | ||||
| internal sealed record OciReferrerIndex( | ||||
|     [property: JsonPropertyName("referrers")] IReadOnlyList<OciArtifactDescriptor> Referrers); | ||||
| @@ -0,0 +1,13 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch; | ||||
|  | ||||
| public sealed record OciAttestationDocument( | ||||
|     Uri SourceUri, | ||||
|     ReadOnlyMemory<byte> Content, | ||||
|     ImmutableDictionary<string, string> Metadata, | ||||
|     string? SubjectDigest, | ||||
|     string? ArtifactDigest, | ||||
|     string? ArtifactType, | ||||
|     string SourceKind); | ||||
| @@ -0,0 +1,258 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.IO; | ||||
| using System.IO.Abstractions; | ||||
| using System.IO.Compression; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Net.Http; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
| using System.Formats.Tar; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch; | ||||
|  | ||||
| public sealed class OciAttestationFetcher | ||||
| { | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly IFileSystem _fileSystem; | ||||
|     private readonly ILogger<OciAttestationFetcher> _logger; | ||||
|  | ||||
|     public OciAttestationFetcher( | ||||
|         IHttpClientFactory httpClientFactory, | ||||
|         IFileSystem fileSystem, | ||||
|         ILogger<OciAttestationFetcher> logger) | ||||
|     { | ||||
|         _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); | ||||
|         _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async IAsyncEnumerable<OciAttestationDocument> FetchAsync( | ||||
|         OciAttestationDiscoveryResult discovery, | ||||
|         OciOpenVexAttestationConnectorOptions options, | ||||
|         [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(discovery); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         foreach (var target in discovery.Targets) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             bool yieldedOffline = false; | ||||
|             if (target.OfflineBundle is not null && target.OfflineBundle.Exists) | ||||
|             { | ||||
|                 await foreach (var offlineDocument in ReadOfflineAsync(target, cancellationToken)) | ||||
|                 { | ||||
|                     yieldedOffline = true; | ||||
|                     yield return offlineDocument; | ||||
|                 } | ||||
|  | ||||
|                 if (!discovery.AllowNetworkFallback) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (discovery.PreferOffline && yieldedOffline && !discovery.AllowNetworkFallback) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!discovery.PreferOffline || discovery.AllowNetworkFallback || !yieldedOffline) | ||||
|             { | ||||
|                 await foreach (var registryDocument in FetchFromRegistryAsync(discovery, options, target, cancellationToken)) | ||||
|                 { | ||||
|                     yield return registryDocument; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async IAsyncEnumerable<OciAttestationDocument> ReadOfflineAsync( | ||||
|         OciAttestationTarget target, | ||||
|         [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) | ||||
|     { | ||||
|         var offline = target.OfflineBundle!; | ||||
|         var path = _fileSystem.Path.GetFullPath(offline.Path); | ||||
|  | ||||
|         if (!_fileSystem.File.Exists(path)) | ||||
|         { | ||||
|             if (offline.Exists) | ||||
|             { | ||||
|                 _logger.LogWarning("Offline bundle {Path} disappeared before processing.", path); | ||||
|             } | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         var extension = _fileSystem.Path.GetExtension(path).ToLowerInvariant(); | ||||
|         var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest; | ||||
|  | ||||
|         if (string.Equals(extension, ".json", StringComparison.OrdinalIgnoreCase) || | ||||
|             string.Equals(extension, ".dsse", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             var bytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); | ||||
|             var metadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest); | ||||
|             yield return new OciAttestationDocument( | ||||
|                 new Uri(path, UriKind.Absolute), | ||||
|                 bytes, | ||||
|                 metadata, | ||||
|                 subjectDigest, | ||||
|                 null, | ||||
|                 null, | ||||
|                 "offline"); | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         if (string.Equals(extension, ".tgz", StringComparison.OrdinalIgnoreCase) || | ||||
|             string.Equals(extension, ".gz", StringComparison.OrdinalIgnoreCase) || | ||||
|             string.Equals(extension, ".tar", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             await foreach (var document in ReadTarArchiveAsync(target, path, subjectDigest, cancellationToken)) | ||||
|             { | ||||
|                 yield return document; | ||||
|             } | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         // Default: treat as binary blob. | ||||
|         var fallbackBytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); | ||||
|         var fallbackMetadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest); | ||||
|         yield return new OciAttestationDocument( | ||||
|             new Uri(path, UriKind.Absolute), | ||||
|             fallbackBytes, | ||||
|             fallbackMetadata, | ||||
|             subjectDigest, | ||||
|             null, | ||||
|             null, | ||||
|             "offline"); | ||||
|     } | ||||
|  | ||||
|     private async IAsyncEnumerable<OciAttestationDocument> ReadTarArchiveAsync( | ||||
|         OciAttestationTarget target, | ||||
|         string path, | ||||
|         string? subjectDigest, | ||||
|         [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) | ||||
|     { | ||||
|         await using var fileStream = _fileSystem.File.OpenRead(path); | ||||
|         Stream archiveStream = fileStream; | ||||
|  | ||||
|         if (path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) || | ||||
|             path.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             archiveStream = new GZipStream(fileStream, CompressionMode.Decompress, leaveOpen: false); | ||||
|         } | ||||
|  | ||||
|         using var tarReader = new TarReader(archiveStream, leaveOpen: false); | ||||
|         TarEntry? entry; | ||||
|  | ||||
|         while ((entry = await tarReader.GetNextEntryAsync(copyData: false, cancellationToken).ConfigureAwait(false)) is not null) | ||||
|         { | ||||
|             if (entry.EntryType is not TarEntryType.RegularFile || entry.DataStream is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             await using var entryStream = entry.DataStream; | ||||
|             using var buffer = new MemoryStream(); | ||||
|             await entryStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var metadata = BuildOfflineMetadata(target, path, entry.Name, subjectDigest); | ||||
|             var sourceUri = new Uri($"{_fileSystem.Path.GetFullPath(path)}#{entry.Name}", UriKind.Absolute); | ||||
|             yield return new OciAttestationDocument( | ||||
|                 sourceUri, | ||||
|                 buffer.ToArray(), | ||||
|                 metadata, | ||||
|                 subjectDigest, | ||||
|                 null, | ||||
|                 null, | ||||
|                 "offline"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async IAsyncEnumerable<OciAttestationDocument> FetchFromRegistryAsync( | ||||
|         OciAttestationDiscoveryResult discovery, | ||||
|         OciOpenVexAttestationConnectorOptions options, | ||||
|         OciAttestationTarget target, | ||||
|         [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) | ||||
|     { | ||||
|         var registryClient = new OciRegistryClient( | ||||
|             _httpClientFactory, | ||||
|             _logger, | ||||
|             discovery.RegistryAuthorization, | ||||
|             options); | ||||
|  | ||||
|         var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest; | ||||
|         if (string.IsNullOrWhiteSpace(subjectDigest)) | ||||
|         { | ||||
|             subjectDigest = await registryClient.ResolveDigestAsync(target.Image, cancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(subjectDigest)) | ||||
|         { | ||||
|             _logger.LogWarning("Unable to resolve subject digest for {Reference}; skipping registry fetch.", target.Image.Canonical); | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest) && | ||||
|             !string.Equals(target.ExpectedSubjectDigest, subjectDigest, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             _logger.LogWarning( | ||||
|                 "Resolved digest {Resolved} does not match expected digest {Expected} for {Reference}.", | ||||
|                 subjectDigest, | ||||
|                 target.ExpectedSubjectDigest, | ||||
|                 target.Image.Canonical); | ||||
|         } | ||||
|  | ||||
|         var descriptors = await registryClient.ListReferrersAsync(target.Image, subjectDigest, cancellationToken).ConfigureAwait(false); | ||||
|         if (descriptors.Count == 0) | ||||
|         { | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         foreach (var descriptor in descriptors) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|             var document = await registryClient.DownloadAttestationAsync(target.Image, descriptor, subjectDigest, cancellationToken).ConfigureAwait(false); | ||||
|             if (document is not null) | ||||
|             { | ||||
|                 yield return document; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static ImmutableDictionary<string, string> BuildOfflineMetadata( | ||||
|         OciAttestationTarget target, | ||||
|         string bundlePath, | ||||
|         string? entryName, | ||||
|         string? subjectDigest) | ||||
|     { | ||||
|         var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal); | ||||
|         builder["oci.image.registry"] = target.Image.Registry; | ||||
|         builder["oci.image.repository"] = target.Image.Repository; | ||||
|         builder["oci.image.reference"] = target.Image.Canonical; | ||||
|         if (!string.IsNullOrWhiteSpace(subjectDigest)) | ||||
|         { | ||||
|             builder["oci.image.subjectDigest"] = subjectDigest; | ||||
|         } | ||||
|         if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest)) | ||||
|         { | ||||
|             builder["oci.image.expectedSubjectDigest"] = target.ExpectedSubjectDigest!; | ||||
|         } | ||||
|  | ||||
|         builder["oci.attestation.sourceKind"] = "offline"; | ||||
|         builder["oci.attestation.source"] = bundlePath; | ||||
|         if (!string.IsNullOrWhiteSpace(entryName)) | ||||
|         { | ||||
|             builder["oci.attestation.bundleEntry"] = entryName!; | ||||
|         } | ||||
|  | ||||
|         return builder.ToImmutable(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,362 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch; | ||||
|  | ||||
| internal sealed class OciRegistryClient | ||||
| { | ||||
|     private const string ManifestMediaType = "application/vnd.oci.image.manifest.v1+json"; | ||||
|     private const string ReferrersArtifactType = "application/vnd.dsse.envelope.v1+json"; | ||||
|     private const string DsseMediaType = "application/vnd.dsse.envelope.v1+json"; | ||||
|     private const string OpenVexMediaType = "application/vnd.cncf.openvex.v1+json"; | ||||
|  | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); | ||||
|  | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly ILogger _logger; | ||||
|     private readonly OciRegistryAuthorization _authorization; | ||||
|     private readonly OciOpenVexAttestationConnectorOptions _options; | ||||
|  | ||||
|     public OciRegistryClient( | ||||
|         IHttpClientFactory httpClientFactory, | ||||
|         ILogger logger, | ||||
|         OciRegistryAuthorization authorization, | ||||
|         OciOpenVexAttestationConnectorOptions options) | ||||
|     { | ||||
|         _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _authorization = authorization ?? throw new ArgumentNullException(nameof(authorization)); | ||||
|         _options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|     } | ||||
|  | ||||
|     public async Task<string?> ResolveDigestAsync(OciImageReference image, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(image); | ||||
|  | ||||
|         if (image.HasDigest) | ||||
|         { | ||||
|             return image.Digest; | ||||
|         } | ||||
|  | ||||
|         var requestUri = BuildRegistryUri(image, $"manifests/{EscapeReference(image.Tag ?? "latest")}"); | ||||
|  | ||||
|         async Task<HttpRequestMessage> RequestFactory() | ||||
|         { | ||||
|             var request = new HttpRequestMessage(HttpMethod.Head, requestUri); | ||||
|             request.Headers.Accept.ParseAdd(ManifestMediaType); | ||||
|             ApplyAuthentication(request); | ||||
|             return await Task.FromResult(request).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             if (response.StatusCode == HttpStatusCode.NotFound) | ||||
|             { | ||||
|                 _logger.LogWarning("Failed to resolve digest for {Reference}; registry returned 404.", image.Canonical); | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|         } | ||||
|  | ||||
|         if (response.Headers.TryGetValues("Docker-Content-Digest", out var values)) | ||||
|         { | ||||
|             var digest = values.FirstOrDefault(); | ||||
|             if (!string.IsNullOrWhiteSpace(digest)) | ||||
|             { | ||||
|                 return digest; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Manifest may have been returned without digest header; fall back to GET. | ||||
|         async Task<HttpRequestMessage> ManifestRequestFactory() | ||||
|         { | ||||
|             var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||||
|             request.Headers.Accept.ParseAdd(ManifestMediaType); | ||||
|             ApplyAuthentication(request); | ||||
|             return await Task.FromResult(request).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         using var manifestResponse = await SendAsync(ManifestRequestFactory, cancellationToken).ConfigureAwait(false); | ||||
|         manifestResponse.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         if (manifestResponse.Headers.TryGetValues("Docker-Content-Digest", out var manifestValues)) | ||||
|         { | ||||
|             return manifestValues.FirstOrDefault(); | ||||
|         } | ||||
|  | ||||
|         _logger.LogWarning("Registry {Registry} did not provide Docker-Content-Digest header for {Reference}.", image.Registry, image.Canonical); | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public async Task<IReadOnlyList<OciArtifactDescriptor>> ListReferrersAsync( | ||||
|         OciImageReference image, | ||||
|         string subjectDigest, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(image); | ||||
|         ArgumentNullException.ThrowIfNull(subjectDigest); | ||||
|  | ||||
|         var query = $"artifactType={Uri.EscapeDataString(ReferrersArtifactType)}"; | ||||
|         var requestUri = BuildRegistryUri(image, $"referrers/{subjectDigest}", query); | ||||
|  | ||||
|         async Task<HttpRequestMessage> RequestFactory() | ||||
|         { | ||||
|             var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||||
|             ApplyAuthentication(request); | ||||
|             request.Headers.Accept.ParseAdd("application/json"); | ||||
|             return await Task.FromResult(request).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false); | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             if (response.StatusCode == HttpStatusCode.NotFound) | ||||
|             { | ||||
|                 _logger.LogDebug("Registry returned 404 for referrers on {Subject}.", subjectDigest); | ||||
|                 return Array.Empty<OciArtifactDescriptor>(); | ||||
|             } | ||||
|  | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|         } | ||||
|  | ||||
|         await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var index = await JsonSerializer.DeserializeAsync<OciReferrerIndex>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false); | ||||
|         return index?.Referrers ?? Array.Empty<OciArtifactDescriptor>(); | ||||
|     } | ||||
|  | ||||
|     public async Task<OciAttestationDocument?> DownloadAttestationAsync( | ||||
|         OciImageReference image, | ||||
|         OciArtifactDescriptor descriptor, | ||||
|         string subjectDigest, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(image); | ||||
|         ArgumentNullException.ThrowIfNull(descriptor); | ||||
|  | ||||
|         if (!IsSupportedDescriptor(descriptor)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var requestUri = BuildRegistryUri(image, $"blobs/{descriptor.Digest}"); | ||||
|  | ||||
|         async Task<HttpRequestMessage> RequestFactory() | ||||
|         { | ||||
|             var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||||
|             ApplyAuthentication(request); | ||||
|             request.Headers.Accept.ParseAdd(descriptor.MediaType ?? "application/octet-stream"); | ||||
|             return await Task.FromResult(request).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false); | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             if (response.StatusCode == HttpStatusCode.NotFound) | ||||
|             { | ||||
|                 _logger.LogWarning("Registry returned 404 while downloading attestation {Digest} for {Subject}.", descriptor.Digest, subjectDigest); | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|         } | ||||
|  | ||||
|         var buffer = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var metadata = BuildMetadata(image, descriptor, "registry", requestUri.ToString(), subjectDigest); | ||||
|         return new OciAttestationDocument( | ||||
|             requestUri, | ||||
|             buffer, | ||||
|             metadata, | ||||
|             subjectDigest, | ||||
|             descriptor.Digest, | ||||
|             descriptor.ArtifactType, | ||||
|             "registry"); | ||||
|     } | ||||
|  | ||||
|     private static bool IsSupportedDescriptor(OciArtifactDescriptor descriptor) | ||||
|     { | ||||
|         if (descriptor is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(descriptor.ArtifactType) && | ||||
|             descriptor.ArtifactType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(descriptor.MediaType) && | ||||
|             (descriptor.MediaType.Equals(DsseMediaType, StringComparison.OrdinalIgnoreCase) || | ||||
|              descriptor.MediaType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase))) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private async Task<HttpResponseMessage> SendAsync( | ||||
|         Func<Task<HttpRequestMessage>> requestFactory, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         const int maxAttempts = 3; | ||||
|         TimeSpan delay = TimeSpan.FromSeconds(1); | ||||
|         Exception? lastError = null; | ||||
|  | ||||
|         for (var attempt = 1; attempt <= maxAttempts; attempt++) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             using var request = await requestFactory().ConfigureAwait(false); | ||||
|             var client = _httpClientFactory.CreateClient(OciOpenVexAttestationConnectorOptions.HttpClientName); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|                 if (response.IsSuccessStatusCode) | ||||
|                 { | ||||
|                     return response; | ||||
|                 } | ||||
|  | ||||
|                 if (response.StatusCode == HttpStatusCode.Unauthorized) | ||||
|                 { | ||||
|                     if (_authorization.Mode == OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback) | ||||
|                     { | ||||
|                         var message = $"Registry request to {request.RequestUri} was unauthorized and anonymous fallback is disabled."; | ||||
|                         response.Dispose(); | ||||
|                         throw new HttpRequestException(message); | ||||
|                     } | ||||
|  | ||||
|                     lastError = new HttpRequestException($"Registry returned 401 Unauthorized for {request.RequestUri}."); | ||||
|                 } | ||||
|                 else if ((int)response.StatusCode >= 500 || response.StatusCode == (HttpStatusCode)429) | ||||
|                 { | ||||
|                     lastError = new HttpRequestException($"Registry returned status {(int)response.StatusCode} ({response.ReasonPhrase}) for {request.RequestUri}."); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     response.EnsureSuccessStatusCode(); | ||||
|                 } | ||||
|  | ||||
|                 response.Dispose(); | ||||
|             } | ||||
|             catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) | ||||
|             { | ||||
|                 lastError = ex; | ||||
|             } | ||||
|  | ||||
|             if (attempt < maxAttempts) | ||||
|             { | ||||
|                 await Task.Delay(delay, cancellationToken).ConfigureAwait(false); | ||||
|                 delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 10)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         throw new HttpRequestException("Failed to execute OCI registry request after multiple attempts.", lastError); | ||||
|     } | ||||
|  | ||||
|     private void ApplyAuthentication(HttpRequestMessage request) | ||||
|     { | ||||
|         switch (_authorization.Mode) | ||||
|         { | ||||
|             case OciRegistryAuthMode.Basic when | ||||
|                 !string.IsNullOrEmpty(_authorization.Username) && | ||||
|                 !string.IsNullOrEmpty(_authorization.Password): | ||||
|                 var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_authorization.Username}:{_authorization.Password}")); | ||||
|                 request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); | ||||
|                 break; | ||||
|             case OciRegistryAuthMode.IdentityToken when !string.IsNullOrWhiteSpace(_authorization.IdentityToken): | ||||
|                 request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.IdentityToken); | ||||
|                 break; | ||||
|             case OciRegistryAuthMode.RefreshToken when !string.IsNullOrWhiteSpace(_authorization.RefreshToken): | ||||
|                 request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.RefreshToken); | ||||
|                 break; | ||||
|             default: | ||||
|                 if (_authorization.Mode != OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback) | ||||
|                 { | ||||
|                     _logger.LogDebug("No authentication header applied for request to {Uri} (mode {Mode}).", request.RequestUri, _authorization.Mode); | ||||
|                 } | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private Uri BuildRegistryUri(OciImageReference image, string relativePath, string? query = null) | ||||
|     { | ||||
|         var scheme = image.Scheme; | ||||
|         if (!string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) && !_options.AllowHttpRegistries) | ||||
|         { | ||||
|             throw new InvalidOperationException($"HTTP access to registry '{image.Registry}' is disabled. Set AllowHttpRegistries to true to enable."); | ||||
|         } | ||||
|  | ||||
|         var builder = new UriBuilder($"{scheme}://{image.Registry}") | ||||
|         { | ||||
|             Path = $"v2/{BuildRepositoryPath(image.Repository)}/{relativePath}" | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(query)) | ||||
|         { | ||||
|             builder.Query = query; | ||||
|         } | ||||
|  | ||||
|         return builder.Uri; | ||||
|     } | ||||
|  | ||||
|     private static string BuildRepositoryPath(string repository) | ||||
|     { | ||||
|         var segments = repository.Split('/', StringSplitOptions.RemoveEmptyEntries); | ||||
|         return string.Join('/', segments.Select(Uri.EscapeDataString)); | ||||
|     } | ||||
|  | ||||
|     private static string EscapeReference(string reference) | ||||
|     { | ||||
|         return Uri.EscapeDataString(reference); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableDictionary<string, string> BuildMetadata( | ||||
|         OciImageReference image, | ||||
|         OciArtifactDescriptor descriptor, | ||||
|         string sourceKind, | ||||
|         string sourcePath, | ||||
|         string subjectDigest) | ||||
|     { | ||||
|         var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal); | ||||
|         builder["oci.image.registry"] = image.Registry; | ||||
|         builder["oci.image.repository"] = image.Repository; | ||||
|         builder["oci.image.reference"] = image.Canonical; | ||||
|         builder["oci.image.subjectDigest"] = subjectDigest; | ||||
|         builder["oci.attestation.sourceKind"] = sourceKind; | ||||
|         builder["oci.attestation.source"] = sourcePath; | ||||
|         builder["oci.attestation.artifactDigest"] = descriptor.Digest; | ||||
|         builder["oci.attestation.mediaType"] = descriptor.MediaType ?? string.Empty; | ||||
|         builder["oci.attestation.artifactType"] = descriptor.ArtifactType ?? string.Empty; | ||||
|         builder["oci.attestation.size"] = descriptor.Size.ToString(CultureInfo.InvariantCulture); | ||||
|  | ||||
|         if (descriptor.Annotations is not null) | ||||
|         { | ||||
|             foreach (var annotation in descriptor.Annotations) | ||||
|             { | ||||
|                 builder[$"oci.attestation.annotations.{annotation.Key}"] = annotation.Value; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return builder.ToImmutable(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,221 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Runtime.CompilerServices; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery; | ||||
| using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest; | ||||
|  | ||||
| public sealed class OciOpenVexAttestationConnector : VexConnectorBase | ||||
| { | ||||
|     private static readonly VexConnectorDescriptor StaticDescriptor = new( | ||||
|             id: "excititor:oci.openvex.attest", | ||||
|             kind: VexProviderKind.Attestation, | ||||
|             displayName: "OCI OpenVEX Attestations") | ||||
|         { | ||||
|             Tags = ImmutableArray.Create("oci", "openvex", "attestation", "cosign", "offline"), | ||||
|         }; | ||||
|  | ||||
|     private readonly OciAttestationDiscoveryService _discoveryService; | ||||
|     private readonly OciAttestationFetcher _fetcher; | ||||
|     private readonly IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>> _validators; | ||||
|  | ||||
|     private OciOpenVexAttestationConnectorOptions? _options; | ||||
|     private OciAttestationDiscoveryResult? _discovery; | ||||
|  | ||||
|     public OciOpenVexAttestationConnector( | ||||
|         OciAttestationDiscoveryService discoveryService, | ||||
|         OciAttestationFetcher fetcher, | ||||
|         ILogger<OciOpenVexAttestationConnector> logger, | ||||
|         TimeProvider timeProvider, | ||||
|         IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>? validators = null) | ||||
|         : base(StaticDescriptor, logger, timeProvider) | ||||
|     { | ||||
|         _discoveryService = discoveryService ?? throw new ArgumentNullException(nameof(discoveryService)); | ||||
|         _fetcher = fetcher ?? throw new ArgumentNullException(nameof(fetcher)); | ||||
|         _validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>(); | ||||
|     } | ||||
|  | ||||
|     public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken) | ||||
|     { | ||||
|         _options = VexConnectorOptionsBinder.Bind( | ||||
|             Descriptor, | ||||
|             settings, | ||||
|             validators: _validators); | ||||
|  | ||||
|         _discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         LogConnectorEvent(LogLevel.Information, "validate", "Resolved OCI attestation targets.", new Dictionary<string, object?> | ||||
|         { | ||||
|             ["targets"] = _discovery.Targets.Length, | ||||
|             ["offlinePreferred"] = _discovery.PreferOffline, | ||||
|             ["allowNetworkFallback"] = _discovery.AllowNetworkFallback, | ||||
|             ["authMode"] = _discovery.RegistryAuthorization.Mode.ToString(), | ||||
|             ["cosignMode"] = _discovery.CosignAuthority.Mode.ToString(), | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|  | ||||
|         if (_options is null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Connector must be validated before fetch operations."); | ||||
|         } | ||||
|  | ||||
|         if (_discovery is null) | ||||
|         { | ||||
|             _discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         var documentCount = 0; | ||||
|         await foreach (var document in _fetcher.FetchAsync(_discovery, _options, cancellationToken)) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var verificationDocument = CreateRawDocument( | ||||
|                 VexDocumentFormat.OciAttestation, | ||||
|                 document.SourceUri, | ||||
|                 document.Content, | ||||
|                 document.Metadata); | ||||
|  | ||||
|             var signatureMetadata = await context.SignatureVerifier.VerifyAsync(verificationDocument, cancellationToken).ConfigureAwait(false); | ||||
|             if (signatureMetadata is not null) | ||||
|             { | ||||
|                 LogConnectorEvent(LogLevel.Debug, "signature", "Signature metadata captured for attestation.", new Dictionary<string, object?> | ||||
|                 { | ||||
|                     ["subject"] = signatureMetadata.Subject, | ||||
|                     ["type"] = signatureMetadata.Type, | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             var enrichedMetadata = BuildProvenanceMetadata(document, signatureMetadata); | ||||
|             var rawDocument = CreateRawDocument( | ||||
|                 VexDocumentFormat.OciAttestation, | ||||
|                 document.SourceUri, | ||||
|                 document.Content, | ||||
|                 enrichedMetadata); | ||||
|  | ||||
|             await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false); | ||||
|             documentCount++; | ||||
|             yield return rawDocument; | ||||
|         } | ||||
|  | ||||
|         LogConnectorEvent(LogLevel.Information, "fetch", "OCI attestation fetch completed.", new Dictionary<string, object?> | ||||
|         { | ||||
|             ["documents"] = documentCount, | ||||
|             ["since"] = context.Since?.ToString("O"), | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|         => throw new NotSupportedException("Attestation documents rely on dedicated normalizers, to be wired in EXCITITOR-CONN-OCI-01-002."); | ||||
|  | ||||
|     public OciAttestationDiscoveryResult? GetCachedDiscovery() => _discovery; | ||||
|  | ||||
|     private ImmutableDictionary<string, string> BuildProvenanceMetadata(OciAttestationDocument document, VexSignatureMetadata? signature) | ||||
|     { | ||||
|         var builder = document.Metadata.ToBuilder(); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(document.SourceKind)) | ||||
|         { | ||||
|             builder["vex.provenance.sourceKind"] = document.SourceKind; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(document.SubjectDigest)) | ||||
|         { | ||||
|             builder["vex.provenance.subjectDigest"] = document.SubjectDigest!; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(document.ArtifactDigest)) | ||||
|         { | ||||
|             builder["vex.provenance.artifactDigest"] = document.ArtifactDigest!; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(document.ArtifactType)) | ||||
|         { | ||||
|             builder["vex.provenance.artifactType"] = document.ArtifactType!; | ||||
|         } | ||||
|  | ||||
|         if (_discovery is not null) | ||||
|         { | ||||
|             builder["vex.provenance.registryAuthMode"] = _discovery.RegistryAuthorization.Mode.ToString(); | ||||
|             var registryAuthority = _discovery.RegistryAuthorization.RegistryAuthority; | ||||
|             if (string.IsNullOrWhiteSpace(registryAuthority)) | ||||
|             { | ||||
|                 if (builder.TryGetValue("oci.image.registry", out var metadataRegistry) && !string.IsNullOrWhiteSpace(metadataRegistry)) | ||||
|                 { | ||||
|                     registryAuthority = metadataRegistry; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(registryAuthority)) | ||||
|             { | ||||
|                 builder["vex.provenance.registryAuthority"] = registryAuthority!; | ||||
|             } | ||||
|  | ||||
|             builder["vex.provenance.cosign.mode"] = _discovery.CosignAuthority.Mode.ToString(); | ||||
|  | ||||
|             if (_discovery.CosignAuthority.Keyless is not null) | ||||
|             { | ||||
|                 var keyless = _discovery.CosignAuthority.Keyless; | ||||
|                 builder["vex.provenance.cosign.issuer"] = keyless!.Issuer; | ||||
|                 builder["vex.provenance.cosign.subject"] = keyless.Subject; | ||||
|                 if (keyless.FulcioUrl is not null) | ||||
|                 { | ||||
|                     builder["vex.provenance.cosign.fulcioUrl"] = keyless.FulcioUrl!.ToString(); | ||||
|                 } | ||||
|  | ||||
|                 if (keyless.RekorUrl is not null) | ||||
|                 { | ||||
|                     builder["vex.provenance.cosign.rekorUrl"] = keyless.RekorUrl!.ToString(); | ||||
|                 } | ||||
|             } | ||||
|             else if (_discovery.CosignAuthority.KeyPair is not null) | ||||
|             { | ||||
|                 var keyPair = _discovery.CosignAuthority.KeyPair; | ||||
|                 builder["vex.provenance.cosign.keyPair"] = "true"; | ||||
|                 if (keyPair!.RekorUrl is not null) | ||||
|                 { | ||||
|                     builder["vex.provenance.cosign.rekorUrl"] = keyPair.RekorUrl!.ToString(); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (signature is not null) | ||||
|         { | ||||
|             builder["vex.signature.type"] = signature.Type; | ||||
|             if (!string.IsNullOrWhiteSpace(signature.Subject)) | ||||
|             { | ||||
|                 builder["vex.signature.subject"] = signature.Subject!; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(signature.Issuer)) | ||||
|             { | ||||
|                 builder["vex.signature.issuer"] = signature.Issuer!; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(signature.KeyId)) | ||||
|             { | ||||
|                 builder["vex.signature.keyId"] = signature.KeyId!; | ||||
|             } | ||||
|  | ||||
|             if (signature.VerifiedAt is not null) | ||||
|             { | ||||
|                 builder["vex.signature.verifiedAt"] = signature.VerifiedAt.Value.ToString("O"); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(signature.TransparencyLogReference)) | ||||
|             { | ||||
|                 builder["vex.signature.transparencyLogReference"] = signature.TransparencyLogReference!; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return builder.ToImmutable(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <WarningsNotAsErrors>NU1903</WarningsNotAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> | ||||
|     <PackageReference Include="System.IO.Abstractions" Version="20.0.28" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -2,6 +2,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md | ||||
| # TASKS | ||||
| | Task | Owner(s) | Depends on | Notes | | ||||
| |---|---|---|---| | ||||
| |EXCITITOR-CONN-OCI-01-001 – OCI discovery & auth plumbing|Team Excititor Connectors – OCI|EXCITITOR-CONN-ABS-01-001|TODO – Resolve OCI references, configure cosign auth (keyless/keyed), and support offline attestation bundles.| | ||||
| |EXCITITOR-CONN-OCI-01-002 – Attestation fetch & verify loop|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-001, EXCITITOR-ATTEST-01-002|TODO – Download DSSE attestations, trigger verification, handle retries/backoff, and persist raw statements with metadata.| | ||||
| |EXCITITOR-CONN-OCI-01-003 – Provenance metadata & policy hooks|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-002, EXCITITOR-POLICY-01-001|TODO – Emit provenance hints (image, subject digest, issuer) and trust metadata for policy weighting/logging.| | ||||
| |EXCITITOR-CONN-OCI-01-001 – OCI discovery & auth plumbing|Team Excititor Connectors – OCI|EXCITITOR-CONN-ABS-01-001|DONE (2025-10-18) – Added connector skeleton, options/validators, discovery caching, cosign/auth descriptors, offline bundle resolution, DI wiring, and regression tests.| | ||||
| |EXCITITOR-CONN-OCI-01-002 – Attestation fetch & verify loop|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-001, EXCITITOR-ATTEST-01-002|DONE (2025-10-18) – Added offline/registry fetch services, DSSE retrieval with retries, signature verification callout, and raw persistence coverage.| | ||||
| |EXCITITOR-CONN-OCI-01-003 – Provenance metadata & policy hooks|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-002, EXCITITOR-POLICY-01-001|DONE (2025-10-18) – Enriched attestation metadata with provenance hints, cosign expectations, registry auth context, and signature diagnostics for policy consumption.| | ||||
|   | ||||
| @@ -0,0 +1,7 @@ | ||||
| # StellaOps Mirror VEX Connector Task Board (Sprint 7) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | EXCITITOR-CONN-STELLA-07-001 | TODO | Excititor Connectors – Stella | EXCITITOR-EXPORT-01-007 | Implement mirror fetch client consuming `https://<domain>.stella-ops.org/excititor/exports/index.json`, validating signatures/digests, storing raw consensus bundles with provenance. | Fetch job downloads mirror manifest, verifies DSSE/signature, stores raw documents + provenance; unit tests cover happy path and tampered manifest failure. | | ||||
| | EXCITITOR-CONN-STELLA-07-002 | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-001 | Normalize mirror bundles into VexClaim sets referencing original provider metadata and mirror provenance. | Normalizer emits VexClaims with mirror provenance + policy metadata, fixtures assert deterministic output parity vs local exports. | | ||||
| | EXCITITOR-CONN-STELLA-07-003 | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-002 | Implement incremental cursor handling per-export digest, support resume, and document configuration for downstream Excititor mirrors. | Connector resumes from last export digest, handles delta/export rotation, docs show configuration; integration test covers resume + new export ingest. | | ||||
| @@ -7,3 +7,5 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md | ||||
| |EXCITITOR-EXPORT-01-003 – Artifact store adapters|Team Excititor Export|EXCITITOR-EXPORT-01-001|**DONE (2025-10-16)** – Implemented multi-store pipeline with filesystem, S3-compatible, and offline bundle adapters (hash verification + manifest/zip output) plus unit coverage and DI hooks.| | ||||
| |EXCITITOR-EXPORT-01-004 – Attestation handoff integration|Team Excititor Export|EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|**DONE (2025-10-17)** – Export engine now invokes attestation client, logs diagnostics, and persists Rekor/envelope metadata on manifests; regression coverage added in `ExportEngineTests.ExportAsync_AttachesAttestationMetadata`.| | ||||
| |EXCITITOR-EXPORT-01-005 – Score & resolve envelope surfaces|Team Excititor Export|EXCITITOR-EXPORT-01-004, EXCITITOR-CORE-02-001|TODO – Emit consensus+score envelopes in export manifests, include policy/scoring digests, and update offline bundle/ORAS layouts to carry signed VEX responses.| | ||||
| |EXCITITOR-EXPORT-01-006 – Quiet provenance packaging|Team Excititor Export|EXCITITOR-EXPORT-01-005, POLICY-CORE-09-005|TODO – Attach `quietedBy` statement IDs, signers, and justification codes to exports/offline bundles, mirror metadata into attested manifest, and add regression fixtures.| | ||||
| |EXCITITOR-EXPORT-01-007 – Mirror bundle + domain manifest|Team Excititor Export|EXCITITOR-EXPORT-01-006|TODO – Create per-domain mirror bundles with consensus/score artifacts, publish signed index for downstream Excititor sync, and ensure deterministic digests + fixtures.|  | ||||
|   | ||||
| @@ -6,3 +6,4 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md | ||||
| |EXCITITOR-WEB-01-002 – Ingest & reconcile endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001|TODO – Implement `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile` with token scope enforcement and structured run telemetry.| | ||||
| |EXCITITOR-WEB-01-003 – Export & verify endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|TODO – Add `/excititor/export`, `/excititor/export/{id}`, `/excititor/export/{id}/download`, `/excititor/verify`, returning artifact + attestation metadata with cache awareness.| | ||||
| |EXCITITOR-WEB-01-004 – Resolve API & signed responses|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-ATTEST-01-002|TODO – Deliver `/excititor/resolve` (subject/context), return consensus + score envelopes, attach cosign/Rekor metadata, and document auth + rate guardrails.| | ||||
| |EXCITITOR-WEB-01-005 – Mirror distribution endpoints|Team Excititor WebService|EXCITITOR-EXPORT-01-007, DEVOPS-MIRROR-08-001|TODO – Provide domain-scoped mirror index/download APIs for consensus exports, enforce quota/auth, and document sync workflow for downstream Excititor deployments.|  | ||||
|   | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Connectors.Email/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Connectors.Email/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Connectors.Email — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement SMTP connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Connectors.Email/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Connectors.Email/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Notify Email Connector Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-CONN-EMAIL-15-701 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement SMTP connector with STARTTLS/implicit TLS support, HTML+text rendering, attachment policy enforcement. | Integration tests with SMTP stub pass; TLS enforced; attachments blocked per policy. | | ||||
| | NOTIFY-CONN-EMAIL-15-702 | TODO | Notify Connectors Guild | NOTIFY-CONN-EMAIL-15-701 | Add DKIM signing optional support and health/test-send flows. | DKIM optional config verified; test-send passes; secrets handled securely. | | ||||
| | NOTIFY-CONN-EMAIL-15-703 | TODO | Notify Connectors Guild | NOTIFY-CONN-EMAIL-15-702 | Package Email connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/email/`; restart validation passes. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Connectors.Slack/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Connectors.Slack/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Connectors.Slack — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Deliver Slack connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Connectors.Slack/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Connectors.Slack/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Notify Slack Connector Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-CONN-SLACK-15-501 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement Slack connector with bot token auth, message rendering (blocks), rate limit handling, retries/backoff. | Integration tests stub Slack API; retries/jitter validated; 429 handling documented. | | ||||
| | NOTIFY-CONN-SLACK-15-502 | TODO | Notify Connectors Guild | NOTIFY-CONN-SLACK-15-501 | Health check & test-send support with minimal scopes and redacted tokens. | `/channels/{id}/test` hitting Slack stub passes; secrets never logged; health endpoint returns diagnostics. | | ||||
| | NOTIFY-CONN-SLACK-15-503 | TODO | Notify Connectors Guild | NOTIFY-CONN-SLACK-15-502 | Package Slack connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/slack/`; restart validation passes. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Connectors.Teams/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Connectors.Teams/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Connectors.Teams — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement Microsoft Teams connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Connectors.Teams/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Connectors.Teams/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Notify Teams Connector Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-CONN-TEAMS-15-601 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement Teams connector using Adaptive Cards 1.5, handle webhook auth, size limits, retries. | Adaptive card payloads validated; 413/429 handling implemented; integration tests cover success/fail. | | ||||
| | NOTIFY-CONN-TEAMS-15-602 | TODO | Notify Connectors Guild | NOTIFY-CONN-TEAMS-15-601 | Provide health/test-send support with fallback text for legacy clients. | Test-send returns card preview; fallback text logged; docs updated. | | ||||
| | NOTIFY-CONN-TEAMS-15-603 | TODO | Notify Connectors Guild | NOTIFY-CONN-TEAMS-15-602 | Package Teams connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/teams/`; restart validation passes. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Connectors.Webhook/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Connectors.Webhook/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Connectors.Webhook — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement generic webhook connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Connectors.Webhook/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Connectors.Webhook/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Notify Webhook Connector Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-CONN-WEBHOOK-15-801 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement webhook connector: JSON payload, signature (HMAC/Ed25519), retries/backoff, status code handling. | Integration tests with webhook stub validate signatures, retries, error handling; payload schema documented. | | ||||
| | NOTIFY-CONN-WEBHOOK-15-802 | TODO | Notify Connectors Guild | NOTIFY-CONN-WEBHOOK-15-801 | Health/test-send support with signature validation hints and secret management. | Test-send returns success with sample payload; docs include verification guide; secrets never logged. | | ||||
| | NOTIFY-CONN-WEBHOOK-15-803 | TODO | Notify Connectors Guild | NOTIFY-CONN-WEBHOOK-15-802 | Package Webhook connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/webhook/`; restart validation passes. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Engine/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Engine/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Engine — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Deliver rule evaluation, digest, and rendering logic per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										8
									
								
								src/StellaOps.Notify.Engine/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/StellaOps.Notify.Engine/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| # Notify Engine Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-ENGINE-15-301 | TODO | Notify Engine Guild | NOTIFY-MODELS-15-101 | Rules evaluation core: tenant/kind filters, severity/delta gates, VEX gating, throttling, idempotency key generation. | Unit tests cover rule permutations; idempotency keys deterministic; documentation updated. | | ||||
| | NOTIFY-ENGINE-15-302 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-301 | Action planner + digest coalescer with window management and dedupe per architecture §4. | Digest windows tested; throttles and digests recorded; metrics counters exposed. | | ||||
| | NOTIFY-ENGINE-15-303 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-302 | Template rendering engine (Slack, Teams, Email, Webhook) with helpers and i18n support. | Rendering fixtures validated; helpers documented; deterministic output proven via golden tests. | | ||||
| | NOTIFY-ENGINE-15-304 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-303 | Test-send sandbox + preview utilities for WebService. | Preview/test functions validated; sample outputs returned; no state persisted. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Models/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Models/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Models — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Define Notify DTOs and contracts per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Models/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Models/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Notify Models Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-MODELS-15-101 | TODO | Notify Models Guild | — | Define core DTOs (Rule, Channel, Template, Event envelope, Delivery) with validation helpers and canonical JSON serialization. | DTOs merged with tests; documented; serialization deterministic. | | ||||
| | NOTIFY-MODELS-15-102 | TODO | Notify Models Guild | NOTIFY-MODELS-15-101 | Publish schema docs + sample payloads for channels, rules, events (used by UI + connectors). | Markdown/JSON schema generated; linked in docs; integration tests reference samples. | | ||||
| | NOTIFY-MODELS-15-103 | TODO | Notify Models Guild | NOTIFY-MODELS-15-101 | Provide versioning and migration helpers (e.g., rule evolution, template revisions). | Migration helpers implemented; tests cover upgrade/downgrade; guidance captured in docs. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Queue/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Queue/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Queue — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Provide event & delivery queues for Notify per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Queue/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Queue/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Notify Queue Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-QUEUE-15-401 | TODO | Notify Queue Guild | NOTIFY-MODELS-15-101 | Build queue abstraction + Redis Streams adapter with ack/claim APIs, idempotency tokens, serialization contracts. | Adapter integration tests cover enqueue/dequeue/ack; ordering preserved; idempotency tokens supported. | | ||||
| | NOTIFY-QUEUE-15-402 | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Add NATS JetStream adapter with configuration binding, health probes, failover. | Health endpoints verified; failover documented; integration tests exercise both adapters. | | ||||
| | NOTIFY-QUEUE-15-403 | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Delivery queue for channel actions with retry schedules, poison queues, and metrics instrumentation. | Delivery queue integration tests cover retries/dead-letter; metrics/logging emitted per spec. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Storage.Mongo/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Storage.Mongo/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Storage.Mongo — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement Mongo persistence (rules, channels, deliveries, digests, locks, audit) per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Notify.Storage.Mongo/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Notify.Storage.Mongo/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Notify Storage Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-STORAGE-15-201 | TODO | Notify Storage Guild | NOTIFY-MODELS-15-101 | Create Mongo schemas/collections (rules, channels, deliveries, digests, locks, audit) with indexes per architecture §7. | Migration scripts authored; indexes tested; integration tests cover CRUD/read paths. | | ||||
| | NOTIFY-STORAGE-15-202 | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-201 | Implement repositories/services with tenant scoping, soft deletes, TTL, causal consistency (majority) options. | Repositories unit-tested; soft delete + TTL validated; majority read/write configuration documented. | | ||||
| | NOTIFY-STORAGE-15-203 | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-201 | Delivery history retention + query APIs (paging, filters). | History queries return expected data; paging verified; docs updated. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.WebService/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.WebService/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.WebService — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement Notify control plane per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										8
									
								
								src/StellaOps.Notify.WebService/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/StellaOps.Notify.WebService/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| # Notify WebService Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-WEB-15-101 | TODO | Notify WebService Guild | NOTIFY-MODELS-15-101 | Bootstrap minimal API host with Authority auth, health endpoints, and plug-in discovery per architecture. | Service starts with config validation, `/healthz`/`/readyz` pass, plug-ins loaded at restart. | | ||||
| | NOTIFY-WEB-15-102 | TODO | Notify WebService Guild | NOTIFY-WEB-15-101 | Rules/channel/template CRUD endpoints with tenant scoping, validation, audit logging. | CRUD endpoints tested; invalid inputs rejected; audit entries persisted. | | ||||
| | NOTIFY-WEB-15-103 | TODO | Notify WebService Guild | NOTIFY-WEB-15-102 | Delivery history + test-send endpoints with rate limits. | `/deliveries` and `/channels/{id}/test` tested; rate limits enforced. | | ||||
| | NOTIFY-WEB-15-104 | TODO | Notify WebService Guild | NOTIFY-STORAGE-15-201, NOTIFY-QUEUE-15-401 | Configuration binding for Mongo/queue/secrets; startup diagnostics. | Misconfiguration fails fast; diagnostics logged; integration tests cover env overrides. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Notify.Worker/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Notify.Worker/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Notify.Worker — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Consume events, evaluate rules, and dispatch deliveries per `docs/ARCHITECTURE_NOTIFY.md`. | ||||
| @@ -0,0 +1,8 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <OutputType>Exe</OutputType> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										8
									
								
								src/StellaOps.Notify.Worker/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/StellaOps.Notify.Worker/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| # Notify Worker Task Board (Sprint 15) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | NOTIFY-WORKER-15-201 | TODO | Notify Worker Guild | NOTIFY-QUEUE-15-401 | Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1–§5). | Worker consumes events from queue, ack/retry behaviour proven in integration tests; logs include correlation IDs. | | ||||
| | NOTIFY-WORKER-15-202 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-301 | Wire rules evaluation pipeline (tenant scoping, filters, throttles, digests, idempotency) with deterministic decisions. | Evaluation unit tests cover rule combinations; throttles/digests produce expected suppression; idempotency keys validated. | | ||||
| | NOTIFY-WORKER-15-203 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-302 | Channel dispatch orchestration: invoke connectors, manage retries/jitter, record delivery outcomes. | Connector mocks show retries/backoff; delivery results stored; metrics incremented per outcome. | | ||||
| | NOTIFY-WORKER-15-204 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-203 | Metrics/telemetry: `notify.sent_total`, `notify.dropped_total`, latency histograms, tracing integration. | Metrics emitted per spec; OTLP spans annotated; dashboards documented. | | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Policy/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Policy/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # StellaOps.Policy — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Deliver the policy engine outlined in `docs/ARCHITECTURE_SCANNER.md` and related prose: | ||||
| - Define YAML schema (ignore rules, VEX inclusion/exclusion, vendor precedence, license gates). | ||||
| - Provide policy snapshot storage with revision digests and diagnostics. | ||||
| - Offer preview APIs to compare policy impacts on existing reports. | ||||
|  | ||||
| ## Expectations | ||||
| - Coordinate with Scanner.WebService, Feedser, Vexer, UI, Notify. | ||||
| - Maintain deterministic serialization and unit tests for precedence rules. | ||||
| - Update `TASKS.md` and broadcast contract changes. | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Policy/StellaOps.Policy.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Policy/StellaOps.Policy.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										13
									
								
								src/StellaOps.Policy/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/StellaOps.Policy/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| # Policy Engine Task Board (Sprint 9) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | POLICY-CORE-09-001 | TODO | Policy Guild | SCANNER-WEB-09-101 | Define YAML schema/binder, diagnostics, CLI validation for policy files. | Schema doc published; binder loads sample policy; validation errors actionable. | | ||||
| | POLICY-CORE-09-002 | TODO | Policy Guild | POLICY-CORE-09-001 | Implement policy snapshot store + revision digests + audit logging. | Snapshots persisted with digest; tests compare revisions; audit entries created. | | ||||
| | POLICY-CORE-09-003 | TODO | Policy Guild | POLICY-CORE-09-002 | `/policy/preview` API (image digest → projected verdict delta). | Preview returns diff JSON; integration tests with mocked report; docs updated. | | ||||
| | POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config with schema validation, trust table, and golden fixtures. | Scoring config documented; fixtures stored; validation CLI passes. | | ||||
| | POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004 | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. | Engine unit tests cover severity weighting; outputs include provenance data. | | ||||
| | POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005 | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | Confidence decay tests pass; docs updated; preview endpoint displays banding. | | ||||
| | POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. | | ||||
| | POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. | | ||||
| | POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. | | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Scanner.Emit/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Scanner.Emit/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # Scanner Emit Task Board (Sprint 10) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCANNER-EMIT-10-601 | TODO | Emit Guild | SCANNER-CACHE-10-101 | Compose inventory SBOM (CycloneDX JSON/Protobuf) from layer fragments with deterministic ordering. | Inventory SBOM validated against schema; fixtures confirm deterministic output. | | ||||
| | SCANNER-EMIT-10-602 | TODO | Emit Guild | SCANNER-EMIT-10-601 | Compose usage SBOM leveraging EntryTrace to flag actual usage; ensure separate view toggles. | Usage SBOM tests confirm correct subset; API contract documented. | | ||||
| | SCANNER-EMIT-10-603 | TODO | Emit Guild | SCANNER-EMIT-10-601 | Generate BOM index sidecar (purl table + roaring bitmap + usedByEntrypoint flag). | Index format validated; query helpers proven; stored artifacts hashed deterministically. | | ||||
| | SCANNER-EMIT-10-604 | TODO | Emit Guild | SCANNER-EMIT-10-602 | Package artifacts for export + attestation (naming, compression, manifests). | Export pipeline produces deterministic file paths/hashes; integration test with storage passes. | | ||||
| | SCANNER-EMIT-10-605 | TODO | Emit Guild | SCANNER-EMIT-10-603 | Emit BOM-Index sidecar schema/fixtures (`bom-index@1`) and note CRITICAL PATH for Scheduler. | Schema + fixtures in docs/artifacts/bom-index; tests `BOMIndexGoldenIsStable` green. | | ||||
| | SCANNER-EMIT-10-606 | TODO | Emit Guild | SCANNER-EMIT-10-605 | Integrate EntryTrace usage flags into BOM-Index; document semantics. | Usage bits present in sidecar; integration tests with EntryTrace fixtures pass. | | ||||
| | SCANNER-EMIT-17-701 | TODO | Emit Guild, Native Analyzer Guild | SCANNER-EMIT-10-602 | Record GNU build-id for ELF components and surface it in inventory/usage SBOM plus diff payloads with deterministic ordering. | Native analyzer emits buildId for every ELF executable/library, SBOM/diff fixtures updated with canonical `buildId` field, regression tests prove stability, docs call out debug-symbol lookup flow. | | ||||
| | SCANNER-EMIT-10-607 | TODO | Emit Guild | SCANNER-EMIT-10-604, POLICY-CORE-09-005 | Embed scoring inputs, confidence band, and `quietedBy` provenance into CycloneDX 1.6 and DSSE predicates; verify deterministic serialization. | SBOM/attestation fixtures include score, inputs, configVersion, quiet metadata; golden tests confirm canonical output. | | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Scanner.Sbomer.BuildXPlugin/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Scanner.Sbomer.BuildXPlugin/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # StellaOps.Scanner.Sbomer.BuildXPlugin — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement the build-time SBOM generator described in `docs/ARCHITECTURE_SCANNER.md` and new buildx dossier requirements: | ||||
| - Provide a deterministic BuildKit/Buildx generator that produces layer SBOM fragments and uploads them to local CAS. | ||||
| - Emit OCI annotations (+provenance) compatible with Scanner.Emit and Attestor hand-offs. | ||||
| - Respect restart-time plug-in policy (`plugins/scanner/buildx/` manifests) and keep CI overhead ≤300 ms per layer. | ||||
|  | ||||
| ## Expectations | ||||
| - Read architecture + upcoming Buildx addendum before coding. | ||||
| - Ensure graceful fallback to post-build scan when generator unavailable. | ||||
| - Provide integration tests with mock BuildKit, and update `TASKS.md` as states change. | ||||
| @@ -0,0 +1,8 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <OutputType>Exe</OutputType> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # BuildX Plugin Task Board (Sprint 9) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SP9-BLDX-09-001 | TODO | BuildX Guild | SCANNER-EMIT-10-601 (awareness) | Scaffold buildx driver, manifest, local CAS handshake; ensure plugin loads from `plugins/scanner/buildx/`. | Plugin manifest + loader tests; local CAS writes succeed; restart required to activate. | | ||||
| | SP9-BLDX-09-002 | TODO | BuildX Guild | SP9-BLDX-09-001 | Emit OCI annotations + provenance metadata for Attestor handoff (image + SBOM). | OCI descriptors include DSSE/provenance placeholders; Attestor mock accepts payload. | | ||||
| | SP9-BLDX-09-003 | TODO | BuildX Guild | SP9-BLDX-09-002 | CI demo pipeline: build sample image, produce SBOM, verify backend report wiring. | GitHub/CI job runs sample build within 5 s overhead; artifacts saved; documentation updated. | | ||||
							
								
								
									
										15
									
								
								src/StellaOps.Scanner.WebService/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/StellaOps.Scanner.WebService/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # Scanner WebService Task Board | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCANNER-WEB-09-101 | TODO | Scanner WebService Guild | SCANNER-CORE-09-501 | Stand up minimal API host with Authority OpTok + DPoP enforcement, health/ready endpoints, and restart-time plug-in loader per architecture §1, §4. | Host boots with configuration validation, `/healthz` and `/readyz` return 200, Authority middleware enforced in integration tests. | | ||||
| | SCANNER-WEB-09-102 | TODO | Scanner WebService Guild | SCANNER-WEB-09-101, SCANNER-QUEUE-09-401 | Implement `/api/v1/scans` submission/status endpoints with deterministic IDs, validation, and cancellation tokens. | Contract documented, e2e test posts scan request and retrieves status, cancellation token honoured. | | ||||
| | SCANNER-WEB-09-103 | TODO | Scanner WebService Guild | SCANNER-WEB-09-102, SCANNER-CORE-09-502 | Emit scan progress via SSE/JSONL with correlation IDs and deterministic timestamps; document API reference. | Streaming endpoint verified in tests, timestamps formatted ISO-8601 UTC, docs updated in `docs/09_API_CLI_REFERENCE.md`. | | ||||
| | SCANNER-WEB-09-104 | TODO | Scanner WebService Guild | SCANNER-STORAGE-09-301, SCANNER-QUEUE-09-401 | Bind configuration for Mongo, MinIO, queue, feature flags; add startup diagnostics and fail-fast policy for missing deps. | Misconfiguration fails fast with actionable errors, configuration bound tests pass, diagnostics logged with correlation IDs. | | ||||
| | SCANNER-POLICY-09-105 | TODO | Scanner WebService Guild | POLICY-CORE-09-001 | Integrate policy schema loader + diagnostics + OpenAPI (YAML ignore rules, VEX include/exclude, vendor precedence). | Policy endpoints documented; validation surfaces actionable errors; OpenAPI schema published. | | ||||
| | SCANNER-POLICY-09-106 | TODO | Scanner WebService Guild | POLICY-CORE-09-002, SCANNER-POLICY-09-105 | `/reports` verdict assembly (Feedser/Vexer/Policy merge) + signed response envelope. | Aggregated report includes policy metadata; integration test verifies signed response; docs updated. | | ||||
| | SCANNER-POLICY-09-107 | TODO | Scanner WebService Guild | POLICY-CORE-09-005, SCANNER-POLICY-09-106 | Surface score inputs, config version, and `quietedBy` provenance in `/reports` response and signed payload; document schema changes. | `/reports` JSON + DSSE contain score, reachability, sourceTrust, confidenceBand, quiet provenance; contract tests updated; docs refreshed. | | ||||
| | SCANNER-RUNTIME-12-301 | TODO | Scanner WebService Guild | ZASTAVA-CORE-12-201 | Implement `/runtime/events` ingestion endpoint with validation, batching, and storage hooks per Zastava contract. | Observer fixtures POST events, data persisted and acked; invalid payloads rejected with deterministic errors. | | ||||
| | SCANNER-RUNTIME-12-302 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-CORE-12-201 | Implement `/policy/runtime` endpoint joining SBOM baseline + policy verdict, returning admission guidance. | Webhook integration test passes; responses include verdict, TTL, reasons; metrics/logging added. | | ||||
| | SCANNER-EVENTS-15-201 | TODO | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Emit `scanner.report.ready` and `scanner.scan.completed` events (bus adapters + tests). | Event envelopes published to queue with schemas; fixtures committed; Notify consumption test passes. | | ||||
| | SCANNER-RUNTIME-17-401 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-OBS-17-005, SCANNER-EMIT-17-701 | Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. | Mongo schema stores optional `buildId`, API/SDK responses document field, integration test resolves debug-store path using stored build-id, docs updated accordingly. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Scheduler.ImpactIndex/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Scheduler.ImpactIndex/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Scheduler.ImpactIndex — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Build the global impact index per `docs/ARCHITECTURE_SCHEDULER.md` (roaring bitmaps, selectors, snapshotting). | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										8
									
								
								src/StellaOps.Scheduler.ImpactIndex/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/StellaOps.Scheduler.ImpactIndex/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| # Scheduler ImpactIndex Task Board (Sprint 16) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCHED-IMPACT-16-300 | DOING | Scheduler ImpactIndex Guild | SAMPLES-10-001 | **STUB** ingest/query using fixtures to unblock Scheduler planning (remove by SP16 end). | Stub merges fixture BOM-Index, query API returns deterministic results, removal note tracked. | | ||||
| | SCHED-IMPACT-16-301 | TODO | Scheduler ImpactIndex Guild | SCANNER-EMIT-10-605 | Implement ingestion of per-image BOM-Index sidecars into roaring bitmap store (contains/usedBy). | Ingestion tests process sample SBOM index; bitmaps persisted; deterministic IDs assigned. | | ||||
| | SCHED-IMPACT-16-302 | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Provide query APIs (ResolveByPurls, ResolveByVulns, ResolveAll, selectors) with tenant/namespace filters. | Query functions tested; performance benchmarks documented; selectors enforce filters. | | ||||
| | SCHED-IMPACT-16-303 | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Snapshot/compaction + invalidation for removed images; persistence to RocksDB/Redis per architecture. | Snapshot routine implemented; invalidation tests pass; docs describe recovery. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Scheduler.Models/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Scheduler.Models/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Scheduler.Models — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Define Scheduler DTOs (Schedule, Run, ImpactSet, Selector, DeltaSummary) per `docs/ARCHITECTURE_SCHEDULER.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Scheduler.Models/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Scheduler.Models/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Scheduler Models Task Board (Sprint 16) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCHED-MODELS-16-101 | TODO | Scheduler Models Guild | — | Define DTOs (Schedule, Run, ImpactSet, Selector, DeltaSummary, AuditRecord) with validation + canonical JSON. | DTOs merged with tests; documentation snippet added; serialization deterministic. | | ||||
| | SCHED-MODELS-16-102 | TODO | Scheduler Models Guild | SCHED-MODELS-16-101 | Publish schema docs & sample payloads for UI/Notify integration. | Samples committed; docs referenced; contract tests pass. | | ||||
| | SCHED-MODELS-16-103 | TODO | Scheduler Models Guild | SCHED-MODELS-16-101 | Versioning/migration helpers (schedule evolution, run state transitions). | Migration helpers implemented; tests cover upgrade/downgrade; guidelines documented. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Scheduler.Queue/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Scheduler.Queue/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Scheduler.Queue — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Provide queue abstraction (Redis Streams / NATS JetStream) for planner inputs and runner segments per `docs/ARCHITECTURE_SCHEDULER.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Scheduler.Queue/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Scheduler.Queue/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Scheduler Queue Task Board (Sprint 16) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCHED-QUEUE-16-401 | TODO | Scheduler Queue Guild | SCHED-MODELS-16-101 | Implement queue abstraction + Redis Streams adapter (planner inputs, runner segments) with ack/lease semantics. | Integration tests cover enqueue/dequeue/ack; lease renewal implemented; ordering preserved. | | ||||
| | SCHED-QUEUE-16-402 | TODO | Scheduler Queue Guild | SCHED-QUEUE-16-401 | Add NATS JetStream adapter with configuration binding, health probes, failover. | Health endpoints verified; failover documented; adapter tested. | | ||||
| | SCHED-QUEUE-16-403 | TODO | Scheduler Queue Guild | SCHED-QUEUE-16-401 | Dead-letter handling + metrics (queue depth, retry counts), configuration toggles. | Dead-letter policy tested; metrics exported; docs updated. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Scheduler.Storage.Mongo/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Scheduler.Storage.Mongo/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Scheduler.Storage.Mongo — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement Mongo persistence (schedules, runs, impact cursors, locks, audit) per `docs/ARCHITECTURE_SCHEDULER.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Scheduler.Storage.Mongo/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Scheduler.Storage.Mongo/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Scheduler Storage Task Board (Sprint 16) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCHED-STORAGE-16-201 | TODO | Scheduler Storage Guild | SCHED-MODELS-16-101 | Create Mongo collections (schedules, runs, impact_cursors, locks, audit) with indexes/migrations per architecture. | Migration scripts and indexes implemented; integration tests cover CRUD paths. | | ||||
| | SCHED-STORAGE-16-202 | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-201 | Implement repositories/services with tenant scoping, soft delete, TTL for completed runs, and causal consistency options. | Unit tests pass; TTL/soft delete validated; documentation updated. | | ||||
| | SCHED-STORAGE-16-203 | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-201 | Audit/logging pipeline + run stats materialized views for UI. | Audit entries persisted; stats queries efficient; docs capture usage. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Scheduler.WebService/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Scheduler.WebService/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Scheduler.WebService — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement Scheduler control plane per `docs/ARCHITECTURE_SCHEDULER.md`. | ||||
| @@ -0,0 +1,7 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										8
									
								
								src/StellaOps.Scheduler.WebService/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/StellaOps.Scheduler.WebService/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| # Scheduler WebService Task Board (Sprint 16) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCHED-WEB-16-101 | TODO | Scheduler WebService Guild | SCHED-MODELS-16-101 | Bootstrap Minimal API host with Authority OpTok + DPoP, health endpoints, plug-in discovery per architecture §§1–2. | Service boots with config validation; `/healthz`/`/readyz` pass; restart-only plug-ins enforced. | | ||||
| | SCHED-WEB-16-102 | TODO | Scheduler WebService Guild | SCHED-WEB-16-101 | Implement schedules CRUD (tenant-scoped) with cron validation, pause/resume, audit logging. | CRUD operations tested; invalid cron inputs rejected; audit entries persisted. | | ||||
| | SCHED-WEB-16-103 | TODO | Scheduler WebService Guild | SCHED-WEB-16-102 | Runs API (list/detail/cancel), ad-hoc run POST, and impact preview endpoints. | Integration tests cover run lifecycle; preview returns counts/sample; cancellation honoured. | | ||||
| | SCHED-WEB-16-104 | TODO | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feedser/Vexer exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. | | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Scheduler.Worker/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Scheduler.Worker/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # StellaOps.Scheduler.Worker — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Implement Scheduler planners/runners per `docs/ARCHITECTURE_SCHEDULER.md`. | ||||
| @@ -0,0 +1,8 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <OutputType>Exe</OutputType> | ||||
|   </PropertyGroup> | ||||
| </Project> | ||||
							
								
								
									
										9
									
								
								src/StellaOps.Scheduler.Worker/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/StellaOps.Scheduler.Worker/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| # Scheduler Worker Task Board (Sprint 16) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCHED-WORKER-16-201 | TODO | Scheduler Worker Guild | SCHED-QUEUE-16-401 | Planner loop (cron + event triggers) with lease management, fairness, and rate limiting (§6). | Planner integration tests cover cron/event triggers; rate limits enforced; logs include run IDs. | | ||||
| | SCHED-WORKER-16-202 | TODO | Scheduler Worker Guild | SCHED-IMPACT-16-301 | Wire ImpactIndex targeting (ResolveByPurls/vulns), dedupe, shard planning. | Targeting tests confirm correct image selection; dedupe documented; shards evenly distributed. | | ||||
| | SCHED-WORKER-16-203 | TODO | Scheduler Worker Guild | SCHED-WORKER-16-202 | Runner execution: call Scanner `/reports` (analysis-only) or `/scans` when configured; collect deltas; handle retries. | Runner tests stub Scanner; retries/backoff validated; deltas aggregated deterministically. | | ||||
| | SCHED-WORKER-16-204 | TODO | Scheduler Worker Guild | SCHED-WORKER-16-203 | Emit events (`scheduler.rescan.delta`, `scanner.report.ready`) for Notify/UI with summaries. | Events published to queue; payload schema documented; integration tests verify consumption. | | ||||
| | SCHED-WORKER-16-205 | TODO | Scheduler Worker Guild | SCHED-WORKER-16-201 | Metrics/telemetry: run stats, queue depth, planner latency, delta counts. | Metrics exported per spec; dashboards updated; alerts configured. | | ||||
							
								
								
									
										11
									
								
								src/StellaOps.UI/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/StellaOps.UI/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| # UI Task Board (Sprints 11 & 13) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | UI-AUTH-13-001 | TODO | UI Guild | AUTH-DPOP-11-001, AUTH-MTLS-11-002 | Integrate Authority OIDC + DPoP flows with session management. | Login/logout flows pass e2e tests; tokens refreshed; DPoP nonce handling validated. | | ||||
| | UI-SCANS-13-002 | TODO | UI Guild | SCANNER-WEB-09-102, SIGNER-API-11-101 | Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets. | Cypress tests cover SBOM/diff; performance budgets met; accessibility checks pass. | | ||||
| | UI-VEX-13-003 | TODO | UI Guild | EXCITITOR-CORE-02-001, EXCITITOR-EXPORT-01-005 | Implement VEX explorer + policy editor with preview integration. | VEX views render consensus/conflicts; staged policy preview works; accessibility checks pass. | | ||||
| | UI-ADMIN-13-004 | TODO | UI Guild | AUTH-MTLS-11-002 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. | Admin e2e tests pass; unauthorized access blocked; telemetry wired. | | ||||
| | UI-ATTEST-11-005 | TODO | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. | | ||||
| | UI-SCHED-13-005 | TODO | UI Guild | SCHED-WEB-16-101 | Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. | Panel functional with mocked endpoints; UX signoff; integration tests added. | | ||||
| | UI-NOTIFY-13-006 | TODO | UI Guild | NOTIFY-WEB-15-101 | Notify panel: channels/rules CRUD, deliveries view, test send integration. | Panel interacts with mocked Notify API; tests cover rule lifecycle; docs updated. | | ||||
							
								
								
									
										9
									
								
								src/StellaOps.Zastava.Observer/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/StellaOps.Zastava.Observer/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| # Zastava Observer Task Board | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | ZASTAVA-OBS-12-001 | TODO | Zastava Observer Guild | ZASTAVA-CORE-12-201 | Build container lifecycle watcher that tails CRI (containerd/cri-o/docker) events and emits deterministic runtime records with buffering + backoff. | Fixture cluster produces start/stop events with stable ordering, jitter/backoff tested, metrics/logging wired. | | ||||
| | ZASTAVA-OBS-12-002 | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-001 | Capture entrypoint traces and loaded libraries, hashing binaries and correlating to SBOM baseline per architecture sections 2.1 and 10. | EntryTrace parser covers shell/python/node launchers, loaded library hashes recorded, fixtures assert linkage to SBOM usage view. | | ||||
| | ZASTAVA-OBS-12-003 | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Implement runtime posture checks (signature/SBOM/attestation presence) with offline caching and warning surfaces. | Observer marks posture status, caches refresh across restarts, integration tests prove offline tolerance. | | ||||
| | ZASTAVA-OBS-12-004 | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Batch `/runtime/events` submissions with disk-backed buffer, rate limits, and deterministic envelopes. | Buffered submissions survive restart, rate-limits enforced in tests, JSON envelopes match schema in docs/events. | | ||||
| | ZASTAVA-OBS-17-005 | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation. | Observer reads build-id via `/proc/<pid>/exe`/notes without pausing workloads, runtime events include `buildId` field, fixtures cover glibc/musl images, docs updated with retrieval notes. | | ||||
		Reference in New Issue
	
	Block a user