Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -137,11 +137,11 @@ internal static class MirrorRegistrationEndpoints
record.Signature,
record.PayloadUrl,
record.TransparencyLog,
record.PortableManifestHash);
record.PortableManifestHash ?? string.Empty);
var paths = new MirrorBundlePaths(
record.PortableManifestPath,
record.EvidenceLockerPath);
record.PortableManifestPath ?? string.Empty,
record.EvidenceLockerPath ?? string.Empty);
var timeline = record.Timeline
.OrderByDescending(e => e.CreatedAt)

View File

@@ -13,8 +13,8 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core.Dsse;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
using StellaOps.Excititor.Formats.OpenVEX;

View File

@@ -23,12 +23,13 @@ using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Core.Verification;
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.Postgres;
using StellaOps.Excititor.Persistence.Extensions;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Excititor.WebService.Endpoints;
using StellaOps.Excititor.WebService.Extensions;
@@ -41,6 +42,7 @@ using StellaOps.Excititor.WebService.Contracts;
using System.Globalization;
using StellaOps.Excititor.WebService.Graph;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.Persistence.Postgres;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
@@ -52,15 +54,36 @@ services.AddOptions<VexStorageOptions>()
services.AddOptions<GraphOptions>()
.Bind(configuration.GetSection("Excititor:Graph"));
services.AddExcititorPostgresStorage(configuration);
services.AddExcititorPersistence(configuration);
services.TryAddSingleton<IVexProviderStore, InMemoryVexProviderStore>();
services.TryAddScoped<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.TryAddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();
services.AddOpenVexNormalizer();
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
// TODO: replace NoopVexSignatureVerifier with hardened verifier once portable bundle signatures are finalized.
// VEX Signature Verification (SPRINT_1227_0004_0001)
// Feature flag controls whether production verification is active.
// When VexSignatureVerification:Enabled is false, NoopVexSignatureVerifier is used.
services.AddVexSignatureVerification(configuration);
// Legacy V1 interface - maintained for backward compatibility during migration
if (configuration.GetValue<bool>("VexSignatureVerification:Enabled", false))
{
services.AddSingleton<IVexSignatureVerifier>(sp =>
{
// Adapter from V2 to V1 interface
return new VexSignatureVerifierV1Adapter(
sp.GetRequiredService<IVexSignatureVerifierV2>(),
sp.GetRequiredService<IOptions<VexSignatureVerifierOptions>>(),
sp.GetRequiredService<ILogger<VexSignatureVerifierV1Adapter>>());
});
}
else
{
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
}
services.Configure<AirgapOptions>(configuration.GetSection(AirgapOptions.SectionName));
services.AddSingleton<AirgapImportValidator>();
services.AddSingleton<AirgapSignerTrustService>();
@@ -2264,6 +2287,7 @@ internal sealed record ExcititorTimelineEvent(
string? TraceId,
string OccurredAt);
// Program class public for WebApplicationFactory<Program>
public partial class Program;
internal sealed record StatusResponse(DateTimeOffset UtcNow, int InlineThreshold, string[] ArtifactStores);

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Excititor.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62513;http://localhost:62514"
}
}
}

View File

@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Excititor.Storage.Postgres;
using StellaOps.Excititor.Persistence.Postgres;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Services;

View File

@@ -0,0 +1,141 @@
// VexSignatureVerifierV1Adapter - Adapts V2 interface to V1 for backward compatibility
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Verification;
namespace StellaOps.Excititor.WebService.Services;
/// <summary>
/// Adapter that bridges the new IVexSignatureVerifierV2 interface to the legacy IVexSignatureVerifier interface.
/// This allows gradual migration while maintaining backward compatibility.
/// </summary>
public sealed class VexSignatureVerifierV1Adapter : IVexSignatureVerifier
{
private readonly IVexSignatureVerifierV2 _v2Verifier;
private readonly VexSignatureVerifierOptions _options;
private readonly ILogger<VexSignatureVerifierV1Adapter> _logger;
public VexSignatureVerifierV1Adapter(
IVexSignatureVerifierV2 v2Verifier,
IOptions<VexSignatureVerifierOptions> options,
ILogger<VexSignatureVerifierV1Adapter> logger)
{
_v2Verifier = v2Verifier ?? throw new ArgumentNullException(nameof(v2Verifier));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async ValueTask<VexSignatureMetadata?> VerifyAsync(
VexRawDocument document,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
try
{
// Create verification context from options
var context = new VexVerificationContext
{
TenantId = ExtractTenantId(document),
CryptoProfile = _options.DefaultProfile,
AllowExpiredCerts = _options.AllowExpiredCerts,
RequireSignature = _options.RequireSignature,
ClockTolerance = _options.ClockTolerance
};
// Call V2 verifier
var result = await _v2Verifier.VerifyAsync(document, context, cancellationToken);
// Convert V2 result to V1 VexSignatureMetadata
return ConvertToV1Metadata(result);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "V1 adapter verification failed for document {Digest}", document.Digest);
// Return null on error (V1 behavior - treat as unsigned)
return null;
}
}
private static string ExtractTenantId(VexRawDocument document)
{
// Try to extract tenant from document metadata
if (document.Metadata.TryGetValue("tenant-id", out var tenantId) &&
!string.IsNullOrWhiteSpace(tenantId))
{
return tenantId;
}
if (document.Metadata.TryGetValue("x-stellaops-tenant", out tenantId) &&
!string.IsNullOrWhiteSpace(tenantId))
{
return tenantId;
}
// Default to global tenant
return "@global";
}
private static VexSignatureMetadata? ConvertToV1Metadata(VexSignatureVerificationResult result)
{
// No signature case
if (result.Method == VerificationMethod.None && !result.Verified)
{
return null;
}
// Failed verification - still return metadata but with details
// This allows the caller to see that verification was attempted
var type = result.Method switch
{
VerificationMethod.Dsse => "dsse",
VerificationMethod.DsseKeyless => "dsse-keyless",
VerificationMethod.Cosign => "cosign",
VerificationMethod.CosignKeyless => "cosign-keyless",
VerificationMethod.Pgp => "pgp",
VerificationMethod.X509 => "x509",
VerificationMethod.InToto => "in-toto",
VerificationMethod.None => "none",
_ => "unknown"
};
// Build transparency log reference
string? transparencyLogRef = null;
if (result.RekorLogIndex.HasValue)
{
transparencyLogRef = result.RekorLogId != null
? $"{result.RekorLogId}:{result.RekorLogIndex}"
: $"rekor:{result.RekorLogIndex}";
}
// Build trust metadata if issuer is known
VexSignatureTrustMetadata? trust = null;
if (!string.IsNullOrEmpty(result.IssuerId))
{
trust = new VexSignatureTrustMetadata(
effectiveWeight: 1.0m, // Full trust for verified signatures
tenantId: "@global",
issuerId: result.IssuerId,
tenantOverrideApplied: false,
retrievedAtUtc: result.VerifiedAt);
}
return new VexSignatureMetadata(
type: type,
subject: result.CertSubject,
issuer: result.IssuerName,
keyId: result.KeyId,
verifiedAt: result.Verified ? result.VerifiedAt : null,
transparencyLogReference: transparencyLogRef,
trust: trust);
}
}

View File

@@ -8,17 +8,17 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Connectors.Abstractions/StellaOps.Excititor.Connectors.Abstractions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
@@ -30,6 +30,6 @@
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Formats.OpenVEX/StellaOps.Excititor.Formats.OpenVEX.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
<ProjectReference Include="../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
</ItemGroup>
</Project>

View File

@@ -243,7 +243,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
LastHeartbeatAt = result.CompletedAt,
LastHeartbeatStatus = VexWorkerHeartbeatStatus.Succeeded.ToString(),
LastArtifactHash = result.LastArtifactHash,
LastCheckpoint = result.LastCheckpoint,
LastCheckpoint = ParseCheckpoint(result.LastCheckpoint),
FailureCount = 0,
NextEligibleRun = null,
LastFailureReason = null
@@ -327,7 +327,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
await SendRemoteCompletionAsync(
context,
new VexWorkerJobResult(0, 0, state.LastCheckpoint, state.LastArtifactHash, now),
new VexWorkerJobResult(0, 0, state.LastCheckpoint?.ToString("O"), state.LastArtifactHash, now),
cancellationToken,
success: false,
failureReason: Truncate($"{errorCode}: {errorMessage}", 256)).ConfigureAwait(false);
@@ -399,7 +399,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
var updated = state with
{
LastCheckpoint = checkpoint.Cursor,
LastCheckpoint = ParseCheckpoint(checkpoint.Cursor),
LastUpdated = checkpoint.LastProcessedAt ?? now,
DocumentDigests = checkpoint.ProcessedDigests.IsDefault
? ImmutableArray<string>.Empty
@@ -447,7 +447,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
return new VexWorkerCheckpoint(
connectorId,
state.LastCheckpoint,
state.LastCheckpoint?.ToString("O"),
state.LastUpdated,
state.DocumentDigests.IsDefault ? ImmutableArray<string>.Empty : state.DocumentDigests,
state.ResumeTokens.IsEmpty ? ImmutableDictionary<string, string>.Empty : state.ResumeTokens);
@@ -471,6 +471,18 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
: value[..maxLength];
}
private static DateTimeOffset? ParseCheckpoint(string? checkpoint)
{
if (string.IsNullOrEmpty(checkpoint))
{
return null;
}
return DateTimeOffset.TryParse(checkpoint, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)
? parsed
: null;
}
private int ResolveLeaseSeconds()
{
var seconds = (int)Math.Round(_options.Value.DefaultLeaseDuration.TotalSeconds);

View File

@@ -6,7 +6,7 @@ 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.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.Core.Storage;
@@ -14,7 +14,7 @@ using StellaOps.Excititor.Core.Orchestration;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Storage.Postgres;
using StellaOps.Excititor.Persistence.Extensions;
using StellaOps.Excititor.Worker.Auth;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Orchestration;
@@ -43,13 +43,14 @@ services.PostConfigure<VexWorkerOptions>(options =>
options.Refresh.Enabled = false;
}
});
services.AddRedHatCsafConnector();
// VEX connectors are loaded via plugin catalog below
// Direct connector registration removed in favor of plugin-based loading
services.AddOptions<VexStorageOptions>()
.Bind(configuration.GetSection("Excititor:Storage"))
.ValidateOnStart();
services.AddExcititorPostgresStorage(configuration);
services.AddExcititorPersistence(configuration);
services.AddSingleton<IVexProviderStore, InMemoryVexProviderStore>();
services.TryAddScoped<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.AddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
@@ -91,20 +92,32 @@ services.PostConfigure<VexWorkerOptions>(options =>
});
}
});
// Load VEX connector plugins
services.AddSingleton<PluginCatalog>(provider =>
{
var pluginOptions = provider.GetRequiredService<IOptions<VexWorkerPluginOptions>>().Value;
var opts = provider.GetRequiredService<IOptions<VexWorkerPluginOptions>>().Value;
var catalog = new PluginCatalog();
var directory = pluginOptions.ResolveDirectory();
var directory = opts.ResolveDirectory();
if (Directory.Exists(directory))
{
catalog.AddFromDirectory(directory, pluginOptions.ResolveSearchPattern());
catalog.AddFromDirectory(directory, opts.ResolveSearchPattern());
}
else
{
var logger = provider.GetRequiredService<ILogger<Program>>();
logger.LogWarning("Excititor worker plugin directory '{Directory}' does not exist; proceeding without external connectors.", directory);
// Fallback: try loading from plugins/excititor directory
var fallbackPath = Path.Combine(AppContext.BaseDirectory, "plugins", "excititor");
if (Directory.Exists(fallbackPath))
{
catalog.AddFromDirectory(fallbackPath, "StellaOps.Excititor.Connectors.*.dll");
}
else
{
var logger = provider.GetRequiredService<ILogger<Program>>();
logger.LogWarning(
"Excititor worker plugin directory '{Directory}' does not exist; proceeding without external connectors.",
directory);
}
}
return catalog;
@@ -139,4 +152,5 @@ services.AddSingleton<ITenantAuthorityClientFactory, TenantAuthorityClientFactor
var host = builder.Build();
await host.RunAsync();
public partial class Program;
// Make Program class file-scoped to prevent it from being exposed to referencing assemblies
file sealed partial class Program;

View File

@@ -9,6 +9,7 @@ using StellaOps.Plugin;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Orchestration;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Orchestration;
using StellaOps.Excititor.Worker.Signature;
@@ -134,7 +135,7 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
var jobContext = await _orchestratorClient.StartJobAsync(
_orchestratorOptions.DefaultTenant,
connector.Id,
stateBeforeRun?.LastCheckpoint,
stateBeforeRun?.LastCheckpoint?.ToString("O"),
cancellationToken).ConfigureAwait(false);
var documentCount = 0;

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - refresh service manages VexConsensus during transition
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
@@ -9,6 +11,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Worker.Options;

View File

@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Worker.Signature;

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Aoc;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Core.Dsse;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;

View File

@@ -8,16 +8,16 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.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.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.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.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" />

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,9 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Export\StellaOps.Excititor.Export.csproj" />

View File

@@ -1,13 +1,9 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
// Re-export DSSE types from Core for backwards compatibility
// The canonical types are in StellaOps.Excititor.Core.Dsse
global using DsseEnvelope = StellaOps.Excititor.Core.Dsse.DsseEnvelope;
global using DsseSignature = StellaOps.Excititor.Core.Dsse.DsseSignature;
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);
// Types re-exported from StellaOps.Excititor.Core.Dsse via global using

View File

@@ -7,9 +7,9 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />

View File

@@ -30,12 +30,12 @@ public sealed class VexAttestationVerificationOptions
/// <summary>
/// When true, DSSE signatures must verify successfully against configured trusted signers.
/// </summary>
public bool RequireSignatureVerification { get; init; }
public bool RequireSignatureVerification { get; set; }
/// <summary>
/// Mapping of trusted signer key identifiers to verification configuration.
/// </summary>
public ImmutableDictionary<string, TrustedSignerOptions> TrustedSigners { get; init; } =
public ImmutableDictionary<string, TrustedSignerOptions> TrustedSigners { get; set; } =
ImmutableDictionary<string, TrustedSignerOptions>.Empty;
public sealed record TrustedSignerOptions

View File

@@ -8,10 +8,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -9,12 +9,12 @@
<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.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -11,9 +11,9 @@
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -266,7 +266,7 @@ public sealed class OracleCsafConnector : VexConnectorBase
builder.Add("oracle.csaf.products", string.Join(",", entry.Products));
}
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, Logger);
});
return CreateRawDocument(VexDocumentFormat.Csaf, entry.DocumentUri, payload.AsMemory(), metadata);

View File

@@ -9,12 +9,12 @@
<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.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -268,7 +268,7 @@ public sealed class RancherHubConnector : VexConnectorBase
builder
.Add("vex.provenance.provider", provider.Id)
.Add("vex.provenance.providerName", provider.DisplayName)
.Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture))
.Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant())
.Add("vex.provenance.trust.weight", provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture));
if (provider.Trust.Cosign is { } cosign)
@@ -283,7 +283,7 @@ public sealed class RancherHubConnector : VexConnectorBase
builder.Add("vex.provenance.pgp.fingerprints", string.Join(',', provider.Trust.PgpFingerprints));
}
var tier = provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture);
var tier = provider.Kind.ToString().ToLowerInvariant();
builder
.Add("vex.provenance.trust.tier", tier)
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");

View File

@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -9,12 +9,12 @@
<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.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -438,7 +438,7 @@ public sealed class UbuntuCsafConnector : VexConnectorBase
builder
.Add("vex.provenance.provider", provider.Id)
.Add("vex.provenance.providerName", provider.DisplayName)
.Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture))
.Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant())
.Add("vex.provenance.trust.weight", provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture));
if (provider.Trust.Cosign is { } cosign)
@@ -455,7 +455,7 @@ public sealed class UbuntuCsafConnector : VexConnectorBase
var tier = !string.IsNullOrWhiteSpace(_options?.TrustTier)
? _options!.TrustTier!
: provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture);
: provider.Kind.ToString().ToLowerInvariant();
builder
.Add("vex.provenance.trust.tier", tier)

