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