Restructure solution layout by module
This commit is contained in:
@@ -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 | DONE (2025-10-21) | Excititor Connectors – Stella | EXCITITOR-EXPORT-01-007 | **DONE (2025-10-21)** – Implemented `StellaOpsMirrorConnector` with `MirrorManifestClient` + `MirrorSignatureVerifier`, digest validation, signature enforcement, raw document + DTO persistence, and resume cursor updates. Added fixture-backed tests covering happy path and tampered manifest rejection. | 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. |
|
||||
25
src/Excititor/StellaOps.Excititor.WebService/AGENTS.md
Normal file
25
src/Excititor/StellaOps.Excititor.WebService/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
ASP.NET Minimal API surface for Excititor ingest, provider administration, reconciliation, export, and verification flows.
|
||||
## Scope
|
||||
- Program bootstrap, DI wiring for connectors/normalizers/export/attestation/policy/storage.
|
||||
- HTTP endpoints `/excititor/*` with authentication, authorization scopes, request validation, and deterministic responses.
|
||||
- Job orchestration bridges for Worker hand-off (when co-hosted) and offline-friendly configuration.
|
||||
- Observability (structured logs, metrics, tracing) aligned with StellaOps conventions.
|
||||
## Participants
|
||||
- StellaOps.Cli sends `excititor` verbs to this service via token-authenticated HTTPS.
|
||||
- Worker receives scheduled jobs and uses shared infrastructure via common DI extensions.
|
||||
- Authority service provides tokens; WebService enforces scopes before executing operations.
|
||||
## Interfaces & contracts
|
||||
- DTOs for ingest/export requests, run metadata, provider management.
|
||||
- Background job interfaces for ingest/resume/reconcile triggering.
|
||||
- Health/status endpoints exposing pull/export history and current policy revision.
|
||||
## In/Out of scope
|
||||
In: HTTP hosting, request orchestration, DI composition, auth/authorization, logging.
|
||||
Out: long-running ingestion loops (Worker), export rendering (Export module), connector implementations.
|
||||
## Observability & security expectations
|
||||
- Enforce bearer token scopes, enforce audit logging (request/response correlation IDs, provider IDs).
|
||||
- Emit structured events for ingest runs, export invocations, attestation references.
|
||||
- Provide built-in counters/histograms for latency and throughput.
|
||||
## Tests
|
||||
- Minimal API contract/unit tests and integration harness will live in `../StellaOps.Excititor.WebService.Tests`.
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
internal static class IngestEndpoints
|
||||
{
|
||||
private const string AdminScope = "vex.admin";
|
||||
|
||||
public static void MapIngestEndpoints(IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/excititor");
|
||||
|
||||
group.MapPost("/init", HandleInitAsync);
|
||||
group.MapPost("/ingest/run", HandleRunAsync);
|
||||
group.MapPost("/ingest/resume", HandleResumeAsync);
|
||||
group.MapPost("/reconcile", HandleReconcileAsync);
|
||||
}
|
||||
|
||||
internal static async Task<IResult> HandleInitAsync(
|
||||
HttpContext httpContext,
|
||||
ExcititorInitRequest request,
|
||||
IVexIngestOrchestrator orchestrator,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var providerIds = NormalizeProviders(request.Providers);
|
||||
_ = timeProvider;
|
||||
var options = new IngestInitOptions(providerIds, request.Resume ?? false);
|
||||
|
||||
var summary = await orchestrator.InitializeAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Initialized {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
|
||||
|
||||
return TypedResults.Ok<object>(new
|
||||
{
|
||||
message,
|
||||
runId = summary.RunId,
|
||||
startedAt = summary.StartedAt,
|
||||
completedAt = summary.CompletedAt,
|
||||
providers = summary.Providers.Select(static provider => new
|
||||
{
|
||||
providerId = provider.ProviderId,
|
||||
displayName = provider.DisplayName,
|
||||
status = provider.Status,
|
||||
durationMs = provider.Duration.TotalMilliseconds,
|
||||
error = provider.Error
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
internal static async Task<IResult> HandleRunAsync(
|
||||
HttpContext httpContext,
|
||||
ExcititorIngestRunRequest request,
|
||||
IVexIngestOrchestrator orchestrator,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryParseDateTimeOffset(request.Since, out var since, out var sinceError))
|
||||
{
|
||||
return TypedResults.BadRequest<object>(new { message = sinceError });
|
||||
}
|
||||
|
||||
if (!TryParseTimeSpan(request.Window, out var window, out var windowError))
|
||||
{
|
||||
return TypedResults.BadRequest<object>(new { message = windowError });
|
||||
}
|
||||
|
||||
_ = timeProvider;
|
||||
var providerIds = NormalizeProviders(request.Providers);
|
||||
var options = new IngestRunOptions(
|
||||
providerIds,
|
||||
since,
|
||||
window,
|
||||
request.Force ?? false);
|
||||
|
||||
var summary = await orchestrator.RunAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Ingest run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
|
||||
|
||||
return TypedResults.Ok<object>(new
|
||||
{
|
||||
message,
|
||||
runId = summary.RunId,
|
||||
startedAt = summary.StartedAt,
|
||||
completedAt = summary.CompletedAt,
|
||||
durationMs = summary.Duration.TotalMilliseconds,
|
||||
providers = summary.Providers.Select(static provider => new
|
||||
{
|
||||
providerId = provider.ProviderId,
|
||||
status = provider.Status,
|
||||
documents = provider.Documents,
|
||||
claims = provider.Claims,
|
||||
startedAt = provider.StartedAt,
|
||||
completedAt = provider.CompletedAt,
|
||||
durationMs = provider.Duration.TotalMilliseconds,
|
||||
lastDigest = provider.LastDigest,
|
||||
lastUpdated = provider.LastUpdated,
|
||||
checkpoint = provider.Checkpoint,
|
||||
error = provider.Error
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
internal static async Task<IResult> HandleResumeAsync(
|
||||
HttpContext httpContext,
|
||||
ExcititorIngestResumeRequest request,
|
||||
IVexIngestOrchestrator orchestrator,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
_ = timeProvider;
|
||||
var providerIds = NormalizeProviders(request.Providers);
|
||||
var options = new IngestResumeOptions(providerIds, request.Checkpoint);
|
||||
|
||||
var summary = await orchestrator.ResumeAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Resume run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
|
||||
|
||||
return TypedResults.Ok<object>(new
|
||||
{
|
||||
message,
|
||||
runId = summary.RunId,
|
||||
startedAt = summary.StartedAt,
|
||||
completedAt = summary.CompletedAt,
|
||||
durationMs = summary.Duration.TotalMilliseconds,
|
||||
providers = summary.Providers.Select(static provider => new
|
||||
{
|
||||
providerId = provider.ProviderId,
|
||||
status = provider.Status,
|
||||
documents = provider.Documents,
|
||||
claims = provider.Claims,
|
||||
startedAt = provider.StartedAt,
|
||||
completedAt = provider.CompletedAt,
|
||||
durationMs = provider.Duration.TotalMilliseconds,
|
||||
since = provider.Since,
|
||||
checkpoint = provider.Checkpoint,
|
||||
error = provider.Error
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
internal static async Task<IResult> HandleReconcileAsync(
|
||||
HttpContext httpContext,
|
||||
ExcititorReconcileRequest request,
|
||||
IVexIngestOrchestrator orchestrator,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryParseTimeSpan(request.MaxAge, out var maxAge, out var error))
|
||||
{
|
||||
return TypedResults.BadRequest<object>(new { message = error });
|
||||
}
|
||||
|
||||
_ = timeProvider;
|
||||
var providerIds = NormalizeProviders(request.Providers);
|
||||
var options = new ReconcileOptions(providerIds, maxAge);
|
||||
|
||||
var summary = await orchestrator.ReconcileAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Reconcile completed for {summary.ProviderCount} provider(s); {summary.ReconciledCount} reconciled, {summary.SkippedCount} skipped, {summary.FailureCount} failed.";
|
||||
|
||||
return TypedResults.Ok<object>(new
|
||||
{
|
||||
message,
|
||||
runId = summary.RunId,
|
||||
startedAt = summary.StartedAt,
|
||||
completedAt = summary.CompletedAt,
|
||||
durationMs = summary.Duration.TotalMilliseconds,
|
||||
providers = summary.Providers.Select(static provider => new
|
||||
{
|
||||
providerId = provider.ProviderId,
|
||||
status = provider.Status,
|
||||
action = provider.Action,
|
||||
lastUpdated = provider.LastUpdated,
|
||||
threshold = provider.Threshold,
|
||||
documents = provider.Documents,
|
||||
claims = provider.Claims,
|
||||
error = provider.Error
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
internal static ImmutableArray<string> NormalizeProviders(IReadOnlyCollection<string>? providers)
|
||||
{
|
||||
if (providers is null || providers.Count == 0)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(provider.Trim());
|
||||
}
|
||||
|
||||
return set.ToImmutableArray();
|
||||
}
|
||||
|
||||
internal static bool TryParseDateTimeOffset(string? value, out DateTimeOffset? result, out string? error)
|
||||
{
|
||||
result = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
value.Trim(),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
result = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Invalid 'since' value. Use ISO-8601 format (e.g. 2025-10-19T12:30:00Z).";
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool TryParseTimeSpan(string? value, out TimeSpan? result, out string? error)
|
||||
{
|
||||
result = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TimeSpan.TryParse(value.Trim(), CultureInfo.InvariantCulture, out var parsed) && parsed >= TimeSpan.Zero)
|
||||
{
|
||||
result = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Invalid duration value. Use TimeSpan format (e.g. 1.00:00:00).";
|
||||
return false;
|
||||
}
|
||||
|
||||
internal sealed record ExcititorInitRequest(IReadOnlyList<string>? Providers, bool? Resume);
|
||||
|
||||
internal sealed record ExcititorIngestRunRequest(
|
||||
IReadOnlyList<string>? Providers,
|
||||
string? Since,
|
||||
string? Window,
|
||||
bool? Force);
|
||||
|
||||
internal sealed record ExcititorIngestResumeRequest(
|
||||
IReadOnlyList<string>? Providers,
|
||||
string? Checkpoint);
|
||||
|
||||
internal sealed record ExcititorReconcileRequest(
|
||||
IReadOnlyList<string>? Providers,
|
||||
string? MaxAge);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
internal static class MirrorEndpoints
|
||||
{
|
||||
public static void MapMirrorEndpoints(WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/excititor/mirror");
|
||||
|
||||
group.MapGet("/domains", HandleListDomainsAsync);
|
||||
group.MapGet("/domains/{domainId}", HandleDomainDetailAsync);
|
||||
group.MapGet("/domains/{domainId}/index", HandleDomainIndexAsync);
|
||||
group.MapGet("/domains/{domainId}/exports/{exportKey}", HandleExportMetadataAsync);
|
||||
group.MapGet("/domains/{domainId}/exports/{exportKey}/download", HandleExportDownloadAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListDomainsAsync(
|
||||
HttpContext httpContext,
|
||||
IOptions<MirrorDistributionOptions> options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var domains = options.Value.Domains
|
||||
.Select(static domain => new MirrorDomainSummary(
|
||||
domain.Id,
|
||||
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
|
||||
domain.RequireAuthentication,
|
||||
Math.Max(domain.MaxIndexRequestsPerHour, 0),
|
||||
Math.Max(domain.MaxDownloadRequestsPerHour, 0)))
|
||||
.ToArray();
|
||||
|
||||
await WriteJsonAsync(httpContext, new MirrorDomainListResponse(domains), StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleDomainDetailAsync(
|
||||
string domainId,
|
||||
HttpContext httpContext,
|
||||
IOptions<MirrorDistributionOptions> options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryFindDomain(options.Value, domainId, out var domain))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var response = new MirrorDomainDetail(
|
||||
domain.Id,
|
||||
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
|
||||
domain.RequireAuthentication,
|
||||
Math.Max(domain.MaxIndexRequestsPerHour, 0),
|
||||
Math.Max(domain.MaxDownloadRequestsPerHour, 0),
|
||||
domain.Exports.Select(static export => export.Key).OrderBy(static key => key, StringComparer.Ordinal).ToImmutableArray());
|
||||
|
||||
await WriteJsonAsync(httpContext, response, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleDomainIndexAsync(
|
||||
string domainId,
|
||||
HttpContext httpContext,
|
||||
IOptions<MirrorDistributionOptions> options,
|
||||
MirrorRateLimiter rateLimiter,
|
||||
IVexExportStore exportStore,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryFindDomain(options.Value, domainId, out var domain))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (domain.RequireAuthentication && (httpContext.User?.Identity?.IsAuthenticated is not true))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire(domain.Id, "index", Math.Max(domain.MaxIndexRequestsPerHour, 0), out var retryAfter))
|
||||
{
|
||||
if (retryAfter is { } retry)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
await WritePlainTextAsync(httpContext, "mirror index quota exceeded", StatusCodes.Status429TooManyRequests, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var resolvedExports = new List<MirrorExportIndexEntry>();
|
||||
foreach (var exportOption in domain.Exports)
|
||||
{
|
||||
if (!MirrorExportPlanner.TryBuild(exportOption, out var plan, out var error))
|
||||
{
|
||||
resolvedExports.Add(new MirrorExportIndexEntry(
|
||||
exportOption.Key,
|
||||
null,
|
||||
null,
|
||||
exportOption.Format,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
error ?? "invalid_export_configuration"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
resolvedExports.Add(new MirrorExportIndexEntry(
|
||||
exportOption.Key,
|
||||
null,
|
||||
plan.Signature.Value,
|
||||
plan.Format.ToString().ToLowerInvariant(),
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
"manifest_not_found"));
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedExports.Add(new MirrorExportIndexEntry(
|
||||
exportOption.Key,
|
||||
manifest.ExportId,
|
||||
manifest.QuerySignature.Value,
|
||||
manifest.Format.ToString().ToLowerInvariant(),
|
||||
manifest.CreatedAt,
|
||||
manifest.Artifact,
|
||||
manifest.SizeBytes,
|
||||
manifest.ConsensusRevision,
|
||||
manifest.Attestation is null ? null : new MirrorExportAttestation(manifest.Attestation.PredicateType, manifest.Attestation.Rekor?.Location, manifest.Attestation.EnvelopeDigest, manifest.Attestation.SignedAt),
|
||||
null));
|
||||
}
|
||||
|
||||
var indexResponse = new MirrorDomainIndex(
|
||||
domain.Id,
|
||||
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
|
||||
timeProvider.GetUtcNow(),
|
||||
resolvedExports.ToImmutableArray());
|
||||
|
||||
await WriteJsonAsync(httpContext, indexResponse, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExportMetadataAsync(
|
||||
string domainId,
|
||||
string exportKey,
|
||||
HttpContext httpContext,
|
||||
IOptions<MirrorDistributionOptions> options,
|
||||
MirrorRateLimiter rateLimiter,
|
||||
IVexExportStore exportStore,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryFindDomain(options.Value, domainId, out var domain))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (domain.RequireAuthentication && (httpContext.User?.Identity?.IsAuthenticated is not true))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!TryFindExport(domain, exportKey, out var exportOptions))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!MirrorExportPlanner.TryBuild(exportOptions, out var plan, out var error))
|
||||
{
|
||||
await WritePlainTextAsync(httpContext, error ?? "invalid_export_configuration", StatusCodes.Status503ServiceUnavailable, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var payload = new MirrorExportMetadata(
|
||||
domain.Id,
|
||||
exportOptions.Key,
|
||||
manifest.ExportId,
|
||||
manifest.QuerySignature.Value,
|
||||
manifest.Format.ToString().ToLowerInvariant(),
|
||||
manifest.CreatedAt,
|
||||
manifest.Artifact,
|
||||
manifest.SizeBytes,
|
||||
manifest.SourceProviders,
|
||||
manifest.Attestation is null ? null : new MirrorExportAttestation(manifest.Attestation.PredicateType, manifest.Attestation.Rekor?.Location, manifest.Attestation.EnvelopeDigest, manifest.Attestation.SignedAt));
|
||||
|
||||
await WriteJsonAsync(httpContext, payload, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExportDownloadAsync(
|
||||
string domainId,
|
||||
string exportKey,
|
||||
HttpContext httpContext,
|
||||
IOptions<MirrorDistributionOptions> options,
|
||||
MirrorRateLimiter rateLimiter,
|
||||
IVexExportStore exportStore,
|
||||
IEnumerable<IVexArtifactStore> artifactStores,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryFindDomain(options.Value, domainId, out var domain))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (domain.RequireAuthentication && (httpContext.User?.Identity?.IsAuthenticated is not true))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire(domain.Id, "download", Math.Max(domain.MaxDownloadRequestsPerHour, 0), out var retryAfter))
|
||||
{
|
||||
if (retryAfter is { } retry)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
await WritePlainTextAsync(httpContext, "mirror download quota exceeded", StatusCodes.Status429TooManyRequests, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!TryFindExport(domain, exportKey, out var exportOptions) || !MirrorExportPlanner.TryBuild(exportOptions, out var plan, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
Stream? contentStream = null;
|
||||
foreach (var store in artifactStores)
|
||||
{
|
||||
contentStream = await store.OpenReadAsync(manifest.Artifact, cancellationToken).ConfigureAwait(false);
|
||||
if (contentStream is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentStream is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
await using (contentStream.ConfigureAwait(false))
|
||||
{
|
||||
var contentType = ResolveContentType(manifest.Format);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status200OK;
|
||||
httpContext.Response.ContentType = contentType;
|
||||
httpContext.Response.Headers.ContentDisposition = FormattableString.Invariant($"attachment; filename=\"{BuildDownloadFileName(domain.Id, exportOptions.Key, manifest.Format)}\"");
|
||||
|
||||
await contentStream.CopyToAsync(httpContext.Response.Body, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static bool TryFindDomain(MirrorDistributionOptions options, string domainId, out MirrorDomainOptions domain)
|
||||
{
|
||||
domain = options.Domains.FirstOrDefault(d => string.Equals(d.Id, domainId, StringComparison.OrdinalIgnoreCase))!;
|
||||
return domain is not null;
|
||||
}
|
||||
|
||||
private static bool TryFindExport(MirrorDomainOptions domain, string exportKey, out MirrorExportOptions export)
|
||||
{
|
||||
export = domain.Exports.FirstOrDefault(e => string.Equals(e.Key, exportKey, StringComparison.OrdinalIgnoreCase))!;
|
||||
return export is not null;
|
||||
}
|
||||
|
||||
private static string ResolveContentType(VexExportFormat format)
|
||||
=> format switch
|
||||
{
|
||||
VexExportFormat.Json => "application/json",
|
||||
VexExportFormat.JsonLines => "application/jsonl",
|
||||
VexExportFormat.OpenVex => "application/json",
|
||||
VexExportFormat.Csaf => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
private static string BuildDownloadFileName(string domainId, string exportKey, VexExportFormat format)
|
||||
{
|
||||
var builder = new StringBuilder(domainId.Length + exportKey.Length + 8);
|
||||
builder.Append(domainId).Append('-').Append(exportKey);
|
||||
builder.Append(format switch
|
||||
{
|
||||
VexExportFormat.Json => ".json",
|
||||
VexExportFormat.JsonLines => ".jsonl",
|
||||
VexExportFormat.OpenVex => ".openvex.json",
|
||||
VexExportFormat.Csaf => ".csaf.json",
|
||||
_ => ".bin",
|
||||
});
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static async Task WritePlainTextAsync(HttpContext context, string message, int statusCode, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "text/plain";
|
||||
await context.Response.WriteAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync<T>(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "application/json";
|
||||
var json = VexCanonicalJsonSerializer.Serialize(payload);
|
||||
await context.Response.WriteAsync(json, cancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed record MirrorDomainListResponse(IReadOnlyList<MirrorDomainSummary> Domains);
|
||||
|
||||
internal sealed record MirrorDomainSummary(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
bool RequireAuthentication,
|
||||
int MaxIndexRequestsPerHour,
|
||||
int MaxDownloadRequestsPerHour);
|
||||
|
||||
internal sealed record MirrorDomainDetail(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
bool RequireAuthentication,
|
||||
int MaxIndexRequestsPerHour,
|
||||
int MaxDownloadRequestsPerHour,
|
||||
IReadOnlyList<string> Exports);
|
||||
|
||||
internal sealed record MirrorDomainIndex(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyList<MirrorExportIndexEntry> Exports);
|
||||
|
||||
internal sealed record MirrorExportIndexEntry(
|
||||
string ExportKey,
|
||||
string? ExportId,
|
||||
string? QuerySignature,
|
||||
string Format,
|
||||
DateTimeOffset? CreatedAt,
|
||||
VexContentAddress? Artifact,
|
||||
long SizeBytes,
|
||||
string? ConsensusRevision,
|
||||
MirrorExportAttestation? Attestation,
|
||||
string? Status);
|
||||
|
||||
internal sealed record MirrorExportAttestation(
|
||||
string PredicateType,
|
||||
string? RekorLocation,
|
||||
string? EnvelopeDigest,
|
||||
DateTimeOffset? SignedAt);
|
||||
|
||||
internal sealed record MirrorExportMetadata(
|
||||
string DomainId,
|
||||
string ExportKey,
|
||||
string ExportId,
|
||||
string QuerySignature,
|
||||
string Format,
|
||||
DateTimeOffset CreatedAt,
|
||||
VexContentAddress Artifact,
|
||||
long SizeBytes,
|
||||
IReadOnlyList<string> SourceProviders,
|
||||
MirrorExportAttestation? Attestation);
|
||||
@@ -0,0 +1,512 @@
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Attestation;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
internal static class ResolveEndpoint
|
||||
{
|
||||
private const int MaxSubjectPairs = 256;
|
||||
private const string ReadScope = "vex.read";
|
||||
|
||||
public static void MapResolveEndpoint(WebApplication app)
|
||||
{
|
||||
app.MapPost("/excititor/resolve", HandleResolveAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleResolveAsync(
|
||||
VexResolveRequest request,
|
||||
HttpContext httpContext,
|
||||
IVexClaimStore claimStore,
|
||||
IVexConsensusStore consensusStore,
|
||||
IVexProviderStore providerStore,
|
||||
IVexPolicyProvider policyProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILoggerFactory loggerFactory,
|
||||
IVexAttestationClient? attestationClient,
|
||||
IVexSigner? signer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, ReadScope);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest("Request payload is required.");
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger("ResolveEndpoint");
|
||||
|
||||
var productKeys = NormalizeValues(request.ProductKeys, request.Purls);
|
||||
var vulnerabilityIds = NormalizeValues(request.VulnerabilityIds);
|
||||
|
||||
if (productKeys.Count == 0)
|
||||
{
|
||||
await WritePlainTextAsync(httpContext, "At least one productKey or purl must be provided.", StatusCodes.Status400BadRequest, cancellationToken);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (vulnerabilityIds.Count == 0)
|
||||
{
|
||||
await WritePlainTextAsync(httpContext, "At least one vulnerabilityId must be provided.", StatusCodes.Status400BadRequest, cancellationToken);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var pairCount = (long)productKeys.Count * vulnerabilityIds.Count;
|
||||
if (pairCount > MaxSubjectPairs)
|
||||
{
|
||||
await WritePlainTextAsync(httpContext, FormattableString.Invariant($"A maximum of {MaxSubjectPairs} subject pairs are allowed per request."), StatusCodes.Status400BadRequest, cancellationToken);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var snapshot = policyProvider.GetSnapshot();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.PolicyRevisionId) &&
|
||||
!string.Equals(request.PolicyRevisionId.Trim(), snapshot.RevisionId, StringComparison.Ordinal))
|
||||
{
|
||||
var conflictPayload = new
|
||||
{
|
||||
message = $"Requested policy revision '{request.PolicyRevisionId}' does not match active revision '{snapshot.RevisionId}'.",
|
||||
activeRevision = snapshot.RevisionId,
|
||||
requestedRevision = request.PolicyRevisionId,
|
||||
};
|
||||
await WriteJsonAsync(httpContext, conflictPayload, StatusCodes.Status409Conflict, cancellationToken);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var resolver = new VexConsensusResolver(snapshot.ConsensusPolicy);
|
||||
var resolvedAt = timeProvider.GetUtcNow();
|
||||
var providerCache = new Dictionary<string, VexProvider>(StringComparer.Ordinal);
|
||||
var results = new List<VexResolveResult>((int)pairCount);
|
||||
|
||||
foreach (var productKey in productKeys)
|
||||
{
|
||||
foreach (var vulnerabilityId in vulnerabilityIds)
|
||||
{
|
||||
var claims = await claimStore.FindAsync(vulnerabilityId, productKey, since: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var claimArray = claims.Count == 0 ? Array.Empty<VexClaim>() : claims.ToArray();
|
||||
var signals = AggregateSignals(claimArray);
|
||||
var providers = await LoadProvidersAsync(claimArray, providerStore, providerCache, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var product = ResolveProduct(claimArray, productKey);
|
||||
var calculatedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var resolution = resolver.Resolve(new VexConsensusRequest(
|
||||
vulnerabilityId,
|
||||
product,
|
||||
claimArray,
|
||||
providers,
|
||||
calculatedAt,
|
||||
snapshot.ConsensusOptions.WeightCeiling,
|
||||
signals,
|
||||
snapshot.RevisionId,
|
||||
snapshot.Digest));
|
||||
|
||||
var consensus = resolution.Consensus;
|
||||
|
||||
if (!string.Equals(consensus.PolicyVersion, snapshot.Version, StringComparison.Ordinal) ||
|
||||
!string.Equals(consensus.PolicyRevisionId, snapshot.RevisionId, StringComparison.Ordinal) ||
|
||||
!string.Equals(consensus.PolicyDigest, snapshot.Digest, StringComparison.Ordinal))
|
||||
{
|
||||
consensus = new VexConsensus(
|
||||
consensus.VulnerabilityId,
|
||||
consensus.Product,
|
||||
consensus.Status,
|
||||
consensus.CalculatedAt,
|
||||
consensus.Sources,
|
||||
consensus.Conflicts,
|
||||
consensus.Signals,
|
||||
snapshot.Version,
|
||||
consensus.Summary,
|
||||
snapshot.RevisionId,
|
||||
snapshot.Digest);
|
||||
}
|
||||
|
||||
await consensusStore.SaveAsync(consensus, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = PreparePayload(consensus);
|
||||
var contentSignature = await TrySignAsync(signer, payload, logger, cancellationToken).ConfigureAwait(false);
|
||||
var attestation = await BuildAttestationAsync(
|
||||
attestationClient,
|
||||
consensus,
|
||||
snapshot,
|
||||
payload,
|
||||
logger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var decisions = resolution.DecisionLog.IsDefault
|
||||
? Array.Empty<VexConsensusDecisionTelemetry>()
|
||||
: resolution.DecisionLog.ToArray();
|
||||
|
||||
results.Add(new VexResolveResult(
|
||||
consensus.VulnerabilityId,
|
||||
consensus.Product.Key,
|
||||
consensus.Status,
|
||||
consensus.CalculatedAt,
|
||||
consensus.Sources,
|
||||
consensus.Conflicts,
|
||||
consensus.Signals,
|
||||
consensus.Summary,
|
||||
consensus.PolicyRevisionId ?? snapshot.RevisionId,
|
||||
consensus.PolicyVersion ?? snapshot.Version,
|
||||
consensus.PolicyDigest ?? snapshot.Digest,
|
||||
decisions,
|
||||
new VexResolveEnvelope(
|
||||
payload.Artifact,
|
||||
contentSignature,
|
||||
attestation.Metadata,
|
||||
attestation.Envelope,
|
||||
attestation.Signature)));
|
||||
}
|
||||
}
|
||||
|
||||
var policy = new VexResolvePolicy(
|
||||
snapshot.RevisionId,
|
||||
snapshot.Version,
|
||||
snapshot.Digest,
|
||||
request.PolicyRevisionId?.Trim());
|
||||
|
||||
var response = new VexResolveResponse(resolvedAt, policy, results);
|
||||
await WriteJsonAsync(httpContext, response, StatusCodes.Status200OK, cancellationToken);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeValues(params IReadOnlyList<string>?[] sources)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var value in source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (seen.Add(normalized))
|
||||
{
|
||||
result.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VexSignalSnapshot? AggregateSignals(IReadOnlyList<VexClaim> claims)
|
||||
{
|
||||
if (claims.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
VexSeveritySignal? bestSeverity = null;
|
||||
double? bestScore = null;
|
||||
bool kevPresent = false;
|
||||
bool kevTrue = false;
|
||||
double? bestEpss = null;
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
if (claim.Signals is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var severity = claim.Signals.Severity;
|
||||
if (severity is not null)
|
||||
{
|
||||
var score = severity.Score;
|
||||
if (bestSeverity is null ||
|
||||
(score is not null && (bestScore is null || score.Value > bestScore.Value)) ||
|
||||
(score is null && bestScore is null && !string.IsNullOrWhiteSpace(severity.Label) && string.IsNullOrWhiteSpace(bestSeverity.Label)))
|
||||
{
|
||||
bestSeverity = severity;
|
||||
bestScore = severity.Score;
|
||||
}
|
||||
}
|
||||
|
||||
if (claim.Signals.Kev is { } kevValue)
|
||||
{
|
||||
kevPresent = true;
|
||||
if (kevValue)
|
||||
{
|
||||
kevTrue = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (claim.Signals.Epss is { } epss)
|
||||
{
|
||||
if (bestEpss is null || epss > bestEpss.Value)
|
||||
{
|
||||
bestEpss = epss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSeverity is null && !kevPresent && bestEpss is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool? kev = kevTrue ? true : (kevPresent ? false : null);
|
||||
return new VexSignalSnapshot(bestSeverity, kev, bestEpss);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, VexProvider>> LoadProvidersAsync(
|
||||
IReadOnlyList<VexClaim> claims,
|
||||
IVexProviderStore providerStore,
|
||||
IDictionary<string, VexProvider> cache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (claims.Count == 0)
|
||||
{
|
||||
return ImmutableDictionary<string, VexProvider>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, VexProvider>(StringComparer.Ordinal);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var providerId in claims.Select(claim => claim.ProviderId))
|
||||
{
|
||||
if (!seen.Add(providerId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cache.TryGetValue(providerId, out var cached))
|
||||
{
|
||||
builder[providerId] = cached;
|
||||
continue;
|
||||
}
|
||||
|
||||
var provider = await providerStore.FindAsync(providerId, cancellationToken).ConfigureAwait(false);
|
||||
if (provider is not null)
|
||||
{
|
||||
cache[providerId] = provider;
|
||||
builder[providerId] = provider;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static VexProduct ResolveProduct(IReadOnlyList<VexClaim> claims, string productKey)
|
||||
{
|
||||
if (claims.Count > 0)
|
||||
{
|
||||
return claims[0].Product;
|
||||
}
|
||||
|
||||
var inferredPurl = productKey.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ? productKey : null;
|
||||
return new VexProduct(productKey, name: null, version: null, purl: inferredPurl);
|
||||
}
|
||||
|
||||
private static ConsensusPayload PreparePayload(VexConsensus consensus)
|
||||
{
|
||||
var canonicalJson = VexCanonicalJsonSerializer.Serialize(consensus);
|
||||
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
var digest = SHA256.HashData(bytes);
|
||||
var digestHex = Convert.ToHexString(digest).ToLowerInvariant();
|
||||
var address = new VexContentAddress("sha256", digestHex);
|
||||
return new ConsensusPayload(address, bytes, canonicalJson);
|
||||
}
|
||||
|
||||
private static async ValueTask<ResolveSignature?> TrySignAsync(
|
||||
IVexSigner? signer,
|
||||
ConsensusPayload payload,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (signer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signature = await signer.SignAsync(payload.Bytes, cancellationToken).ConfigureAwait(false);
|
||||
return new ResolveSignature(signature.Signature, signature.KeyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to sign resolve payload {Digest}", payload.Artifact.ToUri());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<ResolveAttestation> BuildAttestationAsync(
|
||||
IVexAttestationClient? attestationClient,
|
||||
VexConsensus consensus,
|
||||
VexPolicySnapshot snapshot,
|
||||
ConsensusPayload payload,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (attestationClient is null)
|
||||
{
|
||||
return new ResolveAttestation(null, null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var exportId = BuildAttestationExportId(consensus.VulnerabilityId, consensus.Product.Key);
|
||||
var filters = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("vulnerabilityId", consensus.VulnerabilityId),
|
||||
new KeyValuePair<string, string>("productKey", consensus.Product.Key),
|
||||
new KeyValuePair<string, string>("policyRevisionId", snapshot.RevisionId),
|
||||
};
|
||||
|
||||
var querySignature = VexQuerySignature.FromFilters(filters);
|
||||
var providerIds = consensus.Sources
|
||||
.Select(source => source.ProviderId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
metadataBuilder["consensusDigest"] = payload.Artifact.ToUri();
|
||||
metadataBuilder["policyRevisionId"] = snapshot.RevisionId;
|
||||
metadataBuilder["policyVersion"] = snapshot.Version;
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.Digest))
|
||||
{
|
||||
metadataBuilder["policyDigest"] = snapshot.Digest;
|
||||
}
|
||||
|
||||
var response = await attestationClient.SignAsync(new VexAttestationRequest(
|
||||
exportId,
|
||||
querySignature,
|
||||
payload.Artifact,
|
||||
VexExportFormat.Json,
|
||||
consensus.CalculatedAt,
|
||||
providerIds,
|
||||
metadataBuilder.ToImmutable()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var envelopeJson = response.Diagnostics.TryGetValue("envelope", out var envelopeValue)
|
||||
? envelopeValue
|
||||
: null;
|
||||
|
||||
ResolveSignature? signature = null;
|
||||
if (!string.IsNullOrWhiteSpace(envelopeJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson);
|
||||
var dsseSignature = envelope?.Signatures?.FirstOrDefault();
|
||||
if (dsseSignature is not null)
|
||||
{
|
||||
signature = new ResolveSignature(dsseSignature.Signature, dsseSignature.KeyId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Failed to deserialize DSSE envelope for resolve export {ExportId}", exportId);
|
||||
}
|
||||
}
|
||||
|
||||
return new ResolveAttestation(response.Attestation, envelopeJson, signature);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Unable to produce attestation for {VulnerabilityId}/{ProductKey}", consensus.VulnerabilityId, consensus.Product.Key);
|
||||
return new ResolveAttestation(null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAttestationExportId(string vulnerabilityId, string productKey)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(productKey));
|
||||
var digest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return FormattableString.Invariant($"resolve/{vulnerabilityId}/{digest}");
|
||||
}
|
||||
|
||||
private sealed record ConsensusPayload(VexContentAddress Artifact, byte[] Bytes, string CanonicalJson);
|
||||
|
||||
private static async Task WritePlainTextAsync(HttpContext context, string message, int statusCode, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "text/plain";
|
||||
await context.Response.WriteAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync<T>(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "application/json";
|
||||
var json = VexCanonicalJsonSerializer.Serialize(payload);
|
||||
await context.Response.WriteAsync(json, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexResolveRequest(
|
||||
IReadOnlyList<string>? ProductKeys,
|
||||
IReadOnlyList<string>? Purls,
|
||||
IReadOnlyList<string>? VulnerabilityIds,
|
||||
string? PolicyRevisionId);
|
||||
|
||||
internal sealed record VexResolvePolicy(
|
||||
string ActiveRevisionId,
|
||||
string Version,
|
||||
string Digest,
|
||||
string? RequestedRevisionId);
|
||||
|
||||
internal sealed record VexResolveResponse(
|
||||
DateTimeOffset ResolvedAt,
|
||||
VexResolvePolicy Policy,
|
||||
IReadOnlyList<VexResolveResult> Results);
|
||||
|
||||
internal sealed record VexResolveResult(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
VexConsensusStatus Status,
|
||||
DateTimeOffset CalculatedAt,
|
||||
IReadOnlyList<VexConsensusSource> Sources,
|
||||
IReadOnlyList<VexConsensusConflict> Conflicts,
|
||||
VexSignalSnapshot? Signals,
|
||||
string? Summary,
|
||||
string PolicyRevisionId,
|
||||
string PolicyVersion,
|
||||
string PolicyDigest,
|
||||
IReadOnlyList<VexConsensusDecisionTelemetry> Decisions,
|
||||
VexResolveEnvelope Envelope);
|
||||
|
||||
internal sealed record VexResolveEnvelope(
|
||||
VexContentAddress Artifact,
|
||||
ResolveSignature? ContentSignature,
|
||||
VexAttestationMetadata? Attestation,
|
||||
string? AttestationEnvelope,
|
||||
ResolveSignature? AttestationSignature);
|
||||
|
||||
internal sealed record ResolveSignature(string Value, string? KeyId);
|
||||
|
||||
internal sealed record ResolveAttestation(
|
||||
VexAttestationMetadata? Metadata,
|
||||
string? Envelope,
|
||||
ResolveSignature? Signature);
|
||||
267
src/Excititor/StellaOps.Excititor.WebService/Program.cs
Normal file
267
src/Excititor/StellaOps.Excititor.WebService/Program.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Attestation.Extensions;
|
||||
using StellaOps.Excititor.Attestation;
|
||||
using StellaOps.Excititor.Attestation.Transparency;
|
||||
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Formats.CSAF;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
using StellaOps.Excititor.Formats.OpenVEX;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Endpoints;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var configuration = builder.Configuration;
|
||||
var services = builder.Services;
|
||||
|
||||
services.AddOptions<VexMongoStorageOptions>()
|
||||
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddExcititorMongoStorage();
|
||||
services.AddCsafNormalizer();
|
||||
services.AddCycloneDxNormalizer();
|
||||
services.AddOpenVexNormalizer();
|
||||
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
|
||||
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
|
||||
services.AddExcititorAocGuards();
|
||||
services.AddVexExportEngine();
|
||||
services.AddVexExportCacheServices();
|
||||
services.AddVexAttestation();
|
||||
services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client"));
|
||||
services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification"));
|
||||
services.AddVexPolicy();
|
||||
services.AddRedHatCsafConnector();
|
||||
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
|
||||
services.AddSingleton<MirrorRateLimiter>();
|
||||
|
||||
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
|
||||
if (rekorSection.Exists())
|
||||
{
|
||||
services.AddVexRekorClient(opts => rekorSection.Bind(opts));
|
||||
}
|
||||
|
||||
var fileSystemSection = configuration.GetSection("Excititor:Artifacts:FileSystem");
|
||||
if (fileSystemSection.Exists())
|
||||
{
|
||||
services.AddVexFileSystemArtifactStore(opts => fileSystemSection.Bind(opts));
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddVexFileSystemArtifactStore(_ => { });
|
||||
}
|
||||
|
||||
var s3Section = configuration.GetSection("Excititor:Artifacts:S3");
|
||||
if (s3Section.Exists())
|
||||
{
|
||||
services.AddVexS3ArtifactClient(opts => s3Section.GetSection("Client").Bind(opts));
|
||||
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>(provider =>
|
||||
{
|
||||
var options = new S3ArtifactStoreOptions();
|
||||
s3Section.GetSection("Store").Bind(options);
|
||||
return new S3ArtifactStore(
|
||||
provider.GetRequiredService<IS3ArtifactClient>(),
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<S3ArtifactStore>>());
|
||||
});
|
||||
}
|
||||
|
||||
var offlineSection = configuration.GetSection("Excititor:Artifacts:OfflineBundle");
|
||||
if (offlineSection.Exists())
|
||||
{
|
||||
services.AddVexOfflineBundleArtifactStore(opts => offlineSection.Bind(opts));
|
||||
}
|
||||
|
||||
services.AddEndpointsApiExplorer();
|
||||
services.AddHealthChecks();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddMemoryCache();
|
||||
services.AddAuthentication();
|
||||
services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/excititor/status", async (HttpContext context,
|
||||
IEnumerable<IVexArtifactStore> artifactStores,
|
||||
IOptions<VexMongoStorageOptions> mongoOptions,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var payload = new StatusResponse(
|
||||
timeProvider.GetUtcNow(),
|
||||
mongoOptions.Value.RawBucketName,
|
||||
mongoOptions.Value.GridFsInlineThresholdBytes,
|
||||
artifactStores.Select(store => store.GetType().Name).ToArray());
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
await System.Text.Json.JsonSerializer.SerializeAsync(context.Response.Body, payload);
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/excititor/health");
|
||||
|
||||
app.MapPost("/excititor/statements", async (
|
||||
VexStatementIngestRequest request,
|
||||
IVexClaimStore claimStore,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request?.Statements is null || request.Statements.Count == 0)
|
||||
{
|
||||
return Results.BadRequest("At least one statement must be provided.");
|
||||
}
|
||||
|
||||
var claims = request.Statements.Select(statement => statement.ToDomainClaim());
|
||||
await claimStore.AppendAsync(claims, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async (
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
DateTimeOffset? since,
|
||||
IVexClaimStore claimStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
|
||||
{
|
||||
return Results.BadRequest("vulnerabilityId and productKey are required.");
|
||||
}
|
||||
|
||||
var claims = await claimStore.FindAsync(vulnerabilityId.Trim(), productKey.Trim(), since, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(claims);
|
||||
});
|
||||
|
||||
app.MapPost("/excititor/admin/backfill-statements", async (
|
||||
VexStatementBackfillRequest? request,
|
||||
VexStatementBackfillService backfillService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
request ??= new VexStatementBackfillRequest();
|
||||
var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var message = FormattableString.Invariant(
|
||||
$"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}.");
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
message,
|
||||
summary = result
|
||||
});
|
||||
});
|
||||
|
||||
IngestEndpoints.MapIngestEndpoints(app);
|
||||
ResolveEndpoint.MapResolveEndpoint(app);
|
||||
MirrorEndpoints.MapMirrorEndpoints(app);
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
|
||||
|
||||
internal sealed record VexStatementIngestRequest(IReadOnlyList<VexStatementEntry> Statements);
|
||||
|
||||
internal sealed record VexStatementEntry(
|
||||
string VulnerabilityId,
|
||||
string ProviderId,
|
||||
string ProductKey,
|
||||
string? ProductName,
|
||||
string? ProductVersion,
|
||||
string? ProductPurl,
|
||||
string? ProductCpe,
|
||||
IReadOnlyList<string>? ComponentIdentifiers,
|
||||
VexClaimStatus Status,
|
||||
VexJustification? Justification,
|
||||
string? Detail,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastSeen,
|
||||
VexDocumentFormat DocumentFormat,
|
||||
string DocumentDigest,
|
||||
string DocumentUri,
|
||||
string? DocumentRevision,
|
||||
VexSignatureMetadataRequest? Signature,
|
||||
VexConfidenceRequest? Confidence,
|
||||
VexSignalRequest? Signals,
|
||||
IReadOnlyDictionary<string, string>? Metadata)
|
||||
{
|
||||
public VexClaim ToDomainClaim()
|
||||
{
|
||||
var product = new VexProduct(
|
||||
ProductKey,
|
||||
ProductName,
|
||||
ProductVersion,
|
||||
ProductPurl,
|
||||
ProductCpe,
|
||||
ComponentIdentifiers ?? Array.Empty<string>());
|
||||
|
||||
if (!Uri.TryCreate(DocumentUri, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new InvalidOperationException($"DocumentUri '{DocumentUri}' is not a valid absolute URI.");
|
||||
}
|
||||
|
||||
var document = new VexClaimDocument(
|
||||
DocumentFormat,
|
||||
DocumentDigest,
|
||||
uri,
|
||||
DocumentRevision,
|
||||
Signature?.ToDomain());
|
||||
|
||||
var additionalMetadata = Metadata is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: Metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
|
||||
return new VexClaim(
|
||||
VulnerabilityId,
|
||||
ProviderId,
|
||||
product,
|
||||
Status,
|
||||
document,
|
||||
FirstSeen,
|
||||
LastSeen,
|
||||
Justification,
|
||||
Detail,
|
||||
Confidence?.ToDomain(),
|
||||
Signals?.ToDomain(),
|
||||
additionalMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record VexSignatureMetadataRequest(
|
||||
string Type,
|
||||
string? Subject,
|
||||
string? Issuer,
|
||||
string? KeyId,
|
||||
DateTimeOffset? VerifiedAt,
|
||||
string? TransparencyLogReference)
|
||||
{
|
||||
public VexSignatureMetadata ToDomain()
|
||||
=> new(Type, Subject, Issuer, KeyId, VerifiedAt, TransparencyLogReference);
|
||||
}
|
||||
|
||||
internal sealed record VexConfidenceRequest(string Level, double? Score, string? Method)
|
||||
{
|
||||
public VexConfidence ToDomain() => new(Level, Score, Method);
|
||||
}
|
||||
|
||||
internal sealed record VexSignalRequest(VexSeveritySignalRequest? Severity, bool? Kev, double? Epss)
|
||||
{
|
||||
public VexSignalSnapshot ToDomain()
|
||||
=> new(Severity?.ToDomain(), Kev, Epss);
|
||||
}
|
||||
|
||||
internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, string? Label, string? Vector)
|
||||
{
|
||||
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Excititor.WebService.Tests")]
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Services;
|
||||
|
||||
internal sealed class MirrorRateLimiter
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
|
||||
|
||||
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
|
||||
{
|
||||
retryAfter = null;
|
||||
|
||||
if (limit <= 0 || limit == int.MaxValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var key = CreateKey(domainId, scope);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var counter = _cache.Get<Counter>(key);
|
||||
if (counter is null || now - counter.WindowStart >= Window)
|
||||
{
|
||||
counter = new Counter(now, 0);
|
||||
}
|
||||
|
||||
if (counter.Count >= limit)
|
||||
{
|
||||
var windowEnd = counter.WindowStart + Window;
|
||||
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
counter = counter with { Count = counter.Count + 1 };
|
||||
var absoluteExpiration = counter.WindowStart + Window;
|
||||
_cache.Set(key, counter, absoluteExpiration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string CreateKey(string domainId, string scope)
|
||||
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
|
||||
{
|
||||
state.domainId.AsSpan().CopyTo(span);
|
||||
span[state.domainId.Length] = '|';
|
||||
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
|
||||
});
|
||||
|
||||
private sealed record Counter(DateTimeOffset WindowStart, int Count);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Services;
|
||||
|
||||
internal static class ScopeAuthorization
|
||||
{
|
||||
public static IResult? RequireScope(HttpContext context, string scope)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
throw new ArgumentException("Scope must be provided.", nameof(scope));
|
||||
}
|
||||
|
||||
var user = context.User;
|
||||
if (user?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!HasScope(user, scope))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasScope(ClaimsPrincipal user, string requiredScope)
|
||||
{
|
||||
var comparison = StringComparer.OrdinalIgnoreCase;
|
||||
foreach (var claim in user.FindAll("scope").Concat(user.FindAll("scp")))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (scopes.Any(scope => comparison.Equals(scope, requiredScope)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Services;
|
||||
|
||||
internal interface IVexIngestOrchestrator
|
||||
{
|
||||
Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken);
|
||||
|
||||
Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken);
|
||||
|
||||
Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken);
|
||||
|
||||
Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IReadOnlyDictionary<string, IVexConnector> _connectors;
|
||||
private readonly IVexRawStore _rawStore;
|
||||
private readonly IVexClaimStore _claimStore;
|
||||
private readonly IVexProviderStore _providerStore;
|
||||
private readonly IVexConnectorStateRepository _stateRepository;
|
||||
private readonly IVexNormalizerRouter _normalizerRouter;
|
||||
private readonly IVexSignatureVerifier _signatureVerifier;
|
||||
private readonly IVexMongoSessionProvider _sessionProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<VexIngestOrchestrator> _logger;
|
||||
|
||||
public VexIngestOrchestrator(
|
||||
IServiceProvider serviceProvider,
|
||||
IEnumerable<IVexConnector> connectors,
|
||||
IVexRawStore rawStore,
|
||||
IVexClaimStore claimStore,
|
||||
IVexProviderStore providerStore,
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
IVexNormalizerRouter normalizerRouter,
|
||||
IVexSignatureVerifier signatureVerifier,
|
||||
IVexMongoSessionProvider sessionProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VexIngestOrchestrator> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_rawStore = rawStore ?? throw new ArgumentNullException(nameof(rawStore));
|
||||
_claimStore = claimStore ?? throw new ArgumentNullException(nameof(claimStore));
|
||||
_providerStore = providerStore ?? throw new ArgumentNullException(nameof(providerStore));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_normalizerRouter = normalizerRouter ?? throw new ArgumentNullException(nameof(normalizerRouter));
|
||||
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
|
||||
_sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (connectors is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(connectors));
|
||||
}
|
||||
|
||||
_connectors = connectors
|
||||
.GroupBy(connector => connector.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var results = ImmutableArray.CreateBuilder<InitProviderResult>();
|
||||
|
||||
var session = await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var (handles, missing) = ResolveConnectors(options.Providers);
|
||||
foreach (var providerId in missing)
|
||||
{
|
||||
results.Add(new InitProviderResult(providerId, providerId, "missing", TimeSpan.Zero, "Provider connector is not registered."));
|
||||
}
|
||||
|
||||
foreach (var handle in handles)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureProviderRegistrationAsync(handle.Descriptor, session, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
results.Add(new InitProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
handle.Descriptor.DisplayName,
|
||||
"succeeded",
|
||||
stopwatch.Elapsed,
|
||||
Error: null));
|
||||
|
||||
_logger.LogInformation("Excititor init validated provider {ProviderId} in {Duration}ms.", handle.Descriptor.Id, stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
results.Add(new InitProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
handle.Descriptor.DisplayName,
|
||||
"cancelled",
|
||||
stopwatch.Elapsed,
|
||||
"Operation cancelled."));
|
||||
_logger.LogWarning("Excititor init cancelled for provider {ProviderId}.", handle.Descriptor.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
results.Add(new InitProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
handle.Descriptor.DisplayName,
|
||||
"failed",
|
||||
stopwatch.Elapsed,
|
||||
ex.Message));
|
||||
_logger.LogError(ex, "Excititor init failed for provider {ProviderId}: {Message}", handle.Descriptor.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
return new InitSummary(runId, startedAt, completedAt, results.ToImmutable());
|
||||
}
|
||||
|
||||
public async Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var since = ResolveSince(options.Since, options.Window, startedAt);
|
||||
var results = ImmutableArray.CreateBuilder<ProviderRunResult>();
|
||||
var session = await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var (handles, missing) = ResolveConnectors(options.Providers);
|
||||
foreach (var providerId in missing)
|
||||
{
|
||||
results.Add(ProviderRunResult.Missing(providerId, since));
|
||||
}
|
||||
|
||||
foreach (var handle in handles)
|
||||
{
|
||||
var result = await ExecuteRunAsync(handle, since, options.Force, session, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
return new IngestRunSummary(runId, startedAt, completedAt, results.ToImmutable());
|
||||
}
|
||||
|
||||
public async Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var results = ImmutableArray.CreateBuilder<ProviderRunResult>();
|
||||
var session = await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var (handles, missing) = ResolveConnectors(options.Providers);
|
||||
foreach (var providerId in missing)
|
||||
{
|
||||
results.Add(ProviderRunResult.Missing(providerId, since: null));
|
||||
}
|
||||
|
||||
foreach (var handle in handles)
|
||||
{
|
||||
var since = await ResolveResumeSinceAsync(handle.Descriptor.Id, options.Checkpoint, session, cancellationToken).ConfigureAwait(false);
|
||||
var result = await ExecuteRunAsync(handle, since, force: false, session, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
return new IngestRunSummary(runId, startedAt, completedAt, results.ToImmutable());
|
||||
}
|
||||
|
||||
public async Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var threshold = options.MaxAge is null ? (DateTimeOffset?)null : startedAt - options.MaxAge.Value;
|
||||
var results = ImmutableArray.CreateBuilder<ReconcileProviderResult>();
|
||||
var session = await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var (handles, missing) = ResolveConnectors(options.Providers);
|
||||
foreach (var providerId in missing)
|
||||
{
|
||||
results.Add(new ReconcileProviderResult(providerId, "missing", "missing", null, threshold, 0, 0, "Provider connector is not registered."));
|
||||
}
|
||||
|
||||
foreach (var handle in handles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await _stateRepository.GetAsync(handle.Descriptor.Id, cancellationToken, session).ConfigureAwait(false);
|
||||
var lastUpdated = state?.LastUpdated;
|
||||
var stale = threshold.HasValue && (lastUpdated is null || lastUpdated < threshold.Value);
|
||||
|
||||
if (stale || state is null)
|
||||
{
|
||||
var since = stale ? threshold : lastUpdated;
|
||||
var result = await ExecuteRunAsync(handle, since, force: false, session, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(new ReconcileProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
result.Status,
|
||||
"reconciled",
|
||||
result.LastUpdated ?? result.CompletedAt,
|
||||
threshold,
|
||||
result.Documents,
|
||||
result.Claims,
|
||||
result.Error));
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new ReconcileProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
"succeeded",
|
||||
"skipped",
|
||||
lastUpdated,
|
||||
threshold,
|
||||
Documents: 0,
|
||||
Claims: 0,
|
||||
Error: null));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
results.Add(new ReconcileProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
null,
|
||||
threshold,
|
||||
0,
|
||||
0,
|
||||
"Operation cancelled."));
|
||||
_logger.LogWarning("Excititor reconcile cancelled for provider {ProviderId}.", handle.Descriptor.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new ReconcileProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
"failed",
|
||||
"failed",
|
||||
null,
|
||||
threshold,
|
||||
0,
|
||||
0,
|
||||
ex.Message));
|
||||
_logger.LogError(ex, "Excititor reconcile failed for provider {ProviderId}: {Message}", handle.Descriptor.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
return new ReconcileSummary(runId, startedAt, completedAt, results.ToImmutable());
|
||||
}
|
||||
|
||||
private async Task ValidateConnectorAsync(ConnectorHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
await handle.Connector.ValidateAsync(VexConnectorSettings.Empty, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureProviderRegistrationAsync(VexConnectorDescriptor descriptor, IClientSessionHandle session, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _providerStore.FindAsync(descriptor.Id, cancellationToken, session).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = new VexProvider(descriptor.Id, descriptor.DisplayName, descriptor.Kind);
|
||||
await _providerStore.SaveAsync(provider, cancellationToken, session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ProviderRunResult> ExecuteRunAsync(
|
||||
ConnectorHandle handle,
|
||||
DateTimeOffset? since,
|
||||
bool force,
|
||||
IClientSessionHandle session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var providerId = handle.Descriptor.Id;
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureProviderRegistrationAsync(handle.Descriptor, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (force)
|
||||
{
|
||||
var resetState = new VexConnectorState(providerId, null, ImmutableArray<string>.Empty);
|
||||
await _stateRepository.SaveAsync(resetState, cancellationToken, session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var stateBeforeRun = await _stateRepository.GetAsync(providerId, cancellationToken, session).ConfigureAwait(false);
|
||||
var resumeTokens = stateBeforeRun?.ResumeTokens ?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var context = new VexConnectorContext(
|
||||
since,
|
||||
VexConnectorSettings.Empty,
|
||||
_rawStore,
|
||||
_signatureVerifier,
|
||||
_normalizerRouter,
|
||||
_serviceProvider,
|
||||
resumeTokens);
|
||||
|
||||
var documents = 0;
|
||||
var claims = 0;
|
||||
string? lastDigest = null;
|
||||
|
||||
await foreach (var document in handle.Connector.FetchAsync(context, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
documents++;
|
||||
lastDigest = document.Digest;
|
||||
|
||||
var batch = await _normalizerRouter.NormalizeAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
if (!batch.Claims.IsDefaultOrEmpty && batch.Claims.Length > 0)
|
||||
{
|
||||
claims += batch.Claims.Length;
|
||||
await _claimStore.AppendAsync(batch.Claims, _timeProvider.GetUtcNow(), cancellationToken, session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var stateAfterRun = await _stateRepository.GetAsync(providerId, cancellationToken, session).ConfigureAwait(false);
|
||||
|
||||
var checkpoint = stateAfterRun?.DocumentDigests.IsDefaultOrEmpty == false
|
||||
? stateAfterRun.DocumentDigests[^1]
|
||||
: lastDigest;
|
||||
|
||||
var result = new ProviderRunResult(
|
||||
providerId,
|
||||
"succeeded",
|
||||
documents,
|
||||
claims,
|
||||
startedAt,
|
||||
completedAt,
|
||||
stopwatch.Elapsed,
|
||||
lastDigest,
|
||||
stateAfterRun?.LastUpdated,
|
||||
checkpoint,
|
||||
null,
|
||||
since);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Excititor ingest provider {ProviderId} completed: documents={Documents} claims={Claims} since={Since} duration={Duration}ms",
|
||||
providerId,
|
||||
documents,
|
||||
claims,
|
||||
since?.ToString("O", CultureInfo.InvariantCulture),
|
||||
result.Duration.TotalMilliseconds);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var cancelledAt = _timeProvider.GetUtcNow();
|
||||
_logger.LogWarning("Excititor ingest provider {ProviderId} cancelled.", providerId);
|
||||
return new ProviderRunResult(
|
||||
providerId,
|
||||
"cancelled",
|
||||
0,
|
||||
0,
|
||||
startedAt,
|
||||
cancelledAt,
|
||||
stopwatch.Elapsed,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Operation cancelled.",
|
||||
since);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var failedAt = _timeProvider.GetUtcNow();
|
||||
_logger.LogError(ex, "Excititor ingest provider {ProviderId} failed: {Message}", providerId, ex.Message);
|
||||
return new ProviderRunResult(
|
||||
providerId,
|
||||
"failed",
|
||||
0,
|
||||
0,
|
||||
startedAt,
|
||||
failedAt,
|
||||
stopwatch.Elapsed,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ex.Message,
|
||||
since);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DateTimeOffset?> ResolveResumeSinceAsync(string providerId, string? checkpoint, IClientSessionHandle session, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(checkpoint))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(
|
||||
checkpoint.Trim(),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var digest = checkpoint.Trim();
|
||||
var document = await _rawStore.FindByDigestAsync(digest, cancellationToken, session).ConfigureAwait(false);
|
||||
if (document is not null)
|
||||
{
|
||||
return document.RetrievedAt;
|
||||
}
|
||||
}
|
||||
|
||||
var state = await _stateRepository.GetAsync(providerId, cancellationToken, session).ConfigureAwait(false);
|
||||
return state?.LastUpdated;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ResolveSince(DateTimeOffset? since, TimeSpan? window, DateTimeOffset reference)
|
||||
{
|
||||
if (since.HasValue)
|
||||
{
|
||||
return since.Value;
|
||||
}
|
||||
|
||||
if (window is { } duration && duration > TimeSpan.Zero)
|
||||
{
|
||||
var candidate = reference - duration;
|
||||
return candidate < DateTimeOffset.MinValue ? DateTimeOffset.MinValue : candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private (IReadOnlyList<ConnectorHandle> Handles, ImmutableArray<string> Missing) ResolveConnectors(ImmutableArray<string> requestedProviders)
|
||||
{
|
||||
var handles = new List<ConnectorHandle>();
|
||||
var missing = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (requestedProviders.IsDefaultOrEmpty || requestedProviders.Length == 0)
|
||||
{
|
||||
foreach (var connector in _connectors.Values.OrderBy(static x => x.Id, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
handles.Add(new ConnectorHandle(connector, CreateDescriptor(connector)));
|
||||
}
|
||||
|
||||
return (handles, missing.ToImmutable());
|
||||
}
|
||||
|
||||
foreach (var providerId in requestedProviders)
|
||||
{
|
||||
if (_connectors.TryGetValue(providerId, out var connector))
|
||||
{
|
||||
handles.Add(new ConnectorHandle(connector, CreateDescriptor(connector)));
|
||||
}
|
||||
else
|
||||
{
|
||||
missing.Add(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
return (handles, missing.ToImmutable());
|
||||
}
|
||||
|
||||
private static VexConnectorDescriptor CreateDescriptor(IVexConnector connector)
|
||||
=> connector switch
|
||||
{
|
||||
VexConnectorBase baseConnector => baseConnector.Descriptor,
|
||||
_ => new VexConnectorDescriptor(connector.Id, connector.Kind, connector.Id)
|
||||
};
|
||||
|
||||
private sealed record ConnectorHandle(IVexConnector Connector, VexConnectorDescriptor Descriptor);
|
||||
}
|
||||
|
||||
internal sealed record IngestInitOptions(
|
||||
ImmutableArray<string> Providers,
|
||||
bool Resume);
|
||||
|
||||
internal sealed record IngestRunOptions(
|
||||
ImmutableArray<string> Providers,
|
||||
DateTimeOffset? Since,
|
||||
TimeSpan? Window,
|
||||
bool Force);
|
||||
|
||||
internal sealed record IngestResumeOptions(
|
||||
ImmutableArray<string> Providers,
|
||||
string? Checkpoint);
|
||||
|
||||
internal sealed record ReconcileOptions(
|
||||
ImmutableArray<string> Providers,
|
||||
TimeSpan? MaxAge);
|
||||
|
||||
internal sealed record InitSummary(
|
||||
Guid RunId,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CompletedAt,
|
||||
ImmutableArray<InitProviderResult> Providers)
|
||||
{
|
||||
public int ProviderCount => Providers.Length;
|
||||
public int SuccessCount => Providers.Count(result => string.Equals(result.Status, "succeeded", StringComparison.OrdinalIgnoreCase));
|
||||
public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal sealed record InitProviderResult(
|
||||
string ProviderId,
|
||||
string DisplayName,
|
||||
string Status,
|
||||
TimeSpan Duration,
|
||||
string? Error);
|
||||
|
||||
internal sealed record IngestRunSummary(
|
||||
Guid RunId,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CompletedAt,
|
||||
ImmutableArray<ProviderRunResult> Providers)
|
||||
{
|
||||
public int ProviderCount => Providers.Length;
|
||||
|
||||
public int SuccessCount => Providers.Count(provider => string.Equals(provider.Status, "succeeded", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int FailureCount => Providers.Count(provider => string.Equals(provider.Status, "failed", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public TimeSpan Duration => CompletedAt - StartedAt;
|
||||
}
|
||||
|
||||
internal sealed record ProviderRunResult(
|
||||
string ProviderId,
|
||||
string Status,
|
||||
int Documents,
|
||||
int Claims,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CompletedAt,
|
||||
TimeSpan Duration,
|
||||
string? LastDigest,
|
||||
DateTimeOffset? LastUpdated,
|
||||
string? Checkpoint,
|
||||
string? Error,
|
||||
DateTimeOffset? Since)
|
||||
{
|
||||
public static ProviderRunResult Missing(string providerId, DateTimeOffset? since)
|
||||
=> new(providerId, "missing", 0, 0, DateTimeOffset.MinValue, DateTimeOffset.MinValue, TimeSpan.Zero, null, null, null, "Provider connector is not registered.", since);
|
||||
}
|
||||
|
||||
internal sealed record ReconcileSummary(
|
||||
Guid RunId,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CompletedAt,
|
||||
ImmutableArray<ReconcileProviderResult> Providers)
|
||||
{
|
||||
public int ProviderCount => Providers.Length;
|
||||
|
||||
public int ReconciledCount => Providers.Count(result => string.Equals(result.Action, "reconciled", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int SkippedCount => Providers.Count(result => string.Equals(result.Action, "skipped", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public TimeSpan Duration => CompletedAt - StartedAt;
|
||||
}
|
||||
|
||||
internal sealed record ReconcileProviderResult(
|
||||
string ProviderId,
|
||||
string Status,
|
||||
string Action,
|
||||
DateTimeOffset? LastUpdated,
|
||||
DateTimeOffset? Threshold,
|
||||
int Documents,
|
||||
int Claims,
|
||||
string? Error);
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.ArtifactStores.S3/StellaOps.Excititor.ArtifactStores.S3.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Connectors.RedHat.CSAF/StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Formats.CycloneDX/StellaOps.Excititor.Formats.CycloneDX.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Formats.OpenVEX/StellaOps.Excititor.Formats.OpenVEX.csproj" />
|
||||
<ProjectReference Include="../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
94
src/Excititor/StellaOps.Excititor.WebService/TASKS.md
Normal file
94
src/Excititor/StellaOps.Excititor.WebService/TASKS.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# TASKS — Epic 1: Aggregation-Only Contract
|
||||
> **AOC Reminder:** Excititor WebService publishes raw statements/linksets only; derived precedence/severity belongs to Policy overlays.
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|---|
|
||||
| EXCITITOR-WEB-AOC-19-001 `Raw VEX ingestion APIs` | TODO | Excititor WebService Guild | EXCITITOR-CORE-AOC-19-001, EXCITITOR-STORE-AOC-19-001 | Implement `POST /ingest/vex`, `GET /vex/raw*`, and `POST /aoc/verify` endpoints. Enforce Authority scopes, tenant injection, and guard pipeline to ensure only immutable VEX facts are persisted. |
|
||||
> Docs alignment (2025-10-26): See AOC reference §4–5 and authority scopes doc for required tokens/behaviour.
|
||||
| EXCITITOR-WEB-AOC-19-002 `AOC observability + metrics` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-WEB-AOC-19-001 | Export metrics (`ingestion_write_total`, `aoc_violation_total`, signature verification counters) and tracing spans matching Conseiller naming. Ensure structured logging includes tenant, source vendor, upstream id, and content hash. |
|
||||
> Docs alignment (2025-10-26): Metrics/traces/log schema in `docs/observability/observability.md`.
|
||||
| EXCITITOR-WEB-AOC-19-003 `Guard + schema test harness` | TODO | QA Guild | EXCITITOR-WEB-AOC-19-001 | Add unit/integration tests for schema validation, forbidden field rejection (`ERR_AOC_001/006/007`), and supersedes behavior using CycloneDX-VEX & CSAF fixtures with deterministic expectations. |
|
||||
> Docs alignment (2025-10-26): Error codes + CLI verification in `docs/cli/cli-reference.md`.
|
||||
| EXCITITOR-WEB-AOC-19-004 `Batch ingest validation` | TODO | Excititor WebService Guild, QA Guild | EXCITITOR-WEB-AOC-19-003, EXCITITOR-CORE-AOC-19-002 | Build large fixture ingest covering mixed VEX statuses, verifying raw storage parity, metrics, and CLI `aoc verify` compatibility. Document load test/runbook updates. |
|
||||
> Docs alignment (2025-10-26): Offline/air-gap workflows captured in `docs/deploy/containers.md` §5.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-POLICY-20-001 `Policy selection endpoints` | TODO | Excititor WebService Guild | WEB-POLICY-20-001, EXCITITOR-CORE-AOC-19-004 | Provide VEX lookup APIs supporting PURL/advisory batching, scope filtering, and tenant enforcement with deterministic ordering + pagination. |
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-CONSOLE-23-001 `VEX aggregation views` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202 | Expose `/console/vex` endpoints returning grouped VEX statements per advisory/component with status chips, justification metadata, precedence trace pointers, and tenant-scoped filters for Console explorer. |
|
||||
| EXCITITOR-CONSOLE-23-002 `Dashboard VEX deltas` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001, EXCITITOR-LNM-21-203 | Provide aggregated counts for VEX overrides (new, not_affected, revoked) powering Console dashboard + live status ticker; emit metrics for policy explain integration. |
|
||||
| EXCITITOR-CONSOLE-23-003 `VEX search helpers` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001 | Deliver rapid lookup endpoints of VEX by advisory/component for Console global search; ensure response includes provenance and precedence context; include caching and RBAC. |
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-LNM-21-201 `Observation APIs` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-001 | Add VEX observation read endpoints with filters, pagination, RBAC, and tenant scoping. |
|
||||
| EXCITITOR-LNM-21-202 `Linkset APIs` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-002, EXCITITOR-LNM-21-003 | Implement linkset read/export/evidence endpoints returning correlation/conflict payloads and map errors to `ERR_AGG_*`. |
|
||||
| EXCITITOR-LNM-21-203 `Event publishing` | TODO | Excititor WebService Guild, Platform Events Guild | EXCITITOR-LNM-21-005 | Publish `vex.linkset.updated` events, document schema, and ensure idempotent delivery. |
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-GRAPH-24-101 `VEX summary API` | TODO | Excititor WebService Guild | EXCITITOR-GRAPH-24-001 | Provide endpoints delivering VEX status summaries per component/asset for Vuln Explorer integration. |
|
||||
| EXCITITOR-GRAPH-24-102 `Evidence batch API` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-201 | Add batch VEX observation retrieval optimized for Graph overlays/tooltips. |
|
||||
|
||||
## VEX Lens (Sprint 30)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-VEXLENS-30-001 `VEX evidence enrichers` | TODO | Excititor WebService Guild, VEX Lens Guild | EXCITITOR-VULN-29-001, VEXLENS-30-005 | Include issuer hints, signatures, and product trees in evidence payloads for VEX Lens; Label: VEX-Lens. |
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-VULN-29-001 `VEX key canonicalization` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-001 | Canonicalize (lossless) VEX advisory/product keys (map to `advisory_key`, capture product scopes); expose original sources in `links[]`; AOC-compliant: no merge, no derived fields, no suppression; backfill existing records. |
|
||||
| EXCITITOR-VULN-29-002 `Evidence retrieval` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001, VULN-API-29-003 | Provide `/vuln/evidence/vex/{advisory_key}` returning raw VEX statements filtered by tenant/product scope for Explorer evidence tabs. |
|
||||
| EXCITITOR-VULN-29-004 `Observability` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-VULN-29-001 | Add metrics/logs for VEX normalization, suppression scopes, withdrawn statements; emit events consumed by Vuln Explorer resolver. |
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-AIAI-31-001 `Justification enrichment` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001 | Expose normalized VEX justifications, product trees, and paragraph anchors for Advisory AI conflict explanations. |
|
||||
| EXCITITOR-AIAI-31-002 `VEX chunk API` | TODO | Excititor WebService Guild | EXCITITOR-AIAI-31-001, VEXLENS-30-006 | Provide `/vex/evidence/chunks` endpoint returning tenant-scoped VEX statements with signature metadata and scope scores for RAG. |
|
||||
| EXCITITOR-AIAI-31-003 `Telemetry` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-AIAI-31-001 | Emit metrics/logs for VEX chunk usage, signature verification failures, and guardrail triggers. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-WEB-OBS-50-001 `Telemetry adoption` | TODO | Excititor WebService Guild | TELEMETRY-OBS-50-001, EXCITITOR-OBS-50-001 | Adopt telemetry core for VEX APIs, ensure responses include trace IDs & correlation headers, and update structured logging for read endpoints. |
|
||||
| EXCITITOR-WEB-OBS-51-001 `Observability health endpoints` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, WEB-OBS-51-001 | Implement `/obs/excititor/health` summarizing ingest/link SLOs, signature failure counts, and conflict trends for Console dashboards. |
|
||||
| EXCITITOR-WEB-OBS-52-001 `Timeline streaming` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, TIMELINE-OBS-52-003 | Provide SSE bridge for VEX timeline events with tenant filters, pagination, and guardrails. |
|
||||
| EXCITITOR-WEB-OBS-53-001 `Evidence APIs` | TODO | Excititor WebService Guild, Evidence Locker Guild | EXCITITOR-OBS-53-001, EVID-OBS-53-003 | Expose `/evidence/vex/*` endpoints that fetch locker bundles, enforce scopes, and surface verification metadata. |
|
||||
| EXCITITOR-WEB-OBS-54-001 `Attestation APIs` | TODO | Excititor WebService Guild | EXCITITOR-OBS-54-001, PROV-OBS-54-001 | Add `/attestations/vex/*` endpoints returning DSSE verification state, builder identity, and chain-of-custody links. |
|
||||
| EXCITITOR-WEB-OBS-55-001 `Incident mode toggles` | TODO | Excititor WebService Guild, DevOps Guild | EXCITITOR-OBS-55-001, WEB-OBS-55-001 | Provide incident mode API for VEX pipelines with activation audit logs and retention override previews. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-WEB-AIRGAP-56-001 | TODO | Excititor WebService Guild | AIRGAP-IMP-58-001, EXCITITOR-AIRGAP-56-001 | Support mirror bundle registration via APIs, expose bundle provenance in VEX responses, and block external connectors in sealed mode. |
|
||||
| EXCITITOR-WEB-AIRGAP-56-002 | TODO | Excititor WebService Guild, AirGap Time Guild | EXCITITOR-WEB-AIRGAP-56-001, AIRGAP-TIME-58-001 | Return VEX staleness metrics and time anchor info in API responses for Console/CLI use. |
|
||||
| EXCITITOR-WEB-AIRGAP-57-001 | TODO | Excititor WebService Guild, AirGap Policy Guild | AIRGAP-POL-56-001 | Map sealed-mode violations to standardized error payload with remediation guidance. |
|
||||
| EXCITITOR-WEB-AIRGAP-58-001 | TODO | Excititor WebService Guild, AirGap Importer Guild | EXCITITOR-WEB-AIRGAP-56-001, TIMELINE-OBS-53-001 | Emit timeline events for VEX bundle imports with bundle ID, scope, and actor metadata. |
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-WEB-OAS-61-001 | TODO | Excititor WebService Guild | OAS-61-001 | Implement `/.well-known/openapi` discovery endpoint with spec version metadata. |
|
||||
| EXCITITOR-WEB-OAS-61-002 | TODO | Excititor WebService Guild | APIGOV-61-001 | Standardize error envelope responses and update controller/unit tests. |
|
||||
| EXCITITOR-WEB-OAS-62-001 | TODO | Excititor WebService Guild | EXCITITOR-OAS-61-002 | Add curated examples for VEX observation/linkset endpoints and ensure portal displays them. |
|
||||
| EXCITITOR-WEB-OAS-63-001 | TODO | Excititor WebService Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and update docs for retiring VEX APIs. |
|
||||
23
src/Excititor/StellaOps.Excititor.Worker/AGENTS.md
Normal file
23
src/Excititor/StellaOps.Excititor.Worker/AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Background processing host coordinating scheduled pulls, retries, reconciliation, verification, and cache maintenance for Excititor.
|
||||
## Scope
|
||||
- Hosted service (Worker Service) wiring timers/queues for provider pulls and reconciliation cycles.
|
||||
- Resume token management, retry policies, and failure quarantines for connectors.
|
||||
- Re-verification of stored attestations and cache garbage collection routines.
|
||||
- Operational metrics and structured logging for offline-friendly monitoring.
|
||||
## Participants
|
||||
- Triggered by WebService job requests or internal schedules to run connector pulls.
|
||||
- Collaborates with Storage.Mongo repositories and Attestation verification utilities.
|
||||
- Emits telemetry consumed by observability stack and CLI status queries.
|
||||
## Interfaces & contracts
|
||||
- Scheduler abstractions, provider run controllers, retry/backoff strategies, and queue processors.
|
||||
- Hooks for policy revision changes and cache GC thresholds.
|
||||
## In/Out of scope
|
||||
In: background orchestration, job lifecycle management, observability for worker operations.
|
||||
Out: HTTP endpoint definitions, domain modeling, connector-specific parsing logic.
|
||||
## Observability & security expectations
|
||||
- Publish metrics for pull latency, failure counts, retry depth, cache size, and verification outcomes.
|
||||
- Log correlation IDs & provider IDs; avoid leaking secret config values.
|
||||
## Tests
|
||||
- Worker orchestration tests, timer controls, and retry behavior will live in `../StellaOps.Excititor.Worker.Tests`.
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
public sealed class VexWorkerOptions
|
||||
{
|
||||
public TimeSpan DefaultInterval { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
public TimeSpan OfflineInterval { get; set; } = TimeSpan.FromHours(6);
|
||||
|
||||
public TimeSpan DefaultInitialDelay { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public bool OfflineMode { get; set; }
|
||||
|
||||
public IList<VexWorkerProviderOptions> Providers { get; } = new List<VexWorkerProviderOptions>();
|
||||
|
||||
public VexWorkerRetryOptions Retry { get; } = new();
|
||||
|
||||
public VexWorkerRefreshOptions Refresh { get; } = new();
|
||||
|
||||
internal IReadOnlyList<VexWorkerSchedule> ResolveSchedules()
|
||||
{
|
||||
var schedules = new List<VexWorkerSchedule>();
|
||||
foreach (var provider in Providers)
|
||||
{
|
||||
if (!provider.Enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var providerId = provider.ProviderId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var interval = provider.Interval ?? (OfflineMode ? OfflineInterval : DefaultInterval);
|
||||
if (interval <= TimeSpan.Zero)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var initialDelay = provider.InitialDelay ?? DefaultInitialDelay;
|
||||
if (initialDelay < TimeSpan.Zero)
|
||||
{
|
||||
initialDelay = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var connectorSettings = provider.Settings.Count == 0
|
||||
? VexConnectorSettings.Empty
|
||||
: new VexConnectorSettings(provider.Settings.ToImmutableDictionary(StringComparer.Ordinal));
|
||||
|
||||
schedules.Add(new VexWorkerSchedule(providerId, interval, initialDelay, connectorSettings));
|
||||
}
|
||||
|
||||
return schedules;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class VexWorkerProviderOptions
|
||||
{
|
||||
public string ProviderId { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public TimeSpan? Interval { get; set; }
|
||||
|
||||
public TimeSpan? InitialDelay { get; set; }
|
||||
|
||||
public IDictionary<string, string> Settings { get; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
internal sealed class VexWorkerOptionsValidator : IValidateOptions<VexWorkerOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, VexWorkerOptions options)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
if (options.DefaultInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.DefaultInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.OfflineInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.OfflineInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.DefaultInitialDelay < TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.DefaultInitialDelay cannot be negative.");
|
||||
}
|
||||
|
||||
if (options.Retry.BaseDelay <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.BaseDelay must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Retry.MaxDelay < options.Retry.BaseDelay)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.MaxDelay must be greater than or equal to BaseDelay.");
|
||||
}
|
||||
|
||||
if (options.Retry.QuarantineDuration <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.QuarantineDuration must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Retry.FailureThreshold < 1)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.FailureThreshold must be at least 1.");
|
||||
}
|
||||
|
||||
if (options.Retry.JitterRatio < 0 || options.Retry.JitterRatio > 1)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.JitterRatio must be between 0 and 1.");
|
||||
}
|
||||
|
||||
if (options.Retry.RetryCap < options.Retry.BaseDelay)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.RetryCap must be greater than or equal to BaseDelay.");
|
||||
}
|
||||
|
||||
if (options.Retry.RetryCap < options.Retry.MaxDelay)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.RetryCap must be greater than or equal to MaxDelay.");
|
||||
}
|
||||
|
||||
if (options.Refresh.ScanInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.ScanInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Refresh.ConsensusTtl <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.ConsensusTtl must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Refresh.ScanBatchSize <= 0)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.ScanBatchSize must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Refresh.Damper.Minimum < TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.Damper.Minimum cannot be negative.");
|
||||
}
|
||||
|
||||
if (options.Refresh.Damper.Maximum <= options.Refresh.Damper.Minimum)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.Damper.Maximum must be greater than Minimum.");
|
||||
}
|
||||
|
||||
if (options.Refresh.Damper.DefaultDuration < options.Refresh.Damper.Minimum || options.Refresh.Damper.DefaultDuration > options.Refresh.Damper.Maximum)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.Damper.DefaultDuration must be within [Minimum, Maximum].");
|
||||
}
|
||||
|
||||
for (var i = 0; i < options.Refresh.Damper.Rules.Count; i++)
|
||||
{
|
||||
var rule = options.Refresh.Damper.Rules[i];
|
||||
if (rule.MinWeight < 0)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Refresh.Damper.Rules[{i}].MinWeight must be non-negative.");
|
||||
}
|
||||
|
||||
if (rule.Duration <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Refresh.Damper.Rules[{i}].Duration must be greater than zero.");
|
||||
}
|
||||
|
||||
if (rule.Duration < options.Refresh.Damper.Minimum || rule.Duration > options.Refresh.Damper.Maximum)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Refresh.Damper.Rules[{i}].Duration must be within [Minimum, Maximum].");
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < options.Providers.Count; i++)
|
||||
{
|
||||
var provider = options.Providers[i];
|
||||
if (string.IsNullOrWhiteSpace(provider.ProviderId))
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Providers[{i}].ProviderId must be set.");
|
||||
}
|
||||
|
||||
if (provider.Interval is { } interval && interval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Providers[{i}].Interval must be greater than zero when specified.");
|
||||
}
|
||||
|
||||
if (provider.InitialDelay is { } delay && delay < TimeSpan.Zero)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Providers[{i}].InitialDelay cannot be negative.");
|
||||
}
|
||||
}
|
||||
|
||||
return failures.Count > 0
|
||||
? ValidateOptionsResult.Fail(failures)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
public sealed class VexWorkerPluginOptions
|
||||
{
|
||||
public string? Directory { get; set; }
|
||||
|
||||
public string? SearchPattern { get; set; }
|
||||
|
||||
internal string ResolveDirectory()
|
||||
=> string.IsNullOrWhiteSpace(Directory)
|
||||
? Path.Combine(AppContext.BaseDirectory, "plugins")
|
||||
: Path.GetFullPath(Directory);
|
||||
|
||||
internal string ResolveSearchPattern()
|
||||
=> string.IsNullOrWhiteSpace(SearchPattern)
|
||||
? "StellaOps.Excititor.Connectors.*.dll"
|
||||
: SearchPattern!;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
public sealed class VexWorkerRefreshOptions
|
||||
{
|
||||
private static readonly TimeSpan DefaultScanInterval = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan DefaultConsensusTtl = TimeSpan.FromHours(2);
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public TimeSpan ScanInterval { get; set; } = DefaultScanInterval;
|
||||
|
||||
public TimeSpan ConsensusTtl { get; set; } = DefaultConsensusTtl;
|
||||
|
||||
public int ScanBatchSize { get; set; } = 250;
|
||||
|
||||
public VexStabilityDamperOptions Damper { get; } = new();
|
||||
}
|
||||
|
||||
public sealed class VexStabilityDamperOptions
|
||||
{
|
||||
private static readonly TimeSpan DefaultMinimum = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan DefaultMaximum = TimeSpan.FromHours(48);
|
||||
private static readonly TimeSpan DefaultDurationBaseline = TimeSpan.FromHours(36);
|
||||
|
||||
public TimeSpan Minimum { get; set; } = DefaultMinimum;
|
||||
|
||||
public TimeSpan Maximum { get; set; } = DefaultMaximum;
|
||||
|
||||
public TimeSpan DefaultDuration { get; set; } = DefaultDurationBaseline;
|
||||
|
||||
public IList<VexStabilityDamperRule> Rules { get; } = new List<VexStabilityDamperRule>
|
||||
{
|
||||
new() { MinWeight = 0.9, Duration = TimeSpan.FromHours(24) },
|
||||
new() { MinWeight = 0.75, Duration = TimeSpan.FromHours(30) },
|
||||
new() { MinWeight = 0.5, Duration = TimeSpan.FromHours(36) },
|
||||
};
|
||||
|
||||
internal TimeSpan ClampDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration < Minimum)
|
||||
{
|
||||
return Minimum;
|
||||
}
|
||||
|
||||
if (duration > Maximum)
|
||||
{
|
||||
return Maximum;
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
public TimeSpan ResolveDuration(double weight)
|
||||
{
|
||||
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight < 0)
|
||||
{
|
||||
return ClampDuration(DefaultDuration);
|
||||
}
|
||||
|
||||
if (Rules.Count == 0)
|
||||
{
|
||||
return ClampDuration(DefaultDuration);
|
||||
}
|
||||
|
||||
// Evaluate highest weight threshold first.
|
||||
TimeSpan? selected = null;
|
||||
foreach (var rule in Rules.OrderByDescending(static r => r.MinWeight))
|
||||
{
|
||||
if (weight >= rule.MinWeight)
|
||||
{
|
||||
selected = rule.Duration;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ClampDuration(selected ?? DefaultDuration);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class VexStabilityDamperRule
|
||||
{
|
||||
public double MinWeight { get; set; }
|
||||
= 1.0;
|
||||
|
||||
public TimeSpan Duration { get; set; }
|
||||
= TimeSpan.FromHours(24);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
public sealed class VexWorkerRetryOptions
|
||||
{
|
||||
[Range(1, int.MaxValue)]
|
||||
public int FailureThreshold { get; set; } = 3;
|
||||
|
||||
[Range(typeof(double), "0.0", "1.0")]
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
|
||||
public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan MaxDelay { get; set; } = TimeSpan.FromHours(6);
|
||||
|
||||
public TimeSpan QuarantineDuration { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
public TimeSpan RetryCap { get; set; } = TimeSpan.FromHours(24);
|
||||
}
|
||||
93
src/Excititor/StellaOps.Excititor.Worker/Program.cs
Normal file
93
src/Excititor/StellaOps.Excititor.Worker/Program.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
using StellaOps.Excititor.Formats.CSAF;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
using StellaOps.Excititor.Formats.OpenVEX;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using StellaOps.Excititor.Worker.Signature;
|
||||
using StellaOps.Excititor.Attestation.Extensions;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
var services = builder.Services;
|
||||
var configuration = builder.Configuration;
|
||||
services.AddOptions<VexWorkerOptions>()
|
||||
.Bind(configuration.GetSection("Excititor:Worker"))
|
||||
.ValidateOnStart();
|
||||
|
||||
services.Configure<VexWorkerPluginOptions>(configuration.GetSection("Excititor:Worker:Plugins"));
|
||||
services.AddRedHatCsafConnector();
|
||||
|
||||
services.AddOptions<VexMongoStorageOptions>()
|
||||
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddExcititorMongoStorage();
|
||||
services.AddCsafNormalizer();
|
||||
services.AddCycloneDxNormalizer();
|
||||
services.AddOpenVexNormalizer();
|
||||
services.AddSingleton<IVexSignatureVerifier, WorkerSignatureVerifier>();
|
||||
services.AddVexAttestation();
|
||||
services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification"));
|
||||
services.PostConfigure<VexAttestationVerificationOptions>(options =>
|
||||
{
|
||||
// Workers operate in offline-first environments; allow verification to succeed without Rekor.
|
||||
options.AllowOfflineTransparency = true;
|
||||
if (!configuration.GetSection("Excititor:Attestation:Verification").Exists())
|
||||
{
|
||||
options.RequireTransparencyLog = false;
|
||||
}
|
||||
});
|
||||
services.AddExcititorAocGuards();
|
||||
|
||||
services.AddSingleton<IValidateOptions<VexWorkerOptions>, VexWorkerOptionsValidator>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.PostConfigure<VexWorkerOptions>(options =>
|
||||
{
|
||||
if (!options.Providers.Any(provider => string.Equals(provider.ProviderId, "excititor:redhat", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
options.Providers.Add(new VexWorkerProviderOptions
|
||||
{
|
||||
ProviderId = "excititor:redhat",
|
||||
});
|
||||
}
|
||||
});
|
||||
services.AddSingleton<PluginCatalog>(provider =>
|
||||
{
|
||||
var pluginOptions = provider.GetRequiredService<IOptions<VexWorkerPluginOptions>>().Value;
|
||||
var catalog = new PluginCatalog();
|
||||
|
||||
var directory = pluginOptions.ResolveDirectory();
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
catalog.AddFromDirectory(directory, pluginOptions.ResolveSearchPattern());
|
||||
}
|
||||
else
|
||||
{
|
||||
var logger = provider.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogWarning("Excititor worker plugin directory '{Directory}' does not exist; proceeding without external connectors.", directory);
|
||||
}
|
||||
|
||||
return catalog;
|
||||
});
|
||||
|
||||
services.AddSingleton<IVexProviderRunner, DefaultVexProviderRunner>();
|
||||
services.AddSingleton<VexConsensusRefreshService>();
|
||||
services.AddSingleton<IVexConsensusRefreshScheduler>(static provider => provider.GetRequiredService<VexConsensusRefreshService>());
|
||||
services.AddHostedService<VexWorkerHostedService>();
|
||||
services.AddHostedService(static provider => provider.GetRequiredService<VexConsensusRefreshService>());
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Excititor.Worker.Tests")]
|
||||
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Signature;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly PluginCatalog _pluginCatalog;
|
||||
private readonly ILogger<DefaultVexProviderRunner> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly VexWorkerRetryOptions _retryOptions;
|
||||
|
||||
public DefaultVexProviderRunner(
|
||||
IServiceProvider serviceProvider,
|
||||
PluginCatalog pluginCatalog,
|
||||
ILogger<DefaultVexProviderRunner> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<VexWorkerOptions> workerOptions)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_pluginCatalog = pluginCatalog ?? throw new ArgumentNullException(nameof(pluginCatalog));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
if (workerOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(workerOptions));
|
||||
}
|
||||
|
||||
_retryOptions = workerOptions.Value?.Retry ?? throw new InvalidOperationException("VexWorkerOptions.Retry must be configured.");
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(VexWorkerSchedule schedule, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schedule.ProviderId);
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var availablePlugins = _pluginCatalog.GetAvailableConnectorPlugins(scope.ServiceProvider);
|
||||
var matched = availablePlugins.FirstOrDefault(plugin =>
|
||||
string.Equals(plugin.Name, schedule.ProviderId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matched is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Connector plugin {PluginName} ({ProviderId}) is available. Execution hooks will be added in subsequent tasks.",
|
||||
matched.Name,
|
||||
schedule.ProviderId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No legacy connector plugin registered for provider {ProviderId}; falling back to DI-managed connectors.", schedule.ProviderId);
|
||||
}
|
||||
|
||||
var connectors = scope.ServiceProvider.GetServices<IVexConnector>();
|
||||
var connector = connectors.FirstOrDefault(c => string.Equals(c.Id, schedule.ProviderId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (connector is null)
|
||||
{
|
||||
_logger.LogWarning("No IVexConnector implementation registered for provider {ProviderId}; skipping run.", schedule.ProviderId);
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteConnectorAsync(scope.ServiceProvider, connector, schedule.Settings, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ExecuteConnectorAsync(IServiceProvider scopeProvider, IVexConnector connector, VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveSettings = settings ?? VexConnectorSettings.Empty;
|
||||
var rawStore = scopeProvider.GetRequiredService<IVexRawStore>();
|
||||
var providerStore = scopeProvider.GetRequiredService<IVexProviderStore>();
|
||||
var stateRepository = scopeProvider.GetRequiredService<IVexConnectorStateRepository>();
|
||||
var normalizerRouter = scopeProvider.GetRequiredService<IVexNormalizerRouter>();
|
||||
var signatureVerifier = scopeProvider.GetRequiredService<IVexSignatureVerifier>();
|
||||
var sessionProvider = scopeProvider.GetService<IVexMongoSessionProvider>();
|
||||
IClientSessionHandle? session = null;
|
||||
if (sessionProvider is not null)
|
||||
{
|
||||
session = await sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var descriptor = connector switch
|
||||
{
|
||||
VexConnectorBase baseConnector => baseConnector.Descriptor,
|
||||
_ => new VexConnectorDescriptor(connector.Id, VexProviderKind.Vendor, connector.Id)
|
||||
};
|
||||
|
||||
var provider = await providerStore.FindAsync(descriptor.Id, cancellationToken, session).ConfigureAwait(false)
|
||||
?? new VexProvider(descriptor.Id, descriptor.DisplayName, descriptor.Kind);
|
||||
|
||||
await providerStore.SaveAsync(provider, cancellationToken, session).ConfigureAwait(false);
|
||||
|
||||
var stateBeforeRun = await stateRepository.GetAsync(descriptor.Id, cancellationToken, session).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (stateBeforeRun?.NextEligibleRun is { } nextEligible && nextEligible > now)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Connector {ConnectorId} is in backoff until {NextEligible:O}; skipping run.",
|
||||
connector.Id,
|
||||
nextEligible);
|
||||
return;
|
||||
}
|
||||
|
||||
await connector.ValidateAsync(effectiveSettings, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var verifyingSink = new VerifyingVexRawDocumentSink(rawStore, signatureVerifier);
|
||||
|
||||
var context = new VexConnectorContext(
|
||||
Since: stateBeforeRun?.LastUpdated,
|
||||
Settings: effectiveSettings,
|
||||
RawSink: verifyingSink,
|
||||
SignatureVerifier: signatureVerifier,
|
||||
Normalizers: normalizerRouter,
|
||||
Services: scopeProvider,
|
||||
ResumeTokens: stateBeforeRun?.ResumeTokens ?? ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documentCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var document in connector.FetchAsync(context, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
documentCount++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connector {ConnectorId} persisted {DocumentCount} raw document(s) this run.",
|
||||
connector.Id,
|
||||
documentCount);
|
||||
|
||||
await UpdateSuccessStateAsync(stateRepository, descriptor.Id, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await UpdateFailureStateAsync(stateRepository, descriptor.Id, _timeProvider.GetUtcNow(), ex, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateSuccessStateAsync(
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
string connectorId,
|
||||
DateTimeOffset completedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await stateRepository.GetAsync(connectorId, cancellationToken).ConfigureAwait(false)
|
||||
?? new VexConnectorState(connectorId, null, ImmutableArray<string>.Empty);
|
||||
|
||||
var updated = current with
|
||||
{
|
||||
LastSuccessAt = completedAt,
|
||||
FailureCount = 0,
|
||||
NextEligibleRun = null,
|
||||
LastFailureReason = null
|
||||
};
|
||||
|
||||
await stateRepository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateFailureStateAsync(
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
string connectorId,
|
||||
DateTimeOffset failureTime,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await stateRepository.GetAsync(connectorId, cancellationToken).ConfigureAwait(false)
|
||||
?? new VexConnectorState(connectorId, null, ImmutableArray<string>.Empty);
|
||||
|
||||
var failureCount = current.FailureCount + 1;
|
||||
var delay = CalculateDelayWithJitter(failureCount);
|
||||
var nextEligible = failureTime + delay;
|
||||
|
||||
if (failureCount >= _retryOptions.FailureThreshold)
|
||||
{
|
||||
var quarantineUntil = failureTime + _retryOptions.QuarantineDuration;
|
||||
if (quarantineUntil > nextEligible)
|
||||
{
|
||||
nextEligible = quarantineUntil;
|
||||
}
|
||||
}
|
||||
|
||||
var retryCap = failureTime + _retryOptions.RetryCap;
|
||||
if (nextEligible > retryCap)
|
||||
{
|
||||
nextEligible = retryCap;
|
||||
}
|
||||
|
||||
if (nextEligible < failureTime)
|
||||
{
|
||||
nextEligible = failureTime;
|
||||
}
|
||||
|
||||
var updated = current with
|
||||
{
|
||||
FailureCount = failureCount,
|
||||
NextEligibleRun = nextEligible,
|
||||
LastFailureReason = Truncate(exception.Message, 512)
|
||||
};
|
||||
|
||||
await stateRepository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Connector {ConnectorId} failed (attempt {Attempt}). Next eligible run at {NextEligible:O}.",
|
||||
connectorId,
|
||||
failureCount,
|
||||
nextEligible);
|
||||
}
|
||||
|
||||
private TimeSpan CalculateDelayWithJitter(int failureCount)
|
||||
{
|
||||
var exponent = Math.Max(0, failureCount - 1);
|
||||
var factor = Math.Pow(2, exponent);
|
||||
var baselineTicks = (long)Math.Min(_retryOptions.BaseDelay.Ticks * factor, _retryOptions.MaxDelay.Ticks);
|
||||
|
||||
if (_retryOptions.JitterRatio <= 0)
|
||||
{
|
||||
return TimeSpan.FromTicks(baselineTicks);
|
||||
}
|
||||
|
||||
var minFactor = 1.0 - _retryOptions.JitterRatio;
|
||||
var maxFactor = 1.0 + _retryOptions.JitterRatio;
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
var sample = BitConverter.ToUInt64(buffer) / (double)ulong.MaxValue;
|
||||
var jitterFactor = minFactor + (maxFactor - minFactor) * sample;
|
||||
var jitteredTicks = (long)Math.Round(baselineTicks * jitterFactor);
|
||||
|
||||
if (jitteredTicks < _retryOptions.BaseDelay.Ticks)
|
||||
{
|
||||
jitteredTicks = _retryOptions.BaseDelay.Ticks;
|
||||
}
|
||||
|
||||
if (jitteredTicks > _retryOptions.MaxDelay.Ticks)
|
||||
{
|
||||
jitteredTicks = _retryOptions.MaxDelay.Ticks;
|
||||
}
|
||||
|
||||
return TimeSpan.FromTicks(jitteredTicks);
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.Length <= maxLength
|
||||
? value
|
||||
: value[..maxLength];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
public interface IVexConsensusRefreshScheduler
|
||||
{
|
||||
void ScheduleRefresh(string vulnerabilityId, string productKey);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
internal interface IVexProviderRunner
|
||||
{
|
||||
ValueTask RunAsync(VexWorkerSchedule schedule, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsensusRefreshScheduler
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<VexConsensusRefreshService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Channel<RefreshRequest> _refreshRequests;
|
||||
private readonly ConcurrentDictionary<string, byte> _scheduledKeys = new(StringComparer.Ordinal);
|
||||
private readonly IDisposable? _optionsSubscription;
|
||||
private RefreshState _refreshState;
|
||||
|
||||
public VexConsensusRefreshService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<VexWorkerOptions> optionsMonitor,
|
||||
ILogger<VexConsensusRefreshService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_refreshRequests = Channel.CreateUnbounded<RefreshRequest>(new UnboundedChannelOptions
|
||||
{
|
||||
AllowSynchronousContinuations = false,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
if (optionsMonitor is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
_refreshState = RefreshState.FromOptions(options.Refresh);
|
||||
_optionsSubscription = optionsMonitor.OnChange(o =>
|
||||
{
|
||||
var state = RefreshState.FromOptions((o?.Refresh) ?? new VexWorkerRefreshOptions());
|
||||
Volatile.Write(ref _refreshState, state);
|
||||
_logger.LogInformation(
|
||||
"Consensus refresh options updated: enabled={Enabled}, interval={Interval}, ttl={Ttl}, batch={Batch}",
|
||||
state.Enabled,
|
||||
state.ScanInterval,
|
||||
state.ConsensusTtl,
|
||||
state.ScanBatchSize);
|
||||
});
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_optionsSubscription?.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
public void ScheduleRefresh(string vulnerabilityId, string productKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildKey(vulnerabilityId, productKey);
|
||||
if (!_scheduledKeys.TryAdd(key, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new RefreshRequest(vulnerabilityId.Trim(), productKey.Trim());
|
||||
if (!_refreshRequests.Writer.TryWrite(request))
|
||||
{
|
||||
_scheduledKeys.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var queueTask = ProcessQueueAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var options = CurrentOptions;
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessEligibleHoldsAsync(options, stoppingToken).ConfigureAwait(false);
|
||||
if (options.Enabled)
|
||||
{
|
||||
await ProcessTtlRefreshAsync(options, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Consensus refresh disabled; skipping TTL sweep.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Consensus refresh loop failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(options.ScanInterval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshRequests.Writer.TryComplete();
|
||||
try
|
||||
{
|
||||
await queueTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RefreshState CurrentOptions => Volatile.Read(ref _refreshState);
|
||||
|
||||
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await _refreshRequests.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (_refreshRequests.Reader.TryRead(out var request))
|
||||
{
|
||||
var key = BuildKey(request.VulnerabilityId, request.ProductKey);
|
||||
try
|
||||
{
|
||||
await ProcessCandidateAsync(request.VulnerabilityId, request.ProductKey, existingConsensus: null, CurrentOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh consensus for {VulnerabilityId}/{ProductKey} from queue.", request.VulnerabilityId, request.ProductKey);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scheduledKeys.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessEligibleHoldsAsync(RefreshState options, CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var holdStore = scope.ServiceProvider.GetRequiredService<IVexConsensusHoldStore>();
|
||||
var consensusStore = scope.ServiceProvider.GetRequiredService<IVexConsensusStore>();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await foreach (var hold in holdStore.FindEligibleAsync(now, options.ScanBatchSize, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var key = BuildKey(hold.VulnerabilityId, hold.ProductKey);
|
||||
if (!_scheduledKeys.TryAdd(key, 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await consensusStore.SaveAsync(hold.Candidate with { }, cancellationToken).ConfigureAwait(false);
|
||||
await holdStore.RemoveAsync(hold.VulnerabilityId, hold.ProductKey, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Promoted consensus hold for {VulnerabilityId}/{ProductKey}; status={Status}, reason={Reason}",
|
||||
hold.VulnerabilityId,
|
||||
hold.ProductKey,
|
||||
hold.Candidate.Status,
|
||||
hold.Reason);
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to promote consensus hold for {VulnerabilityId}/{ProductKey}.",
|
||||
hold.VulnerabilityId,
|
||||
hold.ProductKey);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scheduledKeys.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessTtlRefreshAsync(RefreshState options, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cutoff = now - options.ConsensusTtl;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var consensusStore = scope.ServiceProvider.GetRequiredService<IVexConsensusStore>();
|
||||
|
||||
await foreach (var consensus in consensusStore.FindCalculatedBeforeAsync(cutoff, options.ScanBatchSize, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var key = BuildKey(consensus.VulnerabilityId, consensus.Product.Key);
|
||||
if (!_scheduledKeys.TryAdd(key, 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessCandidateAsync(consensus.VulnerabilityId, consensus.Product.Key, consensus, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to refresh consensus for {VulnerabilityId}/{ProductKey} during TTL sweep.",
|
||||
consensus.VulnerabilityId,
|
||||
consensus.Product.Key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scheduledKeys.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessCandidateAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexConsensus? existingConsensus,
|
||||
RefreshState options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var consensusStore = scope.ServiceProvider.GetRequiredService<IVexConsensusStore>();
|
||||
var holdStore = scope.ServiceProvider.GetRequiredService<IVexConsensusHoldStore>();
|
||||
var claimStore = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
|
||||
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
|
||||
var policyProvider = scope.ServiceProvider.GetRequiredService<IVexPolicyProvider>();
|
||||
|
||||
existingConsensus ??= await consensusStore.FindAsync(vulnerabilityId, productKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var claims = await claimStore.FindAsync(vulnerabilityId, productKey, since: null, cancellationToken).ConfigureAwait(false);
|
||||
if (claims.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No claims found for {VulnerabilityId}/{ProductKey}; skipping consensus refresh.", vulnerabilityId, productKey);
|
||||
await holdStore.RemoveAsync(vulnerabilityId, productKey, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var claimList = claims as IReadOnlyList<VexClaim> ?? claims.ToList();
|
||||
|
||||
var snapshot = policyProvider.GetSnapshot();
|
||||
var providerCache = new Dictionary<string, VexProvider>(StringComparer.Ordinal);
|
||||
var providers = await LoadProvidersAsync(claimList, providerStore, providerCache, cancellationToken).ConfigureAwait(false);
|
||||
var product = ResolveProduct(claimList, productKey);
|
||||
var calculatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var resolver = new VexConsensusResolver(snapshot.ConsensusPolicy);
|
||||
var request = new VexConsensusRequest(
|
||||
vulnerabilityId,
|
||||
product,
|
||||
claimList.ToArray(),
|
||||
providers,
|
||||
calculatedAt,
|
||||
snapshot.ConsensusOptions.WeightCeiling,
|
||||
AggregateSignals(claimList),
|
||||
snapshot.RevisionId,
|
||||
snapshot.Digest);
|
||||
|
||||
var resolution = resolver.Resolve(request);
|
||||
var candidate = NormalizePolicyMetadata(resolution.Consensus, snapshot);
|
||||
|
||||
await ApplyConsensusAsync(
|
||||
candidate,
|
||||
existingConsensus,
|
||||
holdStore,
|
||||
consensusStore,
|
||||
options.Damper,
|
||||
options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ApplyConsensusAsync(
|
||||
VexConsensus candidate,
|
||||
VexConsensus? existing,
|
||||
IVexConsensusHoldStore holdStore,
|
||||
IVexConsensusStore consensusStore,
|
||||
DamperState damper,
|
||||
RefreshState options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vulnerabilityId = candidate.VulnerabilityId;
|
||||
var productKey = candidate.Product.Key;
|
||||
|
||||
var componentChanged = HasComponentChange(existing, candidate);
|
||||
var statusChanged = existing is not null && existing.Status != candidate.Status;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
await consensusStore.SaveAsync(candidate, cancellationToken).ConfigureAwait(false);
|
||||
await holdStore.RemoveAsync(vulnerabilityId, productKey, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Stored initial consensus for {VulnerabilityId}/{ProductKey} with status {Status}.", vulnerabilityId, productKey, candidate.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan duration = TimeSpan.Zero;
|
||||
if (statusChanged)
|
||||
{
|
||||
if (componentChanged)
|
||||
{
|
||||
duration = TimeSpan.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
var mappedStatus = MapConsensusStatus(candidate.Status);
|
||||
var supportingWeight = mappedStatus is null
|
||||
? 0d
|
||||
: candidate.Sources
|
||||
.Where(source => source.Status == mappedStatus.Value)
|
||||
.Sum(source => source.Weight);
|
||||
duration = damper.ResolveDuration(supportingWeight);
|
||||
}
|
||||
}
|
||||
|
||||
var requestedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
if (statusChanged && duration > TimeSpan.Zero)
|
||||
{
|
||||
var eligibleAt = requestedAt + duration;
|
||||
var reason = componentChanged ? "component_change" : "status_change";
|
||||
var newHold = new VexConsensusHold(vulnerabilityId, productKey, candidate, requestedAt, eligibleAt, reason);
|
||||
var existingHold = await holdStore.FindAsync(vulnerabilityId, productKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existingHold is null || existingHold.Candidate != candidate || existingHold.EligibleAt != newHold.EligibleAt)
|
||||
{
|
||||
await holdStore.SaveAsync(newHold, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Deferred consensus update for {VulnerabilityId}/{ProductKey} until {EligibleAt:O}; status {Status} pending (reason={Reason}).",
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
eligibleAt,
|
||||
candidate.Status,
|
||||
reason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await consensusStore.SaveAsync(candidate, cancellationToken).ConfigureAwait(false);
|
||||
await holdStore.RemoveAsync(vulnerabilityId, productKey, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Updated consensus for {VulnerabilityId}/{ProductKey}; status={Status}, componentChange={ComponentChanged}.",
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
candidate.Status,
|
||||
componentChanged);
|
||||
}
|
||||
|
||||
private static bool HasComponentChange(VexConsensus? existing, VexConsensus candidate)
|
||||
{
|
||||
if (existing is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var previous = existing.Product.ComponentIdentifiers;
|
||||
var current = candidate.Product.ComponentIdentifiers;
|
||||
|
||||
if (previous.IsDefaultOrEmpty && current.IsDefaultOrEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previous.Length != current.Length)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
for (var i = 0; i < previous.Length; i++)
|
||||
{
|
||||
if (!string.Equals(previous[i], current[i], StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static VexConsensus NormalizePolicyMetadata(VexConsensus consensus, VexPolicySnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(consensus.PolicyVersion, snapshot.Version, StringComparison.Ordinal) &&
|
||||
string.Equals(consensus.PolicyRevisionId, snapshot.RevisionId, StringComparison.Ordinal) &&
|
||||
string.Equals(consensus.PolicyDigest, snapshot.Digest, StringComparison.Ordinal))
|
||||
{
|
||||
return consensus;
|
||||
}
|
||||
|
||||
return new VexConsensus(
|
||||
consensus.VulnerabilityId,
|
||||
consensus.Product,
|
||||
consensus.Status,
|
||||
consensus.CalculatedAt,
|
||||
consensus.Sources,
|
||||
consensus.Conflicts,
|
||||
consensus.Signals,
|
||||
snapshot.Version,
|
||||
consensus.Summary,
|
||||
snapshot.RevisionId,
|
||||
snapshot.Digest);
|
||||
}
|
||||
|
||||
private static VexClaimStatus? MapConsensusStatus(VexConsensusStatus status)
|
||||
=> status switch
|
||||
{
|
||||
VexConsensusStatus.Affected => VexClaimStatus.Affected,
|
||||
VexConsensusStatus.NotAffected => VexClaimStatus.NotAffected,
|
||||
VexConsensusStatus.Fixed => VexClaimStatus.Fixed,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string BuildKey(string vulnerabilityId, string productKey)
|
||||
=> string.Create(
|
||||
vulnerabilityId.Length + productKey.Length + 1,
|
||||
(vulnerabilityId, productKey),
|
||||
static (span, tuple) =>
|
||||
{
|
||||
tuple.vulnerabilityId.AsSpan().CopyTo(span);
|
||||
span[tuple.vulnerabilityId.Length] = '|';
|
||||
tuple.productKey.AsSpan().CopyTo(span[(tuple.vulnerabilityId.Length + 1)..]);
|
||||
});
|
||||
|
||||
private static VexProduct ResolveProduct(IReadOnlyList<VexClaim> claims, string productKey)
|
||||
{
|
||||
if (claims.Count > 0)
|
||||
{
|
||||
return claims[0].Product;
|
||||
}
|
||||
|
||||
var inferredPurl = productKey.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ? productKey : null;
|
||||
return new VexProduct(productKey, name: null, version: null, purl: inferredPurl);
|
||||
}
|
||||
|
||||
private static VexSignalSnapshot? AggregateSignals(IReadOnlyList<VexClaim> claims)
|
||||
{
|
||||
if (claims.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
VexSeveritySignal? bestSeverity = null;
|
||||
double? bestScore = null;
|
||||
bool kevPresent = false;
|
||||
bool kevTrue = false;
|
||||
double? bestEpss = null;
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
if (claim.Signals is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var severity = claim.Signals.Severity;
|
||||
if (severity is not null)
|
||||
{
|
||||
var score = severity.Score;
|
||||
if (bestSeverity is null ||
|
||||
(score is not null && (bestScore is null || score.Value > bestScore.Value)) ||
|
||||
(score is null && bestScore is null && !string.IsNullOrWhiteSpace(severity.Label) && string.IsNullOrWhiteSpace(bestSeverity.Label)))
|
||||
{
|
||||
bestSeverity = severity;
|
||||
bestScore = severity.Score;
|
||||
}
|
||||
}
|
||||
|
||||
if (claim.Signals.Kev is { } kevValue)
|
||||
{
|
||||
kevPresent = true;
|
||||
if (kevValue)
|
||||
{
|
||||
kevTrue = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (claim.Signals.Epss is { } epss)
|
||||
{
|
||||
if (bestEpss is null || epss > bestEpss.Value)
|
||||
{
|
||||
bestEpss = epss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSeverity is null && !kevPresent && bestEpss is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool? kev = kevTrue ? true : (kevPresent ? false : null);
|
||||
return new VexSignalSnapshot(bestSeverity, kev, bestEpss);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, VexProvider>> LoadProvidersAsync(
|
||||
IReadOnlyList<VexClaim> claims,
|
||||
IVexProviderStore providerStore,
|
||||
IDictionary<string, VexProvider> cache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (claims.Count == 0)
|
||||
{
|
||||
return ImmutableDictionary<string, VexProvider>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, VexProvider>(StringComparer.Ordinal);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var providerId in claims.Select(claim => claim.ProviderId))
|
||||
{
|
||||
if (!seen.Add(providerId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cache.TryGetValue(providerId, out var cached))
|
||||
{
|
||||
builder[providerId] = cached;
|
||||
continue;
|
||||
}
|
||||
|
||||
var provider = await providerStore.FindAsync(providerId, cancellationToken).ConfigureAwait(false);
|
||||
if (provider is not null)
|
||||
{
|
||||
cache[providerId] = provider;
|
||||
builder[providerId] = provider;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private readonly record struct RefreshRequest(string VulnerabilityId, string ProductKey);
|
||||
|
||||
private sealed record RefreshState(
|
||||
bool Enabled,
|
||||
TimeSpan ScanInterval,
|
||||
TimeSpan ConsensusTtl,
|
||||
int ScanBatchSize,
|
||||
DamperState Damper)
|
||||
{
|
||||
public static RefreshState FromOptions(VexWorkerRefreshOptions options)
|
||||
{
|
||||
var interval = options.ScanInterval > TimeSpan.Zero ? options.ScanInterval : TimeSpan.FromMinutes(10);
|
||||
var ttl = options.ConsensusTtl > TimeSpan.Zero ? options.ConsensusTtl : TimeSpan.FromHours(2);
|
||||
var batchSize = options.ScanBatchSize > 0 ? options.ScanBatchSize : 250;
|
||||
var damper = DamperState.FromOptions(options.Damper);
|
||||
return new RefreshState(options.Enabled, interval, ttl, batchSize, damper);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record DamperState(TimeSpan Minimum, TimeSpan Maximum, TimeSpan DefaultDuration, ImmutableArray<DamperRuleState> Rules)
|
||||
{
|
||||
public static DamperState FromOptions(VexStabilityDamperOptions options)
|
||||
{
|
||||
var minimum = options.Minimum < TimeSpan.Zero ? TimeSpan.Zero : options.Minimum;
|
||||
var maximum = options.Maximum > minimum ? options.Maximum : minimum + TimeSpan.FromHours(1);
|
||||
var defaultDuration = options.ClampDuration(options.DefaultDuration);
|
||||
var rules = options.Rules
|
||||
.Select(rule => new DamperRuleState(Math.Max(0, rule.MinWeight), options.ClampDuration(rule.Duration)))
|
||||
.OrderByDescending(rule => rule.MinWeight)
|
||||
.ToImmutableArray();
|
||||
return new DamperState(minimum, maximum, defaultDuration, rules);
|
||||
}
|
||||
|
||||
public TimeSpan ResolveDuration(double weight)
|
||||
{
|
||||
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight < 0)
|
||||
{
|
||||
return DefaultDuration;
|
||||
}
|
||||
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
if (weight >= rule.MinWeight)
|
||||
{
|
||||
return rule.Duration;
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultDuration;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record DamperRuleState(double MinWeight, TimeSpan Duration);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
internal sealed class VexWorkerHostedService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<VexWorkerOptions> _options;
|
||||
private readonly IVexProviderRunner _runner;
|
||||
private readonly ILogger<VexWorkerHostedService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VexWorkerHostedService(
|
||||
IOptions<VexWorkerOptions> options,
|
||||
IVexProviderRunner runner,
|
||||
ILogger<VexWorkerHostedService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var schedules = _options.Value.ResolveSchedules();
|
||||
if (schedules.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Excititor worker has no configured provider schedules; the service will remain idle.");
|
||||
await Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Excititor worker starting with {ProviderCount} provider schedule(s).", schedules.Count);
|
||||
|
||||
var tasks = new List<Task>(schedules.Count);
|
||||
foreach (var schedule in schedules)
|
||||
{
|
||||
tasks.Add(RunScheduleAsync(schedule, stoppingToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task RunScheduleAsync(VexWorkerSchedule schedule, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (schedule.InitialDelay > TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Provider {ProviderId} initial delay of {InitialDelay} before first execution.",
|
||||
schedule.ProviderId,
|
||||
schedule.InitialDelay);
|
||||
|
||||
await Task.Delay(schedule.InitialDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var timer = new PeriodicTimer(schedule.Interval);
|
||||
do
|
||||
{
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
_logger.LogInformation(
|
||||
"Provider {ProviderId} run started at {StartedAt}. Interval={Interval}.",
|
||||
schedule.ProviderId,
|
||||
startedAt,
|
||||
schedule.Interval);
|
||||
|
||||
try
|
||||
{
|
||||
await _runner.RunAsync(schedule, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var elapsed = completedAt - startedAt;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Provider {ProviderId} run completed at {CompletedAt} (duration {Duration}).",
|
||||
schedule.ProviderId,
|
||||
completedAt,
|
||||
elapsed);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Provider {ProviderId} run cancelled.", schedule.ProviderId);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Provider {ProviderId} run failed: {Message}",
|
||||
schedule.ProviderId,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Provider {ProviderId} schedule cancelled.", schedule.ProviderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
internal sealed record VexWorkerSchedule(string ProviderId, TimeSpan Interval, TimeSpan InitialDelay, VexConnectorSettings Settings);
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Signature;
|
||||
|
||||
internal sealed class VerifyingVexRawDocumentSink : IVexRawDocumentSink
|
||||
{
|
||||
private readonly IVexRawStore _inner;
|
||||
private readonly IVexSignatureVerifier _signatureVerifier;
|
||||
|
||||
public VerifyingVexRawDocumentSink(IVexRawStore inner, IVexSignatureVerifier signatureVerifier)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
|
||||
}
|
||||
|
||||
public async ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var signatureMetadata = await _signatureVerifier.VerifyAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
var enrichedDocument = signatureMetadata is null
|
||||
? document
|
||||
: document with { Metadata = EnrichMetadata(document.Metadata, signatureMetadata) };
|
||||
|
||||
await _inner.StoreAsync(enrichedDocument, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> EnrichMetadata(
|
||||
ImmutableDictionary<string, string> metadata,
|
||||
VexSignatureMetadata signature)
|
||||
{
|
||||
var builder = metadata is null
|
||||
? ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal)
|
||||
: metadata.ToBuilder();
|
||||
|
||||
builder["signature.present"] = "true";
|
||||
builder["signature.verified"] = "true";
|
||||
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,364 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Models;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Signature;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces checksum validation and records signature verification metadata.
|
||||
/// </summary>
|
||||
internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Excititor.Worker", "1.0");
|
||||
private static readonly Counter<long> SignatureVerificationCounter = Meter.CreateCounter<long>(
|
||||
"ingestion_signature_verified_total",
|
||||
description: "Counts signature and checksum verification results for Excititor worker ingestion.");
|
||||
|
||||
private readonly ILogger<WorkerSignatureVerifier> _logger;
|
||||
private readonly IVexAttestationVerifier? _attestationVerifier;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions StatementSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public WorkerSignatureVerifier(
|
||||
ILogger<WorkerSignatureVerifier> logger,
|
||||
IVexAttestationVerifier? attestationVerifier = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_attestationVerifier = attestationVerifier;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var metadata = document.Metadata ?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var expectedDigest = NormalizeDigest(document.Digest);
|
||||
var computedDigest = ComputeDigest(document.Content.Span);
|
||||
|
||||
if (!string.Equals(expectedDigest, computedDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RecordVerification(document.ProviderId, metadata, "fail");
|
||||
_logger.LogError(
|
||||
"Checksum mismatch for provider {ProviderId} (expected={ExpectedDigest}, computed={ComputedDigest}, uri={SourceUri})",
|
||||
document.ProviderId,
|
||||
expectedDigest,
|
||||
computedDigest,
|
||||
document.SourceUri);
|
||||
|
||||
var violation = AocViolation.Create(
|
||||
AocViolationCode.SignatureInvalid,
|
||||
"/upstream/content_hash",
|
||||
$"Content hash mismatch. Expected {expectedDigest}, computed {computedDigest}.");
|
||||
|
||||
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
|
||||
}
|
||||
|
||||
VexSignatureMetadata? signatureMetadata = null;
|
||||
if (document.Format == VexDocumentFormat.OciAttestation && _attestationVerifier is not null)
|
||||
{
|
||||
signatureMetadata = await VerifyAttestationAsync(document, metadata, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
signatureMetadata ??= ExtractSignatureMetadata(metadata);
|
||||
var resultLabel = signatureMetadata is null ? "skipped" : "ok";
|
||||
RecordVerification(document.ProviderId, metadata, resultLabel);
|
||||
|
||||
if (resultLabel == "skipped")
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Signature verification skipped for provider {ProviderId} (no signature metadata).",
|
||||
document.ProviderId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Signature metadata recorded for provider {ProviderId} (type={SignatureType}, subject={Subject}, issuer={Issuer}).",
|
||||
document.ProviderId,
|
||||
signatureMetadata!.Type,
|
||||
signatureMetadata.Subject ?? "<unknown>",
|
||||
signatureMetadata.Issuer ?? "<unknown>");
|
||||
}
|
||||
|
||||
return signatureMetadata;
|
||||
}
|
||||
|
||||
private async ValueTask<VexSignatureMetadata?> VerifyAttestationAsync(
|
||||
VexRawDocument document,
|
||||
ImmutableDictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelopeJson = Encoding.UTF8.GetString(document.Content.Span);
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, EnvelopeSerializerOptions)
|
||||
?? throw new InvalidOperationException("DSSE envelope deserialized to null.");
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
var statement = JsonSerializer.Deserialize<VexInTotoStatement>(payloadBytes, StatementSerializerOptions)
|
||||
?? throw new InvalidOperationException("DSSE statement deserialized to null.");
|
||||
|
||||
if (statement.Subject is null || statement.Subject.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("DSSE statement subject is missing.");
|
||||
}
|
||||
|
||||
var predicate = statement.Predicate ?? throw new InvalidOperationException("DSSE predicate is missing.");
|
||||
var request = BuildAttestationRequest(statement, predicate);
|
||||
var attestationMetadata = BuildAttestationMetadata(statement, envelope, metadata);
|
||||
|
||||
var verificationRequest = new VexAttestationVerificationRequest(
|
||||
request,
|
||||
attestationMetadata,
|
||||
envelopeJson);
|
||||
|
||||
var verification = await _attestationVerifier!
|
||||
.VerifyAsync(verificationRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
var diagnostics = string.Join(", ", verification.Diagnostics.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
_logger.LogError(
|
||||
"Attestation verification failed for provider {ProviderId} (uri={SourceUri}) diagnostics={Diagnostics}",
|
||||
document.ProviderId,
|
||||
document.SourceUri,
|
||||
diagnostics);
|
||||
|
||||
var violation = AocViolation.Create(
|
||||
AocViolationCode.SignatureInvalid,
|
||||
"/upstream/signature",
|
||||
"Attestation verification failed.");
|
||||
|
||||
RecordVerification(document.ProviderId, metadata, "fail");
|
||||
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attestation verification succeeded for provider {ProviderId} (predicate={PredicateType}, subject={Subject}).",
|
||||
document.ProviderId,
|
||||
attestationMetadata.PredicateType,
|
||||
statement.Subject[0].Name ?? "<unknown>");
|
||||
|
||||
return BuildSignatureMetadata(statement, metadata, attestationMetadata, verification.Diagnostics);
|
||||
}
|
||||
catch (ExcititorAocGuardException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to verify attestation for provider {ProviderId} (uri={SourceUri})",
|
||||
document.ProviderId,
|
||||
document.SourceUri);
|
||||
|
||||
var violation = AocViolation.Create(
|
||||
AocViolationCode.SignatureInvalid,
|
||||
"/upstream/signature",
|
||||
$"Attestation verification encountered an error: {ex.Message}");
|
||||
|
||||
RecordVerification(document.ProviderId, metadata, "fail");
|
||||
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
|
||||
}
|
||||
}
|
||||
|
||||
private VexAttestationRequest BuildAttestationRequest(VexInTotoStatement statement, VexAttestationPredicate predicate)
|
||||
{
|
||||
var subject = statement.Subject!.First();
|
||||
var exportId = predicate.ExportId ?? subject.Name ?? throw new InvalidOperationException("Attestation export ID missing.");
|
||||
var querySignature = new VexQuerySignature(predicate.QuerySignature ?? throw new InvalidOperationException("Attestation query signature missing."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(predicate.ArtifactAlgorithm) || string.IsNullOrWhiteSpace(predicate.ArtifactDigest))
|
||||
{
|
||||
throw new InvalidOperationException("Attestation artifact metadata is incomplete.");
|
||||
}
|
||||
|
||||
var artifact = new VexContentAddress(predicate.ArtifactAlgorithm, predicate.ArtifactDigest);
|
||||
|
||||
var sourceProviders = predicate.SourceProviders?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
|
||||
var metadata = predicate.Metadata?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
return new VexAttestationRequest(
|
||||
exportId,
|
||||
querySignature,
|
||||
artifact,
|
||||
predicate.Format,
|
||||
predicate.CreatedAt,
|
||||
sourceProviders,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private VexAttestationMetadata BuildAttestationMetadata(
|
||||
VexInTotoStatement statement,
|
||||
DsseEnvelope envelope,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
VexRekorReference? rekor = null;
|
||||
if (metadata.TryGetValue("vex.signature.transparencyLogReference", out var rekorValue) && !string.IsNullOrWhiteSpace(rekorValue))
|
||||
{
|
||||
rekor = new VexRekorReference("0.1", rekorValue);
|
||||
}
|
||||
|
||||
DateTimeOffset signedAt;
|
||||
if (metadata.TryGetValue("vex.signature.verifiedAt", out var signedAtRaw)
|
||||
&& DateTimeOffset.TryParse(signedAtRaw, out var parsedSignedAt))
|
||||
{
|
||||
signedAt = parsedSignedAt;
|
||||
}
|
||||
else
|
||||
{
|
||||
signedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
return new VexAttestationMetadata(
|
||||
statement.PredicateType ?? "https://stella-ops.org/attestations/vex-export",
|
||||
rekor,
|
||||
VexDsseBuilder.ComputeEnvelopeDigest(envelope),
|
||||
signedAt);
|
||||
}
|
||||
|
||||
private VexSignatureMetadata BuildSignatureMetadata(
|
||||
VexInTotoStatement statement,
|
||||
ImmutableDictionary<string, string> metadata,
|
||||
VexAttestationMetadata attestationMetadata,
|
||||
ImmutableDictionary<string, string> diagnostics)
|
||||
{
|
||||
metadata.TryGetValue("vex.signature.type", out var type);
|
||||
metadata.TryGetValue("vex.provenance.cosign.subject", out var subject);
|
||||
metadata.TryGetValue("vex.provenance.cosign.issuer", out var issuer);
|
||||
metadata.TryGetValue("vex.signature.keyId", out var keyId);
|
||||
metadata.TryGetValue("vex.signature.transparencyLogReference", out var transparencyReference);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
type = statement.PredicateType?.Contains("attest", StringComparison.OrdinalIgnoreCase) == true
|
||||
? "cosign"
|
||||
: "attestation";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject) && statement.Subject is { Count: > 0 })
|
||||
{
|
||||
subject = statement.Subject[0].Name;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(transparencyReference) && attestationMetadata.Rekor is not null)
|
||||
{
|
||||
transparencyReference = attestationMetadata.Rekor.Location;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuer)
|
||||
&& diagnostics.TryGetValue("verification.issuer", out var diagnosticIssuer)
|
||||
&& !string.IsNullOrWhiteSpace(diagnosticIssuer))
|
||||
{
|
||||
issuer = diagnosticIssuer;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId)
|
||||
&& diagnostics.TryGetValue("verification.keyId", out var diagnosticKeyId)
|
||||
&& !string.IsNullOrWhiteSpace(diagnosticKeyId))
|
||||
{
|
||||
keyId = diagnosticKeyId;
|
||||
}
|
||||
|
||||
var verifiedAt = attestationMetadata.SignedAt ?? _timeProvider.GetUtcNow();
|
||||
|
||||
return new VexSignatureMetadata(
|
||||
type!,
|
||||
subject,
|
||||
issuer,
|
||||
keyId,
|
||||
verifiedAt,
|
||||
transparencyReference);
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? digest
|
||||
: $"sha256:{digest}";
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(content, buffer, out _))
|
||||
{
|
||||
var hash = SHA256.HashData(content.ToArray());
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static VexSignatureMetadata? ExtractSignatureMetadata(ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (!metadata.TryGetValue("vex.signature.type", out var type) || string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
metadata.TryGetValue("vex.signature.subject", out var subject);
|
||||
metadata.TryGetValue("vex.signature.issuer", out var issuer);
|
||||
metadata.TryGetValue("vex.signature.keyId", out var keyId);
|
||||
metadata.TryGetValue("vex.signature.verifiedAt", out var verifiedAtRaw);
|
||||
metadata.TryGetValue("vex.signature.transparencyLogReference", out var tlog);
|
||||
|
||||
DateTimeOffset? verifiedAt = null;
|
||||
if (!string.IsNullOrWhiteSpace(verifiedAtRaw) && DateTimeOffset.TryParse(verifiedAtRaw, out var parsed))
|
||||
{
|
||||
verifiedAt = parsed;
|
||||
}
|
||||
|
||||
return new VexSignatureMetadata(type, subject, issuer, keyId, verifiedAt, tlog);
|
||||
}
|
||||
|
||||
private static void RecordVerification(string providerId, ImmutableDictionary<string, string> metadata, string result)
|
||||
{
|
||||
var tags = new List<KeyValuePair<string, object?>>(3)
|
||||
{
|
||||
new("source", providerId),
|
||||
new("result", result),
|
||||
};
|
||||
|
||||
if (!metadata.TryGetValue("tenant", out var tenant) || string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
tenant = "tenant-default";
|
||||
}
|
||||
|
||||
tags.Add(new KeyValuePair<string, object?>("tenant", tenant));
|
||||
|
||||
SignatureVerificationCounter.Add(1, tags.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Connectors.Abstractions/StellaOps.Excititor.Connectors.Abstractions.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Connectors.RedHat.CSAF/StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Formats.CycloneDX/StellaOps.Excititor.Formats.CycloneDX.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Formats.OpenVEX/StellaOps.Excititor.Formats.OpenVEX.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
19
src/Excititor/StellaOps.Excititor.Worker/TASKS.md
Normal file
19
src/Excititor/StellaOps.Excititor.Worker/TASKS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# TASKS — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|---|
|
||||
| EXCITITOR-WORKER-AOC-19-001 `Raw pipeline rewiring` | DONE (2025-10-31) | Excititor Worker Guild | EXCITITOR-CORE-AOC-19-001 | Update ingest pipelines to persist upstream documents directly into `vex_raw` via the new repository guard. Remove consensus/folding hooks and ensure retries respect append-only semantics. |
|
||||
> 2025-10-31: Worker now runs in raw-only mode; `DefaultVexProviderRunner` no longer normalizes or schedules consensus refresh and logs document counts only. Tests updated to assert the normalizer is not invoked.
|
||||
| EXCITITOR-WORKER-AOC-19-002 `Signature & checksum enforcement` | DONE (2025-10-28) | Excititor Worker Guild | EXCITITOR-WORKER-AOC-19-001 | Add signature verification + checksum computation before writes, capturing failure reasons mapped to `ERR_AOC_005`, with structured logs/metrics for verification results. |
|
||||
> 2025-10-28: Resuming implementation to finish attestation metadata plumbing, wiring into runner, and tests (`WorkerSignatureVerifier`, `DefaultVexProviderRunner`).
|
||||
> 2025-10-28: Attestation verification now enriches signature metadata & runner tests cover DSSE path; metrics unchanged.
|
||||
> 2025-10-31: Worker wraps raw sink with checksum enforcement. Digest mismatches raise `ERR_AOC_005`, signature metadata is captured when present, and `ingestion_signature_verified_total` is emitted (`result=ok|fail|skipped`).
|
||||
| EXCITITOR-WORKER-AOC-19-003 `Deterministic batching tests` | DONE (2025-10-28) | QA Guild | EXCITITOR-WORKER-AOC-19-001 | Extend worker integration tests to replay large VEX batches ensuring idempotent upserts, supersedes chaining, and guard enforcement across restart scenarios. |
|
||||
> 2025-10-28: Added Mongo-backed integration suite validating large batch replay, guard-triggered failures, and restart idempotency (`DefaultVexProviderRunnerIntegrationTests`). Worker unit tests now exercise the verifying sink path, and `dotnet test` passes after attestation envelope fixes.
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| EXCITITOR-ORCH-32-001 `Worker SDK adoption` | TODO | Excititor Worker Guild | ORCH-SVC-32-005, WORKER-GO-32-001, WORKER-PY-32-001 | Integrate orchestrator worker SDK in Excititor ingestion jobs, emit heartbeats/progress/artifact hashes, and register source metadata. |
|
||||
| EXCITITOR-ORCH-33-001 `Control compliance` | TODO | Excititor Worker Guild | EXCITITOR-ORCH-32-001, ORCH-SVC-33-001, ORCH-SVC-33-002 | Honor orchestrator pause/throttle/retry actions, classify error outputs, and persist restart checkpoints. |
|
||||
| EXCITITOR-ORCH-34-001 `Backfill & circuit breaker` | TODO | Excititor Worker Guild | EXCITITOR-ORCH-33-001, ORCH-SVC-33-003, ORCH-SVC-34-001 | Implement orchestrator-driven backfills, apply circuit breaker reset rules, and ensure artifact dedupe alignment. |
|
||||
705
src/Excititor/StellaOps.Excititor.sln
Normal file
705
src/Excititor/StellaOps.Excititor.sln
Normal file
@@ -0,0 +1,705 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.WebService", "StellaOps.Excititor.WebService\StellaOps.Excititor.WebService.csproj", "{AF8F1262-FC95-49EB-B096-A028693DD606}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{87631154-82C3-43F6-8F41-46CB877AA16D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{1A49D368-184D-4040-AD11-37A3F6BCD261}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Storage.Mongo", "__Libraries\StellaOps.Excititor.Storage.Mongo\StellaOps.Excititor.Storage.Mongo.csproj", "{5858415D-8AB4-4E45-B316-580879FD8339}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Export", "__Libraries\StellaOps.Excititor.Export\StellaOps.Excititor.Export.csproj", "{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Policy", "__Libraries\StellaOps.Excititor.Policy\StellaOps.Excititor.Policy.csproj", "{400690F2-466B-4DF0-B495-9015DBBAA046}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{5067124E-37E5-4BC4-B758-CAA96E274D8C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Attestation", "__Libraries\StellaOps.Excititor.Attestation\StellaOps.Excititor.Attestation.csproj", "{16E426BF-8697-4DB1-ABC5-5537CDE74D95}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.ArtifactStores.S3", "__Libraries\StellaOps.Excititor.ArtifactStores.S3\StellaOps.Excititor.ArtifactStores.S3.csproj", "{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.RedHat.CSAF", "__Libraries\StellaOps.Excititor.Connectors.RedHat.CSAF\StellaOps.Excititor.Connectors.RedHat.CSAF.csproj", "{CC391919-15F5-43DE-8271-8043090B7D8D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.Abstractions", "__Libraries\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj", "{BB45DABD-1709-40C3-92B5-29C7AFFF9645}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Formats.CSAF", "__Libraries\StellaOps.Excititor.Formats.CSAF\StellaOps.Excititor.Formats.CSAF.csproj", "{181B855F-FBD3-44B6-A679-15EC88E8625A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Formats.CycloneDX", "__Libraries\StellaOps.Excititor.Formats.CycloneDX\StellaOps.Excititor.Formats.CycloneDX.csproj", "{7E839AAE-99FF-4AFD-B986-520306AFA403}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Formats.OpenVEX", "__Libraries\StellaOps.Excititor.Formats.OpenVEX\StellaOps.Excititor.Formats.OpenVEX.csproj", "{863DD74A-947C-431E-B661-9C2A46472CD0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Worker", "StellaOps.Excititor.Worker\StellaOps.Excititor.Worker.csproj", "{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{598E8702-B9D9-45BE-9A33-004A93EE6E25}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{79056784-D88C-47C2-B49D-1A25D58FC03B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.Cisco.CSAF", "__Libraries\StellaOps.Excititor.Connectors.Cisco.CSAF\StellaOps.Excititor.Connectors.Cisco.CSAF.csproj", "{C75036AF-D828-41D3-9322-F67828EF8FBB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.MSRC.CSAF", "__Libraries\StellaOps.Excititor.Connectors.MSRC.CSAF\StellaOps.Excititor.Connectors.MSRC.CSAF.csproj", "{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest", "__Libraries\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.csproj", "{50B53195-F0DD-4DCE-95A7-0949C13D706B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.Oracle.CSAF", "__Libraries\StellaOps.Excititor.Connectors.Oracle.CSAF\StellaOps.Excititor.Connectors.Oracle.CSAF.csproj", "{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub", "__Libraries\StellaOps.Excititor.Connectors.SUSE.RancherVEXHub\StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.csproj", "{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.Ubuntu.CSAF", "__Libraries\StellaOps.Excititor.Connectors.Ubuntu.CSAF\StellaOps.Excititor.Connectors.Ubuntu.CSAF.csproj", "{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.ArtifactStores.S3.Tests", "__Tests\StellaOps.Excititor.ArtifactStores.S3.Tests\StellaOps.Excititor.ArtifactStores.S3.Tests.csproj", "{111BEB1A-8664-4AA6-8275-7440F33E79C9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Attestation.Tests", "__Tests\StellaOps.Excititor.Attestation.Tests\StellaOps.Excititor.Attestation.Tests.csproj", "{26B663A0-404C-4D0C-9687-17079CDFFEBF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.Cisco.CSAF.Tests", "__Tests\StellaOps.Excititor.Connectors.Cisco.CSAF.Tests\StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.csproj", "{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.MSRC.CSAF.Tests", "__Tests\StellaOps.Excititor.Connectors.MSRC.CSAF.Tests\StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.csproj", "{86E49D28-9035-4EB4-8C7F-E3915C5A2046}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests", "__Tests\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.csproj", "{67990ECE-E2D4-4BC4-8F05-734E02379F23}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.Oracle.CSAF.Tests", "__Tests\StellaOps.Excititor.Connectors.Oracle.CSAF.Tests\StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.csproj", "{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.RedHat.CSAF.Tests", "__Tests\StellaOps.Excititor.Connectors.RedHat.CSAF.Tests\StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.csproj", "{EBC3B08D-11E7-4286-940F-27305028148E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests", "__Tests\StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests\StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.csproj", "{640E732E-01C7-4A7E-9AE1-35117B26AB1E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests", "__Tests\StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests\StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.csproj", "{ADFC7CC7-D079-43A1-833C-7E3775184EB6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core.Tests", "__Tests\StellaOps.Excititor.Core.Tests\StellaOps.Excititor.Core.Tests.csproj", "{152EC0B1-8312-40F7-AF96-16B8E6AABA52}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Export.Tests", "__Tests\StellaOps.Excititor.Export.Tests\StellaOps.Excititor.Export.Tests.csproj", "{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Formats.CSAF.Tests", "__Tests\StellaOps.Excititor.Formats.CSAF.Tests\StellaOps.Excititor.Formats.CSAF.Tests.csproj", "{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Formats.CycloneDX.Tests", "__Tests\StellaOps.Excititor.Formats.CycloneDX.Tests\StellaOps.Excititor.Formats.CycloneDX.Tests.csproj", "{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Formats.OpenVEX.Tests", "__Tests\StellaOps.Excititor.Formats.OpenVEX.Tests\StellaOps.Excititor.Formats.OpenVEX.Tests.csproj", "{E83FC97E-B88E-4BE5-89D1-12C01631F575}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Policy.Tests", "__Tests\StellaOps.Excititor.Policy.Tests\StellaOps.Excititor.Policy.Tests.csproj", "{832F539E-17FC-46B4-9E67-39BE5131352D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Storage.Mongo.Tests", "__Tests\StellaOps.Excititor.Storage.Mongo.Tests\StellaOps.Excititor.Storage.Mongo.Tests.csproj", "{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\Concelier\__Libraries\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{13AF13D1-84C3-4D4F-B89A-0653102C3E63}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{79304AC3-6A2E-454B-A0FF-F656D2D75538}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.WebService.Tests", "__Tests\StellaOps.Excititor.WebService.Tests\StellaOps.Excititor.WebService.Tests.csproj", "{A1007C02-2143-48C6-8380-E3785AF3002D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Worker.Tests", "__Tests\StellaOps.Excititor.Worker.Tests\StellaOps.Excititor.Worker.Tests.csproj", "{3F51027B-F194-4321-AC7B-E00DA5CD47E3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Release|x64.Build.0 = Release|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AF8F1262-FC95-49EB-B096-A028693DD606}.Release|x86.Build.0 = Release|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1A49D368-184D-4040-AD11-37A3F6BCD261}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2D19CC50-EFE9-4015-B4DB-6DFF4E41DB11}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Release|x64.Build.0 = Release|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5067124E-37E5-4BC4-B758-CAA96E274D8C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Release|x64.Build.0 = Release|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645}.Release|x86.Build.0 = Release|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403}.Release|x86.Build.0 = Release|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0CE1FE59-B0FB-423B-B55B-C8F31A67D868}.Release|x86.Build.0 = Release|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Release|x64.Build.0 = Release|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{598E8702-B9D9-45BE-9A33-004A93EE6E25}.Release|x86.Build.0 = Release|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{79056784-D88C-47C2-B49D-1A25D58FC03B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Release|x64.Build.0 = Release|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046}.Release|x86.Build.0 = Release|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Release|x64.Build.0 = Release|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23}.Release|x86.Build.0 = Release|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Release|x64.Build.0 = Release|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Release|x64.Build.0 = Release|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92}.Release|x86.Build.0 = Release|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575}.Release|x86.Build.0 = Release|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6507860E-BF0D-4E32-A6AC-49E1CE15E4B7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D6014A0A-6BF4-45C8-918E-9558A24AAC5B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Release|x64.Build.0 = Release|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{13AF13D1-84C3-4D4F-B89A-0653102C3E63}.Release|x86.Build.0 = Release|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Release|x64.Build.0 = Release|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{79304AC3-6A2E-454B-A0FF-F656D2D75538}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{87631154-82C3-43F6-8F41-46CB877AA16D} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{5858415D-8AB4-4E45-B316-580879FD8339} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{E8B20DD0-9282-4DFD-B363-F0AF7F62AED5} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{400690F2-466B-4DF0-B495-9015DBBAA046} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{16E426BF-8697-4DB1-ABC5-5537CDE74D95} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{2603B1D1-E1DE-4903-BEE2-DC593FE2A5C3} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{CC391919-15F5-43DE-8271-8043090B7D8D} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{BB45DABD-1709-40C3-92B5-29C7AFFF9645} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{181B855F-FBD3-44B6-A679-15EC88E8625A} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{7E839AAE-99FF-4AFD-B986-520306AFA403} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{863DD74A-947C-431E-B661-9C2A46472CD0} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{C75036AF-D828-41D3-9322-F67828EF8FBB} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{643BF7A5-2CD1-4CBA-BC94-A1477AB21FC0} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{50B53195-F0DD-4DCE-95A7-0949C13D706B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{D2CD82C4-0D40-4316-A83D-FCC5D715DE95} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{E553CAFD-794B-437C-ABCC-C780DC1ADF3C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{E3DD0BB0-C4C6-4A56-A46E-45870851FB3D} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{111BEB1A-8664-4AA6-8275-7440F33E79C9} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{26B663A0-404C-4D0C-9687-17079CDFFEBF} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{BE9C0870-1912-4EF5-8C6D-BFF42F235F4E} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{86E49D28-9035-4EB4-8C7F-E3915C5A2046} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{67990ECE-E2D4-4BC4-8F05-734E02379F23} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{35DF0F52-8BEE-4969-B7F3-54CFF4AFAD18} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{EBC3B08D-11E7-4286-940F-27305028148E} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{640E732E-01C7-4A7E-9AE1-35117B26AB1E} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{ADFC7CC7-D079-43A1-833C-7E3775184EB6} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{152EC0B1-8312-40F7-AF96-16B8E6AABA52} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{1DFD7A8F-075A-4507-AC7C-EF867F4AEA92} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{43BA0A53-6806-41BA-9C2B-FE781BBCE85B} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{E93FE8CE-28A6-4C7E-96ED-D99406653FDC} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{E83FC97E-B88E-4BE5-89D1-12C01631F575} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{832F539E-17FC-46B4-9E67-39BE5131352D} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{5BB6E9E8-3470-4BFF-94DD-DA3294616C39} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{A1007C02-2143-48C6-8380-E3785AF3002D} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{3F51027B-F194-4321-AC7B-E00DA5CD47E3} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,38 @@
|
||||
using Amazon;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Export;
|
||||
|
||||
namespace StellaOps.Excititor.ArtifactStores.S3.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddVexS3ArtifactClient(this IServiceCollection services, Action<S3ArtifactClientOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.Configure(configure);
|
||||
services.AddSingleton(CreateS3Client);
|
||||
services.AddSingleton<IS3ArtifactClient, S3ArtifactClient>();
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IAmazonS3 CreateS3Client(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<S3ArtifactClientOptions>>().Value;
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
|
||||
ForcePathStyle = options.ForcePathStyle,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
|
||||
{
|
||||
config.ServiceURL = options.ServiceUrl;
|
||||
}
|
||||
|
||||
return new AmazonS3Client(config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Export;
|
||||
|
||||
namespace StellaOps.Excititor.ArtifactStores.S3;
|
||||
|
||||
public sealed class S3ArtifactClientOptions
|
||||
{
|
||||
public string Region { get; set; } = "us-east-1";
|
||||
|
||||
public string? ServiceUrl { get; set; }
|
||||
= null;
|
||||
|
||||
public bool ForcePathStyle { get; set; }
|
||||
= true;
|
||||
}
|
||||
|
||||
public sealed class S3ArtifactClient : IS3ArtifactClient
|
||||
{
|
||||
private readonly IAmazonS3 _s3;
|
||||
private readonly ILogger<S3ArtifactClient> _logger;
|
||||
|
||||
public S3ArtifactClient(IAmazonS3 s3, ILogger<S3ArtifactClient> logger)
|
||||
{
|
||||
_s3 = s3 ?? throw new ArgumentNullException(nameof(s3));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadata = await _s3.GetObjectMetadataAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
|
||||
return metadata.HttpStatusCode == System.Net.HttpStatusCode.OK;
|
||||
}
|
||||
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new PutObjectRequest
|
||||
{
|
||||
BucketName = bucketName,
|
||||
Key = key,
|
||||
InputStream = content,
|
||||
AutoCloseStream = false,
|
||||
};
|
||||
|
||||
foreach (var kvp in metadata)
|
||||
{
|
||||
request.Metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
await _s3.PutObjectAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Uploaded object {Bucket}/{Key}", bucketName, key);
|
||||
}
|
||||
|
||||
public async Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _s3.GetObjectAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
|
||||
var buffer = new MemoryStream();
|
||||
await response.ResponseStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
buffer.Position = 0;
|
||||
return buffer;
|
||||
}
|
||||
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Object {Bucket}/{Key} not found", bucketName, key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
await _s3.DeleteObjectAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Deleted object {Bucket}/{Key}", bucketName, key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Export\StellaOps.Excititor.Export.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Builds and verifies in-toto/DSSE attestations for Excititor exports and integrates with Rekor v2 transparency logs.
|
||||
## Scope
|
||||
- Attestation envelope builders, signing workflows (keyless/keyed), and predicate model definitions.
|
||||
- Rekor v2 client implementation (submit, verify, poll inclusion) with retry/backoff policies.
|
||||
- Verification utilities reused by Worker for periodic revalidation.
|
||||
- Configuration bindings for signer identity, Rekor endpoints, and offline bundle operation.
|
||||
## Participants
|
||||
- Export module calls into this layer to generate attestations after export artifacts are produced.
|
||||
- WebService and Worker consume verification helpers to ensure stored envelopes remain valid.
|
||||
- CLI `excititor verify` leverages verification services through WebService endpoints.
|
||||
## Interfaces & contracts
|
||||
- `IExportAttestor`, `ITransparencyLogClient`, predicate DTOs, and verification result records.
|
||||
- Extension methods to register attestation services in DI across WebService/Worker.
|
||||
## In/Out of scope
|
||||
In: attestation creation, verification, Rekor integration, signer configuration.
|
||||
Out: export artifact generation, storage persistence, CLI interaction layers.
|
||||
## Observability & security expectations
|
||||
- Structured logs for signing/verification with envelope digest, Rekor URI, and latency; never log private keys.
|
||||
- Metrics for attestation successes/failures and Rekor submission durations.
|
||||
## Tests
|
||||
- Unit tests and integration stubs (with fake Rekor) will live in `../StellaOps.Excititor.Attestation.Tests`.
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Dsse;
|
||||
|
||||
public sealed record DsseEnvelope(
|
||||
[property: JsonPropertyName("payload")] string Payload,
|
||||
[property: JsonPropertyName("payloadType")] string PayloadType,
|
||||
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignature> Signatures);
|
||||
|
||||
public sealed record DsseSignature(
|
||||
[property: JsonPropertyName("sig")] string Signature,
|
||||
[property: JsonPropertyName("keyid")] string? KeyId);
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Attestation.Models;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Dsse;
|
||||
|
||||
public sealed class VexDsseBuilder
|
||||
{
|
||||
internal const string PayloadType = "application/vnd.in-toto+json";
|
||||
|
||||
private readonly IVexSigner _signer;
|
||||
private readonly ILogger<VexDsseBuilder> _logger;
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
|
||||
public VexDsseBuilder(IVexSigner signer, ILogger<VexDsseBuilder> logger)
|
||||
{
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_serializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
WriteIndented = false,
|
||||
};
|
||||
_serializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
}
|
||||
|
||||
public async ValueTask<DsseEnvelope> CreateEnvelopeAsync(
|
||||
VexAttestationRequest request,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var predicate = VexAttestationPredicate.FromRequest(request, metadata);
|
||||
var subject = new VexInTotoSubject(
|
||||
request.ExportId,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
{ request.Artifact.Algorithm.ToLowerInvariant(), request.Artifact.Digest }
|
||||
});
|
||||
|
||||
var statement = new VexInTotoStatement(
|
||||
VexInTotoStatement.InTotoType,
|
||||
"https://stella-ops.org/attestations/vex-export",
|
||||
new[] { subject },
|
||||
predicate);
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, _serializerOptions);
|
||||
var signatureResult = await _signer.SignAsync(payloadBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
Convert.ToBase64String(payloadBytes),
|
||||
PayloadType,
|
||||
new[] { new DsseSignature(signatureResult.Signature, signatureResult.KeyId) });
|
||||
|
||||
_logger.LogDebug("DSSE envelope created for export {ExportId}", request.ExportId);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
public static string ComputeEnvelopeDigest(DsseEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
});
|
||||
var bytes = Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
# EXCITITOR-ATTEST-01-003 - Verification & Observability Plan
|
||||
|
||||
- **Date:** 2025-10-19
|
||||
- **Status:** In progress (2025-10-22)
|
||||
- **Owner:** Team Excititor Attestation
|
||||
- **Related tasks:** EXCITITOR-ATTEST-01-003 (Wave 0), EXCITITOR-WEB-01-003/004, EXCITITOR-WORKER-01-003
|
||||
- **Prerequisites satisfied:** EXCITITOR-ATTEST-01-002 (Rekor v2 client integration)
|
||||
|
||||
## 1. Objectives
|
||||
|
||||
1. Provide deterministic attestation verification helpers consumable by Excititor WebService (`/excititor/verify`, `/excititor/export*`) and Worker re-verification loops.
|
||||
2. Surface structured diagnostics for success, soft failures, and hard failures (signature mismatch, Rekor gaps, artifact digest drift).
|
||||
3. Emit observability signals (logs, metrics, optional tracing) that can run offline and degrade gracefully when transparency services are unreachable.
|
||||
4. Add regression tests (unit + integration) covering positive path, negative path, and offline fallback scenarios.
|
||||
|
||||
## 2. Deliverables
|
||||
|
||||
- `IVexAttestationVerifier` abstraction + `VexAttestationVerifier` implementation inside `StellaOps.Excititor.Attestation`, encapsulating DSSE validation, predicate checks, artifact digest confirmation, Rekor inclusion verification, and deterministic diagnostics.
|
||||
- DI wiring (extension method) for registering verifier + instrumentation dependencies alongside the existing signer/rekor client.
|
||||
- Shared `VexAttestationDiagnostics` record describing normalized diagnostic keys consumed by Worker/WebService logging.
|
||||
- Metrics utility (`AttestationMetrics`) exposing counters/histograms via `System.Diagnostics.Metrics`, exported under `StellaOps.Excititor.Attestation` meter.
|
||||
- Activity source (`AttestationActivitySource`) for optional tracing spans around sign/verify operations.
|
||||
- Documentation updates (`EXCITITOR-ATTEST-01-003-plan.md`, `TASKS.md` notes) describing instrumentation + test expectations.
|
||||
- Test coverage in `StellaOps.Excititor.Attestation.Tests` (unit) and scaffolding notes for WebService/Worker integration tests.
|
||||
|
||||
## 3. Verification Flow
|
||||
|
||||
### 3.1 Inputs
|
||||
|
||||
- `VexAttestationRequest` from Core (contains export identifiers, artifact digest, metadata, source providers).
|
||||
- Optional Rekor reference from previous signing (`VexAttestationMetadata.Rekor`).
|
||||
- Configured policies (tolerated clock skew, Rekor verification toggle, offline mode flag, maximum metadata drift).
|
||||
|
||||
### 3.2 Steps
|
||||
|
||||
1. **Envelope decode** - retrieve DSSE envelope + predicate from storage (Worker) or request payload (WebService), canonicalize JSON, compute digest, compare with metadata `envelopeDigest`.
|
||||
2. **Subject validation** - ensure subject digest matches exported artifact digest (algorithm & value) and export identifier matches `request.ExportId`.
|
||||
3. **Signature verification** - delegate to signer/verifier abstraction (cosign/x509) using configured trust anchors; record `signature_state` diagnostic (verified, skipped_offline, failed).
|
||||
4. **Provenance checks** - confirm predicate type (`https://stella-ops.org/attestations/vex-export`) and metadata shape; enforce deterministic timestamp tolerance.
|
||||
5. **Transparency log** - if Rekor reference present and verification enabled, call `ITransparencyLogClient.VerifyAsync` with retry/backoff budget; support offline bypass with diagnostic `rekor_state=unreachable`.
|
||||
6. **Result aggregation** - produce `VexAttestationVerification` containing `IsValid` flag and diagnostics map (includes `failure_reason` when invalid).
|
||||
|
||||
### 3.3 Failure Categories & Handling
|
||||
|
||||
| Category | Detection | Handling |
|
||||
|---|---|---|
|
||||
| Signature mismatch | Signer verification failure or subject digest mismatch | Mark invalid, emit warning log, increment `verify.failed` counter with `reason=signature_mismatch`. |
|
||||
| Rekor absence/stale | Rekor verify returns false | Mark invalid unless offline mode configured; log with correlation ID; `reason=rekor_missing`. |
|
||||
| Predicate schema drift | Predicate type or required fields missing | Mark invalid, include `reason=predicate_invalid`. |
|
||||
| Time skew | `signedAt` older than policy threshold | Mark invalid (hard) or warn (soft) per options; include `reason=stale_attestation`. |
|
||||
| Unexpected metadata | Unknown export format, provider mismatch | Mark invalid; `reason=metadata_mismatch`. |
|
||||
| Offline Rekor | HTTP client throws | Mark soft failure if `AllowOfflineTransparency` true; degrade metrics with `rekor_state=offline`. |
|
||||
|
||||
## 4. Observability
|
||||
|
||||
### 4.1 Metrics (Meter name: `StellaOps.Excititor.Attestation`)
|
||||
|
||||
| Metric | Type | Dimensions | Description |
|
||||
|---|---|---|---|
|
||||
| `stellaops.excititor.attestation.verify.total` | Counter<long> | `result` (`success`/`failure`/`soft_failure`), `component` (`webservice`/`worker`), `reverify` (`true`/`false`) | Counts verification attempts. |
|
||||
| `stellaops.excititor.attestation.verify.duration.ms` | Histogram<double> | `component`, `result` | Measures end-to-end verification latency. |
|
||||
| `stellaops.excititor.attestation.verify.rekor.calls` | Counter<long> | `result` (`verified`/`unreachable`/`skipped`) | Rekor verification outcomes. |
|
||||
| `stellaops.excititor.attestation.verify.cache.hit` | Counter<long> | `hit` (`true`/`false`) | Tracks reuse of cached verification results (Worker loop). |
|
||||
|
||||
Metrics must register via static helper using `Meter` and support offline operation (no exporter dependency). Histogram records double milliseconds; use `Stopwatch.GetElapsedTime` for monotonic timing.
|
||||
|
||||
### 4.2 Logging
|
||||
|
||||
- Use structured logs (`ILogger<VexAttestationVerifier>`) with event IDs: `AttestationVerified` (Information), `AttestationVerificationFailed` (Warning), `AttestationVerificationError` (Error).
|
||||
- Include correlation ID (`request.QuerySignature.Value`), `exportId`, `envelopeDigest`, `rekorLocation`, `reason`, and `durationMs`.
|
||||
- Avoid logging private keys or full envelope; log envelope digest only. For debug builds, gate optional envelope JSON behind `LogLevel.Trace` and configuration flag.
|
||||
|
||||
### 4.3 Tracing
|
||||
|
||||
- Activity source name `StellaOps.Excititor.Attestation` with spans `attestation.verify` (parent from WebService request or Worker job) including tags: `stellaops.export_id`, `stellaops.result`, `stellaops.rekor.state`.
|
||||
- Propagate Activity through Rekor client via `HttpClient` instrumentation (auto instrumentation available).
|
||||
|
||||
## 5. Integration Points
|
||||
|
||||
### 5.1 WebService
|
||||
|
||||
- Inject `IVexAttestationVerifier` into export endpoints and `/excititor/verify` handler.
|
||||
- Persist verification result diagnostics alongside response payload for deterministic clients.
|
||||
- Return HTTP 200 with `{ valid: true }` when verified; 409 for invalid attestation with diagnostics JSON; 503 when Rekor unreachable and offline override disabled.
|
||||
- Add caching for idempotent verification (e.g., by envelope digest) to reduce Rekor calls and surface via metrics.
|
||||
|
||||
### 5.2 Worker
|
||||
|
||||
- Schedule background job (`EXCITITOR-WORKER-01-003`) to re-verify stored attestations on TTL (default 12h) using new verifier; on failure, flag export for re-sign and notify via event bus (future task).
|
||||
- Emit logs/metrics with `component=worker`; include job IDs and next scheduled run.
|
||||
- Provide cancellation-aware loops (respect `CancellationToken`) and deterministic order (sorted by export id).
|
||||
|
||||
### 5.3 Storage / Cache Hooks
|
||||
|
||||
- Store latest verification status and diagnostics in attestation metadata collection (Mongo) keyed by `envelopeDigest` + `artifactDigest` to avoid duplicate work.
|
||||
- Expose read API (via WebService) for clients to fetch last verification timestamp + result.
|
||||
|
||||
## 6. Test Strategy
|
||||
|
||||
### 6.1 Unit Tests (`StellaOps.Excititor.Attestation.Tests`)
|
||||
|
||||
- `VexAttestationVerifierTests.VerifyAsync_Succeeds_WhenSignatureAndRekorValid` - uses fake signer/verifier + in-memory Rekor client returning success.
|
||||
- `...ReturnsSoftFailure_WhenRekorOfflineAndAllowed` - ensure `IsValid=true`, diagnostic `rekor_state=offline`, metric increments `result=soft_failure`.
|
||||
- `...Fails_WhenDigestMismatch` - ensures invalid result, log entry recorded, metrics increment `result=failure` with `reason=signature_mismatch`.
|
||||
- `...Fails_WhenPredicateTypeUnexpected` - invalid with `reason=predicate_invalid`.
|
||||
- `...RespectsCancellation` - cancellation token triggered before Rekor call results in `OperationCanceledException` and no metrics increments beyond started attempt.
|
||||
|
||||
### 6.2 WebService Integration Tests (`StellaOps.Excititor.WebService.Tests`)
|
||||
|
||||
- `VerifyEndpoint_Returns200_OnValidAttestation` - mocks verifier to return success, asserts response payload, metrics stub invoked.
|
||||
- `VerifyEndpoint_Returns409_OnInvalid` - invalid diag forwarded, ensures logging occurs.
|
||||
- `ExportEndpoint_IncludesVerificationDiagnostics` - ensures signed export responses include last verification metadata.
|
||||
|
||||
### 6.3 Worker Tests (`StellaOps.Excititor.Worker.Tests`)
|
||||
|
||||
- `ReverificationJob_RequeuesOnFailure` - invalid result triggers requeue/backoff.
|
||||
- `ReverificationJob_PersistsStatusAndMetrics` - success path updates repository & metrics.
|
||||
|
||||
### 6.4 Determinism/Regression
|
||||
|
||||
- Golden test verifying that identical inputs produce identical diagnostics dictionaries (sorted keys).
|
||||
- Ensure metrics dimensions remain stable via snapshot test (e.g., capturing tags in fake meter listener).
|
||||
|
||||
## 7. Implementation Sequencing
|
||||
|
||||
1. Introduce verifier abstraction + implementation with basic tests (signature + Rekor success/failure).
|
||||
2. Add observability helpers (metrics, activity, logging) and wire into verifier; extend tests to assert instrumentation (using in-memory listener/log sink).
|
||||
3. Update WebService DI/service layer to use verifier; craft endpoint integration tests.
|
||||
4. Update Worker scheduling code to call verifier & emit metrics.
|
||||
5. Wire persistence/caching and document configuration knobs (retry, offline, TTL).
|
||||
6. Finalize documentation (architecture updates, runbook entries) before closing task.
|
||||
|
||||
## 8. Configuration Defaults
|
||||
|
||||
- `AttestationVerificationOptions` (new): `RequireRekor=true`, `AllowOfflineTransparency=false`, `MaxClockSkew=PT5M`, `ReverifyInterval=PT12H`, `CacheWindow=PT1H`.
|
||||
- Options bind from configuration section `Excititor:Attestation` across WebService/Worker; offline kit ships defaults.
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- Should verification gracefully accept legacy predicate types (pre-1.0) or hard fail? (Proposed: allow via allowlist with warning diagnostics.)
|
||||
- Do we need cross-module eventing when verification fails (e.g., notify Export module) or is logging sufficient in Wave 0? (Proposed: log + metrics now, escalate in later wave.)
|
||||
- Confirm whether Worker re-verification writes to Mongo or triggers Export module to re-sign artifacts automatically; placeholder: record status + timestamp only.
|
||||
|
||||
## 10. Acceptance Criteria
|
||||
|
||||
- Plan approved by Attestation + WebService + Worker leads.
|
||||
- Metrics/logging names peer-reviewed to avoid collisions.
|
||||
- Test backlog items entered into respective `TASKS.md` once implementation starts.
|
||||
- Documentation (this plan) linked from `TASKS.md` notes for discoverability.
|
||||
|
||||
## 11. 2025-10-22 Progress Notes
|
||||
|
||||
- Implemented `IVexAttestationVerifier`/`VexAttestationVerifier` with structural validation (subject/predicate checks, digest comparison, Rekor probes) and diagnostics map.
|
||||
- Added `VexAttestationVerificationOptions` (RequireTransparencyLog, AllowOfflineTransparency, MaxClockSkew) and wired configuration through WebService DI.
|
||||
- Created `VexAttestationMetrics` (`excititor.attestation.verify_total`, `excititor.attestation.verify_duration_seconds`) and hooked into verification flow with component/rekor tags.
|
||||
- `VexAttestationClient.VerifyAsync` now delegates to the verifier; DI registers metrics + verifier via `AddVexAttestation`.
|
||||
- Added unit coverage in `VexAttestationVerifierTests` (happy path, digest mismatch, offline Rekor) and updated client/export/webservice stubs to new verification signature.
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Transparency;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Extensions;
|
||||
|
||||
public static class VexAttestationServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddVexAttestation(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<VexDsseBuilder>();
|
||||
services.AddSingleton<VexAttestationMetrics>();
|
||||
services.AddSingleton<IVexAttestationVerifier, VexAttestationVerifier>();
|
||||
services.AddSingleton<IVexAttestationClient, VexAttestationClient>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddVexRekorClient(this IServiceCollection services, Action<RekorHttpClientOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
services.Configure(configure);
|
||||
services.AddHttpClient<ITransparencyLogClient, RekorHttpClient>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Models;
|
||||
|
||||
public sealed record VexAttestationPredicate(
|
||||
string ExportId,
|
||||
string QuerySignature,
|
||||
string ArtifactAlgorithm,
|
||||
string ArtifactDigest,
|
||||
VexExportFormat Format,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<string> SourceProviders,
|
||||
IReadOnlyDictionary<string, string> Metadata)
|
||||
{
|
||||
public static VexAttestationPredicate FromRequest(
|
||||
VexAttestationRequest request,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
=> new(
|
||||
request.ExportId,
|
||||
request.QuerySignature.Value,
|
||||
request.Artifact.Algorithm,
|
||||
request.Artifact.Digest,
|
||||
request.Format,
|
||||
request.CreatedAt,
|
||||
request.SourceProviders,
|
||||
metadata is null ? ImmutableDictionary<string, string>.Empty : metadata.ToImmutableDictionary(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public sealed record VexInTotoSubject(
|
||||
string Name,
|
||||
IReadOnlyDictionary<string, string> Digest);
|
||||
|
||||
public sealed record VexInTotoStatement(
|
||||
[property: JsonPropertyName("_type")] string Type,
|
||||
string PredicateType,
|
||||
IReadOnlyList<VexInTotoSubject> Subject,
|
||||
VexAttestationPredicate Predicate)
|
||||
{
|
||||
public static readonly string InTotoType = "https://in-toto.io/Statement/v0.1";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Signing;
|
||||
|
||||
public sealed record VexSignedPayload(string Signature, string? KeyId);
|
||||
|
||||
public interface IVexSigner
|
||||
{
|
||||
ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md and ./AGENTS.md).
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-ATTEST-01-001 – In-toto predicate & DSSE builder|Team Excititor Attestation|EXCITITOR-CORE-01-001|**DONE (2025-10-16)** – Added deterministic in-toto predicate/statement models, DSSE envelope builder wired to signer abstraction, and attestation client producing metadata + diagnostics.|
|
||||
|EXCITITOR-ATTEST-01-002 – Rekor v2 client integration|Team Excititor Attestation|EXCITITOR-ATTEST-01-001|**DONE (2025-10-16)** – Implemented Rekor HTTP client with retry/backoff, transparency log abstraction, DI helpers, and attestation client integration capturing Rekor metadata + diagnostics.|
|
||||
|EXCITITOR-ATTEST-01-003 – Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-22) – Continuing implementation: build `IVexAttestationVerifier`, wire metrics/logging, and add regression tests. Draft plan in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19) guides scope; updating with worknotes as progress lands.|
|
||||
|
||||
> Remark (2025-10-22): Added verifier implementation + metrics/tests; next steps include wiring into WebService/Worker flows and expanding negative-path coverage.
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Transparency;
|
||||
|
||||
public sealed record TransparencyLogEntry(string Id, string Location, string? LogIndex, string? InclusionProofUrl);
|
||||
|
||||
public interface ITransparencyLogClient
|
||||
{
|
||||
ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Transparency;
|
||||
|
||||
internal sealed class RekorHttpClient : ITransparencyLogClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly RekorHttpClientOptions _options;
|
||||
private readonly ILogger<RekorHttpClient> _logger;
|
||||
|
||||
public RekorHttpClient(HttpClient httpClient, IOptions<RekorHttpClientOptions> options, ILogger<RekorHttpClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BaseAddress))
|
||||
{
|
||||
_httpClient.BaseAddress = new Uri(_options.BaseAddress, UriKind.Absolute);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Add("Authorization", _options.ApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
var payload = JsonSerializer.Serialize(envelope);
|
||||
using var content = new StringContent(payload);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
HttpResponseMessage? response = null;
|
||||
for (var attempt = 0; attempt < _options.RetryCount; attempt++)
|
||||
{
|
||||
response = await _httpClient.PostAsync("/api/v2/log/entries", content, cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Rekor submission failed with status {Status}; attempt {Attempt}", response.StatusCode, attempt + 1);
|
||||
if (attempt + 1 < _options.RetryCount)
|
||||
{
|
||||
await Task.Delay(_options.RetryDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (response is null || !response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Failed to submit attestation to Rekor ({response?.StatusCode}).");
|
||||
}
|
||||
|
||||
var entryLocation = response.Headers.Location?.ToString() ?? string.Empty;
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entry = ParseEntryLocation(entryLocation, body);
|
||||
_logger.LogInformation("Rekor entry recorded at {Location}", entry.Location);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryLocation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(entryLocation, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private static TransparencyLogEntry ParseEntryLocation(string location, JsonElement body)
|
||||
{
|
||||
var id = body.TryGetProperty("uuid", out var uuid) ? uuid.GetString() ?? string.Empty : Guid.NewGuid().ToString();
|
||||
var logIndex = body.TryGetProperty("logIndex", out var logIndexElement) ? logIndexElement.GetString() : null;
|
||||
string? inclusionProof = null;
|
||||
if (body.TryGetProperty("verification", out var verification) && verification.TryGetProperty("inclusionProof", out var inclusion))
|
||||
{
|
||||
inclusionProof = inclusion.GetProperty("logIndex").GetRawText();
|
||||
}
|
||||
|
||||
return new TransparencyLogEntry(id, location, logIndex, inclusionProof);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Excititor.Attestation.Transparency;
|
||||
|
||||
public sealed class RekorHttpClientOptions
|
||||
{
|
||||
public string BaseAddress { get; set; } = "https://rekor.sigstore.dev";
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
= null;
|
||||
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Verification;
|
||||
|
||||
public interface IVexAttestationVerifier
|
||||
{
|
||||
ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Verification;
|
||||
|
||||
public sealed class VexAttestationMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.Excititor.Attestation";
|
||||
public const string MeterVersion = "1.0";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private bool _disposed;
|
||||
|
||||
public VexAttestationMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName, MeterVersion);
|
||||
VerifyTotal = _meter.CreateCounter<long>("excititor.attestation.verify_total", description: "Attestation verification attempts grouped by result/component/rekor.");
|
||||
VerifyDuration = _meter.CreateHistogram<double>("excititor.attestation.verify_duration_seconds", unit: "s", description: "Attestation verification latency in seconds.");
|
||||
}
|
||||
|
||||
public Counter<long> VerifyTotal { get; }
|
||||
|
||||
public Histogram<double> VerifyDuration { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_meter.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Verification;
|
||||
|
||||
public sealed class VexAttestationVerificationOptions
|
||||
{
|
||||
private TimeSpan _maxClockSkew = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// When true, verification fails if no transparency record is present.
|
||||
/// </summary>
|
||||
public bool RequireTransparencyLog { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allows verification to succeed when the transparency log cannot be reached.
|
||||
/// A diagnostic entry is still emitted to signal the degraded state.
|
||||
/// </summary>
|
||||
public bool AllowOfflineTransparency { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum tolerated clock skew between the attestation creation time and the verification context timestamp.
|
||||
/// </summary>
|
||||
public TimeSpan MaxClockSkew
|
||||
{
|
||||
get => _maxClockSkew;
|
||||
set => _maxClockSkew = value < TimeSpan.Zero ? TimeSpan.Zero : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Models;
|
||||
using StellaOps.Excititor.Attestation.Transparency;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Verification;
|
||||
|
||||
internal sealed class VexAttestationVerifier : IVexAttestationVerifier
|
||||
{
|
||||
private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions StatementSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
private readonly ILogger<VexAttestationVerifier> _logger;
|
||||
private readonly ITransparencyLogClient? _transparencyLogClient;
|
||||
private readonly VexAttestationVerificationOptions _options;
|
||||
private readonly VexAttestationMetrics _metrics;
|
||||
|
||||
public VexAttestationVerifier(
|
||||
ILogger<VexAttestationVerifier> logger,
|
||||
ITransparencyLogClient? transparencyLogClient,
|
||||
IOptions<VexAttestationVerificationOptions> options,
|
||||
VexAttestationMetrics metrics)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_transparencyLogClient = transparencyLogClient;
|
||||
_options = options.Value;
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
}
|
||||
|
||||
public async ValueTask<VexAttestationVerification> VerifyAsync(
|
||||
VexAttestationVerificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var diagnostics = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
var resultLabel = "valid";
|
||||
var rekorState = "skipped";
|
||||
var component = request.IsReverify ? "worker" : "webservice";
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Envelope))
|
||||
{
|
||||
diagnostics["envelope.state"] = "missing";
|
||||
_logger.LogWarning("Attestation envelope is missing for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!TryDeserializeEnvelope(request.Envelope, out var envelope, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("Failed to deserialize attestation envelope for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!string.Equals(envelope.PayloadType, VexDsseBuilder.PayloadType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
diagnostics["payload.type"] = envelope.PayloadType ?? string.Empty;
|
||||
_logger.LogWarning(
|
||||
"Unexpected DSSE payload type {PayloadType} for export {ExportId}",
|
||||
envelope.PayloadType,
|
||||
request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
|
||||
{
|
||||
diagnostics["signature.state"] = "missing";
|
||||
_logger.LogWarning("Attestation envelope for export {ExportId} does not contain signatures.", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
var payloadBase64 = envelope.Payload ?? string.Empty;
|
||||
if (!TryDecodePayload(payloadBase64, out var payloadBytes, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("Failed to decode attestation payload for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!TryDeserializeStatement(payloadBytes, out var statement, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("Failed to deserialize DSSE statement for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!ValidatePredicateType(statement, request, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("Predicate type mismatch for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!ValidateSubject(statement, request, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("Subject mismatch for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!ValidatePredicate(statement, request, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("Predicate payload mismatch for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!ValidateMetadataDigest(envelope, request.Metadata, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("Attestation digest mismatch for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
if (!ValidateSignedAt(request.Metadata, request.Attestation.CreatedAt, diagnostics))
|
||||
{
|
||||
_logger.LogWarning("SignedAt validation failed for export {ExportId}", request.Attestation.ExportId);
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
rekorState = await VerifyTransparencyAsync(request.Metadata, diagnostics, cancellationToken).ConfigureAwait(false);
|
||||
if (rekorState is "missing" or "unverified" or "client_unavailable")
|
||||
{
|
||||
resultLabel = "invalid";
|
||||
return BuildResult(false);
|
||||
}
|
||||
|
||||
diagnostics["signature.state"] = "present";
|
||||
return BuildResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics["error"] = ex.GetType().Name;
|
||||
diagnostics["error.message"] = ex.Message;
|
||||
resultLabel = "error";
|
||||
_logger.LogError(ex, "Unexpected exception verifying attestation for export {ExportId}", request.Attestation.ExportId);
|
||||
return BuildResult(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("result", resultLabel),
|
||||
new("component", component),
|
||||
new("rekor", rekorState),
|
||||
};
|
||||
_metrics.VerifyTotal.Add(1, tags);
|
||||
_metrics.VerifyDuration.Record(stopwatch.Elapsed.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
VexAttestationVerification BuildResult(bool isValid)
|
||||
{
|
||||
diagnostics["result"] = resultLabel;
|
||||
diagnostics["component"] = component;
|
||||
diagnostics["rekor.state"] = rekorState;
|
||||
return new VexAttestationVerification(isValid, diagnostics.ToImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeserializeEnvelope(
|
||||
string envelopeJson,
|
||||
out DsseEnvelope envelope,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, EnvelopeSerializerOptions)
|
||||
?? throw new InvalidOperationException("Envelope deserialized to null.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics["envelope.error"] = ex.GetType().Name;
|
||||
envelope = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodePayload(
|
||||
string payloadBase64,
|
||||
out byte[] payloadBytes,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
diagnostics["payload.base64"] = "invalid";
|
||||
payloadBytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeserializeStatement(
|
||||
byte[] payload,
|
||||
out VexInTotoStatement statement,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
try
|
||||
{
|
||||
statement = JsonSerializer.Deserialize<VexInTotoStatement>(payload, StatementSerializerOptions)
|
||||
?? throw new InvalidOperationException("Statement deserialized to null.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics["payload.error"] = ex.GetType().Name;
|
||||
statement = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidatePredicateType(
|
||||
VexInTotoStatement statement,
|
||||
VexAttestationVerificationRequest request,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
var predicateType = statement.PredicateType ?? string.Empty;
|
||||
if (!string.Equals(predicateType, request.Metadata.PredicateType, StringComparison.Ordinal))
|
||||
{
|
||||
diagnostics["predicate.type"] = predicateType;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateSubject(
|
||||
VexInTotoStatement statement,
|
||||
VexAttestationVerificationRequest request,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
if (statement.Subject is null || statement.Subject.Count != 1)
|
||||
{
|
||||
diagnostics["subject.count"] = (statement.Subject?.Count ?? 0).ToString();
|
||||
return false;
|
||||
}
|
||||
|
||||
var subject = statement.Subject[0];
|
||||
if (!string.Equals(subject.Name, request.Attestation.ExportId, StringComparison.Ordinal))
|
||||
{
|
||||
diagnostics["subject.name"] = subject.Name ?? string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subject.Digest is null)
|
||||
{
|
||||
diagnostics["subject.digest"] = "missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
var algorithmKey = request.Attestation.Artifact.Algorithm.ToLowerInvariant();
|
||||
if (!subject.Digest.TryGetValue(algorithmKey, out var digest)
|
||||
|| !string.Equals(digest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
diagnostics["subject.digest"] = digest ?? string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ValidatePredicate(
|
||||
VexInTotoStatement statement,
|
||||
VexAttestationVerificationRequest request,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
var predicate = statement.Predicate;
|
||||
if (predicate is null)
|
||||
{
|
||||
diagnostics["predicate.state"] = "missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(predicate.ExportId, request.Attestation.ExportId, StringComparison.Ordinal))
|
||||
{
|
||||
diagnostics["predicate.exportId"] = predicate.ExportId ?? string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(predicate.QuerySignature, request.Attestation.QuerySignature.Value, StringComparison.Ordinal))
|
||||
{
|
||||
diagnostics["predicate.querySignature"] = predicate.QuerySignature ?? string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(predicate.ArtifactAlgorithm, request.Attestation.Artifact.Algorithm, StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(predicate.ArtifactDigest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
diagnostics["predicate.artifact"] = $"{predicate.ArtifactAlgorithm}:{predicate.ArtifactDigest}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (predicate.Format != request.Attestation.Format)
|
||||
{
|
||||
diagnostics["predicate.format"] = predicate.Format.ToString();
|
||||
return false;
|
||||
}
|
||||
|
||||
var createdDelta = (predicate.CreatedAt - request.Attestation.CreatedAt).Duration();
|
||||
if (createdDelta > _options.MaxClockSkew)
|
||||
{
|
||||
diagnostics["predicate.createdAtDelta"] = createdDelta.ToString();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SetEquals(predicate.SourceProviders, request.Attestation.SourceProviders))
|
||||
{
|
||||
diagnostics["predicate.sourceProviders"] = string.Join(",", predicate.SourceProviders ?? Array.Empty<string>());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.Attestation.Metadata.Count > 0)
|
||||
{
|
||||
if (predicate.Metadata is null)
|
||||
{
|
||||
diagnostics["predicate.metadata"] = "missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var kvp in request.Attestation.Metadata)
|
||||
{
|
||||
if (!predicate.Metadata.TryGetValue(kvp.Key, out var actual)
|
||||
|| !string.Equals(actual, kvp.Value, StringComparison.Ordinal))
|
||||
{
|
||||
diagnostics[$"predicate.metadata.{kvp.Key}"] = actual ?? string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ValidateMetadataDigest(
|
||||
DsseEnvelope envelope,
|
||||
VexAttestationMetadata metadata,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(metadata.EnvelopeDigest))
|
||||
{
|
||||
diagnostics["metadata.envelopeDigest"] = "missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
var computed = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
|
||||
if (!string.Equals(computed, metadata.EnvelopeDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
diagnostics["metadata.envelopeDigest"] = metadata.EnvelopeDigest;
|
||||
diagnostics["metadata.envelopeDigest.computed"] = computed;
|
||||
return false;
|
||||
}
|
||||
|
||||
diagnostics["metadata.envelopeDigest"] = "match";
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ValidateSignedAt(
|
||||
VexAttestationMetadata metadata,
|
||||
DateTimeOffset createdAt,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics)
|
||||
{
|
||||
if (metadata.SignedAt is null)
|
||||
{
|
||||
diagnostics["metadata.signedAt"] = "missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
var delta = (metadata.SignedAt.Value - createdAt).Duration();
|
||||
if (delta > _options.MaxClockSkew)
|
||||
{
|
||||
diagnostics["metadata.signedAtDelta"] = delta.ToString();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ValueTask<string> VerifyTransparencyAsync(
|
||||
VexAttestationMetadata metadata,
|
||||
ImmutableDictionary<string, string>.Builder diagnostics,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (metadata.Rekor is null)
|
||||
{
|
||||
if (_options.RequireTransparencyLog)
|
||||
{
|
||||
diagnostics["rekor.state"] = "missing";
|
||||
return "missing";
|
||||
}
|
||||
|
||||
diagnostics["rekor.state"] = "disabled";
|
||||
return "disabled";
|
||||
}
|
||||
|
||||
if (_transparencyLogClient is null)
|
||||
{
|
||||
diagnostics["rekor.state"] = "client_unavailable";
|
||||
return _options.RequireTransparencyLog ? "client_unavailable" : "disabled";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verified = await _transparencyLogClient.VerifyAsync(metadata.Rekor.Location, cancellationToken).ConfigureAwait(false);
|
||||
diagnostics["rekor.state"] = verified ? "verified" : "unverified";
|
||||
return verified ? "verified" : "unverified";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics["rekor.error"] = ex.GetType().Name;
|
||||
if (_options.AllowOfflineTransparency)
|
||||
{
|
||||
diagnostics["rekor.state"] = "offline";
|
||||
return "offline";
|
||||
}
|
||||
|
||||
diagnostics["rekor.state"] = "unreachable";
|
||||
return "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SetEquals(IReadOnlyCollection<string>? left, ImmutableArray<string> right)
|
||||
{
|
||||
if (left is null)
|
||||
{
|
||||
return right.IsDefaultOrEmpty;
|
||||
}
|
||||
|
||||
if (left.Count != right.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var leftSet = new HashSet<string>(left, StringComparer.Ordinal);
|
||||
return right.All(leftSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Models;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Attestation.Transparency;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation;
|
||||
|
||||
public sealed class VexAttestationClientOptions
|
||||
{
|
||||
public IReadOnlyDictionary<string, string> DefaultMetadata { get; set; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
public sealed class VexAttestationClient : IVexAttestationClient
|
||||
{
|
||||
private readonly VexDsseBuilder _builder;
|
||||
private readonly ILogger<VexAttestationClient> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IReadOnlyDictionary<string, string> _defaultMetadata;
|
||||
private readonly ITransparencyLogClient? _transparencyLogClient;
|
||||
private readonly IVexAttestationVerifier _verifier;
|
||||
|
||||
public VexAttestationClient(
|
||||
VexDsseBuilder builder,
|
||||
IOptions<VexAttestationClientOptions> options,
|
||||
ILogger<VexAttestationClient> logger,
|
||||
IVexAttestationVerifier verifier,
|
||||
TimeProvider? timeProvider = null,
|
||||
ITransparencyLogClient? transparencyLogClient = null)
|
||||
{
|
||||
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_defaultMetadata = options.Value.DefaultMetadata;
|
||||
_transparencyLogClient = transparencyLogClient;
|
||||
}
|
||||
|
||||
public async ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var mergedMetadata = MergeMetadata(request.Metadata, _defaultMetadata);
|
||||
|
||||
var envelope = await _builder.CreateEnvelopeAsync(request, mergedMetadata, cancellationToken).ConfigureAwait(false);
|
||||
var envelopeDigest = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
|
||||
var signedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var diagnosticsBuilder = ImmutableDictionary<string, string>.Empty
|
||||
.Add("envelope", JsonSerializer.Serialize(envelope))
|
||||
.Add("predicateType", "https://stella-ops.org/attestations/vex-export");
|
||||
|
||||
VexRekorReference? rekorReference = null;
|
||||
if (_transparencyLogClient is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = await _transparencyLogClient.SubmitAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||||
rekorReference = new VexRekorReference("0.2", entry.Location, entry.LogIndex, entry.InclusionProofUrl is not null ? new Uri(entry.InclusionProofUrl) : null);
|
||||
diagnosticsBuilder = diagnosticsBuilder.Add("rekorLocation", entry.Location);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to submit attestation to Rekor transparency log");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = new VexAttestationMetadata(
|
||||
predicateType: "https://stella-ops.org/attestations/vex-export",
|
||||
rekor: rekorReference,
|
||||
envelopeDigest: envelopeDigest,
|
||||
signedAt: signedAt);
|
||||
|
||||
_logger.LogInformation("Generated DSSE envelope for export {ExportId} ({Digest})", request.ExportId, envelopeDigest);
|
||||
|
||||
return new VexAttestationResponse(metadata, diagnosticsBuilder);
|
||||
}
|
||||
|
||||
public ValueTask<VexAttestationVerification> VerifyAsync(
|
||||
VexAttestationVerificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> _verifier.VerifyAsync(request, cancellationToken);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> MergeMetadata(
|
||||
IReadOnlyDictionary<string, string> requestMetadata,
|
||||
IReadOnlyDictionary<string, string> defaults)
|
||||
{
|
||||
if (defaults.Count == 0)
|
||||
{
|
||||
return requestMetadata;
|
||||
}
|
||||
|
||||
var merged = new Dictionary<string, string>(defaults, StringComparer.Ordinal);
|
||||
foreach (var kvp in requestMetadata)
|
||||
{
|
||||
merged[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return merged.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Defines shared connector infrastructure for Excititor, including base contexts, result contracts, configuration binding, and helper utilities reused by all connector plug-ins.
|
||||
## Scope
|
||||
- `IVexConnector` context implementation, raw store helpers, verification hooks, and telemetry utilities.
|
||||
- Configuration primitives (YAML parsing, secrets handling guidelines) and options validation.
|
||||
- Connector lifecycle helpers for retries, paging, `.well-known` discovery, and resume markers.
|
||||
- Documentation for connector packaging, plugin manifest metadata, and DI registration (see `docs/dev/30_EXCITITOR_CONNECTOR_GUIDE.md` and `docs/dev/templates/excititor-connector/`).
|
||||
## Participants
|
||||
- All Excititor connector projects reference this module to obtain base classes and context services.
|
||||
- WebService/Worker instantiate connectors via plugin loader leveraging abstractions defined here.
|
||||
## Interfaces & contracts
|
||||
- Connector context, result, and telemetry interfaces; `VexConnectorDescriptor`, `VexConnectorBase`, options binder/validators, authentication helpers.
|
||||
- Utility classes for HTTP clients, throttling, and deterministic logging.
|
||||
## In/Out of scope
|
||||
In: shared abstractions, helper utilities, configuration binding, documentation for connector authors.
|
||||
Out: provider-specific logic (implemented in individual connector modules), storage persistence, HTTP host code.
|
||||
## Observability & security expectations
|
||||
- Provide structured logging helpers, correlation IDs, and metrics instrumentation toggles for connectors.
|
||||
- Enforce redaction of secrets in logs and config dumps.
|
||||
## Tests
|
||||
- Abstraction/unit tests will live in `../StellaOps.Excititor.Connectors.Abstractions.Tests`, covering default behaviors and sample harness.
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Custom validator hook executed after connector options are bound.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">Connector-specific options type.</typeparam>
|
||||
public interface IVexConnectorOptionsValidator<in TOptions>
|
||||
{
|
||||
void Validate(VexConnectorDescriptor descriptor, TOptions options, IList<string> errors);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md and ./AGENTS.md).
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-ABS-01-001 – Connector context & base classes|Team Excititor Connectors|EXCITITOR-CORE-01-003|**DONE (2025-10-17)** – Added `StellaOps.Excititor.Connectors.Abstractions` project with `VexConnectorBase`, deterministic logging scopes, metadata builder helpers, and connector descriptors; docs updated to highlight the shared abstractions.|
|
||||
|EXCITITOR-CONN-ABS-01-002 – YAML options & validation|Team Excititor Connectors|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Delivered `VexConnectorOptionsBinder` + binder options/validators, environment-variable expansion, data-annotation checks, and custom validation hooks with documentation updates covering the workflow.|
|
||||
|EXCITITOR-CONN-ABS-01-003 – Plugin packaging & docs|Team Excititor Connectors|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Authored `docs/dev/30_EXCITITOR_CONNECTOR_GUIDE.md`, added quick-start template under `docs/dev/templates/excititor-connector/`, and updated module docs to reference the packaging workflow.|
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience base class for implementing <see cref="IVexConnector" />.
|
||||
/// </summary>
|
||||
public abstract class VexConnectorBase : IVexConnector
|
||||
{
|
||||
protected VexConnectorBase(VexConnectorDescriptor descriptor, ILogger logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
TimeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => Descriptor.Id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public VexProviderKind Kind => Descriptor.Kind;
|
||||
|
||||
public VexConnectorDescriptor Descriptor { get; }
|
||||
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
protected TimeProvider TimeProvider { get; }
|
||||
|
||||
protected DateTimeOffset UtcNow() => TimeProvider.GetUtcNow();
|
||||
|
||||
protected VexRawDocument CreateRawDocument(
|
||||
VexDocumentFormat format,
|
||||
Uri sourceUri,
|
||||
ReadOnlyMemory<byte> content,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
if (sourceUri is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sourceUri));
|
||||
}
|
||||
|
||||
var digest = ComputeSha256(content.Span);
|
||||
var captured = TimeProvider.GetUtcNow();
|
||||
return new VexRawDocument(
|
||||
Descriptor.Id,
|
||||
format,
|
||||
sourceUri,
|
||||
captured,
|
||||
digest,
|
||||
content,
|
||||
metadata ?? ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
protected IDisposable BeginConnectorScope(string operation, IReadOnlyDictionary<string, object?>? metadata = null)
|
||||
=> VexConnectorLogScope.Begin(Logger, Descriptor, operation, metadata);
|
||||
|
||||
protected void LogConnectorEvent(LogLevel level, string eventName, string message, IReadOnlyDictionary<string, object?>? metadata = null, Exception? exception = null)
|
||||
{
|
||||
using var scope = BeginConnectorScope(eventName, metadata);
|
||||
if (exception is null)
|
||||
{
|
||||
Logger.Log(level, "{Message}", message);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Log(level, exception, "{Message}", message);
|
||||
}
|
||||
}
|
||||
|
||||
protected ImmutableDictionary<string, string> BuildMetadata(Action<VexConnectorMetadataBuilder> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
var builder = new VexConnectorMetadataBuilder();
|
||||
configure(builder);
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> content)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
if (SHA256.TryHashData(content, buffer, out _))
|
||||
{
|
||||
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(content.ToArray());
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public abstract ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken);
|
||||
|
||||
public abstract IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken);
|
||||
|
||||
public abstract ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Static descriptor for a Excititor connector plug-in.
|
||||
/// </summary>
|
||||
public sealed record VexConnectorDescriptor
|
||||
{
|
||||
public VexConnectorDescriptor(string id, VexProviderKind kind, string displayName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
throw new ArgumentException("Connector id must be provided.", nameof(id));
|
||||
}
|
||||
|
||||
Id = id;
|
||||
Kind = kind;
|
||||
DisplayName = string.IsNullOrWhiteSpace(displayName) ? id : displayName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable connector identifier (matches provider id).
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider kind served by the connector.
|
||||
/// </summary>
|
||||
public VexProviderKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human friendly name used in logs/diagnostics.
|
||||
/// </summary>
|
||||
public string DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional friendly description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Document formats the connector is expected to emit.
|
||||
/// </summary>
|
||||
public ImmutableArray<VexDocumentFormat> SupportedFormats { get; init; } = ImmutableArray<VexDocumentFormat>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional tags surfaced in diagnostics (e.g. "beta", "offline").
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public override string ToString() => $"{Id} ({Kind})";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Helper to establish deterministic logging scopes for connector operations.
|
||||
/// </summary>
|
||||
public static class VexConnectorLogScope
|
||||
{
|
||||
public static IDisposable Begin(ILogger logger, VexConnectorDescriptor descriptor, string operation, IReadOnlyDictionary<string, object?>? metadata = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentException.ThrowIfNullOrEmpty(operation);
|
||||
|
||||
var scopeValues = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("vex.connector.id", descriptor.Id),
|
||||
new("vex.connector.kind", descriptor.Kind.ToString()),
|
||||
new("vex.connector.operation", operation),
|
||||
};
|
||||
|
||||
if (!string.Equals(descriptor.DisplayName, descriptor.Id, StringComparison.Ordinal))
|
||||
{
|
||||
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.displayName", descriptor.DisplayName));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(descriptor.Description))
|
||||
{
|
||||
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.description", descriptor.Description));
|
||||
}
|
||||
|
||||
if (!descriptor.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.tags", string.Join(",", descriptor.Tags)));
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var kvp in metadata.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
scopeValues.Add(new KeyValuePair<string, object?>($"vex.{kvp.Key}", kvp.Value));
|
||||
}
|
||||
}
|
||||
|
||||
return logger.BeginScope(scopeValues)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic metadata dictionaries for raw documents and logging scopes.
|
||||
/// </summary>
|
||||
public sealed class VexConnectorMetadataBuilder
|
||||
{
|
||||
private readonly SortedDictionary<string, string> _values = new(StringComparer.Ordinal);
|
||||
|
||||
public VexConnectorMetadataBuilder Add(string key, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_values[key] = value!;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public VexConnectorMetadataBuilder Add(string key, DateTimeOffset value)
|
||||
=> Add(key, value.ToUniversalTime().ToString("O"));
|
||||
|
||||
public VexConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string?>> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
Add(item.Key, item.Value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public ImmutableDictionary<string, string> Build()
|
||||
=> _values.ToImmutableDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides strongly typed binding and validation for connector options.
|
||||
/// </summary>
|
||||
public static class VexConnectorOptionsBinder
|
||||
{
|
||||
public static TOptions Bind<TOptions>(
|
||||
VexConnectorDescriptor descriptor,
|
||||
VexConnectorSettings settings,
|
||||
VexConnectorOptionsBinderOptions? options = null,
|
||||
IEnumerable<IVexConnectorOptionsValidator<TOptions>>? validators = null)
|
||||
where TOptions : class, new()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
var binderSettings = options ?? new VexConnectorOptionsBinderOptions();
|
||||
var transformed = TransformValues(settings, binderSettings);
|
||||
|
||||
var configuration = BuildConfiguration(transformed);
|
||||
|
||||
var result = new TOptions();
|
||||
var errors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
configuration.Bind(
|
||||
result,
|
||||
binderOptions => binderOptions.ErrorOnUnknownConfiguration = !binderSettings.AllowUnknownKeys);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (!binderSettings.AllowUnknownKeys)
|
||||
{
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
|
||||
binderSettings.PostConfigure?.Invoke(result);
|
||||
|
||||
if (binderSettings.ValidateDataAnnotations)
|
||||
{
|
||||
ValidateDataAnnotations(result, errors);
|
||||
}
|
||||
|
||||
if (validators is not null)
|
||||
{
|
||||
foreach (var validator in validators)
|
||||
{
|
||||
validator?.Validate(descriptor, result, errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new VexConnectorOptionsValidationException(descriptor.Id, errors);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string?> TransformValues(
|
||||
VexConnectorSettings settings,
|
||||
VexConnectorOptionsBinderOptions binderOptions)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in settings.Values)
|
||||
{
|
||||
var value = kvp.Value;
|
||||
if (binderOptions.TrimWhitespace && value is not null)
|
||||
{
|
||||
value = value.Trim();
|
||||
}
|
||||
|
||||
if (binderOptions.TreatEmptyAsNull && string.IsNullOrEmpty(value))
|
||||
{
|
||||
value = null;
|
||||
}
|
||||
|
||||
if (value is not null && binderOptions.ExpandEnvironmentVariables)
|
||||
{
|
||||
value = Environment.ExpandEnvironmentVariables(value);
|
||||
}
|
||||
|
||||
if (binderOptions.ValueTransformer is not null)
|
||||
{
|
||||
value = binderOptions.ValueTransformer.Invoke(kvp.Key, value);
|
||||
}
|
||||
|
||||
builder[kvp.Key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IConfiguration BuildConfiguration(ImmutableDictionary<string, string?> values)
|
||||
{
|
||||
var sources = new List<KeyValuePair<string, string?>>();
|
||||
foreach (var kvp in values)
|
||||
{
|
||||
if (kvp.Value is not null)
|
||||
{
|
||||
sources.Add(new KeyValuePair<string, string?>(kvp.Key, kvp.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var configurationBuilder = new ConfigurationBuilder();
|
||||
configurationBuilder.Add(new DictionaryConfigurationSource(sources));
|
||||
return configurationBuilder.Build();
|
||||
}
|
||||
|
||||
private static void ValidateDataAnnotations<TOptions>(TOptions options, IList<string> errors)
|
||||
{
|
||||
var validationResults = new List<ValidationResult>();
|
||||
var validationContext = new ValidationContext(options!);
|
||||
if (!Validator.TryValidateObject(options!, validationContext, validationResults, validateAllProperties: true))
|
||||
{
|
||||
foreach (var validationResult in validationResults)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(validationResult.ErrorMessage))
|
||||
{
|
||||
errors.Add(validationResult.ErrorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DictionaryConfigurationSource : IConfigurationSource
|
||||
{
|
||||
private readonly IReadOnlyList<KeyValuePair<string, string?>> _data;
|
||||
|
||||
public DictionaryConfigurationSource(IEnumerable<KeyValuePair<string, string?>> data)
|
||||
{
|
||||
_data = data?.ToList() ?? new List<KeyValuePair<string, string?>>();
|
||||
}
|
||||
|
||||
public IConfigurationProvider Build(IConfigurationBuilder builder) => new DictionaryConfigurationProvider(_data);
|
||||
}
|
||||
|
||||
private sealed class DictionaryConfigurationProvider : ConfigurationProvider
|
||||
{
|
||||
public DictionaryConfigurationProvider(IEnumerable<KeyValuePair<string, string?>> data)
|
||||
{
|
||||
foreach (var pair in data)
|
||||
{
|
||||
if (pair.Value is not null)
|
||||
{
|
||||
Data[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Customisation options for connector options binding.
|
||||
/// </summary>
|
||||
public sealed class VexConnectorOptionsBinderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether environment variables should be expanded in option values.
|
||||
/// Defaults to <c>true</c>.
|
||||
/// </summary>
|
||||
public bool ExpandEnvironmentVariables { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c> the binder trims whitespace around option values.
|
||||
/// </summary>
|
||||
public bool TrimWhitespace { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Converts empty strings to <c>null</c> before binding. Default: <c>true</c>.
|
||||
/// </summary>
|
||||
public bool TreatEmptyAsNull { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>false</c>, binding fails if unknown configuration keys are provided.
|
||||
/// Default: <c>true</c> (permitting unknown keys).
|
||||
/// </summary>
|
||||
public bool AllowUnknownKeys { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables <see cref="System.ComponentModel.DataAnnotations"/> validation after binding.
|
||||
/// Default: <c>true</c>.
|
||||
/// </summary>
|
||||
public bool ValidateDataAnnotations { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional post-configuration callback executed after binding.
|
||||
/// </summary>
|
||||
public Action<object>? PostConfigure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional hook to transform raw configuration values before binding.
|
||||
/// </summary>
|
||||
public Func<string, string?, string?>? ValueTransformer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
public sealed class VexConnectorOptionsValidationException : Exception
|
||||
{
|
||||
public VexConnectorOptionsValidationException(
|
||||
string connectorId,
|
||||
IEnumerable<string> errors)
|
||||
: base(BuildMessage(connectorId, errors))
|
||||
{
|
||||
ConnectorId = connectorId;
|
||||
Errors = errors?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
public string ConnectorId { get; }
|
||||
|
||||
public ImmutableArray<string> Errors { get; }
|
||||
|
||||
private static string BuildMessage(string connectorId, IEnumerable<string> errors)
|
||||
{
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append("Connector options validation failed for '");
|
||||
builder.Append(connectorId);
|
||||
builder.Append("'.");
|
||||
|
||||
var list = errors?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
|
||||
if (!list.IsDefaultOrEmpty)
|
||||
{
|
||||
builder.Append(" Errors: ");
|
||||
builder.Append(string.Join("; ", list));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Connector responsible for ingesting Cisco CSAF VEX advisories and handing raw documents to normalizers with Cisco-specific metadata.
|
||||
## Scope
|
||||
- Discovery of Cisco CSAF collection endpoints, authentication (when required), and pagination routines.
|
||||
- HTTP retries/backoff, checksum verification, and document deduplication before storage.
|
||||
- Mapping Cisco advisory identifiers, product hierarchies, and severity hints into connector metadata.
|
||||
- Surfacing provider trust configuration aligned with policy expectations.
|
||||
## Participants
|
||||
- Worker drives scheduled pulls; WebService may trigger manual runs.
|
||||
- CSAF normalizer consumes raw documents to emit claims.
|
||||
- Policy module references connector trust hints (e.g., Cisco signing identities).
|
||||
## Interfaces & contracts
|
||||
- Implements `IVexConnector` using shared abstractions for HTTP/resume handling.
|
||||
- Provides options for API tokens, rate limits, and concurrency.
|
||||
## In/Out of scope
|
||||
In: data fetching, provider metadata, retry controls, raw document persistence.
|
||||
Out: normalization/export, attestation, Mongo wiring (handled in other modules).
|
||||
## Observability & security expectations
|
||||
- Log fetch batches with document counts/durations; mask credentials.
|
||||
- Emit metrics for rate-limit hits, retries, and quarantine events.
|
||||
## Tests
|
||||
- Unit tests plus HTTP harness fixtures will live in `../StellaOps.Excititor.Connectors.Cisco.CSAF.Tests`.
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF;
|
||||
|
||||
public sealed class CiscoCsafConnector : VexConnectorBase
|
||||
{
|
||||
private static readonly VexConnectorDescriptor DescriptorInstance = new(
|
||||
id: "excititor:cisco",
|
||||
kind: VexProviderKind.Vendor,
|
||||
displayName: "Cisco CSAF")
|
||||
{
|
||||
Tags = ImmutableArray.Create("cisco", "csaf"),
|
||||
};
|
||||
|
||||
private readonly CiscoProviderMetadataLoader _metadataLoader;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IVexConnectorStateRepository _stateRepository;
|
||||
private readonly IEnumerable<IVexConnectorOptionsValidator<CiscoConnectorOptions>> _validators;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private CiscoConnectorOptions? _options;
|
||||
private CiscoProviderMetadataResult? _providerMetadata;
|
||||
|
||||
public CiscoCsafConnector(
|
||||
CiscoProviderMetadataLoader metadataLoader,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
IEnumerable<IVexConnectorOptionsValidator<CiscoConnectorOptions>>? validators,
|
||||
ILogger<CiscoCsafConnector> logger,
|
||||
TimeProvider timeProvider)
|
||||
: base(DescriptorInstance, logger, timeProvider)
|
||||
{
|
||||
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<CiscoConnectorOptions>>();
|
||||
}
|
||||
|
||||
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
_options = VexConnectorOptionsBinder.Bind(
|
||||
Descriptor,
|
||||
settings,
|
||||
validators: _validators);
|
||||
|
||||
_providerMetadata = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
LogConnectorEvent(LogLevel.Information, "validate", "Cisco CSAF metadata loaded.", new Dictionary<string, object?>
|
||||
{
|
||||
["baseUriCount"] = _providerMetadata.Provider.BaseUris.Length,
|
||||
["fromOffline"] = _providerMetadata.FromOfflineSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
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 (_providerMetadata is null)
|
||||
{
|
||||
_providerMetadata = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
var knownDigests = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
|
||||
var digestSet = new HashSet<string>(knownDigests, StringComparer.OrdinalIgnoreCase);
|
||||
var digestList = new List<string>(knownDigests);
|
||||
var since = context.Since ?? state?.LastUpdated ?? DateTimeOffset.MinValue;
|
||||
var latestTimestamp = state?.LastUpdated ?? since;
|
||||
var stateChanged = false;
|
||||
|
||||
var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
|
||||
foreach (var directory in _providerMetadata.Provider.BaseUris)
|
||||
{
|
||||
await foreach (var advisory in EnumerateCatalogAsync(client, directory, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var published = advisory.LastModified ?? advisory.Published ?? DateTimeOffset.MinValue;
|
||||
if (published <= since)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var contentResponse = await client.GetAsync(advisory.DocumentUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
contentResponse.EnsureSuccessStatusCode();
|
||||
var payload = await contentResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var rawDocument = CreateRawDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
advisory.DocumentUri,
|
||||
payload,
|
||||
BuildMetadata(builder => builder
|
||||
.Add("cisco.csaf.advisoryId", advisory.Id)
|
||||
.Add("cisco.csaf.revision", advisory.Revision)
|
||||
.Add("cisco.csaf.published", advisory.Published?.ToString("O"))
|
||||
.Add("cisco.csaf.modified", advisory.LastModified?.ToString("O"))
|
||||
.Add("cisco.csaf.sha256", advisory.Sha256)));
|
||||
|
||||
if (!digestSet.Add(rawDocument.Digest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
|
||||
digestList.Add(rawDocument.Digest);
|
||||
stateChanged = true;
|
||||
if (published > latestTimestamp)
|
||||
{
|
||||
latestTimestamp = published;
|
||||
}
|
||||
|
||||
yield return rawDocument;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged)
|
||||
{
|
||||
var baseState = state ?? new VexConnectorState(
|
||||
Descriptor.Id,
|
||||
null,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null);
|
||||
var newState = baseState with
|
||||
{
|
||||
LastUpdated = latestTimestamp == DateTimeOffset.MinValue ? state?.LastUpdated : latestTimestamp,
|
||||
DocumentDigests = digestList.ToImmutableArray(),
|
||||
};
|
||||
await _stateRepository.SaveAsync(newState, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException("CiscoCsafConnector relies on CSAF normalizers for document processing.");
|
||||
|
||||
private async IAsyncEnumerable<CiscoAdvisoryEntry> EnumerateCatalogAsync(HttpClient client, Uri directory, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var nextUri = BuildIndexUri(directory, null);
|
||||
while (nextUri is not null)
|
||||
{
|
||||
using var response = await client.GetAsync(nextUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var page = JsonSerializer.Deserialize<CiscoAdvisoryIndex>(json, _serializerOptions);
|
||||
if (page?.Advisories is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var advisory in page.Advisories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(advisory.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(advisory.Url, UriKind.RelativeOrAbsolute, out var documentUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!documentUri.IsAbsoluteUri)
|
||||
{
|
||||
documentUri = new Uri(directory, documentUri);
|
||||
}
|
||||
|
||||
yield return new CiscoAdvisoryEntry(
|
||||
advisory.Id ?? documentUri.Segments.LastOrDefault()?.Trim('/') ?? documentUri.ToString(),
|
||||
documentUri,
|
||||
advisory.Revision,
|
||||
advisory.Published,
|
||||
advisory.LastModified,
|
||||
advisory.Sha256);
|
||||
}
|
||||
|
||||
nextUri = ResolveNextUri(directory, page.Next);
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildIndexUri(Uri directory, string? relative)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
{
|
||||
var baseText = directory.ToString();
|
||||
if (!baseText.EndsWith('/'))
|
||||
{
|
||||
baseText += "/";
|
||||
}
|
||||
|
||||
return new Uri(new Uri(baseText, UriKind.Absolute), "index.json");
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(relative, UriKind.Absolute, out var absolute))
|
||||
{
|
||||
return absolute;
|
||||
}
|
||||
|
||||
var baseTextRelative = directory.ToString();
|
||||
if (!baseTextRelative.EndsWith('/'))
|
||||
{
|
||||
baseTextRelative += "/";
|
||||
}
|
||||
|
||||
return new Uri(new Uri(baseTextRelative, UriKind.Absolute), relative);
|
||||
}
|
||||
|
||||
private static Uri? ResolveNextUri(Uri directory, string? next)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildIndexUri(directory, next);
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisoryIndex
|
||||
{
|
||||
public List<CiscoAdvisory>? Advisories { get; init; }
|
||||
public string? Next { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisory
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public string? Revision { get; init; }
|
||||
public DateTimeOffset? Published { get; init; }
|
||||
public DateTimeOffset? LastModified { get; init; }
|
||||
public string? Sha256 { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisoryEntry(
|
||||
string Id,
|
||||
Uri DocumentUri,
|
||||
string? Revision,
|
||||
DateTimeOffset? Published,
|
||||
DateTimeOffset? LastModified,
|
||||
string? Sha256);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
|
||||
public sealed class CiscoConnectorOptions : IValidatableObject
|
||||
{
|
||||
public const string HttpClientName = "cisco-csaf";
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint for Cisco CSAF provider metadata discovery.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string MetadataUri { get; set; } = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json";
|
||||
|
||||
/// <summary>
|
||||
/// Optional bearer token used when Cisco endpoints require authentication.
|
||||
/// </summary>
|
||||
public string? ApiToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How long provider metadata remains cached.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to prefer offline snapshots when fetching metadata.
|
||||
/// </summary>
|
||||
public bool PreferOfflineSnapshot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, provider metadata will be persisted to the given file path.
|
||||
/// </summary>
|
||||
public bool PersistOfflineSnapshot { get; set; }
|
||||
|
||||
public string? OfflineSnapshotPath { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(MetadataUri))
|
||||
{
|
||||
yield return new ValidationResult("MetadataUri must be provided.", new[] { nameof(MetadataUri) });
|
||||
}
|
||||
else if (!Uri.TryCreate(MetadataUri, UriKind.Absolute, out _))
|
||||
{
|
||||
yield return new ValidationResult("MetadataUri must be an absolute URI.", new[] { nameof(MetadataUri) });
|
||||
}
|
||||
|
||||
if (MetadataCacheDuration <= TimeSpan.Zero)
|
||||
{
|
||||
yield return new ValidationResult("MetadataCacheDuration must be greater than zero.", new[] { nameof(MetadataCacheDuration) });
|
||||
}
|
||||
|
||||
if (PersistOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
|
||||
{
|
||||
yield return new ValidationResult("OfflineSnapshotPath must be provided when PersistOfflineSnapshot is enabled.", new[] { nameof(OfflineSnapshotPath) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
|
||||
public sealed class CiscoConnectorOptionsValidator : IVexConnectorOptionsValidator<CiscoConnectorOptions>
|
||||
{
|
||||
public void Validate(VexConnectorDescriptor descriptor, CiscoConnectorOptions options, IList<string> errors)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(errors);
|
||||
|
||||
var validationResults = new List<ValidationResult>();
|
||||
if (!Validator.TryValidateObject(options, new ValidationContext(options), validationResults, validateAllProperties: true))
|
||||
{
|
||||
foreach (var result in validationResults)
|
||||
{
|
||||
errors.Add(result.ErrorMessage ?? "Cisco connector options validation failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.DependencyInjection;
|
||||
|
||||
public static class CiscoConnectorServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCiscoCsafConnector(this IServiceCollection services, Action<CiscoConnectorOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<CiscoConnectorOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
configure?.Invoke(options);
|
||||
})
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IMemoryCache, MemoryCache>();
|
||||
services.TryAddSingleton<IFileSystem, FileSystem>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IVexConnectorOptionsValidator<CiscoConnectorOptions>, CiscoConnectorOptionsValidator>());
|
||||
|
||||
services.AddHttpClient(CiscoConnectorOptions.HttpClientName)
|
||||
.ConfigureHttpClient((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<CiscoConnectorOptions>>().Value;
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
if (!string.IsNullOrWhiteSpace(options.ApiToken))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken);
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<CiscoProviderMetadataLoader>();
|
||||
services.AddSingleton<IVexConnector, CiscoCsafConnector>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
|
||||
public sealed class CiscoProviderMetadataLoader
|
||||
{
|
||||
public const string CacheKey = "StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly ILogger<CiscoProviderMetadataLoader> _logger;
|
||||
private readonly CiscoConnectorOptions _options;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
public CiscoProviderMetadataLoader(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache memoryCache,
|
||||
IOptions<CiscoConnectorOptions> options,
|
||||
ILogger<CiscoProviderMetadataLoader> logger,
|
||||
IFileSystem? fileSystem = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_fileSystem = fileSystem ?? new FileSystem();
|
||||
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CiscoProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is not null && !cached.IsExpired())
|
||||
{
|
||||
_logger.LogDebug("Returning cached Cisco provider metadata (expires {Expires}).", cached.ExpiresAt);
|
||||
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is not null && !cached.IsExpired())
|
||||
{
|
||||
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
|
||||
}
|
||||
|
||||
CacheEntry? previous = cached;
|
||||
|
||||
if (!_options.PreferOfflineSnapshot)
|
||||
{
|
||||
var network = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
|
||||
if (network is not null)
|
||||
{
|
||||
StoreCache(network);
|
||||
return new CiscoProviderMetadataResult(network.Provider, network.FetchedAt, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
var offline = TryLoadFromOffline();
|
||||
if (offline is not null)
|
||||
{
|
||||
var entry = offline with
|
||||
{
|
||||
FetchedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
||||
FromOffline = true,
|
||||
};
|
||||
StoreCache(entry);
|
||||
return new CiscoProviderMetadataResult(entry.Provider, entry.FetchedAt, true, false);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to load Cisco CSAF provider metadata from network or offline snapshot.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApiToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
|
||||
{
|
||||
request.Headers.IfNoneMatch.Add(etag);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
|
||||
{
|
||||
_logger.LogDebug("Cisco provider metadata not modified (etag {ETag}).", previous.ETag);
|
||||
return previous with
|
||||
{
|
||||
FetchedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
||||
};
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var provider = ParseProvider(payload);
|
||||
var etagHeader = response.Headers.ETag?.ToString();
|
||||
|
||||
if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
_fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
|
||||
_logger.LogDebug("Persisted Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to persist Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
|
||||
}
|
||||
}
|
||||
|
||||
return new CacheEntry(
|
||||
provider,
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
||||
etagHeader,
|
||||
FromOffline: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch Cisco provider metadata from {Uri}; falling back to offline snapshot when available.", _options.MetadataUri);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private CacheEntry? TryLoadFromOffline()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
|
||||
{
|
||||
_logger.LogWarning("Cisco offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
|
||||
var provider = ParseProvider(payload);
|
||||
return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, null, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load Cisco provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private VexProvider ParseProvider(string payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
throw new InvalidOperationException("Cisco provider metadata payload was empty.");
|
||||
}
|
||||
|
||||
ProviderMetadataDocument? document;
|
||||
try
|
||||
{
|
||||
document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Cisco provider metadata.", ex);
|
||||
}
|
||||
|
||||
if (document?.Metadata?.Publisher?.ContactDetails is null || string.IsNullOrWhiteSpace(document.Metadata.Publisher.ContactDetails.Id))
|
||||
{
|
||||
throw new InvalidOperationException("Cisco provider metadata did not include a publisher identifier.");
|
||||
}
|
||||
|
||||
var discovery = new VexProviderDiscovery(document.Discovery?.WellKnown, document.Discovery?.RolIe);
|
||||
var trust = document.Trust is null
|
||||
? VexProviderTrust.Default
|
||||
: new VexProviderTrust(
|
||||
document.Trust.Weight ?? 1.0,
|
||||
document.Trust.Cosign is null ? null : new VexCosignTrust(document.Trust.Cosign.Issuer ?? string.Empty, document.Trust.Cosign.IdentityPattern ?? string.Empty),
|
||||
document.Trust.PgpFingerprints ?? Enumerable.Empty<string>());
|
||||
|
||||
var directories = document.Distributions?.Directories is null
|
||||
? Enumerable.Empty<Uri>()
|
||||
: document.Distributions.Directories
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => Uri.TryCreate(s, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)!
|
||||
.Select(static uri => uri!);
|
||||
|
||||
return new VexProvider(
|
||||
id: document.Metadata.Publisher.ContactDetails.Id,
|
||||
displayName: document.Metadata.Publisher.Name ?? document.Metadata.Publisher.ContactDetails.Id,
|
||||
kind: document.Metadata.Publisher.Category?.Equals("vendor", StringComparison.OrdinalIgnoreCase) == true ? VexProviderKind.Vendor : VexProviderKind.Hub,
|
||||
baseUris: directories,
|
||||
discovery: discovery,
|
||||
trust: trust,
|
||||
enabled: true);
|
||||
}
|
||||
|
||||
private void StoreCache(CacheEntry entry)
|
||||
{
|
||||
var options = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = entry.ExpiresAt,
|
||||
};
|
||||
_memoryCache.Set(CacheKey, entry, options);
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(
|
||||
VexProvider Provider,
|
||||
DateTimeOffset FetchedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string? ETag,
|
||||
bool FromOffline)
|
||||
{
|
||||
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CiscoProviderMetadataResult(
|
||||
VexProvider Provider,
|
||||
DateTimeOffset FetchedAt,
|
||||
bool FromOfflineSnapshot,
|
||||
bool ServedFromCache);
|
||||
|
||||
#region document models
|
||||
|
||||
internal sealed class ProviderMetadataDocument
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("metadata")]
|
||||
public ProviderMetadataMetadata Metadata { get; set; } = new();
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("discovery")]
|
||||
public ProviderMetadataDiscovery? Discovery { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("trust")]
|
||||
public ProviderMetadataTrust? Trust { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("distributions")]
|
||||
public ProviderMetadataDistributions? Distributions { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataMetadata
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("publisher")]
|
||||
public ProviderMetadataPublisher Publisher { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataPublisher
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("category")]
|
||||
public string? Category { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("contact_details")]
|
||||
public ProviderMetadataPublisherContact ContactDetails { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataPublisherContact
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataDiscovery
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("well_known")]
|
||||
public Uri? WellKnown { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("rolie")]
|
||||
public Uri? RolIe { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataTrust
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("weight")]
|
||||
public double? Weight { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("cosign")]
|
||||
public ProviderMetadataTrustCosign? Cosign { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("pgp_fingerprints")]
|
||||
public string[]? PgpFingerprints { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataTrustCosign
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("identity_pattern")]
|
||||
public string? IdentityPattern { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataDistributions
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("directories")]
|
||||
public string[]? Directories { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -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>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Storage.Mongo\StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md and ./AGENTS.md).
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-CISCO-01-001 – Endpoint discovery & auth plumbing|Team Excititor Connectors – Cisco|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added `CiscoProviderMetadataLoader` with bearer token support, offline snapshot fallback, DI helpers, and tests covering network/offline discovery to unblock subsequent fetch work.|
|
||||
|EXCITITOR-CONN-CISCO-01-002 – CSAF pull loop & pagination|Team Excititor Connectors – Cisco|EXCITITOR-CONN-CISCO-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-17)** – Implemented paginated advisory fetch using provider directories, raw document persistence with dedupe/state tracking, offline resiliency, and unit coverage.|
|
||||
|EXCITITOR-CONN-CISCO-01-003 – Provider trust metadata|Team Excititor Connectors – Cisco|EXCITITOR-CONN-CISCO-01-002, EXCITITOR-POLICY-01-001|**DOING (2025-10-19)** – Prereqs confirmed (both DONE); implementing cosign/PGP trust metadata emission and advisory provenance hints for policy weighting.|
|
||||
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Connector for Microsoft Security Response Center (MSRC) CSAF advisories, handling authenticated downloads, throttling, and raw document persistence.
|
||||
## Scope
|
||||
- MSRC API onboarding (AAD client credentials), metadata discovery, and CSAF listing retrieval.
|
||||
- Download pipeline with retry/backoff, checksum validation, and document deduplication.
|
||||
- Mapping MSRC-specific identifiers (CVE, ADV, KB) and remediation guidance into connector metadata.
|
||||
- Emitting trust metadata (AAD issuer, signing certificates) for policy weighting.
|
||||
## Participants
|
||||
- Worker schedules MSRC pulls honoring rate limits; WebService may trigger manual runs for urgent updates.
|
||||
- CSAF normalizer processes retrieved documents into claims.
|
||||
- Policy subsystem references connector trust hints for consensus scoring.
|
||||
## Interfaces & contracts
|
||||
- Implements `IVexConnector`, requires configuration options for tenant/client/secret or managed identity.
|
||||
- Uses shared HTTP helpers, resume markers, and telemetry from Abstractions module.
|
||||
## In/Out of scope
|
||||
In: authenticated fetching, raw document storage, metadata mapping, retry logic.
|
||||
Out: normalization/export, attestation, storage implementations (handled elsewhere).
|
||||
## Observability & security expectations
|
||||
- Log request batches, rate-limit responses, and token refresh events without leaking secrets.
|
||||
- Track metrics for documents fetched, retries, and failure categories.
|
||||
## Tests
|
||||
- Connector tests with mocked MSRC endpoints and AAD token flow will live in `../StellaOps.Excititor.Connectors.MSRC.CSAF.Tests`.
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
|
||||
public interface IMsrcTokenProvider
|
||||
{
|
||||
ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MsrcTokenProvider : IMsrcTokenProvider, IDisposable
|
||||
{
|
||||
private const string CachePrefix = "StellaOps.Excititor.Connectors.MSRC.CSAF.Token";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<MsrcTokenProvider> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly MsrcConnectorOptions _options;
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
|
||||
public MsrcTokenProvider(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
IFileSystem fileSystem,
|
||||
IOptions<MsrcConnectorOptions> options,
|
||||
ILogger<MsrcTokenProvider> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate(_fileSystem);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.PreferOfflineToken)
|
||||
{
|
||||
return LoadOfflineToken();
|
||||
}
|
||||
|
||||
var cacheKey = CreateCacheKey();
|
||||
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out var cachedToken) &&
|
||||
cachedToken is not null &&
|
||||
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out cachedToken) &&
|
||||
cachedToken is not null &&
|
||||
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
var token = await RequestTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var absoluteExpiration = token.ExpiresAt == DateTimeOffset.MaxValue
|
||||
? (DateTimeOffset?)null
|
||||
: token.ExpiresAt;
|
||||
|
||||
var options = new MemoryCacheEntryOptions();
|
||||
if (absoluteExpiration.HasValue)
|
||||
{
|
||||
options.AbsoluteExpiration = absoluteExpiration.Value;
|
||||
}
|
||||
|
||||
_cache.Set(cacheKey, token, options);
|
||||
return token;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private MsrcAccessToken LoadOfflineToken()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.StaticAccessToken))
|
||||
{
|
||||
return new MsrcAccessToken(_options.StaticAccessToken!, "Bearer", DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.OfflineTokenPath))
|
||||
{
|
||||
throw new InvalidOperationException("Offline token mode is enabled but no token was provided.");
|
||||
}
|
||||
|
||||
if (!_fileSystem.File.Exists(_options.OfflineTokenPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Offline token path '{_options.OfflineTokenPath}' does not exist.");
|
||||
}
|
||||
|
||||
var token = _fileSystem.File.ReadAllText(_options.OfflineTokenPath).Trim();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
throw new InvalidOperationException("Offline token file was empty.");
|
||||
}
|
||||
|
||||
return new MsrcAccessToken(token, "Bearer", DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
private async Task<MsrcAccessToken> RequestTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Fetching MSRC AAD access token for tenant {TenantId}.", _options.TenantId);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(MsrcConnectorOptions.TokenClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, BuildTokenUri())
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = _options.ClientId,
|
||||
["client_secret"] = _options.ClientSecret!,
|
||||
["grant_type"] = "client_credentials",
|
||||
["scope"] = string.IsNullOrWhiteSpace(_options.Scope) ? MsrcConnectorOptions.DefaultScope : _options.Scope,
|
||||
}),
|
||||
};
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to acquire MSRC access token ({(int)response.StatusCode}). Response: {payload}");
|
||||
}
|
||||
|
||||
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Token endpoint returned an empty payload.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenResponse.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("Token endpoint response did not include an access_token.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = tokenResponse.ExpiresIn > _options.ExpiryLeewaySeconds
|
||||
? now.AddSeconds(tokenResponse.ExpiresIn - _options.ExpiryLeewaySeconds)
|
||||
: now.AddMinutes(5);
|
||||
|
||||
return new MsrcAccessToken(tokenResponse.AccessToken!, tokenResponse.TokenType ?? "Bearer", expiresAt);
|
||||
}
|
||||
|
||||
private string CreateCacheKey()
|
||||
=> $"{CachePrefix}:{_options.TenantId}:{_options.ClientId}:{_options.Scope}";
|
||||
|
||||
private Uri BuildTokenUri()
|
||||
=> new($"https://login.microsoftonline.com/{_options.TenantId}/oauth2/v2.0/token");
|
||||
|
||||
public void Dispose() => _refreshLock.Dispose();
|
||||
|
||||
private sealed record TokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string? AccessToken { get; init; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string? TokenType { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MsrcAccessToken(string Value, string Type, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
|
||||
public sealed class MsrcConnectorOptions
|
||||
{
|
||||
public const string TokenClientName = "excititor.connector.msrc.token";
|
||||
public const string DefaultScope = "https://api.msrc.microsoft.com/.default";
|
||||
public const string ApiClientName = "excititor.connector.msrc.api";
|
||||
public const string DefaultBaseUri = "https://api.msrc.microsoft.com/sug/v2.0/";
|
||||
public const string DefaultLocale = "en-US";
|
||||
public const string DefaultApiVersion = "2024-08-01";
|
||||
|
||||
/// <summary>
|
||||
/// Azure AD tenant identifier (GUID or domain).
|
||||
/// </summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Azure AD application (client) identifier.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Azure AD application secret for client credential flow.
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
/// <summary>
|
||||
/// OAuth scope requested for MSRC API access.
|
||||
/// </summary>
|
||||
public string Scope { get; set; } = DefaultScope;
|
||||
|
||||
/// <summary>
|
||||
/// When true, token acquisition is skipped and the connector expects offline handling.
|
||||
/// </summary>
|
||||
public bool PreferOfflineToken { get; set; }
|
||||
/// <summary>
|
||||
/// Optional path to a pre-provisioned bearer token used when <see cref="PreferOfflineToken"/> is enabled.
|
||||
/// </summary>
|
||||
public string? OfflineTokenPath { get; set; }
|
||||
/// <summary>
|
||||
/// Optional fixed bearer token for constrained environments (e.g., short-lived offline bundles).
|
||||
/// </summary>
|
||||
public string? StaticAccessToken { get; set; }
|
||||
/// <summary>
|
||||
/// Minimum buffer (seconds) subtracted from token expiry before refresh.
|
||||
/// </summary>
|
||||
public int ExpiryLeewaySeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Base URI for MSRC Security Update Guide API.
|
||||
/// </summary>
|
||||
public Uri BaseUri { get; set; } = new(DefaultBaseUri, UriKind.Absolute);
|
||||
|
||||
/// <summary>
|
||||
/// Locale requested when fetching summaries.
|
||||
/// </summary>
|
||||
public string Locale { get; set; } = DefaultLocale;
|
||||
|
||||
/// <summary>
|
||||
/// API version appended to MSRC requests.
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = DefaultApiVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Page size used while enumerating summaries.
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum CSAF advisories fetched per connector run.
|
||||
/// </summary>
|
||||
public int MaxAdvisoriesPerFetch { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Overlap window applied when resuming from the last modified cursor.
|
||||
/// </summary>
|
||||
public TimeSpan CursorOverlap { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Delay between CSAF downloads to respect rate limits.
|
||||
/// </summary>
|
||||
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts for summary/detail fetch operations.
|
||||
/// </summary>
|
||||
public int MaxRetryAttempts { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Base delay applied between retries (jitter handled by connector).
|
||||
/// </summary>
|
||||
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Optional lower bound for initial synchronisation when no cursor is stored.
|
||||
/// </summary>
|
||||
public DateTimeOffset? InitialLastModified { get; set; } = DateTimeOffset.UtcNow.AddDays(-30);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of document digests persisted for deduplication.
|
||||
/// </summary>
|
||||
public int MaxTrackedDigests { get; set; } = 2048;
|
||||
|
||||
public void Validate(IFileSystem? fileSystem = null)
|
||||
{
|
||||
if (PreferOfflineToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(OfflineTokenPath) && string.IsNullOrWhiteSpace(StaticAccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("OfflineTokenPath or StaticAccessToken must be provided when PreferOfflineToken is enabled.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
throw new InvalidOperationException("TenantId is required when not operating in offline token mode.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("ClientId is required when not operating in offline token mode.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientSecret))
|
||||
{
|
||||
throw new InvalidOperationException("ClientSecret is required when not operating in offline token mode.");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Scope))
|
||||
{
|
||||
Scope = DefaultScope;
|
||||
}
|
||||
|
||||
if (ExpiryLeewaySeconds < 10)
|
||||
{
|
||||
ExpiryLeewaySeconds = 10;
|
||||
}
|
||||
|
||||
if (BaseUri is null || !BaseUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("BaseUri must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Locale))
|
||||
{
|
||||
throw new InvalidOperationException("Locale must be provided.");
|
||||
}
|
||||
|
||||
if (!CultureInfo.GetCultures(CultureTypes.AllCultures).Any(c => string.Equals(c.Name, Locale, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidOperationException($"Locale '{Locale}' is not recognised.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApiVersion))
|
||||
{
|
||||
throw new InvalidOperationException("ApiVersion must be provided.");
|
||||
}
|
||||
|
||||
if (PageSize <= 0 || PageSize > 500)
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(PageSize)} must be between 1 and 500.");
|
||||
}
|
||||
|
||||
if (MaxAdvisoriesPerFetch <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(MaxAdvisoriesPerFetch)} must be greater than zero.");
|
||||
}
|
||||
|
||||
if (CursorOverlap < TimeSpan.Zero || CursorOverlap > TimeSpan.FromHours(6))
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(CursorOverlap)} must be within 0-6 hours.");
|
||||
}
|
||||
|
||||
if (RequestDelay < TimeSpan.Zero || RequestDelay > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(RequestDelay)} must be between 0 and 10 seconds.");
|
||||
}
|
||||
|
||||
if (MaxRetryAttempts <= 0 || MaxRetryAttempts > 10)
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(MaxRetryAttempts)} must be between 1 and 10.");
|
||||
}
|
||||
|
||||
if (RetryBaseDelay < TimeSpan.Zero || RetryBaseDelay > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(RetryBaseDelay)} must be between 0 and 5 minutes.");
|
||||
}
|
||||
|
||||
if (MaxTrackedDigests <= 0 || MaxTrackedDigests > 10000)
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(MaxTrackedDigests)} must be between 1 and 10000.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OfflineTokenPath))
|
||||
{
|
||||
var fs = fileSystem ?? new FileSystem();
|
||||
var directory = Path.GetDirectoryName(OfflineTokenPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
|
||||
{
|
||||
fs.Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using System.IO.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.DependencyInjection;
|
||||
|
||||
public static class MsrcConnectorServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddMsrcCsafConnector(this IServiceCollection services, Action<MsrcConnectorOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<IMemoryCache, MemoryCache>();
|
||||
services.TryAddSingleton<IFileSystem, FileSystem>();
|
||||
|
||||
services.AddOptions<MsrcConnectorOptions>()
|
||||
.Configure(options => configure?.Invoke(options));
|
||||
|
||||
services.AddHttpClient(MsrcConnectorOptions.TokenClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.MSRC.CSAF/1.0");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
});
|
||||
|
||||
services.AddHttpClient(MsrcConnectorOptions.ApiClientName)
|
||||
.ConfigureHttpClient((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<MsrcConnectorOptions>>().Value;
|
||||
client.BaseAddress = options.BaseUri;
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.MSRC.CSAF/1.0");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
});
|
||||
|
||||
services.AddSingleton<IMsrcTokenProvider, MsrcTokenProvider>();
|
||||
services.AddSingleton<IVexConnector, MsrcCsafConnector>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF;
|
||||
|
||||
public sealed class MsrcCsafConnector : VexConnectorBase
|
||||
{
|
||||
private const string QuarantineMetadataKey = "excititor.quarantine.reason";
|
||||
private const string FormatMetadataKey = "msrc.csaf.format";
|
||||
private const string VulnerabilityMetadataKey = "msrc.vulnerabilityId";
|
||||
private const string AdvisoryIdMetadataKey = "msrc.advisoryId";
|
||||
private const string LastModifiedMetadataKey = "msrc.lastModified";
|
||||
private const string ReleaseDateMetadataKey = "msrc.releaseDate";
|
||||
private const string CvssSeverityMetadataKey = "msrc.severity";
|
||||
private const string CvrfUrlMetadataKey = "msrc.cvrfUrl";
|
||||
|
||||
private static readonly VexConnectorDescriptor DescriptorInstance = new(
|
||||
id: "excititor:msrc",
|
||||
kind: VexProviderKind.Vendor,
|
||||
displayName: "Microsoft MSRC CSAF")
|
||||
{
|
||||
Description = "Authenticated connector for Microsoft Security Response Center CSAF advisories.",
|
||||
SupportedFormats = ImmutableArray.Create(VexDocumentFormat.Csaf),
|
||||
Tags = ImmutableArray.Create("microsoft", "csaf", "vendor"),
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMsrcTokenProvider _tokenProvider;
|
||||
private readonly IVexConnectorStateRepository _stateRepository;
|
||||
private readonly IOptions<MsrcConnectorOptions> _options;
|
||||
private readonly ILogger<MsrcCsafConnector> _logger;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
private MsrcConnectorOptions? _validatedOptions;
|
||||
|
||||
public MsrcCsafConnector(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMsrcTokenProvider tokenProvider,
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
IOptions<MsrcConnectorOptions> options,
|
||||
ILogger<MsrcCsafConnector> logger,
|
||||
TimeProvider timeProvider)
|
||||
: base(DescriptorInstance, logger, timeProvider)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public override ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value ?? throw new InvalidOperationException("MSRC connector options were not registered.");
|
||||
options.Validate();
|
||||
_validatedOptions = options;
|
||||
|
||||
LogConnectorEvent(
|
||||
LogLevel.Information,
|
||||
"validate",
|
||||
"Validated MSRC CSAF connector options.",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["baseUri"] = options.BaseUri.ToString(),
|
||||
["locale"] = options.Locale,
|
||||
["apiVersion"] = options.ApiVersion,
|
||||
["pageSize"] = options.PageSize,
|
||||
["maxAdvisories"] = options.MaxAdvisoriesPerFetch,
|
||||
});
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(
|
||||
VexConnectorContext context,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var options = EnsureOptionsValidated();
|
||||
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
var (from, to) = CalculateWindow(context.Since, state, options);
|
||||
|
||||
LogConnectorEvent(
|
||||
LogLevel.Information,
|
||||
"fetch.window",
|
||||
$"Fetching MSRC CSAF advisories updated between {from:O} and {to:O}.",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["from"] = from,
|
||||
["to"] = to,
|
||||
["cursorOverlapSeconds"] = options.CursorOverlap.TotalSeconds,
|
||||
});
|
||||
|
||||
var client = await CreateAuthenticatedClientAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var knownDigests = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
|
||||
var digestSet = new HashSet<string>(knownDigests, StringComparer.OrdinalIgnoreCase);
|
||||
var digestList = new List<string>(knownDigests);
|
||||
var latest = state?.LastUpdated ?? from;
|
||||
var fetched = 0;
|
||||
var stateChanged = false;
|
||||
|
||||
await foreach (var summary in EnumerateSummariesAsync(client, options, from, to, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (fetched >= options.MaxAdvisoriesPerFetch)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(summary.CvrfUrl))
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Debug, "skip.no-cvrf", $"Skipping MSRC advisory {summary.Id} because no CSAF URL was provided.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var documentUri = ResolveCvrfUri(options.BaseUri, summary.CvrfUrl);
|
||||
|
||||
VexRawDocument? rawDocument = null;
|
||||
try
|
||||
{
|
||||
rawDocument = await DownloadCsafAsync(client, summary, documentUri, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "fetch.error", $"Failed to download MSRC CSAF package {documentUri}.", new Dictionary<string, object?>
|
||||
{
|
||||
["advisoryId"] = summary.Id,
|
||||
["vulnerabilityId"] = summary.VulnerabilityId ?? summary.Id,
|
||||
}, ex);
|
||||
|
||||
await Task.Delay(GetRetryDelay(options, 1), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!digestSet.Add(rawDocument.Digest))
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Debug, "skip.duplicate", $"Skipping MSRC CSAF package {documentUri} because it was already processed.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
|
||||
digestList.Add(rawDocument.Digest);
|
||||
stateChanged = true;
|
||||
fetched++;
|
||||
|
||||
latest = DetermineLatest(summary, latest) ?? latest;
|
||||
|
||||
var quarantineReason = rawDocument.Metadata.TryGetValue(QuarantineMetadataKey, out var reason) ? reason : null;
|
||||
if (quarantineReason is not null)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "quarantine", $"Quarantined MSRC CSAF package {documentUri} ({quarantineReason}).");
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return rawDocument;
|
||||
|
||||
if (options.RequestDelay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(options.RequestDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged)
|
||||
{
|
||||
if (digestList.Count > options.MaxTrackedDigests)
|
||||
{
|
||||
var trimmed = digestList.Count - options.MaxTrackedDigests;
|
||||
digestList.RemoveRange(0, trimmed);
|
||||
}
|
||||
|
||||
var baseState = state ?? new VexConnectorState(
|
||||
Descriptor.Id,
|
||||
null,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null);
|
||||
var newState = baseState with
|
||||
{
|
||||
LastUpdated = latest == DateTimeOffset.MinValue ? state?.LastUpdated : latest,
|
||||
DocumentDigests = digestList.ToImmutableArray(),
|
||||
};
|
||||
|
||||
await _stateRepository.SaveAsync(newState, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
LogConnectorEvent(
|
||||
LogLevel.Information,
|
||||
"fetch.completed",
|
||||
$"MSRC CSAF fetch completed with {fetched} new documents.",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["fetched"] = fetched,
|
||||
["stateChanged"] = stateChanged,
|
||||
["lastUpdated"] = latest,
|
||||
});
|
||||
}
|
||||
|
||||
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException("MSRC CSAF connector relies on CSAF normalizers for document processing.");
|
||||
|
||||
private async Task<VexRawDocument> DownloadCsafAsync(
|
||||
HttpClient client,
|
||||
MsrcVulnerabilitySummary summary,
|
||||
Uri documentUri,
|
||||
MsrcConnectorOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await SendWithRetryAsync(
|
||||
client,
|
||||
() => new HttpRequestMessage(HttpMethod.Get, documentUri),
|
||||
options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var validation = ValidateCsafPayload(payload);
|
||||
var metadata = BuildMetadata(builder =>
|
||||
{
|
||||
builder.Add(AdvisoryIdMetadataKey, summary.Id);
|
||||
builder.Add(VulnerabilityMetadataKey, summary.VulnerabilityId ?? summary.Id);
|
||||
builder.Add(CvrfUrlMetadataKey, documentUri.ToString());
|
||||
builder.Add(FormatMetadataKey, validation.Format);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(summary.Severity))
|
||||
{
|
||||
builder.Add(CvssSeverityMetadataKey, summary.Severity);
|
||||
}
|
||||
|
||||
if (summary.LastModifiedDate is not null)
|
||||
{
|
||||
builder.Add(LastModifiedMetadataKey, summary.LastModifiedDate.Value.ToString("O"));
|
||||
}
|
||||
|
||||
if (summary.ReleaseDate is not null)
|
||||
{
|
||||
builder.Add(ReleaseDateMetadataKey, summary.ReleaseDate.Value.ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(validation.QuarantineReason))
|
||||
{
|
||||
builder.Add(QuarantineMetadataKey, validation.QuarantineReason);
|
||||
}
|
||||
|
||||
if (response.Headers.ETag is not null)
|
||||
{
|
||||
builder.Add("http.etag", response.Headers.ETag.Tag);
|
||||
}
|
||||
|
||||
if (response.Content.Headers.LastModified is { } lastModified)
|
||||
{
|
||||
builder.Add("http.lastModified", lastModified.ToString("O"));
|
||||
}
|
||||
});
|
||||
|
||||
return CreateRawDocument(VexDocumentFormat.Csaf, documentUri, payload, metadata);
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateAuthenticatedClientAsync(MsrcConnectorOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var client = _httpClientFactory.CreateClient(MsrcConnectorOptions.ApiClientName);
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Authorization");
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"{token.Type} {token.Value}");
|
||||
client.DefaultRequestHeaders.Remove("Accept-Language");
|
||||
client.DefaultRequestHeaders.Add("Accept-Language", options.Locale);
|
||||
client.DefaultRequestHeaders.Remove("api-version");
|
||||
client.DefaultRequestHeaders.Add("api-version", options.ApiVersion);
|
||||
client.DefaultRequestHeaders.Remove("Accept");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendWithRetryAsync(
|
||||
HttpClient client,
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
MsrcConnectorOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Exception? lastError = null;
|
||||
HttpResponseMessage? response = null;
|
||||
|
||||
for (var attempt = 1; attempt <= options.MaxRetryAttempts; attempt++)
|
||||
{
|
||||
response?.Dispose();
|
||||
using var request = requestFactory();
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!ShouldRetry(response.StatusCode) || attempt == options.MaxRetryAttempts)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (IsTransient(ex) && attempt < options.MaxRetryAttempts)
|
||||
{
|
||||
lastError = ex;
|
||||
LogConnectorEvent(LogLevel.Warning, "retry", $"Retrying MSRC request (attempt {attempt}/{options.MaxRetryAttempts}).", exception: ex);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
response?.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(GetRetryDelay(options, attempt), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
response?.Dispose();
|
||||
throw lastError ?? new InvalidOperationException("MSRC request retries exhausted.");
|
||||
}
|
||||
|
||||
private TimeSpan GetRetryDelay(MsrcConnectorOptions options, int attempt)
|
||||
{
|
||||
var baseDelay = options.RetryBaseDelay.TotalMilliseconds;
|
||||
var multiplier = Math.Pow(2, Math.Max(0, attempt - 1));
|
||||
var jitter = Random.Shared.NextDouble() * baseDelay * 0.25;
|
||||
var delayMs = Math.Min(baseDelay * multiplier + jitter, TimeSpan.FromMinutes(5).TotalMilliseconds);
|
||||
return TimeSpan.FromMilliseconds(delayMs);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<MsrcVulnerabilitySummary> EnumerateSummariesAsync(
|
||||
HttpClient client,
|
||||
MsrcConnectorOptions options,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var fetched = 0;
|
||||
var requestUri = BuildSummaryUri(options, from, to);
|
||||
|
||||
while (requestUri is not null && fetched < options.MaxAdvisoriesPerFetch)
|
||||
{
|
||||
using var response = await SendWithRetryAsync(
|
||||
client,
|
||||
() => new HttpRequestMessage(HttpMethod.Get, requestUri),
|
||||
options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var payload = await JsonSerializer.DeserializeAsync<MsrcSummaryResponse>(stream, _serializerOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new MsrcSummaryResponse();
|
||||
|
||||
foreach (var summary in payload.Value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(summary.CvrfUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return summary;
|
||||
fetched++;
|
||||
|
||||
if (fetched >= options.MaxAdvisoriesPerFetch)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.NextLink))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(payload.NextLink, UriKind.Absolute, out requestUri))
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "pagination.invalid", $"MSRC pagination returned invalid next link '{payload.NextLink}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildSummaryUri(MsrcConnectorOptions options, DateTimeOffset from, DateTimeOffset to)
|
||||
{
|
||||
var baseText = options.BaseUri.ToString().TrimEnd('/');
|
||||
var builder = new StringBuilder(baseText.Length + 128);
|
||||
builder.Append(baseText);
|
||||
if (!baseText.EndsWith("/vulnerabilities", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Append("/vulnerabilities");
|
||||
}
|
||||
|
||||
builder.Append("?");
|
||||
builder.Append("$top=").Append(options.PageSize);
|
||||
builder.Append("&lastModifiedStartDateTime=").Append(Uri.EscapeDataString(from.ToUniversalTime().ToString("O")));
|
||||
builder.Append("&lastModifiedEndDateTime=").Append(Uri.EscapeDataString(to.ToUniversalTime().ToString("O")));
|
||||
builder.Append("&$orderby=lastModifiedDate");
|
||||
builder.Append("&locale=").Append(Uri.EscapeDataString(options.Locale));
|
||||
builder.Append("&api-version=").Append(Uri.EscapeDataString(options.ApiVersion));
|
||||
|
||||
return new Uri(builder.ToString(), UriKind.Absolute);
|
||||
}
|
||||
|
||||
private (DateTimeOffset From, DateTimeOffset To) CalculateWindow(
|
||||
DateTimeOffset? contextSince,
|
||||
VexConnectorState? state,
|
||||
MsrcConnectorOptions options)
|
||||
{
|
||||
var now = UtcNow();
|
||||
var since = contextSince ?? state?.LastUpdated ?? options.InitialLastModified ?? now.AddDays(-30);
|
||||
|
||||
if (state?.LastUpdated is { } persisted && persisted > since)
|
||||
{
|
||||
since = persisted;
|
||||
}
|
||||
|
||||
if (options.CursorOverlap > TimeSpan.Zero)
|
||||
{
|
||||
since = since.Add(-options.CursorOverlap);
|
||||
}
|
||||
|
||||
if (since < now.AddYears(-20))
|
||||
{
|
||||
since = now.AddYears(-20);
|
||||
}
|
||||
|
||||
return (since, now);
|
||||
}
|
||||
|
||||
private static bool ShouldRetry(HttpStatusCode statusCode)
|
||||
=> statusCode == HttpStatusCode.TooManyRequests ||
|
||||
(int)statusCode >= 500;
|
||||
|
||||
private static bool IsTransient(Exception exception)
|
||||
=> exception is HttpRequestException or IOException or TaskCanceledException;
|
||||
|
||||
private static Uri ResolveCvrfUri(Uri baseUri, string cvrfUrl)
|
||||
=> Uri.TryCreate(cvrfUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute
|
||||
: new Uri(baseUri, cvrfUrl);
|
||||
|
||||
private static CsafValidationResult ValidateCsafPayload(ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsZip(payload.Span))
|
||||
{
|
||||
using var zipStream = new MemoryStream(payload.ToArray(), writable: false);
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
?? archive.Entries.FirstOrDefault();
|
||||
if (entry is null)
|
||||
{
|
||||
return new CsafValidationResult("zip", "Zip archive did not contain any entries.");
|
||||
}
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream, Encoding.UTF8);
|
||||
using var json = JsonDocument.Parse(reader.ReadToEnd());
|
||||
return CsafValidationResult.Valid("zip");
|
||||
}
|
||||
|
||||
if (IsGzip(payload.Span))
|
||||
{
|
||||
using var input = new MemoryStream(payload.ToArray(), writable: false);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var reader = new StreamReader(gzip, Encoding.UTF8);
|
||||
using var json = JsonDocument.Parse(reader.ReadToEnd());
|
||||
return CsafValidationResult.Valid("gzip");
|
||||
}
|
||||
|
||||
using var jsonDocument = JsonDocument.Parse(payload);
|
||||
return CsafValidationResult.Valid("json");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new CsafValidationResult("json", $"JSON parse failed: {ex.Message}");
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return new CsafValidationResult("invalid", ex.Message);
|
||||
}
|
||||
catch (EndOfStreamException ex)
|
||||
{
|
||||
return new CsafValidationResult("invalid", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsZip(ReadOnlySpan<byte> content)
|
||||
=> content.Length > 3 && content[0] == 0x50 && content[1] == 0x4B;
|
||||
|
||||
private static bool IsGzip(ReadOnlySpan<byte> content)
|
||||
=> content.Length > 2 && content[0] == 0x1F && content[1] == 0x8B;
|
||||
|
||||
private static DateTimeOffset? DetermineLatest(MsrcVulnerabilitySummary summary, DateTimeOffset? current)
|
||||
{
|
||||
var candidate = summary.LastModifiedDate ?? summary.ReleaseDate;
|
||||
if (candidate is null)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
if (current is null || candidate > current)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private MsrcConnectorOptions EnsureOptionsValidated()
|
||||
{
|
||||
if (_validatedOptions is not null)
|
||||
{
|
||||
return _validatedOptions;
|
||||
}
|
||||
|
||||
var options = _options.Value ?? throw new InvalidOperationException("MSRC connector options were not registered.");
|
||||
options.Validate();
|
||||
_validatedOptions = options;
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed record CsafValidationResult(string Format, string? QuarantineReason)
|
||||
{
|
||||
public static CsafValidationResult Valid(string format) => new(format, null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record MsrcSummaryResponse
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public List<MsrcVulnerabilitySummary> Value { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("@odata.nextLink")]
|
||||
public string? NextLink { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record MsrcVulnerabilitySummary
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
[JsonPropertyName("lastModifiedDate")]
|
||||
public DateTimeOffset? LastModifiedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("cvrfUrl")]
|
||||
public string? CvrfUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Storage.Mongo\StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md and ./AGENTS.md).
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-MS-01-001 – AAD onboarding & token cache|Team Excititor Connectors – MSRC|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added MSRC connector project with configurable AAD options, token provider (offline/online modes), DI wiring, and unit tests covering caching and fallback scenarios.|
|
||||
|EXCITITOR-CONN-MS-01-002 – CSAF download pipeline|Team Excititor Connectors – MSRC|EXCITITOR-CONN-MS-01-001, EXCITITOR-STORAGE-01-003|**DOING (2025-10-19)** – Prereqs verified (EXCITITOR-CONN-MS-01-001, EXCITITOR-STORAGE-01-003); drafting fetch/retry plan and storage wiring before implementation of CSAF package download, checksum validation, and quarantine flows.|
|
||||
|EXCITITOR-CONN-MS-01-003 – Trust metadata & provenance hints|Team Excititor Connectors – MSRC|EXCITITOR-CONN-MS-01-002, EXCITITOR-POLICY-01-001|TODO – Emit cosign/AAD issuer metadata, attach provenance details, and document policy integration.|
|
||||
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Connector for OCI registry OpenVEX attestations, discovering images, downloading attestations, and projecting statements into raw storage.
|
||||
## Scope
|
||||
- OCI registry discovery, authentication (cosign OIDC/key), and ref resolution for provided image digests/tags.
|
||||
- Fetching DSSE envelopes, verifying signatures (delegated to Attestation module), and persisting raw statements.
|
||||
- Mapping OCI manifest metadata (repository, digest, subject) to connector provenance.
|
||||
- Managing offline bundles that seed attestations without registry access.
|
||||
## Participants
|
||||
- Worker schedules polls for configured registries/images; WebService supports manual refresh.
|
||||
- OpenVEX normalizer consumes statements to create claims.
|
||||
- Attestation module is reused to verify upstream envelopes prior to storage.
|
||||
## Interfaces & contracts
|
||||
- Implements `IVexConnector` with options for image list, auth, parallelism, and offline file seeds.
|
||||
- Utilizes shared abstractions for retries, telemetry, and resume markers.
|
||||
## In/Out of scope
|
||||
In: OCI interaction, attestation retrieval, verification trigger, raw persistence.
|
||||
Out: normalization/export, policy evaluation, storage implementation.
|
||||
## Observability & security expectations
|
||||
- Log image references, attestation counts, verification outcomes; redact credentials.
|
||||
- Emit metrics for attestation reuse ratio, verification duration, and failures.
|
||||
## Tests
|
||||
- Connector tests with mock OCI registry/attestation responses will live in `../StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests`.
|
||||
@@ -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,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</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="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md and ./AGENTS.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|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,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Connector for Oracle CSAF advisories, including CPU and other bulletin releases, projecting documents into raw storage for normalization.
|
||||
## Scope
|
||||
- Discovery of Oracle CSAF catalogue, navigation of quarterly CPU bundles, and delta detection.
|
||||
- HTTP fetch with retry/backoff, checksum validation, and deduplication across revisions.
|
||||
- Mapping Oracle advisory metadata (CPU ID, component families) into connector context.
|
||||
- Publishing trust metadata (PGP keys/cosign options) aligned with policy expectations.
|
||||
## Participants
|
||||
- Worker orchestrates regular pulls respecting Oracle publication cadence; WebService offers manual triggers.
|
||||
- CSAF normalizer processes raw documents to claims.
|
||||
- Policy engine leverages trust metadata and provenance hints.
|
||||
## Interfaces & contracts
|
||||
- Implements `IVexConnector` using shared abstractions for HTTP/resume and telemetry.
|
||||
- Configuration options for CPU schedule, credentials (if required), and offline snapshot ingestion.
|
||||
## In/Out of scope
|
||||
In: fetching, metadata mapping, raw persistence, trust hints.
|
||||
Out: normalization, storage internals, export/attestation flows.
|
||||
## Observability & security expectations
|
||||
- Log CPU release windows, document counts, and fetch durations; redact any secrets.
|
||||
- Emit metrics for deduped vs new documents and quarantine rates.
|
||||
## Tests
|
||||
- Harness tests with mocked Oracle catalogues will live in `../StellaOps.Excititor.Connectors.Oracle.CSAF.Tests`.
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
|
||||
|
||||
public sealed class OracleConnectorOptions
|
||||
{
|
||||
public const string HttpClientName = "excititor.connector.oracle.catalog";
|
||||
|
||||
/// <summary>
|
||||
/// Oracle CSAF catalog endpoint hosting advisory metadata.
|
||||
/// </summary>
|
||||
public Uri CatalogUri { get; set; } = new("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json");
|
||||
|
||||
/// <summary>
|
||||
/// Optional CPU calendar endpoint providing upcoming release dates.
|
||||
/// </summary>
|
||||
public Uri? CpuCalendarUri { get; set; }
|
||||
/// <summary>
|
||||
/// Duration the discovery metadata should be cached before refresh.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>
|
||||
/// When true, the loader will prefer offline snapshot data over network fetches.
|
||||
/// </summary>
|
||||
public bool PreferOfflineSnapshot { get; set; }
|
||||
/// <summary>
|
||||
/// Optional file path for persisting or ingesting catalog snapshots.
|
||||
/// </summary>
|
||||
public string? OfflineSnapshotPath { get; set; }
|
||||
/// <summary>
|
||||
/// Enables writing fresh catalog responses to <see cref="OfflineSnapshotPath"/>.
|
||||
/// </summary>
|
||||
public bool PersistOfflineSnapshot { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional request delay when iterating catalogue entries (for rate limiting).
|
||||
/// </summary>
|
||||
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
public void Validate(IFileSystem? fileSystem = null)
|
||||
{
|
||||
if (CatalogUri is null || !CatalogUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("CatalogUri must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (CatalogUri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new InvalidOperationException("CatalogUri must use HTTP or HTTPS.");
|
||||
}
|
||||
|
||||
if (CpuCalendarUri is not null && (!CpuCalendarUri.IsAbsoluteUri || CpuCalendarUri.Scheme is not ("http" or "https")))
|
||||
{
|
||||
throw new InvalidOperationException("CpuCalendarUri must be an absolute HTTP(S) URI when provided.");
|
||||
}
|
||||
|
||||
if (MetadataCacheDuration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
|
||||
}
|
||||
|
||||
if (RequestDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("RequestDelay cannot be negative.");
|
||||
}
|
||||
|
||||
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
|
||||
{
|
||||
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
|
||||
{
|
||||
var fs = fileSystem ?? new FileSystem();
|
||||
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
|
||||
{
|
||||
fs.Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
|
||||
|
||||
public sealed class OracleConnectorOptionsValidator : IVexConnectorOptionsValidator<OracleConnectorOptions>
|
||||
{
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
public OracleConnectorOptionsValidator(IFileSystem fileSystem)
|
||||
{
|
||||
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||
}
|
||||
|
||||
public void Validate(VexConnectorDescriptor descriptor, OracleConnectorOptions 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,45 @@
|
||||
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.Oracle.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.DependencyInjection;
|
||||
|
||||
public static class OracleConnectorServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddOracleCsafConnector(this IServiceCollection services, Action<OracleConnectorOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<IMemoryCache, MemoryCache>();
|
||||
services.TryAddSingleton<IFileSystem, FileSystem>();
|
||||
|
||||
services.AddOptions<OracleConnectorOptions>()
|
||||
.Configure(options => configure?.Invoke(options));
|
||||
|
||||
services.AddSingleton<IVexConnectorOptionsValidator<OracleConnectorOptions>, OracleConnectorOptionsValidator>();
|
||||
|
||||
services.AddHttpClient(OracleConnectorOptions.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.Oracle.CSAF/1.0");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
});
|
||||
|
||||
services.AddSingleton<OracleCatalogLoader>();
|
||||
services.AddSingleton<IVexConnector, OracleCsafConnector>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user