View File

@@ -9,6 +9,7 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core.Dsse;
namespace StellaOps.Excititor.Core.AutoVex;
@@ -363,24 +364,7 @@ public sealed record RuntimeNotReachableEvidence
public ImmutableArray<NotReachableReason>? Reasons { get; init; }
}
/// <summary>
/// DSSE envelope for signed statements.
/// </summary>
public sealed record DsseEnvelope
{
public required string PayloadType { get; init; }
public required string Payload { get; init; }
public required ImmutableArray<DsseSignature> Signatures { get; init; }
}
/// <summary>
/// DSSE signature.
/// </summary>
public sealed record DsseSignature
{
public required string KeyId { get; init; }
public required string Sig { get; init; }
}
// DsseEnvelope and DsseSignature types are imported from StellaOps.Excititor.Core.Dsse
/// <summary>
/// Default implementation of not-reachable justification service.

View File

@@ -0,0 +1,23 @@
// DsseEnvelope.cs - Shared DSSE types for VEX signature verification
// Extracted to Core to avoid circular dependency between Core and Attestation
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.Core.Dsse;
/// <summary>
/// DSSE (Dead Simple Signing Envelope) envelope structure.
/// See: https://github.com/secure-systems-lab/dsse
/// </summary>
public sealed record DsseEnvelope(
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignature> Signatures);
/// <summary>
/// DSSE signature with key identifier.
/// </summary>
public sealed record DsseSignature(
[property: JsonPropertyName("sig")] string Signature,
[property: JsonPropertyName("keyid")] string? KeyId);

View File

@@ -0,0 +1,290 @@
// -----------------------------------------------------------------------------
// VexDeltaModels.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-007)
// Task: VEX delta models for tracking status changes between artifact versions
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Represents a VEX status change between two artifact versions.
/// </summary>
public sealed record VexDeltaEntity
{
/// <summary>
/// Unique identifier for this delta record.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Digest of the source artifact (before).
/// Format: "sha256:{hex}"
/// </summary>
public required string FromArtifactDigest { get; init; }
/// <summary>
/// Digest of the target artifact (after).
/// Format: "sha256:{hex}"
/// </summary>
public required string ToArtifactDigest { get; init; }
/// <summary>
/// CVE identifier (e.g., "CVE-2024-1234").
/// </summary>
public required string Cve { get; init; }
/// <summary>
/// VEX status in the source artifact.
/// </summary>
public required VexDeltaStatus FromStatus { get; init; }
/// <summary>
/// VEX status in the target artifact.
/// </summary>
public required VexDeltaStatus ToStatus { get; init; }
/// <summary>
/// Rationale for the status change.
/// </summary>
public required VexDeltaRationale Rationale { get; init; }
/// <summary>
/// Replay hash for deterministic verification.
/// SHA256 of inputs that produced this delta.
/// </summary>
public required string ReplayHash { get; init; }
/// <summary>
/// Digest of the signed attestation (if signed).
/// </summary>
public string? AttestationDigest { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// When this delta was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// VEX status enum for deltas.
/// </summary>
public enum VexDeltaStatus
{
Unknown,
Affected,
NotAffected,
Fixed,
UnderInvestigation
}
/// <summary>
/// Rationale for a VEX status change.
/// </summary>
public sealed record VexDeltaRationale
{
/// <summary>
/// Human-readable reason for the change.
/// </summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>
/// Link to evidence supporting this change.
/// </summary>
[JsonPropertyName("evidenceLink")]
public string? EvidenceLink { get; init; }
/// <summary>
/// Additional metadata about the change.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Source that triggered the change.
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
/// <summary>
/// Justification code (e.g., "component_not_present", "vulnerable_code_not_in_execute_path").
/// </summary>
[JsonPropertyName("justificationCode")]
public string? JustificationCode { get; init; }
}
/// <summary>
/// Query options for VEX deltas.
/// </summary>
public sealed record VexDeltaQuery
{
/// <summary>
/// Filter by source artifact digest.
/// </summary>
public string? FromArtifactDigest { get; init; }
/// <summary>
/// Filter by target artifact digest.
/// </summary>
public string? ToArtifactDigest { get; init; }
/// <summary>
/// Filter by CVE.
/// </summary>
public string? Cve { get; init; }
/// <summary>
/// Filter by from status.
/// </summary>
public VexDeltaStatus? FromStatus { get; init; }
/// <summary>
/// Filter by to status.
/// </summary>
public VexDeltaStatus? ToStatus { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public Guid TenantId { get; init; }
/// <summary>
/// Created after this timestamp.
/// </summary>
public DateTimeOffset? CreatedAfter { get; init; }
/// <summary>
/// Created before this timestamp.
/// </summary>
public DateTimeOffset? CreatedBefore { get; init; }
/// <summary>
/// Maximum results to return.
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Offset for pagination.
/// </summary>
public int Offset { get; init; }
}
/// <summary>
/// Result of a VEX delta query.
/// </summary>
public sealed record VexDeltaQueryResult
{
/// <summary>
/// Matching deltas.
/// </summary>
public required IReadOnlyList<VexDeltaEntity> Deltas { get; init; }
/// <summary>
/// Total count of matching deltas.
/// </summary>
public required int TotalCount { get; init; }
/// <summary>
/// Whether there are more results.
/// </summary>
public bool HasMore => TotalCount > (Deltas.Count + (Query?.Offset ?? 0));
/// <summary>
/// Query that produced this result.
/// </summary>
public VexDeltaQuery? Query { get; init; }
}
/// <summary>
/// Summary of VEX deltas for UI display.
/// </summary>
public sealed record VexDeltaSummary
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Previous status.
/// </summary>
[JsonPropertyName("fromStatus")]
public required string FromStatus { get; init; }
/// <summary>
/// New status.
/// </summary>
[JsonPropertyName("toStatus")]
public required string ToStatus { get; init; }
/// <summary>
/// Reason for the change.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
/// <summary>
/// Link to evidence.
/// </summary>
[JsonPropertyName("evidenceLink")]
public string? EvidenceLink { get; init; }
/// <summary>
/// Severity level of the change.
/// </summary>
[JsonPropertyName("severity")]
public string? Severity { get; init; }
}
/// <summary>
/// Helper methods for VEX delta status.
/// </summary>
public static class VexDeltaStatusExtensions
{
/// <summary>
/// Converts status enum to string.
/// </summary>
public static string ToStatusString(this VexDeltaStatus status) => status switch
{
VexDeltaStatus.Affected => "affected",
VexDeltaStatus.NotAffected => "not_affected",
VexDeltaStatus.Fixed => "fixed",
VexDeltaStatus.UnderInvestigation => "under_investigation",
_ => "unknown"
};
/// <summary>
/// Parses string to status enum.
/// </summary>
public static VexDeltaStatus ParseStatus(string? status) => status?.ToLowerInvariant() switch
{
"affected" => VexDeltaStatus.Affected,
"not_affected" => VexDeltaStatus.NotAffected,
"fixed" => VexDeltaStatus.Fixed,
"under_investigation" => VexDeltaStatus.UnderInvestigation,
_ => VexDeltaStatus.Unknown
};
/// <summary>
/// Determines the severity of a status transition.
/// </summary>
public static string GetTransitionSeverity(VexDeltaStatus from, VexDeltaStatus to) =>
(from, to) switch
{
(VexDeltaStatus.NotAffected, VexDeltaStatus.Affected) => "critical",
(VexDeltaStatus.Fixed, VexDeltaStatus.Affected) => "high",
(VexDeltaStatus.Affected, VexDeltaStatus.NotAffected) => "resolved",
(VexDeltaStatus.Affected, VexDeltaStatus.Fixed) => "resolved",
(VexDeltaStatus.UnderInvestigation, VexDeltaStatus.Affected) => "high",
(VexDeltaStatus.UnderInvestigation, VexDeltaStatus.NotAffected) => "resolved",
_ => "info"
};
}

View File

@@ -8,8 +8,14 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - store interfaces use VexConsensus during transition
namespace StellaOps.Excititor.Core.Storage;
/// <summary>
@@ -11,3 +13,17 @@ public interface IVexConsensusStore
IAsyncEnumerable<VexConsensus> FindCalculatedBeforeAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken);
}
/// <summary>
/// Persistence abstraction for consensus holds (damper-based delay records).
/// </summary>
public interface IVexConsensusHoldStore
{
ValueTask SaveAsync(VexConsensusHold hold, CancellationToken cancellationToken);
ValueTask<VexConsensusHold?> FindAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken);
IAsyncEnumerable<VexConsensusHold> FindEligibleAsync(DateTimeOffset asOf, int limit, CancellationToken cancellationToken);
ValueTask RemoveAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,178 @@
// CryptoProfileSelector - Selects crypto profile based on context
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// Selects appropriate cryptographic profile based on issuer, tenant, or document hints.
/// </summary>
public interface ICryptoProfileSelector
{
/// <summary>
/// Select crypto profile for a verification context.
/// </summary>
/// <param name="issuer">Issuer information if available.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="documentHints">Hints from document metadata.</param>
/// <returns>Selected profile identifier.</returns>
string SelectProfile(
IssuerInfo? issuer,
string tenantId,
IReadOnlyDictionary<string, string>? documentHints);
}
/// <summary>
/// Default implementation of crypto profile selector.
/// </summary>
public sealed class CryptoProfileSelector : ICryptoProfileSelector
{
private readonly VexSignatureVerifierOptions _options;
private readonly ILogger<CryptoProfileSelector> _logger;
// Jurisdiction to profile mapping
private static readonly Dictionary<string, string> JurisdictionProfiles = new(StringComparer.OrdinalIgnoreCase)
{
// United States - FIPS 140-3
["US"] = "fips",
["USA"] = "fips",
// European Union - eIDAS
["EU"] = "eidas",
["DE"] = "eidas",
["FR"] = "eidas",
["IT"] = "eidas",
["ES"] = "eidas",
["NL"] = "eidas",
// Russia - GOST
["RU"] = "gost",
["RUS"] = "gost",
// China - SM
["CN"] = "sm",
["CHN"] = "sm",
// South Korea - KCMVP
["KR"] = "kcmvp",
["KOR"] = "kcmvp"
};
// Tag-based profile hints
private static readonly Dictionary<string, string> TagProfiles = new(StringComparer.OrdinalIgnoreCase)
{
["fips"] = "fips",
["fips-140-3"] = "fips",
["eidas"] = "eidas",
["gost"] = "gost",
["gost-r-34.11"] = "gost",
["sm"] = "sm",
["sm2"] = "sm",
["sm3"] = "sm",
["kcmvp"] = "kcmvp"
};
public CryptoProfileSelector(
IOptions<VexSignatureVerifierOptions> options,
ILogger<CryptoProfileSelector> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public string SelectProfile(
IssuerInfo? issuer,
string tenantId,
IReadOnlyDictionary<string, string>? documentHints)
{
// 1. Check document hints first (explicit override)
if (documentHints != null)
{
if (documentHints.TryGetValue("crypto-profile", out var hintProfile) &&
IsValidProfile(hintProfile))
{
_logger.LogDebug(
"Using crypto profile '{Profile}' from document hint",
hintProfile);
return hintProfile;
}
// Check for compliance hint
if (documentHints.TryGetValue("compliance", out var compliance))
{
var complianceProfile = MapComplianceToProfile(compliance);
if (complianceProfile != null)
{
_logger.LogDebug(
"Using crypto profile '{Profile}' from compliance hint '{Compliance}'",
complianceProfile,
compliance);
return complianceProfile;
}
}
}
// 2. Check issuer jurisdiction
if (issuer?.Jurisdiction != null)
{
if (JurisdictionProfiles.TryGetValue(issuer.Jurisdiction, out var jurisdictionProfile))
{
_logger.LogDebug(
"Using crypto profile '{Profile}' from issuer jurisdiction '{Jurisdiction}'",
jurisdictionProfile,
issuer.Jurisdiction);
return jurisdictionProfile;
}
}
// 3. Check issuer tags
if (issuer?.Tags != null)
{
foreach (var tag in issuer.Tags)
{
if (TagProfiles.TryGetValue(tag, out var tagProfile))
{
_logger.LogDebug(
"Using crypto profile '{Profile}' from issuer tag '{Tag}'",
tagProfile,
tag);
return tagProfile;
}
}
}
// 4. Fall back to default
_logger.LogDebug(
"Using default crypto profile '{Profile}'",
_options.DefaultProfile);
return _options.DefaultProfile;
}
private static bool IsValidProfile(string profile)
{
return profile.ToLowerInvariant() switch
{
"world" or "fips" or "gost" or "sm" or "kcmvp" or "eidas" => true,
_ => false
};
}
private static string? MapComplianceToProfile(string compliance)
{
return compliance.ToLowerInvariant() switch
{
"fips" or "fips-140-3" or "fips-140-2" => "fips",
"eidas" or "eu" => "eidas",
"gost" or "gost-r-34.11-2012" => "gost",
"sm" or "gb/t" or "sm2" or "sm3" => "sm",
"kcmvp" => "kcmvp",
"iso" or "world" or "international" => "world",
_ => null
};
}
}

View File

@@ -0,0 +1,165 @@
// IVexSignatureVerifierV2 - Enhanced Signature Verification Interface
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// Enhanced signature/attestation verification service for VEX documents.
/// Supports batch verification, crypto profiles, and IssuerDirectory integration.
/// </summary>
public interface IVexSignatureVerifierV2
{
/// <summary>
/// Verify all signatures on a VEX document.
/// </summary>
/// <param name="document">The raw VEX document to verify.</param>
/// <param name="context">Verification context with tenant, profile, and options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result with detailed diagnostics.</returns>
Task<VexSignatureVerificationResult> VerifyAsync(
VexRawDocument document,
VexVerificationContext context,
CancellationToken ct = default);
/// <summary>
/// Batch verification for ingest performance.
/// </summary>
/// <param name="documents">Documents to verify.</param>
/// <param name="context">Verification context shared across batch.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification results for each document.</returns>
Task<IReadOnlyList<VexSignatureVerificationResult>> VerifyBatchAsync(
IEnumerable<VexRawDocument> documents,
VexVerificationContext context,
CancellationToken ct = default);
/// <summary>
/// Check if a specific issuer's key is currently revoked.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the key is revoked.</returns>
Task<bool> IsKeyRevokedAsync(
string keyId,
string tenantId,
CancellationToken ct = default);
}
/// <summary>
/// Cache service for verification results.
/// </summary>
public interface IVerificationCacheService
{
/// <summary>
/// Try to get a cached verification result.
/// </summary>
/// <param name="key">Cache key.</param>
/// <param name="result">Cached result if found.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if cache hit.</returns>
Task<bool> TryGetAsync(
string key,
out VexSignatureVerificationResult? result,
CancellationToken ct = default);
/// <summary>
/// Store a verification result in cache.
/// </summary>
/// <param name="key">Cache key.</param>
/// <param name="result">Result to cache.</param>
/// <param name="ttl">Time to live.</param>
/// <param name="ct">Cancellation token.</param>
Task SetAsync(
string key,
VexSignatureVerificationResult result,
TimeSpan ttl,
CancellationToken ct = default);
/// <summary>
/// Invalidate all cached results for a specific issuer (e.g., on key revocation).
/// </summary>
/// <param name="issuerId">Issuer identifier.</param>
/// <param name="ct">Cancellation token.</param>
Task InvalidateByIssuerAsync(
string issuerId,
CancellationToken ct = default);
}
/// <summary>
/// Client interface for IssuerDirectory lookups.
/// </summary>
public interface IIssuerDirectoryClient
{
/// <summary>
/// Get issuer information by key ID.
/// </summary>
Task<IssuerInfo?> GetIssuerByKeyIdAsync(
string keyId,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Get a specific key by issuer and key ID.
/// </summary>
Task<IssuerKeyInfo?> GetKeyAsync(
string issuerId,
string keyId,
CancellationToken ct = default);
/// <summary>
/// Check if a key is revoked.
/// </summary>
Task<bool> IsKeyRevokedAsync(
string keyId,
CancellationToken ct = default);
/// <summary>
/// Get all active keys for an issuer.
/// </summary>
Task<IReadOnlyList<IssuerKeyInfo>> GetActiveKeysForIssuerAsync(
string issuerId,
CancellationToken ct = default);
/// <summary>
/// Get issuer by ID.
/// </summary>
Task<IssuerInfo?> GetIssuerAsync(
string issuerId,
string tenantId,
CancellationToken ct = default);
}
/// <summary>
/// Issuer information from IssuerDirectory.
/// </summary>
public sealed record IssuerInfo
{
public required string Id { get; init; }
public required string TenantId { get; init; }
public required string DisplayName { get; init; }
public string? Jurisdiction { get; init; }
public decimal TrustWeight { get; init; } = 1.0m;
public bool IsActive { get; init; } = true;
public IReadOnlyList<string> Tags { get; init; } = [];
}
/// <summary>
/// Key information from IssuerDirectory.
/// </summary>
public sealed record IssuerKeyInfo
{
public required string KeyId { get; init; }
public required string IssuerId { get; init; }
public required string Algorithm { get; init; }
public required ReadOnlyMemory<byte> PublicKey { get; init; }
public required string Fingerprint { get; init; }
public DateTimeOffset? NotBefore { get; init; }
public DateTimeOffset? NotAfter { get; init; }
public bool IsRevoked { get; init; }
public DateTimeOffset? RevokedAt { get; init; }
}

View File

@@ -0,0 +1,390 @@
// InMemoryIssuerDirectoryClient - In-memory stub for IssuerDirectory
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// In-memory implementation of IssuerDirectory client for development and testing.
/// Production deployments should use HttpIssuerDirectoryClient to connect to the real service.
/// </summary>
public sealed class InMemoryIssuerDirectoryClient : IIssuerDirectoryClient
{
private readonly ConcurrentDictionary<string, IssuerInfo> _issuers;
private readonly ConcurrentDictionary<string, IssuerKeyInfo> _keys;
private readonly ConcurrentDictionary<string, string> _keyToIssuerMap;
private readonly ILogger<InMemoryIssuerDirectoryClient> _logger;
public InMemoryIssuerDirectoryClient(ILogger<InMemoryIssuerDirectoryClient> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_issuers = new ConcurrentDictionary<string, IssuerInfo>(StringComparer.OrdinalIgnoreCase);
_keys = new ConcurrentDictionary<string, IssuerKeyInfo>(StringComparer.OrdinalIgnoreCase);
_keyToIssuerMap = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Seed with well-known issuers
SeedWellKnownIssuers();
}
/// <inheritdoc />
public Task<IssuerInfo?> GetIssuerByKeyIdAsync(
string keyId,
string tenantId,
CancellationToken ct = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
if (_keyToIssuerMap.TryGetValue(keyId, out var issuerId) &&
_issuers.TryGetValue(issuerId, out var issuer))
{
// Check tenant match or global issuer
if (issuer.TenantId == "@global" ||
issuer.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
{
VexVerificationMetrics.RecordIssuerLookup(true, stopwatch.Elapsed);
return Task.FromResult<IssuerInfo?>(issuer);
}
}
VexVerificationMetrics.RecordIssuerLookup(false, stopwatch.Elapsed);
return Task.FromResult<IssuerInfo?>(null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error looking up issuer by key {KeyId}", keyId);
VexVerificationMetrics.RecordIssuerLookup(false, stopwatch.Elapsed);
return Task.FromResult<IssuerInfo?>(null);
}
}
/// <inheritdoc />
public Task<IssuerKeyInfo?> GetKeyAsync(
string issuerId,
string keyId,
CancellationToken ct = default)
{
if (_keys.TryGetValue(keyId, out var key) &&
key.IssuerId.Equals(issuerId, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<IssuerKeyInfo?>(key);
}
return Task.FromResult<IssuerKeyInfo?>(null);
}
/// <inheritdoc />
public Task<bool> IsKeyRevokedAsync(
string keyId,
CancellationToken ct = default)
{
if (_keys.TryGetValue(keyId, out var key))
{
return Task.FromResult(key.IsRevoked);
}
// Unknown keys treated as potentially compromised
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<IReadOnlyList<IssuerKeyInfo>> GetActiveKeysForIssuerAsync(
string issuerId,
CancellationToken ct = default)
{
var activeKeys = _keys.Values
.Where(k => k.IssuerId.Equals(issuerId, StringComparison.OrdinalIgnoreCase) && !k.IsRevoked)
.ToList();
return Task.FromResult<IReadOnlyList<IssuerKeyInfo>>(activeKeys);
}
/// <inheritdoc />
public Task<IssuerInfo?> GetIssuerAsync(
string issuerId,
string tenantId,
CancellationToken ct = default)
{
if (_issuers.TryGetValue(issuerId, out var issuer))
{
if (issuer.TenantId == "@global" ||
issuer.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<IssuerInfo?>(issuer);
}
}
return Task.FromResult<IssuerInfo?>(null);
}
/// <summary>
/// Register an issuer for testing.
/// </summary>
public void RegisterIssuer(IssuerInfo issuer)
{
_issuers[issuer.Id] = issuer;
_logger.LogDebug("Registered issuer {IssuerId}: {DisplayName}", issuer.Id, issuer.DisplayName);
}
/// <summary>
/// Register a key for testing.
/// </summary>
public void RegisterKey(IssuerKeyInfo key)
{
_keys[key.KeyId] = key;
_keyToIssuerMap[key.KeyId] = key.IssuerId;
_logger.LogDebug("Registered key {KeyId} for issuer {IssuerId}", key.KeyId, key.IssuerId);
}
private void SeedWellKnownIssuers()
{
// Seed with common VEX/CSAF publishers for development
RegisterIssuer(new IssuerInfo
{
Id = "redhat",
TenantId = "@global",
DisplayName = "Red Hat Product Security",
Jurisdiction = "US",
TrustWeight = 0.90m,
IsActive = true,
Tags = new[] { "vendor", "linux", "enterprise" }
});
RegisterIssuer(new IssuerInfo
{
Id = "ubuntu",
TenantId = "@global",
DisplayName = "Ubuntu Security",
Jurisdiction = "US",
TrustWeight = 0.85m,
IsActive = true,
Tags = new[] { "distro", "linux", "debian" }
});
RegisterIssuer(new IssuerInfo
{
Id = "oracle",
TenantId = "@global",
DisplayName = "Oracle Security",
Jurisdiction = "US",
TrustWeight = 0.85m,
IsActive = true,
Tags = new[] { "vendor", "enterprise" }
});
RegisterIssuer(new IssuerInfo
{
Id = "suse",
TenantId = "@global",
DisplayName = "SUSE Product Security",
Jurisdiction = "DE",
TrustWeight = 0.85m,
IsActive = true,
Tags = new[] { "vendor", "linux", "enterprise", "eidas" }
});
RegisterIssuer(new IssuerInfo
{
Id = "cisco",
TenantId = "@global",
DisplayName = "Cisco PSIRT",
Jurisdiction = "US",
TrustWeight = 0.90m,
IsActive = true,
Tags = new[] { "vendor", "network", "fips" }
});
RegisterIssuer(new IssuerInfo
{
Id = "microsoft",
TenantId = "@global",
DisplayName = "Microsoft Security Response Center",
Jurisdiction = "US",
TrustWeight = 0.90m,
IsActive = true,
Tags = new[] { "vendor", "fips" }
});
_logger.LogInformation(
"Seeded {Count} well-known issuers into in-memory IssuerDirectory",
_issuers.Count);
}
}
/// <summary>
/// HTTP client for real IssuerDirectory service.
/// </summary>
public sealed class HttpIssuerDirectoryClient : IIssuerDirectoryClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<HttpIssuerDirectoryClient> _logger;
private readonly IssuerDirectoryClientOptions _options;
public HttpIssuerDirectoryClient(
HttpClient httpClient,
IssuerDirectoryClientOptions options,
ILogger<HttpIssuerDirectoryClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (!string.IsNullOrEmpty(_options.ServiceUrl))
{
_httpClient.BaseAddress = new Uri(_options.ServiceUrl);
}
_httpClient.Timeout = _options.Timeout;
}
/// <inheritdoc />
public async Task<IssuerInfo?> GetIssuerByKeyIdAsync(
string keyId,
string tenantId,
CancellationToken ct = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/keys/{Uri.EscapeDataString(keyId)}/issuer?tenantId={Uri.EscapeDataString(tenantId)}",
ct);
if (!response.IsSuccessStatusCode)
{
VexVerificationMetrics.RecordIssuerLookup(false, stopwatch.Elapsed);
return null;
}
var issuer = await response.Content.ReadFromJsonAsync<IssuerInfo>(ct);
VexVerificationMetrics.RecordIssuerLookup(issuer != null, stopwatch.Elapsed);
return issuer;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to lookup issuer by key {KeyId}", keyId);
VexVerificationMetrics.RecordIssuerLookup(false, stopwatch.Elapsed);
return null;
}
}
/// <inheritdoc />
public async Task<IssuerKeyInfo?> GetKeyAsync(
string issuerId,
string keyId,
CancellationToken ct = default)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/issuers/{Uri.EscapeDataString(issuerId)}/keys/{Uri.EscapeDataString(keyId)}",
ct);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<IssuerKeyInfo>(ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get key {KeyId} for issuer {IssuerId}", keyId, issuerId);
return null;
}
}
/// <inheritdoc />
public async Task<bool> IsKeyRevokedAsync(
string keyId,
CancellationToken ct = default)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/keys/{Uri.EscapeDataString(keyId)}/status",
ct);
if (!response.IsSuccessStatusCode)
{
// Treat unknown as potentially revoked
return true;
}
var status = await response.Content.ReadFromJsonAsync<KeyStatusResponse>(ct);
return status?.IsRevoked ?? true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check revocation status for key {KeyId}", keyId);
return true;
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<IssuerKeyInfo>> GetActiveKeysForIssuerAsync(
string issuerId,
CancellationToken ct = default)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/issuers/{Uri.EscapeDataString(issuerId)}/keys?status=active",
ct);
if (!response.IsSuccessStatusCode)
{
return Array.Empty<IssuerKeyInfo>();
}
var keys = await response.Content.ReadFromJsonAsync<IssuerKeyInfo[]>(ct);
return keys ?? Array.Empty<IssuerKeyInfo>();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get active keys for issuer {IssuerId}", issuerId);
return Array.Empty<IssuerKeyInfo>();
}
}
/// <inheritdoc />
public async Task<IssuerInfo?> GetIssuerAsync(
string issuerId,
string tenantId,
CancellationToken ct = default)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/issuers/{Uri.EscapeDataString(issuerId)}?tenantId={Uri.EscapeDataString(tenantId)}",
ct);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<IssuerInfo>(ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get issuer {IssuerId}", issuerId);
return null;
}
}
private sealed record KeyStatusResponse(bool IsRevoked, DateTimeOffset? RevokedAt);
}

