Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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. |

View 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`.

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View 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);
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Excititor.WebService.Tests")]

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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>

View 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 §45 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. |

View 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`.

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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!;
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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;

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Excititor.Worker.Tests")]

View File

@@ -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];
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Excititor.Worker.Scheduling;
public interface IVexConsensusRefreshScheduler
{
void ScheduleRefresh(string vulnerabilityId, string productKey);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Excititor.Worker.Scheduling;
internal interface IVexProviderRunner
{
ValueTask RunAsync(VexWorkerSchedule schedule, CancellationToken cancellationToken);
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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>

View 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. |

View 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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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`.

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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.|

View File

@@ -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);
}

View File

@@ -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})";
}

View File

@@ -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)!;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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`.

View File

@@ -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);
}

View File

@@ -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) });
}
}
}

View File

@@ -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.");
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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.|

View File

@@ -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`.

View File

@@ -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;
}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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.|

View File

@@ -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`.

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciAttestationTarget(
OciImageReference Image,
string? ExpectedSubjectDigest,
OciOfflineBundleReference? OfflineBundle);

View File

@@ -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 };
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
using System;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciOfflineBundleReference(string Path, bool Exists, string? ExpectedSubjectDigest);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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.|

View File

@@ -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`.

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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