Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Excititor.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62513;http://localhost:62514"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
@@ -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';
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Excititor.Storage.Postgres.Models;
|
||||
namespace StellaOps.Excititor.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// VEX status values per OpenVEX specification.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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\**\*">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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\**\*">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user