View File

@@ -0,0 +1,815 @@
// ProductionVexSignatureVerifier - Production Signature Verification Implementation
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Excititor.Core.Dsse;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// Production VEX signature verifier with full cryptographic verification,
/// IssuerDirectory integration, and caching support.
/// </summary>
public sealed class ProductionVexSignatureVerifier : IVexSignatureVerifierV2
{
private static readonly ActivitySource ActivitySource = new("StellaOps.Excititor.Verification");
private readonly IIssuerDirectoryClient _issuerDirectory;
private readonly ICryptoProviderRegistry _cryptoProviders;
private readonly IVerificationCacheService? _cache;
private readonly ILogger<ProductionVexSignatureVerifier> _logger;
private readonly VexSignatureVerifierOptions _options;
public ProductionVexSignatureVerifier(
IIssuerDirectoryClient issuerDirectory,
ICryptoProviderRegistry cryptoProviders,
IOptions<VexSignatureVerifierOptions> options,
ILogger<ProductionVexSignatureVerifier> logger,
IVerificationCacheService? cache = null)
{
_issuerDirectory = issuerDirectory ?? throw new ArgumentNullException(nameof(issuerDirectory));
_cryptoProviders = cryptoProviders ?? throw new ArgumentNullException(nameof(cryptoProviders));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_cache = cache;
}
/// <inheritdoc />
public async Task<VexSignatureVerificationResult> VerifyAsync(
VexRawDocument document,
VexVerificationContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(context);
using var activity = ActivitySource.StartActivity("VexSignatureVerifier.VerifyAsync");
activity?.SetTag("document.digest", document.Digest);
activity?.SetTag("context.tenant", context.TenantId);
activity?.SetTag("context.profile", context.CryptoProfile);
var stopwatch = Stopwatch.StartNew();
try
{
// 1. Check cache
var cacheKey = ComputeCacheKey(document.Digest, context.CryptoProfile);
if (_cache != null && await _cache.TryGetAsync(cacheKey, out var cached, ct))
{
_logger.LogDebug(
"Cache hit for document {Digest} with profile {Profile}",
document.Digest,
context.CryptoProfile);
VexVerificationMetrics.RecordCacheHit();
return cached! with { VerifiedAt = DateTimeOffset.UtcNow };
}
VexVerificationMetrics.RecordCacheMiss();
// 2. Extract signature info
var sigInfo = ExtractSignatureInfo(document);
if (sigInfo is null)
{
_logger.LogDebug("No signature found in document {Digest}", document.Digest);
var noSigResult = VexSignatureVerificationResult.NoSignature(document.Digest);
if (context.RequireSignature)
{
VexVerificationMetrics.RecordVerification(
VerificationMethod.None,
false,
context.CryptoProfile,
stopwatch.Elapsed);
return noSigResult;
}
// Allow unsigned documents when not required
return noSigResult with
{
Warnings = new[]
{
new VerificationWarning("NO_SIGNATURE", "Document is unsigned")
}
};
}
// 3. Lookup issuer by key ID
IssuerInfo? issuer = null;
IssuerKeyInfo? key = null;
if (!string.IsNullOrEmpty(sigInfo.KeyId))
{
issuer = await _issuerDirectory.GetIssuerByKeyIdAsync(
sigInfo.KeyId, context.TenantId, ct);
if (issuer != null)
{
key = await _issuerDirectory.GetKeyAsync(
issuer.Id, sigInfo.KeyId, ct);
}
}
// 4. Check issuer allowlist
if (context.AllowedIssuers != null && issuer != null)
{
if (!context.AllowedIssuers.Contains(issuer.Id, StringComparer.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Issuer {IssuerId} not in allowed list for document {Digest}",
issuer.Id,
document.Digest);
return VexSignatureVerificationResult.Failure(
document.Digest,
sigInfo.Method,
VerificationFailureReason.IssuerNotAllowed,
$"Issuer '{issuer.DisplayName}' is not in the allowed list",
sigInfo.KeyId);
}
}
// 5. Select verification strategy based on method
var result = sigInfo.Method switch
{
VerificationMethod.Dsse =>
await VerifyDsseAsync(document, sigInfo, issuer, key, context, ct),
VerificationMethod.DsseKeyless =>
await VerifyDsseKeylessAsync(document, sigInfo, context, ct),
VerificationMethod.Cosign =>
await VerifyCosignAsync(document, sigInfo, issuer, key, context, ct),
VerificationMethod.CosignKeyless =>
await VerifyCosignKeylessAsync(document, sigInfo, context, ct),
VerificationMethod.Pgp =>
await VerifyPgpAsync(document, sigInfo, issuer, key, context, ct),
VerificationMethod.X509 =>
await VerifyX509Async(document, sigInfo, context, ct),
_ =>
VexSignatureVerificationResult.Failure(
document.Digest,
sigInfo.Method,
VerificationFailureReason.InternalError,
$"Unsupported verification method: {sigInfo.Method}")
};
// 6. Cache result
if (_cache != null && result.Verified)
{
await _cache.SetAsync(cacheKey, result, _options.CacheTtl, ct);
}
VexVerificationMetrics.RecordVerification(
result.Method,
result.Verified,
context.CryptoProfile,
stopwatch.Elapsed);
activity?.SetTag("verification.result", result.Verified ? "verified" : "failed");
activity?.SetTag("verification.method", result.Method.ToString());
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying document {Digest}", document.Digest);
VexVerificationMetrics.RecordVerification(
VerificationMethod.None,
false,
context.CryptoProfile,
stopwatch.Elapsed);
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.None,
VerificationFailureReason.InternalError,
ex.Message);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexSignatureVerificationResult>> VerifyBatchAsync(
IEnumerable<VexRawDocument> documents,
VexVerificationContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(documents);
ArgumentNullException.ThrowIfNull(context);
var docList = documents.ToList();
var results = new ConcurrentBag<(int Index, VexSignatureVerificationResult Result)>();
// Process in parallel with configurable concurrency
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = _options.MaxBatchParallelism,
CancellationToken = ct
};
await Parallel.ForEachAsync(
docList.Select((doc, idx) => (doc, idx)),
parallelOptions,
async (item, token) =>
{
var result = await VerifyAsync(item.doc, context, token);
results.Add((item.idx, result));
});
// Return results in original order
return results
.OrderBy(r => r.Index)
.Select(r => r.Result)
.ToList();
}
/// <inheritdoc />
public async Task<bool> IsKeyRevokedAsync(
string keyId,
string tenantId,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return await _issuerDirectory.IsKeyRevokedAsync(keyId, ct);
}
#region Signature Extraction
private ExtractedSignatureInfo? ExtractSignatureInfo(VexRawDocument document)
{
// Try to detect signature type from metadata or content
if (document.Metadata.TryGetValue("signature-type", out var sigType))
{
return sigType.ToLowerInvariant() switch
{
"dsse" => ExtractDsseSignature(document),
"cosign" => ExtractCosignSignature(document),
"pgp" => ExtractPgpSignature(document),
"x509" => ExtractX509Signature(document),
_ => null
};
}
// Try to auto-detect from content
return TryAutoDetectSignature(document);
}
private ExtractedSignatureInfo? ExtractDsseSignature(VexRawDocument document)
{
// Check if content is a DSSE envelope
try
{
var json = Encoding.UTF8.GetString(document.Content.Span);
// Quick check for DSSE structure
if (!json.Contains("\"payloadType\"", StringComparison.OrdinalIgnoreCase) ||
!json.Contains("\"signatures\"", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json);
if (envelope?.Signatures is not { Count: > 0 })
{
return null;
}
var firstSig = envelope.Signatures[0];
var sigBytes = Convert.FromBase64String(firstSig.Signature);
return new ExtractedSignatureInfo
{
Method = string.IsNullOrEmpty(firstSig.KeyId)
? VerificationMethod.DsseKeyless
: VerificationMethod.Dsse,
SignatureBytes = sigBytes,
KeyId = firstSig.KeyId,
DsseEnvelope = json
};
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract DSSE signature from document");
return null;
}
}
private ExtractedSignatureInfo? ExtractCosignSignature(VexRawDocument document)
{
// Check metadata for cosign signature
if (!document.Metadata.TryGetValue("cosign-signature", out var sig))
{
return null;
}
document.Metadata.TryGetValue("cosign-keyid", out var keyId);
document.Metadata.TryGetValue("cosign-bundle", out var bundle);
try
{
var sigBytes = Convert.FromBase64String(sig);
var isKeyless = string.IsNullOrEmpty(keyId);
return new ExtractedSignatureInfo
{
Method = isKeyless ? VerificationMethod.CosignKeyless : VerificationMethod.Cosign,
SignatureBytes = sigBytes,
KeyId = keyId,
RekorBundle = bundle
};
}
catch
{
return null;
}
}
private ExtractedSignatureInfo? ExtractPgpSignature(VexRawDocument document)
{
// Check for detached PGP signature in metadata
if (!document.Metadata.TryGetValue("pgp-signature", out var sig))
{
return null;
}
document.Metadata.TryGetValue("pgp-keyid", out var keyId);
try
{
var sigBytes = Convert.FromBase64String(sig);
return new ExtractedSignatureInfo
{
Method = VerificationMethod.Pgp,
SignatureBytes = sigBytes,
KeyId = keyId
};
}
catch
{
return null;
}
}
private ExtractedSignatureInfo? ExtractX509Signature(VexRawDocument document)
{
// Check for X.509 signature
if (!document.Metadata.TryGetValue("x509-signature", out var sig))
{
return null;
}
document.Metadata.TryGetValue("x509-cert-chain", out var certChainBase64);
try
{
var sigBytes = Convert.FromBase64String(sig);
List<byte[]>? certChain = null;
if (!string.IsNullOrEmpty(certChainBase64))
{
var certs = certChainBase64.Split(';');
certChain = certs.Select(c => Convert.FromBase64String(c)).ToList();
}
return new ExtractedSignatureInfo
{
Method = VerificationMethod.X509,
SignatureBytes = sigBytes,
CertificateChain = certChain
};
}
catch
{
return null;
}
}
private ExtractedSignatureInfo? TryAutoDetectSignature(VexRawDocument document)
{
// Try DSSE first (most common)
var dsse = ExtractDsseSignature(document);
if (dsse != null) return dsse;
// Try cosign
var cosign = ExtractCosignSignature(document);
if (cosign != null) return cosign;
// Try PGP
var pgp = ExtractPgpSignature(document);
if (pgp != null) return pgp;
// Try X.509
var x509 = ExtractX509Signature(document);
if (x509 != null) return x509;
return null;
}
#endregion
#region Verification Methods
private async Task<VexSignatureVerificationResult> VerifyDsseAsync(
VexRawDocument document,
ExtractedSignatureInfo sigInfo,
IssuerInfo? issuer,
IssuerKeyInfo? key,
VexVerificationContext context,
CancellationToken ct)
{
// Verify DSSE envelope with known key
if (key is null)
{
if (issuer is null)
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Dsse,
VerificationFailureReason.UnknownIssuer,
$"Key ID '{sigInfo.KeyId}' not found in IssuerDirectory",
sigInfo.KeyId);
}
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Dsse,
VerificationFailureReason.KeyNotFound,
$"Key '{sigInfo.KeyId}' not found for issuer '{issuer.DisplayName}'",
sigInfo.KeyId);
}
// Check key validity
var keyValidation = ValidateKeyValidity(key, context);
if (keyValidation != null)
{
return keyValidation with { DocumentDigest = document.Digest };
}
// Parse DSSE envelope
if (string.IsNullOrEmpty(sigInfo.DsseEnvelope))
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Dsse,
VerificationFailureReason.InternalError,
"DSSE envelope is missing");
}
try
{
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(sigInfo.DsseEnvelope);
if (envelope is null)
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Dsse,
VerificationFailureReason.InvalidSignature,
"Failed to parse DSSE envelope");
}
// Build PAE (Pre-Authentication Encoding)
var payloadBytes = Convert.FromBase64String(envelope.Payload);
var pae = BuildPae(envelope.PayloadType, payloadBytes);
// Verify signature
var verified = await VerifySignatureWithKeyAsync(
pae,
sigInfo.SignatureBytes,
key,
context.CryptoProfile,
ct);
if (!verified)
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Dsse,
VerificationFailureReason.InvalidSignature,
"Signature verification failed",
sigInfo.KeyId);
}
return VexSignatureVerificationResult.Success(
document.Digest,
VerificationMethod.Dsse,
keyId: sigInfo.KeyId,
issuerName: issuer?.DisplayName,
issuerId: issuer?.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DSSE verification failed for document {Digest}", document.Digest);
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Dsse,
VerificationFailureReason.InvalidSignature,
ex.Message,
sigInfo.KeyId);
}
}
private async Task<VexSignatureVerificationResult> VerifyDsseKeylessAsync(
VexRawDocument document,
ExtractedSignatureInfo sigInfo,
VexVerificationContext context,
CancellationToken ct)
{
// Keyless verification requires certificate chain
if (sigInfo.CertificateChain is not { Count: > 0 })
{
// Try to extract from Rekor bundle
if (string.IsNullOrEmpty(sigInfo.RekorBundle))
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.DsseKeyless,
VerificationFailureReason.ChainValidationFailed,
"No certificate chain available for keyless verification");
}
}
// For keyless, we would verify against Fulcio roots
// This is a simplified implementation - full implementation would
// validate the certificate chain against configured trust anchors
_logger.LogDebug(
"Keyless DSSE verification for document {Digest} - delegating to trust anchor validation",
document.Digest);
// Placeholder for full keyless verification
// In production, this would:
// 1. Extract certificate from DSSE envelope or Rekor bundle
// 2. Validate certificate chain against Fulcio roots
// 3. Check certificate validity window
// 4. Verify signature using certificate's public key
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.DsseKeyless,
VerificationFailureReason.InternalError,
"Keyless verification not fully implemented - requires Fulcio trust anchor configuration");
}
private async Task<VexSignatureVerificationResult> VerifyCosignAsync(
VexRawDocument document,
ExtractedSignatureInfo sigInfo,
IssuerInfo? issuer,
IssuerKeyInfo? key,
VexVerificationContext context,
CancellationToken ct)
{
if (key is null)
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Cosign,
VerificationFailureReason.KeyNotFound,
$"Cosign key '{sigInfo.KeyId}' not found",
sigInfo.KeyId);
}
var keyValidation = ValidateKeyValidity(key, context);
if (keyValidation != null)
{
return keyValidation with { DocumentDigest = document.Digest };
}
// Verify cosign signature over document digest
var digestBytes = ComputeDocumentDigest(document);
var verified = await VerifySignatureWithKeyAsync(
digestBytes,
sigInfo.SignatureBytes,
key,
context.CryptoProfile,
ct);
if (!verified)
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Cosign,
VerificationFailureReason.InvalidSignature,
"Cosign signature verification failed",
sigInfo.KeyId);
}
return VexSignatureVerificationResult.Success(
document.Digest,
VerificationMethod.Cosign,
keyId: sigInfo.KeyId,
issuerName: issuer?.DisplayName,
issuerId: issuer?.Id);
}
private Task<VexSignatureVerificationResult> VerifyCosignKeylessAsync(
VexRawDocument document,
ExtractedSignatureInfo sigInfo,
VexVerificationContext context,
CancellationToken ct)
{
// Similar to DsseKeyless - requires Fulcio/Rekor verification
return Task.FromResult(VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.CosignKeyless,
VerificationFailureReason.InternalError,
"Cosign keyless verification not fully implemented"));
}
private async Task<VexSignatureVerificationResult> VerifyPgpAsync(
VexRawDocument document,
ExtractedSignatureInfo sigInfo,
IssuerInfo? issuer,
IssuerKeyInfo? key,
VexVerificationContext context,
CancellationToken ct)
{
if (key is null)
{
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Pgp,
VerificationFailureReason.KeyNotFound,
$"PGP key '{sigInfo.KeyId}' not found",
sigInfo.KeyId);
}
// PGP verification would use BouncyCastle or similar
// This is a placeholder for the full implementation
_logger.LogDebug(
"PGP verification for document {Digest} with key {KeyId}",
document.Digest,
sigInfo.KeyId);
return VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.Pgp,
VerificationFailureReason.InternalError,
"PGP verification not fully implemented");
}
private Task<VexSignatureVerificationResult> VerifyX509Async(
VexRawDocument document,
ExtractedSignatureInfo sigInfo,
VexVerificationContext context,
CancellationToken ct)
{
// X.509 verification with certificate chain
if (sigInfo.CertificateChain is not { Count: > 0 })
{
return Task.FromResult(VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.X509,
VerificationFailureReason.ChainValidationFailed,
"No certificate chain provided"));
}
// Full implementation would:
// 1. Build X509 certificate chain
// 2. Validate against configured trust anchors
// 3. Check validity periods
// 4. Verify signature with leaf certificate's public key
return Task.FromResult(VexSignatureVerificationResult.Failure(
document.Digest,
VerificationMethod.X509,
VerificationFailureReason.InternalError,
"X.509 verification not fully implemented"));
}
#endregion
#region Helpers
private VexSignatureVerificationResult? ValidateKeyValidity(
IssuerKeyInfo key,
VexVerificationContext context)
{
var now = context.VerificationTime;
if (key.IsRevoked)
{
return VexSignatureVerificationResult.Failure(
string.Empty,
VerificationMethod.None,
VerificationFailureReason.KeyRevoked,
$"Key '{key.KeyId}' was revoked at {key.RevokedAt}",
key.KeyId);
}
if (key.NotBefore.HasValue && now < key.NotBefore.Value - context.ClockTolerance)
{
return VexSignatureVerificationResult.Failure(
string.Empty,
VerificationMethod.None,
VerificationFailureReason.KeyNotYetValid,
$"Key '{key.KeyId}' is not valid until {key.NotBefore}",
key.KeyId);
}
if (key.NotAfter.HasValue && !context.AllowExpiredCerts)
{
if (now > key.NotAfter.Value + context.ClockTolerance)
{
return VexSignatureVerificationResult.Failure(
string.Empty,
VerificationMethod.None,
VerificationFailureReason.KeyExpired,
$"Key '{key.KeyId}' expired at {key.NotAfter}",
key.KeyId);
}
}
return null;
}
private async Task<bool> VerifySignatureWithKeyAsync(
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
IssuerKeyInfo key,
string cryptoProfile,
CancellationToken ct)
{
try
{
// Resolve crypto provider for the algorithm
var algorithm = key.Algorithm.ToUpperInvariant() switch
{
"ECDSA-P256" or "ES256" => SignatureAlgorithms.Es256,
"ECDSA-P384" or "ES384" => SignatureAlgorithms.Es384,
"ED25519" => SignatureAlgorithms.Ed25519,
"RSA-SHA256" or "RS256" => SignatureAlgorithms.Rs256,
_ => key.Algorithm
};
if (!_cryptoProviders.TryResolve(_options.PreferredProvider, out var provider))
{
provider = _cryptoProviders.ResolveOrThrow(CryptoCapability.Signing, algorithm);
}
// Use the provider to verify using ephemeral verifier
var verifier = provider.CreateEphemeralVerifier(algorithm, key.PublicKey.Span);
return await verifier.VerifyAsync(data, signature);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Signature verification failed with key {KeyId}", key.KeyId);
return false;
}
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
// DSSE PAE (Pre-Authentication Encoding)
// PAE(type, payload) = "DSSEv1" + len(type) + type + len(payload) + payload
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
using var ms = new System.IO.MemoryStream();
using var writer = new System.IO.BinaryWriter(ms);
// DSSEv1 prefix
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
writer.Write((long)typeBytes.Length);
writer.Write(Encoding.UTF8.GetBytes(" "));
writer.Write(typeBytes);
writer.Write(Encoding.UTF8.GetBytes(" "));
writer.Write((long)payload.Length);
writer.Write(Encoding.UTF8.GetBytes(" "));
writer.Write(payload);
return ms.ToArray();
}
private static byte[] ComputeDocumentDigest(VexRawDocument document)
{
return SHA256.HashData(document.Content.Span);
}
private static string ComputeCacheKey(string documentDigest, string cryptoProfile)
{
return $"vex-sig:{documentDigest}:{cryptoProfile}";
}
#endregion
}

View File

@@ -0,0 +1,175 @@
// InMemoryVerificationCacheService - In-memory cache for verification results
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// In-memory implementation of verification cache service.
/// For production, use ValkeyVerificationCacheService.
/// </summary>
public sealed class InMemoryVerificationCacheService : IVerificationCacheService
{
private readonly IMemoryCache _cache;
private readonly ConcurrentDictionary<string, HashSet<string>> _issuerKeyIndex;
private readonly ILogger<InMemoryVerificationCacheService> _logger;
private readonly VexSignatureVerifierOptions _options;
public InMemoryVerificationCacheService(
IMemoryCache cache,
IOptions<VexSignatureVerifierOptions> options,
ILogger<InMemoryVerificationCacheService> logger)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_issuerKeyIndex = new ConcurrentDictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc />
public Task<bool> TryGetAsync(
string key,
out VexSignatureVerificationResult? result,
CancellationToken ct = default)
{
if (_cache.TryGetValue(key, out var cached) && cached is VexSignatureVerificationResult cachedResult)
{
result = cachedResult;
return Task.FromResult(true);
}
result = null;
return Task.FromResult(false);
}
/// <inheritdoc />
public Task SetAsync(
string key,
VexSignatureVerificationResult result,
TimeSpan ttl,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(result);
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl,
Size = 1
};
_cache.Set(key, result, cacheOptions);
// Track keys by issuer for bulk invalidation
if (!string.IsNullOrEmpty(result.IssuerId))
{
var issuerKeys = _issuerKeyIndex.GetOrAdd(
result.IssuerId,
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase));
lock (issuerKeys)
{
issuerKeys.Add(key);
}
}
_logger.LogDebug("Cached verification result for key {Key} with TTL {Ttl}", key, ttl);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task InvalidateByIssuerAsync(
string issuerId,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
if (_issuerKeyIndex.TryRemove(issuerId, out var keys))
{
var keysSnapshot = keys.ToArray();
foreach (var key in keysSnapshot)
{
_cache.Remove(key);
}
_logger.LogInformation(
"Invalidated {Count} cached verification results for issuer {IssuerId}",
keysSnapshot.Length,
issuerId);
}
return Task.CompletedTask;
}
}
/// <summary>
/// Stub implementation for Valkey-backed verification cache.
/// Requires StackExchange.Redis or similar Valkey client.
/// </summary>
public sealed class ValkeyVerificationCacheService : IVerificationCacheService
{
private readonly ILogger<ValkeyVerificationCacheService> _logger;
private readonly string _keyPrefix;
public ValkeyVerificationCacheService(
ILogger<ValkeyVerificationCacheService> logger,
string keyPrefix = "trust-verdict:")
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_keyPrefix = keyPrefix;
}
/// <inheritdoc />
public Task<bool> TryGetAsync(
string key,
out VexSignatureVerificationResult? result,
CancellationToken ct = default)
{
// TODO: Implement Valkey/Redis lookup
// var db = _valkey.GetDatabase();
// var value = await db.StringGetAsync($"{_keyPrefix}{key}");
// if (value.IsNullOrEmpty) { result = null; return false; }
// result = JsonSerializer.Deserialize<VexSignatureVerificationResult>(value!);
// return true;
_logger.LogDebug("Valkey cache not implemented - cache miss for {Key}", key);
result = null;
return Task.FromResult(false);
}
/// <inheritdoc />
public Task SetAsync(
string key,
VexSignatureVerificationResult result,
TimeSpan ttl,
CancellationToken ct = default)
{
// TODO: Implement Valkey/Redis storage
// var db = _valkey.GetDatabase();
// var value = JsonSerializer.Serialize(result);
// await db.StringSetAsync($"{_keyPrefix}{key}", value, ttl);
_logger.LogDebug("Valkey cache not implemented - skipping cache for {Key}", key);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task InvalidateByIssuerAsync(
string issuerId,
CancellationToken ct = default)
{
// TODO: Implement bulk invalidation
// This would use Redis SCAN to find matching keys or maintain a secondary index
_logger.LogDebug("Valkey cache invalidation not implemented for issuer {IssuerId}", issuerId);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,162 @@
// VexSignatureVerifierOptions - Configuration for VEX Signature Verification
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Collections.Generic;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// Configuration options for VEX signature verification.
/// </summary>
public sealed class VexSignatureVerifierOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "VexSignatureVerification";
/// <summary>
/// Whether signature verification is enabled.
/// Default: false (feature flag for gradual rollout).
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Default cryptographic profile to use.
/// Options: "world", "fips", "gost", "sm", "kcmvp", "eidas"
/// Default: "world"
/// </summary>
public string DefaultProfile { get; set; } = "world";
/// <summary>
/// Preferred crypto provider name.
/// </summary>
public string? PreferredProvider { get; set; }
/// <summary>
/// Whether to require signatures on all documents.
/// If false, unsigned documents are allowed with a warning.
/// Default: false
/// </summary>
public bool RequireSignature { get; set; } = false;
/// <summary>
/// Whether to allow expired certificates.
/// Useful for historical verification.
/// Default: false
/// </summary>
public bool AllowExpiredCerts { get; set; } = false;
/// <summary>
/// Cache TTL for verification results.
/// Default: 4 hours
/// </summary>
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Maximum parallelism for batch verification.
/// Default: 8
/// </summary>
public int MaxBatchParallelism { get; set; } = 8;
/// <summary>
/// Clock tolerance for certificate validity checks.
/// Default: 5 minutes
/// </summary>
public TimeSpan ClockTolerance { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// IssuerDirectory configuration.
/// </summary>
public IssuerDirectoryClientOptions IssuerDirectory { get; set; } = new();
/// <summary>
/// Trust anchor configuration.
/// </summary>
public TrustAnchorOptions TrustAnchors { get; set; } = new();
/// <summary>
/// Behavior when verification fails.
/// </summary>
public VerificationFailureBehavior FailureBehavior { get; set; } = VerificationFailureBehavior.Warn;
}
/// <summary>
/// IssuerDirectory client configuration.
/// </summary>
public sealed class IssuerDirectoryClientOptions
{
/// <summary>
/// Service URL for IssuerDirectory API.
/// </summary>
public string? ServiceUrl { get; set; }
/// <summary>
/// Request timeout.
/// Default: 5 seconds
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Path to offline bundle for air-gapped deployments.
/// </summary>
public string? OfflineBundle { get; set; }
/// <summary>
/// Whether to use offline mode.
/// </summary>
public bool OfflineMode { get; set; } = false;
}
/// <summary>
/// Trust anchor configuration for keyless verification.
/// </summary>
public sealed class TrustAnchorOptions
{
/// <summary>
/// Paths to Fulcio root certificates.
/// </summary>
public List<string> FulcioRoots { get; set; } = new();
/// <summary>
/// Paths to Sigstore root certificates.
/// </summary>
public List<string> SigstoreRoots { get; set; } = new();
/// <summary>
/// Paths to custom trust anchors.
/// </summary>
public List<string> CustomRoots { get; set; } = new();
/// <summary>
/// Whether to auto-refresh trust anchors from TUF.
/// </summary>
public bool AutoRefresh { get; set; } = false;
/// <summary>
/// TUF repository URL for trust anchor updates.
/// </summary>
public string? TufRepository { get; set; }
}
/// <summary>
/// Behavior when signature verification fails.
/// </summary>
public enum VerificationFailureBehavior
{
/// <summary>
/// Allow the document but log a warning.
/// </summary>
Warn,
/// <summary>
/// Reject the document and fail ingestion.
/// </summary>
Block,
/// <summary>
/// Silently allow (not recommended for production).
/// </summary>
Allow
}

View File

@@ -0,0 +1,92 @@
// VexVerificationMetrics - Telemetry for VEX Signature Verification
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// OpenTelemetry metrics for VEX signature verification.
/// </summary>
public static class VexVerificationMetrics
{
private static readonly Meter Meter = new("StellaOps.Excititor.Verification", "1.0.0");
private static readonly Counter<long> VerificationCounter = Meter.CreateCounter<long>(
"excititor_vex_signature_verification_total",
description: "Total number of VEX signature verification attempts");
private static readonly Histogram<double> VerificationLatency = Meter.CreateHistogram<double>(
"excititor_vex_signature_verification_latency_seconds",
unit: "s",
description: "Latency of VEX signature verification operations");
private static readonly Counter<long> CacheHitCounter = Meter.CreateCounter<long>(
"excititor_vex_signature_cache_hits_total",
description: "Number of verification cache hits");
private static readonly Counter<long> CacheMissCounter = Meter.CreateCounter<long>(
"excititor_vex_signature_cache_misses_total",
description: "Number of verification cache misses");
private static readonly Counter<long> IssuerLookupCounter = Meter.CreateCounter<long>(
"excititor_vex_issuer_lookup_total",
description: "Number of IssuerDirectory lookups");
private static readonly Histogram<double> IssuerLookupLatency = Meter.CreateHistogram<double>(
"excititor_vex_issuer_lookup_latency_seconds",
unit: "s",
description: "Latency of IssuerDirectory lookups");
/// <summary>
/// Record a verification attempt.
/// </summary>
public static void RecordVerification(
VerificationMethod method,
bool success,
string cryptoProfile,
TimeSpan latency)
{
var tags = new TagList
{
{ "method", method.ToString().ToLowerInvariant() },
{ "outcome", success ? "verified" : "failed" },
{ "profile", cryptoProfile }
};
VerificationCounter.Add(1, tags);
VerificationLatency.Record(latency.TotalSeconds, tags);
}
/// <summary>
/// Record a cache hit.
/// </summary>
public static void RecordCacheHit()
{
CacheHitCounter.Add(1);
}
/// <summary>
/// Record a cache miss.
/// </summary>
public static void RecordCacheMiss()
{
CacheMissCounter.Add(1);
}
/// <summary>
/// Record an issuer lookup.
/// </summary>
public static void RecordIssuerLookup(bool found, TimeSpan latency)
{
var tags = new TagList
{
{ "found", found.ToString().ToLowerInvariant() }
};
IssuerLookupCounter.Add(1, tags);
IssuerLookupLatency.Record(latency.TotalSeconds, tags);
}
}

View File

@@ -0,0 +1,340 @@
// VEX Signature Verification Models
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Cryptography;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// Verification context populated by the orchestrator for signature verification.
/// </summary>
public sealed record VexVerificationContext
{
/// <summary>
/// Tenant identifier for issuer directory lookups.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Cryptographic profile to use (world, fips, gost, sm, etc.).
/// </summary>
public required string CryptoProfile { get; init; }
/// <summary>
/// Time of verification for certificate validity checks.
/// </summary>
public DateTimeOffset VerificationTime { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Whether to allow expired certificates (useful for historical verification).
/// </summary>
public bool AllowExpiredCerts { get; init; } = false;
/// <summary>
/// Whether to require a timestamp in the signature.
/// </summary>
public bool RequireTimestamp { get; init; } = false;
/// <summary>
/// Optional list of allowed issuer IDs. If null, all issuers are allowed.
/// </summary>
public IReadOnlyList<string>? AllowedIssuers { get; init; }
/// <summary>
/// Whether verification is required for unsigned documents.
/// </summary>
public bool RequireSignature { get; init; } = false;
/// <summary>
/// Clock tolerance for certificate validity checks.
/// </summary>
public TimeSpan ClockTolerance { get; init; } = TimeSpan.FromMinutes(5);
}
/// <summary>
/// Result of VEX document signature verification.
/// </summary>
public sealed record VexSignatureVerificationResult
{
/// <summary>
/// Digest of the verified document.
/// </summary>
public required string DocumentDigest { get; init; }
/// <summary>
/// Whether the signature was successfully verified.
/// </summary>
public required bool Verified { get; init; }
/// <summary>
/// Verification method used.
/// </summary>
public required VerificationMethod Method { get; init; }
/// <summary>
/// Key identifier used for verification, if applicable.
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Name of the issuer from IssuerDirectory.
/// </summary>
public string? IssuerName { get; init; }
/// <summary>
/// Issuer identifier from IssuerDirectory.
/// </summary>
public string? IssuerId { get; init; }
/// <summary>
/// Certificate subject for keyless attestations.
/// </summary>
public string? CertSubject { get; init; }
/// <summary>
/// Certificate fingerprint for audit purposes.
/// </summary>
public string? CertFingerprint { get; init; }
/// <summary>
/// Warnings encountered during verification.
/// </summary>
public IReadOnlyList<VerificationWarning>? Warnings { get; init; }
/// <summary>
/// Reason for verification failure, if applicable.
/// </summary>
public VerificationFailureReason? FailureReason { get; init; }
/// <summary>
/// Human-readable failure message.
/// </summary>
public string? FailureMessage { get; init; }
/// <summary>
/// Timestamp when verification was performed.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Rekor log index if transparency log was verified.
/// </summary>
public long? RekorLogIndex { get; init; }
/// <summary>
/// Rekor log ID.
/// </summary>
public string? RekorLogId { get; init; }
/// <summary>
/// Additional diagnostic information.
/// </summary>
public ImmutableDictionary<string, string>? Diagnostics { get; init; }
/// <summary>
/// Create a successful verification result.
/// </summary>
public static VexSignatureVerificationResult Success(
string documentDigest,
VerificationMethod method,
string? keyId = null,
string? issuerName = null,
string? issuerId = null,
string? certSubject = null,
long? rekorLogIndex = null,
string? rekorLogId = null)
{
return new VexSignatureVerificationResult
{
DocumentDigest = documentDigest,
Verified = true,
Method = method,
KeyId = keyId,
IssuerName = issuerName,
IssuerId = issuerId,
CertSubject = certSubject,
RekorLogIndex = rekorLogIndex,
RekorLogId = rekorLogId,
VerifiedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Create a failed verification result.
/// </summary>
public static VexSignatureVerificationResult Failure(
string documentDigest,
VerificationMethod method,
VerificationFailureReason reason,
string? message = null,
string? keyId = null)
{
return new VexSignatureVerificationResult
{
DocumentDigest = documentDigest,
Verified = false,
Method = method,
KeyId = keyId,
FailureReason = reason,
FailureMessage = message ?? reason.ToString(),
VerifiedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Create a result for documents without signatures.
/// </summary>
public static VexSignatureVerificationResult NoSignature(string documentDigest)
{
return new VexSignatureVerificationResult
{
DocumentDigest = documentDigest,
Verified = false,
Method = VerificationMethod.None,
FailureReason = VerificationFailureReason.NoSignature,
FailureMessage = "Document does not contain a signature",
VerifiedAt = DateTimeOffset.UtcNow
};
}
}
/// <summary>
/// Warning encountered during verification (non-fatal).
/// </summary>
public sealed record VerificationWarning(
string Code,
string Message,
string? Details = null);
/// <summary>
/// Verification method used for signature validation.
/// </summary>
public enum VerificationMethod
{
/// <summary>No signature present.</summary>
None,
/// <summary>Cosign signature with stored key.</summary>
Cosign,
/// <summary>Cosign keyless signature with Fulcio certificate.</summary>
CosignKeyless,
/// <summary>PGP signature.</summary>
Pgp,
/// <summary>X.509 certificate signature.</summary>
X509,
/// <summary>DSSE envelope with stored key.</summary>
Dsse,
/// <summary>DSSE envelope with keyless (Fulcio) certificate.</summary>
DsseKeyless,
/// <summary>In-toto attestation.</summary>
InToto
}
/// <summary>
/// Reason for signature verification failure.
/// </summary>
public enum VerificationFailureReason
{
/// <summary>Document has no signature.</summary>
NoSignature,
/// <summary>Signature is cryptographically invalid.</summary>
InvalidSignature,
/// <summary>Certificate has expired.</summary>
ExpiredCertificate,
/// <summary>Certificate has been revoked.</summary>
RevokedCertificate,
/// <summary>Issuer is not in the IssuerDirectory.</summary>
UnknownIssuer,
/// <summary>Issuer is known but not trusted.</summary>
UntrustedIssuer,
/// <summary>Signing key not found in IssuerDirectory.</summary>
KeyNotFound,
/// <summary>Certificate chain validation failed.</summary>
ChainValidationFailed,
/// <summary>Required timestamp is missing.</summary>
TimestampMissing,
/// <summary>Algorithm is not allowed in current crypto profile.</summary>
AlgorithmNotAllowed,
/// <summary>Key has been revoked.</summary>
KeyRevoked,
/// <summary>Key validity window has expired.</summary>
KeyExpired,
/// <summary>Key validity window has not yet started.</summary>
KeyNotYetValid,
/// <summary>Rekor transparency log verification failed.</summary>
TransparencyLogFailed,
/// <summary>Issuer is not in the allowed list.</summary>
IssuerNotAllowed,
/// <summary>Internal error during verification.</summary>
InternalError
}
/// <summary>
/// Extracted signature information from a VEX document.
/// </summary>
public sealed record ExtractedSignatureInfo
{
/// <summary>
/// Detected verification method.
/// </summary>
public required VerificationMethod Method { get; init; }
/// <summary>
/// Raw signature bytes (base64 decoded).
/// </summary>
public required ReadOnlyMemory<byte> SignatureBytes { get; init; }
/// <summary>
/// Key identifier if present.
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Algorithm identifier (e.g., "ecdsa-p256", "ed25519").
/// </summary>
public string? Algorithm { get; init; }
/// <summary>
/// Certificate chain for keyless verification.
/// </summary>
public IReadOnlyList<byte[]>? CertificateChain { get; init; }
/// <summary>
/// DSSE envelope if applicable.
/// </summary>
public string? DsseEnvelope { get; init; }
/// <summary>
/// Rekor bundle for transparency verification.
/// </summary>
public string? RekorBundle { get; init; }
/// <summary>
/// Timestamp from signature.
/// </summary>
public DateTimeOffset? SignedAt { get; init; }
}

View File

@@ -0,0 +1,154 @@
// VexVerificationServiceCollectionExtensions - DI Registration for Verification Services
// Part of SPRINT_1227_0004_0001: Activate VEX Signature Verification Pipeline
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Excititor.Core.Verification;
/// <summary>
/// Extension methods for registering VEX signature verification services.
/// </summary>
public static class VexVerificationServiceCollectionExtensions
{
/// <summary>
/// Add VEX signature verification services with feature flag support.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration for options binding.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddVexSignatureVerification(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind options
services.Configure<VexSignatureVerifierOptions>(
configuration.GetSection(VexSignatureVerifierOptions.SectionName));
// Register crypto profile selector
services.TryAddSingleton<ICryptoProfileSelector, CryptoProfileSelector>();
// Register cache service (in-memory by default)
services.TryAddSingleton<IVerificationCacheService, InMemoryVerificationCacheService>();
// Register IssuerDirectory client based on configuration
services.TryAddSingleton<IIssuerDirectoryClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexSignatureVerifierOptions>>().Value;
var logger = sp.GetRequiredService<ILogger<InMemoryIssuerDirectoryClient>>();
if (options.IssuerDirectory.OfflineMode || string.IsNullOrEmpty(options.IssuerDirectory.ServiceUrl))
{
// Use in-memory client for development/offline
return new InMemoryIssuerDirectoryClient(logger);
}
// Use HTTP client for production
var httpLogger = sp.GetRequiredService<ILogger<HttpIssuerDirectoryClient>>();
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("IssuerDirectory");
return new HttpIssuerDirectoryClient(httpClient, options.IssuerDirectory, httpLogger);
});
// Register verifier based on feature flag
// The actual registration happens at runtime to support feature flag
services.TryAddSingleton<ProductionVexSignatureVerifier>();
services.TryAddSingleton<IVexSignatureVerifierV2>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexSignatureVerifierOptions>>().Value;
if (options.Enabled)
{
return sp.GetRequiredService<ProductionVexSignatureVerifier>();
}
// Return a noop verifier that delegates to the V1 interface pattern
return new NoopVexSignatureVerifierV2();
});
return services;
}
/// <summary>
/// Add VEX signature verification with Valkey cache support.
/// </summary>
public static IServiceCollection AddVexSignatureVerificationWithValkey(
this IServiceCollection services,
IConfiguration configuration,
string valkeyConnectionString)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Base registration
services.AddVexSignatureVerification(configuration);
// Replace in-memory cache with Valkey
services.RemoveAll<IVerificationCacheService>();
services.AddSingleton<IVerificationCacheService>(sp =>
{
var logger = sp.GetRequiredService<ILogger<ValkeyVerificationCacheService>>();
return new ValkeyVerificationCacheService(logger);
});
return services;
}
}
/// <summary>
/// Noop implementation of IVexSignatureVerifierV2 for when feature is disabled.
/// </summary>
internal sealed class NoopVexSignatureVerifierV2 : IVexSignatureVerifierV2
{
public Task<VexSignatureVerificationResult> VerifyAsync(
VexRawDocument document,
VexVerificationContext context,
CancellationToken ct = default)
{
// Return a result indicating no verification was performed
return Task.FromResult(new VexSignatureVerificationResult
{
DocumentDigest = document.Digest,
Verified = true, // Allow through when disabled
Method = VerificationMethod.None,
Warnings = new[]
{
new VerificationWarning(
"VERIFICATION_DISABLED",
"Signature verification is disabled by configuration")
},
VerifiedAt = DateTimeOffset.UtcNow
});
}
public async Task<IReadOnlyList<VexSignatureVerificationResult>> VerifyBatchAsync(
IEnumerable<VexRawDocument> documents,
VexVerificationContext context,
CancellationToken ct = default)
{
var results = new List<VexSignatureVerificationResult>();
foreach (var doc in documents)
{
results.Add(await VerifyAsync(doc, context, ct));
}
return results;
}
public Task<bool> IsKeyRevokedAsync(
string keyId,
string tenantId,
CancellationToken ct = default)
{
// When disabled, assume keys are not revoked
return Task.FromResult(false);
}
}

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - serializer includes VexConsensus for backward compatibility
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.Serialization;

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - VexConsensusHold holds deprecated VexConsensus during transition
namespace StellaOps.Excititor.Core;
public sealed record VexConsensusHold

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - exporter uses VexConsensus in VexExportRequest during transition
using System;
using System.Collections.Immutable;
using System.IO;

View File

@@ -8,14 +8,14 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Policy\StellaOps.Excititor.Policy.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - export uses VexConsensus during transition
using System;
using System.Collections.Generic;
using System.Collections.Immutable;

View File

@@ -7,8 +7,8 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />

View File

@@ -191,7 +191,7 @@ public sealed class CycloneDxExporter : IVexExporter
return null;
}
return ImmutableArray.Create(new CycloneDxAffectVersion(version.Trim(), range: null, status: null));
return ImmutableArray.Create(new CycloneDxAffectVersion(version.Trim(), Range: null, Status: null));
}
private static CycloneDxSource? BuildSource(VexClaim claim)

View File

@@ -10,8 +10,8 @@
<InternalsVisibleTo Include="StellaOps.Excititor.Formats.CycloneDX.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />

View File

@@ -27,7 +27,7 @@ public static class MergeTraceWriter
{
sb.AppendLine($" Conflict: {trace.LeftSource} ({trace.LeftStatus}) vs {trace.RightSource} ({trace.RightStatus})");
sb.AppendLine(
$" Trust: {trace.LeftTrust.ToString(\"P0\", CultureInfo.InvariantCulture)} vs {trace.RightTrust.ToString(\"P0\", CultureInfo.InvariantCulture)}");
$" Trust: {trace.LeftTrust.ToString("P0", CultureInfo.InvariantCulture)} vs {trace.RightTrust.ToString("P0", CultureInfo.InvariantCulture)}");
sb.AppendLine($" Resolution: {trace.Explanation}");
sb.AppendLine();
}

View File

@@ -7,8 +7,8 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Excititor.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for Excititor module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// </summary>
public class ExcititorDbContext : DbContext
{
public ExcititorDbContext(DbContextOptions<ExcititorDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("vex");
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -3,25 +3,26 @@ using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.Storage.Postgres.Repositories;
using StellaOps.Excititor.Persistence.Postgres;
using StellaOps.Excititor.Persistence.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Excititor.Storage.Postgres;
namespace StellaOps.Excititor.Persistence.Extensions;
/// <summary>
/// Extension methods for configuring Excititor PostgreSQL storage services.
/// Extension methods for configuring Excititor persistence services.
/// </summary>
public static class ServiceCollectionExtensions
public static class ExcititorPersistenceExtensions
{
/// <summary>
/// Adds Excititor PostgreSQL storage services.
/// Adds Excititor PostgreSQL persistence services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddExcititorPostgresStorage(
public static IServiceCollection AddExcititorPersistence(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:Excititor")
@@ -50,12 +51,12 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Adds Excititor PostgreSQL storage services with explicit options.
/// Adds Excititor PostgreSQL persistence services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddExcititorPostgresStorage(
public static IServiceCollection AddExcititorPersistence(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{

View File

@@ -0,0 +1,419 @@
-- Excititor Consolidated Schema Migration 001: Initial Schema
-- Version: 1.0.0
-- Date: 2025-12-27
--
-- This migration creates the complete Excititor/VEX schema from scratch.
-- It consolidates previously separate migrations (001-006) into a single
-- idempotent schema definition.
--
-- Archives of incremental migrations are preserved in: _archived/pre_1.0/
--
-- Target: Fresh empty database
-- Prerequisites: PostgreSQL >= 16
BEGIN;
-- ============================================================================
-- SECTION 1: Schema Creation
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS vex;
CREATE SCHEMA IF NOT EXISTS vex_app;
CREATE SCHEMA IF NOT EXISTS excititor;
-- ============================================================================
-- SECTION 2: Helper Functions
-- ============================================================================
-- Refresh updated_at whenever rows change
CREATE OR REPLACE FUNCTION vex.touch_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Tenant context helper function for Row-Level Security
CREATE OR REPLACE FUNCTION vex_app.require_current_tenant()
RETURNS TEXT
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
DECLARE
v_tenant TEXT;
BEGIN
v_tenant := current_setting('app.tenant_id', true);
IF v_tenant IS NULL OR v_tenant = '' THEN
RAISE EXCEPTION 'app.tenant_id session variable not set'
USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', ''<tenant>'', false)',
ERRCODE = 'P0001';
END IF;
RETURN v_tenant;
END;
$$;
REVOKE ALL ON FUNCTION vex_app.require_current_tenant() FROM PUBLIC;
-- ============================================================================
-- SECTION 3: VEX Linkset Tables (Append-Only Semantics)
-- ============================================================================
-- Core linkset table (append-only semantics; updated_at is refreshed on append)
CREATE TABLE vex.linksets (
linkset_id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
vulnerability_id TEXT NOT NULL,
product_key TEXT NOT NULL,
scope JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant, vulnerability_id, product_key)
);
CREATE INDEX idx_linksets_updated ON vex.linksets (tenant, updated_at DESC);
-- Trigger to update updated_at on linksets
CREATE TRIGGER trg_linksets_touch_updated_at
BEFORE UPDATE ON vex.linksets
FOR EACH ROW EXECUTE FUNCTION vex.touch_updated_at();
-- Observation references recorded per linkset (immutable; deduplicated)
CREATE TABLE vex.linkset_observations (
id BIGSERIAL PRIMARY KEY,
linkset_id TEXT NOT NULL REFERENCES vex.linksets(linkset_id) ON DELETE CASCADE,
observation_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('affected', 'not_affected', 'fixed', 'under_investigation')),
confidence NUMERIC(4,3),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (linkset_id, observation_id, provider_id, status)
);
CREATE INDEX idx_linkset_observations_linkset ON vex.linkset_observations (linkset_id);
CREATE INDEX idx_linkset_observations_provider ON vex.linkset_observations (linkset_id, provider_id);
CREATE INDEX idx_linkset_observations_status ON vex.linkset_observations (linkset_id, status);
-- Disagreements/conflicts recorded per linkset (immutable; deduplicated)
CREATE TABLE vex.linkset_disagreements (
id BIGSERIAL PRIMARY KEY,
linkset_id TEXT NOT NULL REFERENCES vex.linksets(linkset_id) ON DELETE CASCADE,
provider_id TEXT NOT NULL,
status TEXT NOT NULL,
justification TEXT,
confidence NUMERIC(4,3),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (linkset_id, provider_id, status, justification)
);
CREATE INDEX idx_linkset_disagreements_linkset ON vex.linkset_disagreements (linkset_id);
-- Append-only mutation log for deterministic replay/audit
CREATE TABLE vex.linkset_mutations (
sequence_number BIGSERIAL PRIMARY KEY,
linkset_id TEXT NOT NULL REFERENCES vex.linksets(linkset_id) ON DELETE CASCADE,
mutation_type TEXT NOT NULL CHECK (mutation_type IN ('linkset_created', 'observation_added', 'disagreement_added')),
observation_id TEXT,
provider_id TEXT,
status TEXT,
confidence NUMERIC(4,3),
justification TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_linkset_mutations_linkset ON vex.linkset_mutations (linkset_id, sequence_number);
-- ============================================================================
-- SECTION 4: VEX Raw Document Storage
-- ============================================================================
-- Raw documents (append-only) with generated columns for JSONB hot fields
CREATE TABLE vex.vex_raw_documents (
digest TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
provider_id TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('openvex','csaf','cyclonedx','custom','unknown')),
source_uri TEXT NOT NULL,
etag TEXT NULL,
retrieved_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
supersedes_digest TEXT NULL REFERENCES vex.vex_raw_documents(digest),
content_json JSONB NOT NULL,
content_size_bytes INT NOT NULL,
metadata_json JSONB NOT NULL,
provenance_json JSONB NOT NULL,
inline_payload BOOLEAN NOT NULL DEFAULT TRUE,
-- Generated columns for efficient querying (from migration 004)
doc_format_version TEXT GENERATED ALWAYS AS (metadata_json->>'formatVersion') STORED,
doc_tool_name TEXT GENERATED ALWAYS AS (metadata_json->>'toolName') STORED,
doc_tool_version TEXT GENERATED ALWAYS AS (metadata_json->>'toolVersion') STORED,
doc_author TEXT GENERATED ALWAYS AS (provenance_json->>'author') STORED,
doc_timestamp TIMESTAMPTZ GENERATED ALWAYS AS ((provenance_json->>'timestamp')::timestamptz) STORED,
UNIQUE (tenant, provider_id, source_uri, COALESCE(etag, ''))
);
-- Core indexes on vex_raw_documents
CREATE INDEX idx_vex_raw_documents_tenant_retrieved ON vex.vex_raw_documents (tenant, retrieved_at DESC, digest);
CREATE INDEX idx_vex_raw_documents_provider ON vex.vex_raw_documents (tenant, provider_id, retrieved_at DESC);
CREATE INDEX idx_vex_raw_documents_supersedes ON vex.vex_raw_documents (tenant, supersedes_digest);
CREATE INDEX idx_vex_raw_documents_metadata ON vex.vex_raw_documents USING GIN (metadata_json);
CREATE INDEX idx_vex_raw_documents_provenance ON vex.vex_raw_documents USING GIN (provenance_json);
-- Indexes on generated columns for efficient filtering
CREATE INDEX idx_vex_raw_docs_format_version ON vex.vex_raw_documents (doc_format_version) WHERE doc_format_version IS NOT NULL;
CREATE INDEX idx_vex_raw_docs_tool_name ON vex.vex_raw_documents (tenant, doc_tool_name) WHERE doc_tool_name IS NOT NULL;
CREATE INDEX idx_vex_raw_docs_author ON vex.vex_raw_documents (tenant, doc_author) WHERE doc_author IS NOT NULL;
CREATE INDEX idx_vex_raw_docs_tool_time ON vex.vex_raw_documents (tenant, doc_tool_name, doc_timestamp DESC) WHERE doc_tool_name IS NOT NULL;
CREATE INDEX idx_vex_raw_docs_listing ON vex.vex_raw_documents (tenant, retrieved_at DESC) INCLUDE (format, doc_format_version, doc_tool_name, doc_author);
-- Large payloads stored separately when inline threshold exceeded
CREATE TABLE vex.vex_raw_blobs (
digest TEXT PRIMARY KEY REFERENCES vex.vex_raw_documents(digest) ON DELETE CASCADE,
payload BYTEA NOT NULL,
payload_hash TEXT NOT NULL
);
-- Optional attachment support
CREATE TABLE vex.vex_raw_attachments (
digest TEXT REFERENCES vex.vex_raw_documents(digest) ON DELETE CASCADE,
name TEXT NOT NULL,
media_type TEXT NOT NULL,
payload BYTEA NOT NULL,
payload_hash TEXT NOT NULL,
PRIMARY KEY (digest, name)
);
-- ============================================================================
-- SECTION 5: Timeline Events (Partitioned Table)
-- ============================================================================
-- Partitioned timeline events table (monthly by occurred_at)
CREATE TABLE vex.timeline_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
project_id UUID,
event_type TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
actor TEXT,
details JSONB DEFAULT '{}',
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
COMMENT ON TABLE vex.timeline_events IS
'VEX timeline events. Partitioned monthly by occurred_at.';
-- Create initial partitions dynamically (past 6 months + 4 months ahead)
DO $$
DECLARE
v_start DATE;
v_end DATE;
v_partition_name TEXT;
BEGIN
-- Start from 6 months ago
v_start := date_trunc('month', NOW() - INTERVAL '6 months')::DATE;
-- Create partitions until 4 months ahead
WHILE v_start <= date_trunc('month', NOW() + INTERVAL '4 months')::DATE LOOP
v_end := (v_start + INTERVAL '1 month')::DATE;
v_partition_name := 'timeline_events_' || to_char(v_start, 'YYYY_MM');
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = 'vex' AND c.relname = v_partition_name
) THEN
EXECUTE format(
'CREATE TABLE vex.%I PARTITION OF vex.timeline_events
FOR VALUES FROM (%L) TO (%L)',
v_partition_name, v_start, v_end
);
RAISE NOTICE 'Created partition vex.%', v_partition_name;
END IF;
v_start := v_end;
END LOOP;
END
$$;
-- Create default partition for any data outside defined ranges
CREATE TABLE IF NOT EXISTS vex.timeline_events_default
PARTITION OF vex.timeline_events DEFAULT;
-- Indexes on partitioned timeline_events table
CREATE INDEX ix_timeline_part_tenant_time ON vex.timeline_events (tenant_id, occurred_at DESC);
CREATE INDEX ix_timeline_part_entity ON vex.timeline_events (entity_type, entity_id);
CREATE INDEX ix_timeline_part_project ON vex.timeline_events (project_id) WHERE project_id IS NOT NULL;
CREATE INDEX ix_timeline_part_event_type ON vex.timeline_events (event_type, occurred_at DESC);
CREATE INDEX ix_timeline_part_occurred_at_brin ON vex.timeline_events USING BRIN (occurred_at) WITH (pages_per_range = 32);
-- ============================================================================
-- SECTION 6: Excititor Calibration Tables
-- ============================================================================
CREATE TABLE excititor.calibration_manifests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
manifest_id TEXT NOT NULL UNIQUE,
tenant TEXT NOT NULL,
epoch_number INTEGER NOT NULL,
epoch_start TIMESTAMPTZ NOT NULL,
epoch_end TIMESTAMPTZ NOT NULL,
metrics_json JSONB NOT NULL,
manifest_digest TEXT NOT NULL,
signature TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_at TIMESTAMPTZ,
UNIQUE (tenant, epoch_number)
);
CREATE TABLE excititor.calibration_adjustments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
manifest_id TEXT NOT NULL REFERENCES excititor.calibration_manifests(manifest_id),
source_id TEXT NOT NULL,
old_provenance DOUBLE PRECISION NOT NULL,
old_coverage DOUBLE PRECISION NOT NULL,
old_replayability DOUBLE PRECISION NOT NULL,
new_provenance DOUBLE PRECISION NOT NULL,
new_coverage DOUBLE PRECISION NOT NULL,
new_replayability DOUBLE PRECISION NOT NULL,
delta DOUBLE PRECISION NOT NULL,
reason TEXT NOT NULL,
sample_count INTEGER NOT NULL,
accuracy_before DOUBLE PRECISION NOT NULL,
accuracy_after DOUBLE PRECISION NOT NULL
);
CREATE TABLE excititor.source_trust_vectors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant TEXT NOT NULL,
source_id TEXT NOT NULL,
provenance DOUBLE PRECISION NOT NULL,
coverage DOUBLE PRECISION NOT NULL,
replayability DOUBLE PRECISION NOT NULL,
calibration_manifest_id TEXT REFERENCES excititor.calibration_manifests(manifest_id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant, source_id)
);
CREATE INDEX idx_calibration_tenant_epoch ON excititor.calibration_manifests(tenant, epoch_number DESC);
CREATE INDEX idx_calibration_adjustments_manifest ON excititor.calibration_adjustments(manifest_id);
CREATE INDEX idx_source_vectors_tenant ON excititor.source_trust_vectors(tenant);
-- ============================================================================
-- SECTION 7: Row-Level Security Policies
-- ============================================================================
-- vex.linksets
ALTER TABLE vex.linksets ENABLE ROW LEVEL SECURITY;
ALTER TABLE vex.linksets FORCE ROW LEVEL SECURITY;
CREATE POLICY linksets_tenant_isolation ON vex.linksets
FOR ALL
USING (tenant = vex_app.require_current_tenant())
WITH CHECK (tenant = vex_app.require_current_tenant());
-- vex.linkset_observations (inherits tenant via FK to linksets)
ALTER TABLE vex.linkset_observations ENABLE ROW LEVEL SECURITY;
ALTER TABLE vex.linkset_observations FORCE ROW LEVEL SECURITY;
CREATE POLICY linkset_observations_tenant_isolation ON vex.linkset_observations
FOR ALL
USING (
linkset_id IN (
SELECT linkset_id FROM vex.linksets
WHERE tenant = vex_app.require_current_tenant()
)
);
-- vex.linkset_disagreements (inherits tenant via FK to linksets)
ALTER TABLE vex.linkset_disagreements ENABLE ROW LEVEL SECURITY;
ALTER TABLE vex.linkset_disagreements FORCE ROW LEVEL SECURITY;
CREATE POLICY linkset_disagreements_tenant_isolation ON vex.linkset_disagreements
FOR ALL
USING (
linkset_id IN (
SELECT linkset_id FROM vex.linksets
WHERE tenant = vex_app.require_current_tenant()
)
);
-- vex.linkset_mutations (inherits tenant via FK to linksets)
ALTER TABLE vex.linkset_mutations ENABLE ROW LEVEL SECURITY;
ALTER TABLE vex.linkset_mutations FORCE ROW LEVEL SECURITY;
CREATE POLICY linkset_mutations_tenant_isolation ON vex.linkset_mutations
FOR ALL
USING (
linkset_id IN (
SELECT linkset_id FROM vex.linksets
WHERE tenant = vex_app.require_current_tenant()
)
);
-- vex.timeline_events
ALTER TABLE vex.timeline_events ENABLE ROW LEVEL SECURITY;
-- ============================================================================
-- SECTION 8: Admin Roles
-- ============================================================================
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'vex_admin') THEN
CREATE ROLE vex_admin WITH NOLOGIN BYPASSRLS;
END IF;
END
$$;
-- ============================================================================
-- SECTION 9: Partition Management Registration (Optional)
-- ============================================================================
-- Register timeline_events with partition_mgmt if the schema exists
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'partition_mgmt') THEN
INSERT INTO partition_mgmt.managed_tables (
schema_name,
table_name,
partition_key,
partition_type,
retention_months,
months_ahead,
created_at
) VALUES (
'vex',
'timeline_events',
'occurred_at',
'monthly',
36, -- 3 year retention
4, -- Create 4 months ahead
NOW()
) ON CONFLICT (schema_name, table_name) DO NOTHING;
END IF;
END
$$;
COMMIT;
-- ============================================================================
-- Migration Verification (run manually to confirm):
-- ============================================================================
--
-- -- Verify all schemas exist:
-- SELECT nspname FROM pg_namespace WHERE nspname IN ('vex', 'vex_app', 'excititor');
--
-- -- Verify all tables:
-- SELECT schemaname, tablename FROM pg_tables
-- WHERE schemaname IN ('vex', 'excititor') ORDER BY schemaname, tablename;
--
-- -- Verify partitions:
-- SELECT tableoid::regclass FROM vex.timeline_events LIMIT 1;
--
-- -- Verify RLS is enabled:
-- SELECT relname, relrowsecurity, relforcerowsecurity
-- FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid
-- WHERE n.nspname = 'vex' AND c.relkind = 'r';
--
-- -- Verify generated columns:
-- SELECT column_name, is_generated
-- FROM information_schema.columns
-- WHERE table_schema = 'vex' AND table_name = 'vex_raw_documents'
-- AND is_generated = 'ALWAYS';

View File

@@ -4,7 +4,7 @@ using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Excititor.Storage.Postgres;
namespace StellaOps.Excititor.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for the Excititor (VEX) module.

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Excititor.Storage.Postgres.Models;
namespace StellaOps.Excititor.Persistence.Postgres.Models;
/// <summary>
/// Represents a project entity in the vex schema.

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Excititor.Storage.Postgres.Models;
namespace StellaOps.Excititor.Persistence.Postgres.Models;
/// <summary>
/// VEX status values per OpenVEX specification.

View File

@@ -1,6 +1,6 @@
using StellaOps.Excititor.Storage.Postgres.Models;
using StellaOps.Excititor.Persistence.Postgres.Models;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// Repository interface for VEX statement operations.

View File

@@ -4,7 +4,7 @@ using Npgsql;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed append-only checkpoint store for deterministic connector state persistence.

View File

@@ -4,7 +4,7 @@ using Npgsql;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IAppendOnlyLinksetStore"/> backed by append-only tables.

View File

@@ -10,7 +10,7 @@ using NpgsqlTypes;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed connector state repository for orchestrator checkpoints and heartbeats.

View File

@@ -5,7 +5,7 @@ using Npgsql;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed store for VEX attestations.

View File

@@ -0,0 +1,407 @@
// -----------------------------------------------------------------------------
// PostgresVexDeltaRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-008)
// Updated: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-026)
// Task: Implement IVexDeltaRepository with PostgreSQL + attestation digest support
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Excititor.Persistence.Repositories;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IVexDeltaRepository"/>.
/// Uses the vex_deltas table for storing VEX status changes.
/// </summary>
public sealed class PostgresVexDeltaRepository : RepositoryBase<ExcititorDataSource>, IVexDeltaRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresVexDeltaRepository(ExcititorDataSource dataSource, ILogger<PostgresVexDeltaRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc/>
public async Task<bool> AddAsync(VexDelta delta, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
INSERT INTO vex.deltas (
id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
)
VALUES (
@id, @from_artifact_digest, @to_artifact_digest, @cve,
@from_status, @to_status, @rationale, @replay_hash,
@attestation_digest, @tenant_id, @created_at
)
ON CONFLICT (from_artifact_digest, to_artifact_digest, cve, tenant_id) DO UPDATE SET
from_status = EXCLUDED.from_status,
to_status = EXCLUDED.to_status,
rationale = EXCLUDED.rationale,
replay_hash = EXCLUDED.replay_hash,
attestation_digest = EXCLUDED.attestation_digest,
created_at = EXCLUDED.created_at
RETURNING id";
await using var connection = await DataSource.OpenConnectionAsync(delta.TenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", delta.Id);
AddParameter(command, "@from_artifact_digest", delta.FromArtifactDigest);
AddParameter(command, "@to_artifact_digest", delta.ToArtifactDigest);
AddParameter(command, "@cve", delta.Cve);
AddParameter(command, "@from_status", delta.FromStatus);
AddParameter(command, "@to_status", delta.ToStatus);
AddJsonParameter(command, "@rationale", delta.Rationale);
AddParameter(command, "@replay_hash", delta.ReplayHash ?? (object)DBNull.Value);
AddParameter(command, "@attestation_digest", delta.AttestationDigest ?? (object)DBNull.Value);
AddParameter(command, "@tenant_id", delta.TenantId);
AddParameter(command, "@created_at", delta.CreatedAt);
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is not null;
}
/// <inheritdoc/>
public async Task<int> AddBatchAsync(IEnumerable<VexDelta> deltas, CancellationToken ct = default)
{
var deltaList = deltas.ToList();
if (deltaList.Count == 0)
{
return 0;
}
await EnsureTableAsync(ct).ConfigureAwait(false);
var tenantId = deltaList[0].TenantId;
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
var count = 0;
foreach (var delta in deltaList)
{
const string sql = @"
INSERT INTO vex.deltas (
id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
)
VALUES (
@id, @from_artifact_digest, @to_artifact_digest, @cve,
@from_status, @to_status, @rationale, @replay_hash,
@attestation_digest, @tenant_id, @created_at
)
ON CONFLICT (from_artifact_digest, to_artifact_digest, cve, tenant_id) DO NOTHING";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddParameter(command, "@id", delta.Id);
AddParameter(command, "@from_artifact_digest", delta.FromArtifactDigest);
AddParameter(command, "@to_artifact_digest", delta.ToArtifactDigest);
AddParameter(command, "@cve", delta.Cve);
AddParameter(command, "@from_status", delta.FromStatus);
AddParameter(command, "@to_status", delta.ToStatus);
AddJsonParameter(command, "@rationale", delta.Rationale);
AddParameter(command, "@replay_hash", delta.ReplayHash ?? (object)DBNull.Value);
AddParameter(command, "@attestation_digest", delta.AttestationDigest ?? (object)DBNull.Value);
AddParameter(command, "@tenant_id", delta.TenantId);
AddParameter(command, "@created_at", delta.CreatedAt);
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
count += affected;
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
return count;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<VexDelta>> GetDeltasAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
FROM vex.deltas
WHERE from_artifact_digest = @from_digest
AND to_artifact_digest = @to_digest
AND tenant_id = @tenant_id
ORDER BY cve, created_at DESC";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@from_digest", fromDigest);
AddParameter(command, "@to_digest", toDigest);
AddParameter(command, "@tenant_id", tenantId);
return await ReadDeltasAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<VexDelta>> GetDeltasByCveAsync(string cve, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
FROM vex.deltas
WHERE cve = @cve AND tenant_id = @tenant_id
ORDER BY created_at DESC";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@cve", cve);
AddParameter(command, "@tenant_id", tenantId);
return await ReadDeltasAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<VexDelta>> GetDeltasForArtifactAsync(string artifactDigest, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
FROM vex.deltas
WHERE (from_artifact_digest = @digest OR to_artifact_digest = @digest)
AND tenant_id = @tenant_id
ORDER BY created_at DESC";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@digest", artifactDigest);
AddParameter(command, "@tenant_id", tenantId);
return await ReadDeltasAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<VexDelta>> GetRecentDeltasAsync(string tenantId, int limit = 100, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
FROM vex.deltas
WHERE tenant_id = @tenant_id
ORDER BY created_at DESC
LIMIT @limit";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant_id", tenantId);
AddParameter(command, "@limit", limit);
return await ReadDeltasAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<VexDelta?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
FROM vex.deltas
WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", id);
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
return MapDelta(reader);
}
return null;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<VexDelta>> GetUnAttestedDeltasAsync(string tenantId, int limit = 100, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, from_artifact_digest, to_artifact_digest, cve,
from_status, to_status, rationale, replay_hash,
attestation_digest, tenant_id, created_at
FROM vex.deltas
WHERE tenant_id = @tenant_id
AND attestation_digest IS NULL
ORDER BY created_at ASC
LIMIT @limit";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant_id", tenantId);
AddParameter(command, "@limit", limit);
return await ReadDeltasAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<bool> UpdateAttestationDigestAsync(Guid deltaId, string attestationDigest, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
UPDATE vex.deltas
SET attestation_digest = @attestation_digest
WHERE id = @id
RETURNING id";
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", deltaId);
AddParameter(command, "@attestation_digest", attestationDigest);
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is not null;
}
/// <inheritdoc/>
public async Task<int> DeleteForArtifactAsync(string artifactDigest, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
DELETE FROM vex.deltas
WHERE (from_artifact_digest = @digest OR to_artifact_digest = @digest)
AND tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@digest", artifactDigest);
AddParameter(command, "@tenant_id", tenantId);
return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private async Task<List<VexDelta>> ReadDeltasAsync(NpgsqlCommand command, CancellationToken ct)
{
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
var results = new List<VexDelta>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(MapDelta(reader));
}
return results;
}
private static VexDelta MapDelta(NpgsqlDataReader reader)
{
var rationaleJson = reader.IsDBNull(reader.GetOrdinal("rationale"))
? null
: reader.GetString(reader.GetOrdinal("rationale"));
var rationale = string.IsNullOrEmpty(rationaleJson)
? null
: JsonSerializer.Deserialize<VexDeltaRationale>(rationaleJson, JsonOptions);
return new VexDelta
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
FromArtifactDigest = reader.GetString(reader.GetOrdinal("from_artifact_digest")),
ToArtifactDigest = reader.GetString(reader.GetOrdinal("to_artifact_digest")),
Cve = reader.GetString(reader.GetOrdinal("cve")),
FromStatus = reader.GetString(reader.GetOrdinal("from_status")),
ToStatus = reader.GetString(reader.GetOrdinal("to_status")),
Rationale = rationale,
ReplayHash = reader.IsDBNull(reader.GetOrdinal("replay_hash"))
? null
: reader.GetString(reader.GetOrdinal("replay_hash")),
AttestationDigest = reader.IsDBNull(reader.GetOrdinal("attestation_digest"))
? null
: reader.GetString(reader.GetOrdinal("attestation_digest")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at"))
};
}
private void AddJsonParameter(NpgsqlCommand command, string name, object? value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.NpgsqlDbType = NpgsqlDbType.Jsonb;
parameter.Value = value is null ? DBNull.Value : JsonSerializer.Serialize(value, JsonOptions);
command.Parameters.Add(parameter);
}
private async Task EnsureTableAsync(CancellationToken ct)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS vex;
CREATE TABLE IF NOT EXISTS vex.deltas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_artifact_digest TEXT NOT NULL,
to_artifact_digest TEXT NOT NULL,
cve TEXT NOT NULL,
from_status TEXT NOT NULL,
to_status TEXT NOT NULL,
rationale JSONB,
replay_hash TEXT,
attestation_digest TEXT,
tenant_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_vex_delta UNIQUE (from_artifact_digest, to_artifact_digest, cve, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_vex_deltas_from ON vex.deltas (from_artifact_digest, tenant_id);
CREATE INDEX IF NOT EXISTS idx_vex_deltas_to ON vex.deltas (to_artifact_digest, tenant_id);
CREATE INDEX IF NOT EXISTS idx_vex_deltas_cve ON vex.deltas (cve, tenant_id);
CREATE INDEX IF NOT EXISTS idx_vex_deltas_tenant ON vex.deltas (tenant_id);
CREATE INDEX IF NOT EXISTS idx_vex_deltas_created ON vex.deltas (created_at DESC);
";
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -7,7 +7,7 @@ using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed store for VEX observations with complex nested structures.

View File

@@ -6,7 +6,7 @@ using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed provider store for VEX provider registry.

View File

@@ -12,7 +12,7 @@ using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IVexRawStore"/> for raw document and blob storage.

View File

@@ -5,7 +5,7 @@ using Npgsql;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed store for VEX timeline events.

View File

@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Excititor.Storage.Postgres.Models;
using StellaOps.Excititor.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
namespace StellaOps.Excititor.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for VEX statement operations.

View File

@@ -0,0 +1,177 @@
// -----------------------------------------------------------------------------
// IVexDeltaRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-008)
// Updated: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-026)
// Task: Implement IVexDeltaRepository interface + attestation digest support
// -----------------------------------------------------------------------------
namespace StellaOps.Excititor.Persistence.Repositories;
/// <summary>
/// Repository for VEX status deltas between artifact versions.
/// Tracks how VEX status changes across the SBOM lineage graph.
/// </summary>
public interface IVexDeltaRepository
{
/// <summary>
/// Adds a VEX delta record.
/// </summary>
Task<bool> AddAsync(VexDelta delta, CancellationToken ct = default);
/// <summary>
/// Adds multiple VEX deltas in a batch.
/// </summary>
Task<int> AddBatchAsync(IEnumerable<VexDelta> deltas, CancellationToken ct = default);
/// <summary>
/// Gets a VEX delta by ID.
/// </summary>
Task<VexDelta?> GetByIdAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Gets VEX deltas between two artifact versions.
/// </summary>
Task<IReadOnlyList<VexDelta>> GetDeltasAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default);
/// <summary>
/// Gets all VEX deltas for a specific CVE.
/// </summary>
Task<IReadOnlyList<VexDelta>> GetDeltasByCveAsync(string cve, string tenantId, CancellationToken ct = default);
/// <summary>
/// Gets VEX deltas for an artifact (as source or target).
/// </summary>
Task<IReadOnlyList<VexDelta>> GetDeltasForArtifactAsync(string artifactDigest, string tenantId, CancellationToken ct = default);
/// <summary>
/// Gets recent VEX deltas across all artifacts.
/// </summary>
Task<IReadOnlyList<VexDelta>> GetRecentDeltasAsync(string tenantId, int limit = 100, CancellationToken ct = default);
/// <summary>
/// Gets VEX deltas that don't have an attestation yet.
/// </summary>
Task<IReadOnlyList<VexDelta>> GetUnAttestedDeltasAsync(string tenantId, int limit = 100, CancellationToken ct = default);
/// <summary>
/// Updates the attestation digest for a VEX delta.
/// </summary>
Task<bool> UpdateAttestationDigestAsync(Guid deltaId, string attestationDigest, CancellationToken ct = default);
/// <summary>
/// Deletes VEX deltas for an artifact.
/// </summary>
Task<int> DeleteForArtifactAsync(string artifactDigest, string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Represents a VEX status delta between two artifact versions.
/// </summary>
public sealed record VexDelta
{
/// <summary>
/// Unique delta identifier.
/// </summary>
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// Source artifact digest (the "from" version).
/// </summary>
public required string FromArtifactDigest { get; init; }
/// <summary>
/// Target artifact digest (the "to" version).
/// </summary>
public required string ToArtifactDigest { get; init; }
/// <summary>
/// CVE identifier.
/// </summary>
public required string Cve { get; init; }
/// <summary>
/// VEX status in the source artifact.
/// </summary>
public required string FromStatus { get; init; }
/// <summary>
/// VEX status in the target artifact.
/// </summary>
public required string ToStatus { get; init; }
/// <summary>
/// Rationale for the status change (JSONB).
/// </summary>
public VexDeltaRationale? Rationale { get; init; }
/// <summary>
/// Replay hash for reproducibility verification.
/// </summary>
public string? ReplayHash { get; init; }
/// <summary>
/// Attestation digest if the delta is attested.
/// </summary>
public string? AttestationDigest { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// When the delta was recorded.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Rationale for a VEX status change.
/// </summary>
public sealed record VexDeltaRationale
{
/// <summary>
/// Reason for the status change.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Source of the new status (vendor, analyst, automated).
/// </summary>
public string? Source { get; init; }
/// <summary>
/// Confidence score (0-1).
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Reference URLs supporting the change.
/// </summary>
public IReadOnlyList<string>? References { get; init; }
/// <summary>
/// Analyst notes if manually reviewed.
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// Whether the change was triggered by component update.
/// </summary>
public bool ComponentUpdated { get; init; }
/// <summary>
/// Whether reachability analysis changed.
/// </summary>
public bool ReachabilityChanged { get; init; }
/// <summary>
/// Previous confidence score.
/// </summary>
public double? PreviousConfidence { get; init; }
/// <summary>
/// New confidence score.
/// </summary>
public double? NewConfidence { get; init; }
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Excititor.Persistence</RootNamespace>
<AssemblyName>StellaOps.Excititor.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps Excititor module</Description>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - policy snapshot uses deprecated VexConsensusPolicyOptions
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;

View File

@@ -7,9 +7,9 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - policy binder uses VexConsensusPolicyOptions during transition
using System.Collections.Immutable;
using System.IO;
using System.Linq;

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - digest computation uses VexConsensusPolicyOptions during transition
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

View File

@@ -1,3 +1,5 @@
#pragma warning disable EXCITITOR001 // Consensus logic is deprecated - processing creates deprecated VexConsensusPolicyOptions during transition
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Excititor.Core;

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Excititor.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using Amazon.S3;
using Amazon.S3;
using Amazon.S3.Model;
using Moq;
using StellaOps.Excititor.ArtifactStores.S3;
@@ -35,7 +35,6 @@ public sealed class S3ArtifactClientTests
var client = new S3ArtifactClient(mock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger<S3ArtifactClient>.Instance);
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
using StellaOps.TestKit;
await client.PutObjectAsync("bucket", "key", stream, new Dictionary<string, string> { ["a"] = "b" }, default);
mock.Verify(x => x.PutObjectAsync(It.Is<PutObjectRequest>(r => r.Metadata["a"] == "b"), default), Times.Once);

View File

@@ -8,7 +8,7 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.ArtifactStores.S3/StellaOps.Excititor.ArtifactStores.S3.csproj" />

View File

@@ -6,7 +6,6 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
@@ -14,21 +13,9 @@
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core.Dsse;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;

View File

@@ -2,9 +2,11 @@ using System.Collections.Immutable;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core.Dsse;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
@@ -208,7 +210,7 @@ public sealed class VexAttestationVerifierTests : IDisposable
var options = Options.Create(new VexAttestationClientOptions());
var transparency = includeRekor ? new FakeTransparencyLogClient() : null;
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparency);
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, timeProvider: null, transparencyLogClient: transparency);
var providers = sourceProviders ?? ImmutableArray.Create("provider-a");
var request = new VexAttestationRequest(
@@ -334,7 +336,7 @@ public sealed class VexAttestationVerifierTests : IDisposable
=> throw new NotSupportedException();
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_success && signature.Span.SequenceEqual(_expectedSignature.Span));
=> ValueTask.FromResult(_success && signature.Span.SequenceEqual(_expectedSignature.AsSpan()));
public JsonWebKey ExportPublicJsonWebKey()
=> new JsonWebKey();

View File

@@ -5,10 +5,12 @@
// Description: Fixture-based parser/normalizer tests for Cisco CSAF connector
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.TestKit;
using Xunit;
@@ -31,7 +33,7 @@ public sealed class CiscoCsafNormalizerTests
public CiscoCsafNormalizerTests()
{
_normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
_provider = new VexProvider("cisco-csaf", "Cisco PSIRT", VexProviderRole.Vendor);
_provider = new VexProvider("cisco-csaf", "Cisco PSIRT", VexProviderKind.Vendor);
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
}
@@ -114,11 +116,13 @@ public sealed class CiscoCsafNormalizerTests
{
var content = System.Text.Encoding.UTF8.GetBytes(json);
return new VexRawDocument(
VexDocumentFormat.Csaf,
new Uri("https://sec.cloudapps.cisco.com/security/center/test.json"),
content,
"sha256:test-" + Guid.NewGuid().ToString("N")[..8],
DateTimeOffset.UtcNow);
ProviderId: "cisco-csaf",
Format: VexDocumentFormat.Csaf,
SourceUri: new Uri("https://sec.cloudapps.cisco.com/security/center/test.json"),
RetrievedAt: DateTimeOffset.UtcNow,
Digest: "sha256:test-" + Guid.NewGuid().ToString("N")[..8],
Content: content,
Metadata: ImmutableDictionary<string, string>.Empty);
}
private static string SerializeClaims(IReadOnlyList<VexClaim> claims)

View File

@@ -12,6 +12,7 @@ using StellaOps.Excititor.Connectors.Cisco.CSAF;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using System.Collections.Immutable;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
@@ -281,6 +282,14 @@ public sealed class CiscoCsafConnectorTests
CurrentState = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
{
var list = CurrentState is null
? Array.Empty<VexConnectorState>()
: new[] { CurrentState };
return ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(list);
}
}
private sealed class StubProviderStore : IVexProviderStore

View File

@@ -6,16 +6,16 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Cisco.CSAF/StellaOps.Excititor.Connectors.Cisco.CSAF.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
@@ -29,4 +29,4 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
</Project>

View File

@@ -16,6 +16,7 @@ using StellaOps.Excititor.Connectors.MSRC.CSAF;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using Xunit;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
@@ -329,6 +330,14 @@ public sealed class MsrcCsafConnectorTests
State = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
{
IReadOnlyCollection<VexConnectorState> result = State is not null
? new[] { State }
: Array.Empty<VexConnectorState>();
return ValueTask.FromResult(result);
}
}
@@ -392,26 +401,26 @@ public sealed class MsrcCsafConnectorTests
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = Path.GetTempFileName();
var json = $"""
{{
var json = $$"""
{
"schemaVersion": "1.0.0",
"generatedAt": "2025-11-20T00:00:00Z",
"connectors": [
{{
"connectorId": "{connectorId}",
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
"issuerTier": "{tier}",
{
"connectorId": "{{connectorId}}",
"provider": { "name": "{{connectorId}}", "slug": "{{connectorId}}" },
"issuerTier": "{{tier}}",
"signers": [
{{
{
"usage": "csaf",
"fingerprints": [
{{ "alg": "sha256", "format": "pgp", "value": "{fingerprint}" }}
{ "alg": "sha256", "format": "pgp", "value": "{{fingerprint}}" }
]
}}
}
]
}}
}
]
}}
}
""";
File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);

View File

@@ -31,7 +31,7 @@ public sealed class MsrcCsafNormalizerTests
public MsrcCsafNormalizerTests()
{
_normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
_provider = new VexProvider("msrc-csaf", "Microsoft Security Response Center", VexProviderRole.Vendor);
_provider = new VexProvider("msrc-csaf", "Microsoft Security Response Center", VexProviderKind.Vendor);
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
}
@@ -95,11 +95,13 @@ public sealed class MsrcCsafNormalizerTests
{
var content = System.Text.Encoding.UTF8.GetBytes(json);
return new VexRawDocument(
"msrc-csaf",
VexDocumentFormat.Csaf,
new Uri("https://api.msrc.microsoft.com/cvrf/v3.0/test.json"),
content,
DateTimeOffset.UtcNow,
"sha256:test-" + Guid.NewGuid().ToString("N")[..8],
DateTimeOffset.UtcNow);
content,
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
}
private static string SerializeClaims(IReadOnlyList<VexClaim> claims)

View File

@@ -13,10 +13,10 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*">

View File

@@ -223,7 +223,7 @@ public async Task FetchAsync_EnrichesSignerTrustMetadataWhenConfigured()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{"payload":"","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":""}]}")
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}")
});
using var cache = new MemoryCache(new MemoryCacheOptions());
@@ -257,7 +257,7 @@ public async Task FetchAsync_EnrichesSignerTrustMetadataWhenConfigured()
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
SignatureVerifier: new NoopVexSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
@@ -277,26 +277,26 @@ public async Task FetchAsync_EnrichesSignerTrustMetadataWhenConfigured()
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = System.IO.Path.GetTempFileName();
var json = $"""
{{
var json = $$"""
{
"schemaVersion": "1.0.0",
"generatedAt": "2025-11-20T00:00:00Z",
"connectors": [
{{
"connectorId": "{connectorId}",
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
"issuerTier": "{tier}",
{
"connectorId": "{{connectorId}}",
"provider": { "name": "{{connectorId}}", "slug": "{{connectorId}}" },
"issuerTier": "{{tier}}",
"signers": [
{{
{
"usage": "attestation",
"fingerprints": [
{{ "alg": "sha256", "format": "cosign", "value": "{fingerprint}" }}
{ "alg": "sha256", "format": "cosign", "value": "{{fingerprint}}" }
]
}}
}
]
}}
}
]
}}
}
""";
System.IO.File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);

View File

@@ -13,9 +13,12 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*">

View File

@@ -17,6 +17,7 @@ using StellaOps.Excititor.Connectors.Oracle.CSAF;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
@@ -263,6 +264,10 @@ public sealed class OracleCsafConnectorTests
State = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(
State is not null ? new[] { State } : Array.Empty<VexConnectorState>());
}
private sealed class InMemoryRawSink : IVexRawDocumentSink

Some files were not shown because too many files have changed in this diff Show More