release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
namespace StellaOps.AdvisoryAI.Plugin.Unified;
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
// Type aliases to disambiguate between AdvisoryAI and Plugin.Abstractions types
|
||||
using AdvisoryLlmRequest = StellaOps.AdvisoryAI.Inference.LlmProviders.LlmCompletionRequest;
|
||||
using AdvisoryLlmResult = StellaOps.AdvisoryAI.Inference.LlmProviders.LlmCompletionResult;
|
||||
using AdvisoryStreamChunk = StellaOps.AdvisoryAI.Inference.LlmProviders.LlmStreamChunk;
|
||||
using PluginLlmRequest = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionRequest;
|
||||
using PluginLlmResult = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionResult;
|
||||
using PluginStreamChunk = StellaOps.Plugin.Abstractions.Capabilities.LlmStreamChunk;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts an existing ILlmProvider to the unified IPlugin and ILlmCapability interfaces.
|
||||
/// This enables gradual migration of AdvisoryAI LLM providers to the unified plugin architecture.
|
||||
/// </summary>
|
||||
public sealed class LlmPluginAdapter : IPlugin, ILlmCapability
|
||||
{
|
||||
private readonly ILlmProvider _inner;
|
||||
private readonly ILlmProviderPlugin _plugin;
|
||||
private readonly int _priority;
|
||||
private IPluginContext? _context;
|
||||
private PluginLifecycleState _state = PluginLifecycleState.Discovered;
|
||||
private List<LlmModelInfo> _models = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new adapter for an existing LLM provider.
|
||||
/// </summary>
|
||||
/// <param name="inner">The existing LLM provider to wrap.</param>
|
||||
/// <param name="plugin">The plugin metadata for this provider.</param>
|
||||
/// <param name="priority">Provider priority (higher = preferred).</param>
|
||||
public LlmPluginAdapter(ILlmProvider inner, ILlmProviderPlugin plugin, int priority = 10)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
|
||||
_priority = priority;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginInfo Info => new(
|
||||
Id: $"com.stellaops.llm.{_inner.ProviderId}",
|
||||
Name: _plugin.DisplayName,
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: _plugin.Description);
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginCapabilities Capabilities => PluginCapabilities.Llm | PluginCapabilities.Network;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginLifecycleState State => _state;
|
||||
|
||||
#region ILlmCapability
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderId => _inner.ProviderId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Priority => _priority;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<LlmModelInfo> AvailableModels => _models;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsAvailableAsync(CancellationToken ct)
|
||||
{
|
||||
return _inner.IsAvailableAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginLlmResult> CompleteAsync(PluginLlmRequest request, CancellationToken ct)
|
||||
{
|
||||
var advisoryRequest = ToAdvisoryRequest(request);
|
||||
var result = await _inner.CompleteAsync(advisoryRequest, ct);
|
||||
return ToPluginResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<PluginStreamChunk> CompleteStreamAsync(PluginLlmRequest request, CancellationToken ct)
|
||||
{
|
||||
var advisoryRequest = ToAdvisoryRequest(request);
|
||||
return StreamAdapter(_inner.CompleteStreamAsync(advisoryRequest, ct), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<LlmEmbeddingResult?> EmbedAsync(string text, CancellationToken ct)
|
||||
{
|
||||
// Embedding is not supported by the base ILlmProvider interface
|
||||
// Specific providers that support embedding would need custom adapters
|
||||
return Task.FromResult<LlmEmbeddingResult?>(null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IPlugin
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_context = context;
|
||||
_state = PluginLifecycleState.Initializing;
|
||||
|
||||
// Check if the provider is available
|
||||
var available = await _inner.IsAvailableAsync(ct);
|
||||
if (!available)
|
||||
{
|
||||
_state = PluginLifecycleState.Failed;
|
||||
throw new InvalidOperationException($"LLM provider '{_inner.ProviderId}' is not available");
|
||||
}
|
||||
|
||||
// Initialize with a default model entry (provider-specific models would be discovered at runtime)
|
||||
_models = new List<LlmModelInfo>
|
||||
{
|
||||
new(
|
||||
Id: _inner.ProviderId,
|
||||
Name: _plugin.DisplayName,
|
||||
Description: _plugin.Description,
|
||||
ParameterCount: null,
|
||||
ContextLength: null,
|
||||
Capabilities: new[] { "chat", "completion" })
|
||||
};
|
||||
|
||||
_state = PluginLifecycleState.Active;
|
||||
context.Logger.Info("LLM plugin adapter initialized for {ProviderId}", _inner.ProviderId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var available = await _inner.IsAvailableAsync(ct);
|
||||
|
||||
if (available)
|
||||
{
|
||||
return HealthCheckResult.Healthy()
|
||||
.WithDetails(new Dictionary<string, object>
|
||||
{
|
||||
["providerId"] = _inner.ProviderId,
|
||||
["priority"] = _priority
|
||||
});
|
||||
}
|
||||
|
||||
return HealthCheckResult.Unhealthy($"LLM provider '{_inner.ProviderId}' is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_state = PluginLifecycleState.Stopped;
|
||||
_inner.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Type Mapping
|
||||
|
||||
private static AdvisoryLlmRequest ToAdvisoryRequest(PluginLlmRequest request)
|
||||
{
|
||||
return new AdvisoryLlmRequest
|
||||
{
|
||||
UserPrompt = request.UserPrompt,
|
||||
SystemPrompt = request.SystemPrompt,
|
||||
Model = request.Model,
|
||||
Temperature = request.Temperature,
|
||||
MaxTokens = request.MaxTokens,
|
||||
Seed = request.Seed,
|
||||
StopSequences = request.StopSequences,
|
||||
RequestId = request.RequestId
|
||||
};
|
||||
}
|
||||
|
||||
private static PluginLlmResult ToPluginResult(AdvisoryLlmResult result)
|
||||
{
|
||||
return new PluginLlmResult(
|
||||
Content: result.Content,
|
||||
ModelId: result.ModelId,
|
||||
ProviderId: result.ProviderId,
|
||||
InputTokens: result.InputTokens,
|
||||
OutputTokens: result.OutputTokens,
|
||||
TimeToFirstTokenMs: result.TimeToFirstTokenMs,
|
||||
TotalTimeMs: result.TotalTimeMs,
|
||||
FinishReason: result.FinishReason,
|
||||
Deterministic: result.Deterministic,
|
||||
RequestId: result.RequestId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<PluginStreamChunk> StreamAdapter(
|
||||
IAsyncEnumerable<AdvisoryStreamChunk> source,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
await foreach (var chunk in source.WithCancellation(ct))
|
||||
{
|
||||
yield return new PluginStreamChunk(
|
||||
Content: chunk.Content,
|
||||
IsFinal: chunk.IsFinal,
|
||||
FinishReason: chunk.FinishReason);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace StellaOps.AdvisoryAI.Plugin.Unified;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating unified LLM plugin adapters from existing providers.
|
||||
/// </summary>
|
||||
public sealed class LlmPluginAdapterFactory
|
||||
{
|
||||
private readonly LlmProviderCatalog _catalog;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly Dictionary<string, LlmPluginAdapter> _adapters = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new factory instance.
|
||||
/// </summary>
|
||||
/// <param name="catalog">The LLM provider catalog.</param>
|
||||
/// <param name="serviceProvider">Service provider for creating providers.</param>
|
||||
public LlmPluginAdapterFactory(LlmProviderCatalog catalog, IServiceProvider serviceProvider)
|
||||
{
|
||||
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available unified LLM plugins.
|
||||
/// </summary>
|
||||
/// <returns>List of unified LLM plugins.</returns>
|
||||
public IReadOnlyList<IPlugin> GetAllPlugins()
|
||||
{
|
||||
var plugins = _catalog.GetAvailablePlugins(_serviceProvider);
|
||||
var result = new List<IPlugin>();
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
var adapter = GetOrCreateAdapter(plugin.ProviderId);
|
||||
if (adapter != null)
|
||||
{
|
||||
result.Add(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a unified LLM plugin by provider ID.
|
||||
/// </summary>
|
||||
/// <param name="providerId">Provider identifier.</param>
|
||||
/// <returns>Unified LLM plugin, or null if not found.</returns>
|
||||
public IPlugin? GetPlugin(string providerId)
|
||||
{
|
||||
return GetOrCreateAdapter(providerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the LLM capability for a provider.
|
||||
/// </summary>
|
||||
/// <param name="providerId">Provider identifier.</param>
|
||||
/// <returns>LLM capability, or null if not found.</returns>
|
||||
public ILlmCapability? GetCapability(string providerId)
|
||||
{
|
||||
return GetOrCreateAdapter(providerId);
|
||||
}
|
||||
|
||||
private LlmPluginAdapter? GetOrCreateAdapter(string providerId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_adapters.TryGetValue(providerId, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var plugin = _catalog.GetPlugin(providerId);
|
||||
if (plugin == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var config = _catalog.GetConfiguration(providerId);
|
||||
if (config == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var validation = plugin.ValidateConfiguration(config);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = plugin.Create(_serviceProvider, config);
|
||||
var priority = GetPriority(providerId);
|
||||
|
||||
var adapter = new LlmPluginAdapter(provider, plugin, priority);
|
||||
_adapters[providerId] = adapter;
|
||||
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetPriority(string providerId)
|
||||
{
|
||||
// Default priorities (higher = preferred for local/airgap scenarios)
|
||||
return providerId.ToLowerInvariant() switch
|
||||
{
|
||||
"llama-server" => 100, // Local, highest priority for airgap
|
||||
"ollama" => 90, // Local
|
||||
"claude" => 20, // Remote, lower priority
|
||||
"openai" => 10, // Remote, lowest priority
|
||||
_ => 50
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering unified LLM plugin services.
|
||||
/// </summary>
|
||||
public static class LlmPluginAdapterExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds unified LLM plugin adapter services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnifiedLlmPlugins(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<LlmPluginAdapterFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Unified plugin adapter for AdvisoryAI LLM providers</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\Plugin\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,347 @@
|
||||
namespace StellaOps.AdvisoryAI.Scm.Plugin.Unified;
|
||||
|
||||
using StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
// Type aliases to disambiguate between AdvisoryAI and Plugin.Abstractions types
|
||||
using AdvisoryBranchResult = StellaOps.AdvisoryAI.Remediation.ScmConnector.BranchResult;
|
||||
using AdvisoryFileUpdateResult = StellaOps.AdvisoryAI.Remediation.ScmConnector.FileUpdateResult;
|
||||
using AdvisoryPrCreateResult = StellaOps.AdvisoryAI.Remediation.ScmConnector.PrCreateResult;
|
||||
using AdvisoryPrStatusResult = StellaOps.AdvisoryAI.Remediation.ScmConnector.PrStatusResult;
|
||||
using AdvisoryCiStatusResult = StellaOps.AdvisoryAI.Remediation.ScmConnector.CiStatusResult;
|
||||
using AdvisoryCiCheck = StellaOps.AdvisoryAI.Remediation.ScmConnector.CiCheck;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts an existing IScmConnector to the unified IPlugin and IScmCapability interfaces.
|
||||
/// This enables gradual migration of AdvisoryAI SCM connectors to the unified plugin architecture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The AdvisoryAI IScmConnector focuses on PR/write operations while IScmCapability focuses on
|
||||
/// read operations. This adapter bridges both, implementing what it can from the underlying connector.
|
||||
/// </remarks>
|
||||
public sealed class ScmPluginAdapter : IPlugin, IScmCapability
|
||||
{
|
||||
private readonly IScmConnector _inner;
|
||||
private readonly IScmConnectorPlugin _plugin;
|
||||
private readonly ScmConnectorOptions _options;
|
||||
private IPluginContext? _context;
|
||||
private PluginLifecycleState _state = PluginLifecycleState.Discovered;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new adapter for an existing SCM connector.
|
||||
/// </summary>
|
||||
/// <param name="inner">The existing SCM connector to wrap.</param>
|
||||
/// <param name="plugin">The plugin metadata for this connector.</param>
|
||||
/// <param name="options">Connector configuration options.</param>
|
||||
public ScmPluginAdapter(IScmConnector inner, IScmConnectorPlugin plugin, ScmConnectorOptions options)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginInfo Info => new(
|
||||
Id: $"com.stellaops.scm.{_inner.ScmType}",
|
||||
Name: $"{_plugin.DisplayName} SCM Connector",
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: $"{_plugin.DisplayName} source control management connector");
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginCapabilities Capabilities => PluginCapabilities.Scm | PluginCapabilities.Network;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginLifecycleState State => _state;
|
||||
|
||||
#region IScmCapability - Core properties
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ConnectorType => $"scm.{_inner.ScmType}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => _plugin.DisplayName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ScmType => _inner.ScmType;
|
||||
|
||||
#endregion
|
||||
|
||||
#region IScmCapability - Connection
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(string repositoryUrl)
|
||||
{
|
||||
return _plugin.CanHandle(repositoryUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(CancellationToken ct)
|
||||
{
|
||||
// Try to perform any operation to test connectivity
|
||||
// Since IScmConnector doesn't have a dedicated test method, we assume success
|
||||
// if the connector was created successfully
|
||||
await Task.Delay(0, ct);
|
||||
return ConnectionTestResult.Succeeded(TimeSpan.FromMilliseconds(1));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConnectionInfo> GetConnectionInfoAsync(CancellationToken ct)
|
||||
{
|
||||
await Task.Delay(0, ct);
|
||||
return new ConnectionInfo(
|
||||
EndpointUrl: _options.BaseUrl ?? $"https://api.{_inner.ScmType}.com",
|
||||
AuthenticatedAs: "configured-token",
|
||||
Metadata: new Dictionary<string, object>
|
||||
{
|
||||
["scmType"] = _inner.ScmType,
|
||||
["hasToken"] = !string.IsNullOrEmpty(_options.ApiToken)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IScmCapability - Read operations (limited support via IScmConnector)
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ScmBranch>> ListBranchesAsync(string repositoryUrl, CancellationToken ct)
|
||||
{
|
||||
// IScmConnector doesn't support listing branches
|
||||
// This would require extending the adapter or using direct API calls
|
||||
throw new NotSupportedException(
|
||||
"Branch listing not supported via IScmConnector adapter. " +
|
||||
"Use the IScmCapability-native implementation or extend this adapter.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ScmCommit>> ListCommitsAsync(
|
||||
string repositoryUrl,
|
||||
string branch,
|
||||
int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"Commit listing not supported via IScmConnector adapter.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScmCommit> GetCommitAsync(string repositoryUrl, string commitSha, CancellationToken ct)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"Commit retrieval not supported via IScmConnector adapter.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScmFileContent> GetFileAsync(
|
||||
string repositoryUrl,
|
||||
string filePath,
|
||||
string? reference = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"File retrieval not supported via IScmConnector adapter.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetArchiveAsync(
|
||||
string repositoryUrl,
|
||||
string reference,
|
||||
ArchiveFormat format = ArchiveFormat.TarGz,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"Archive download not supported via IScmConnector adapter.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScmWebhook> UpsertWebhookAsync(
|
||||
string repositoryUrl,
|
||||
ScmWebhookConfig config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"Webhook management not supported via IScmConnector adapter.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScmUser> GetCurrentUserAsync(CancellationToken ct)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"User info retrieval not supported via IScmConnector adapter.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended SCM operations (from IScmConnector)
|
||||
|
||||
/// <summary>
|
||||
/// Creates a branch from the base branch.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryBranchResult> CreateBranchAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string branchName,
|
||||
string baseBranch,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.CreateBranchAsync(owner, repo, branchName, baseBranch, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates or creates a file in a branch.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryFileUpdateResult> UpdateFileAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string branch,
|
||||
string filePath,
|
||||
string content,
|
||||
string commitMessage,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.UpdateFileAsync(owner, repo, branch, filePath, content, commitMessage, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pull request / merge request.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryPrCreateResult> CreatePullRequestAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string headBranch,
|
||||
string baseBranch,
|
||||
string title,
|
||||
string body,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.CreatePullRequestAsync(owner, repo, headBranch, baseBranch, title, body, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets pull request details and status.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryPrStatusResult> GetPullRequestStatusAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.GetPullRequestStatusAsync(owner, repo, prNumber, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets CI/CD pipeline status for a commit.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryCiStatusResult> GetCiStatusAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string commitSha,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.GetCiStatusAsync(owner, repo, commitSha, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates pull request body/description.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdatePullRequestAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
string? title,
|
||||
string? body,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.UpdatePullRequestAsync(owner, repo, prNumber, title, body, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a comment to a pull request.
|
||||
/// </summary>
|
||||
public async Task<bool> AddCommentAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
string comment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.AddCommentAsync(owner, repo, prNumber, comment, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes a pull request without merging.
|
||||
/// </summary>
|
||||
public async Task<bool> ClosePullRequestAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _inner.ClosePullRequestAsync(owner, repo, prNumber, ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IPlugin
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_context = context;
|
||||
_state = PluginLifecycleState.Initializing;
|
||||
|
||||
// Verify the connector is available
|
||||
if (!_plugin.IsAvailable(_options))
|
||||
{
|
||||
_state = PluginLifecycleState.Failed;
|
||||
throw new InvalidOperationException(
|
||||
$"SCM connector '{_inner.ScmType}' is not available. Check API token configuration.");
|
||||
}
|
||||
|
||||
_state = PluginLifecycleState.Active;
|
||||
context.Logger.Info("SCM plugin adapter initialized for {ScmType}", _inner.ScmType);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testResult = await TestConnectionAsync(ct);
|
||||
|
||||
if (testResult.Success)
|
||||
{
|
||||
return HealthCheckResult.Healthy()
|
||||
.WithDetails(new Dictionary<string, object>
|
||||
{
|
||||
["scmType"] = _inner.ScmType,
|
||||
["displayName"] = _plugin.DisplayName,
|
||||
["latencyMs"] = testResult.Latency?.TotalMilliseconds ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
return HealthCheckResult.Unhealthy(testResult.Message ?? "Connection test failed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_state = PluginLifecycleState.Stopped;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace StellaOps.AdvisoryAI.Scm.Plugin.Unified;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating unified SCM plugin adapters from existing connectors.
|
||||
/// </summary>
|
||||
public sealed class ScmPluginAdapterFactory
|
||||
{
|
||||
private readonly ScmConnectorCatalog _catalog;
|
||||
private readonly Dictionary<string, ScmPluginAdapter> _adapters = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new factory instance.
|
||||
/// </summary>
|
||||
/// <param name="catalog">The SCM connector catalog.</param>
|
||||
public ScmPluginAdapterFactory(ScmConnectorCatalog catalog)
|
||||
{
|
||||
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available unified SCM plugins.
|
||||
/// </summary>
|
||||
/// <param name="options">Connector options.</param>
|
||||
/// <returns>List of unified SCM plugins.</returns>
|
||||
public IReadOnlyList<IPlugin> GetAllPlugins(ScmConnectorOptions options)
|
||||
{
|
||||
var result = new List<IPlugin>();
|
||||
|
||||
foreach (var plugin in _catalog.Plugins)
|
||||
{
|
||||
if (plugin.IsAvailable(options))
|
||||
{
|
||||
var adapter = GetOrCreateAdapter(plugin.ScmType, options);
|
||||
if (adapter != null)
|
||||
{
|
||||
result.Add(adapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a unified SCM plugin by SCM type.
|
||||
/// </summary>
|
||||
/// <param name="scmType">SCM type identifier.</param>
|
||||
/// <param name="options">Connector options.</param>
|
||||
/// <returns>Unified SCM plugin, or null if not found.</returns>
|
||||
public IPlugin? GetPlugin(string scmType, ScmConnectorOptions options)
|
||||
{
|
||||
return GetOrCreateAdapter(scmType, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a unified SCM plugin that can handle the given repository URL.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">Repository URL to handle.</param>
|
||||
/// <param name="options">Connector options.</param>
|
||||
/// <returns>Unified SCM plugin, or null if not found.</returns>
|
||||
public IPlugin? GetPluginForRepository(string repositoryUrl, ScmConnectorOptions options)
|
||||
{
|
||||
var plugin = _catalog.Plugins.FirstOrDefault(p => p.CanHandle(repositoryUrl));
|
||||
if (plugin == null)
|
||||
return null;
|
||||
|
||||
return GetOrCreateAdapter(plugin.ScmType, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SCM capability for a connector.
|
||||
/// </summary>
|
||||
/// <param name="scmType">SCM type identifier.</param>
|
||||
/// <param name="options">Connector options.</param>
|
||||
/// <returns>SCM capability, or null if not found.</returns>
|
||||
public IScmCapability? GetCapability(string scmType, ScmConnectorOptions options)
|
||||
{
|
||||
return GetOrCreateAdapter(scmType, options);
|
||||
}
|
||||
|
||||
private ScmPluginAdapter? GetOrCreateAdapter(string scmType, ScmConnectorOptions options)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_adapters.TryGetValue(scmType, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var plugin = _catalog.Plugins.FirstOrDefault(
|
||||
p => p.ScmType.Equals(scmType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (plugin == null || !plugin.IsAvailable(options))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var connector = _catalog.GetConnector(scmType, options);
|
||||
if (connector == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var adapter = new ScmPluginAdapter(connector, plugin, options);
|
||||
_adapters[scmType] = adapter;
|
||||
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering unified SCM plugin services.
|
||||
/// </summary>
|
||||
public static class ScmPluginAdapterExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds unified SCM plugin adapter services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnifiedScmPlugins(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ScmPluginAdapterFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Unified plugin adapter for AdvisoryAI SCM connectors</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\Plugin\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -6,6 +6,7 @@ using System.Collections.Immutable;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
|
||||
@@ -87,6 +88,8 @@ public static class EvidencePackEndpoints
|
||||
private static async Task<IResult> HandleCreateEvidencePack(
|
||||
CreateEvidencePackRequest request,
|
||||
IEvidencePackService evidencePackService,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -108,7 +111,7 @@ public static class EvidencePackEndpoints
|
||||
|
||||
var claims = request.Claims.Select(c => new EvidenceClaim
|
||||
{
|
||||
ClaimId = c.ClaimId ?? $"claim-{Guid.NewGuid():N}"[..16],
|
||||
ClaimId = c.ClaimId ?? $"claim-{guidProvider.NewGuid():N}"[..16],
|
||||
Text = c.Text,
|
||||
Type = Enum.TryParse<ClaimType>(c.Type, true, out var ct) ? ct : ClaimType.Custom,
|
||||
Status = c.Status,
|
||||
@@ -119,11 +122,11 @@ public static class EvidencePackEndpoints
|
||||
|
||||
var evidence = request.Evidence.Select(e => new EvidenceItem
|
||||
{
|
||||
EvidenceId = e.EvidenceId ?? $"ev-{Guid.NewGuid():N}"[..12],
|
||||
EvidenceId = e.EvidenceId ?? $"ev-{guidProvider.NewGuid():N}"[..12],
|
||||
Type = Enum.TryParse<EvidenceType>(e.Type, true, out var et) ? et : EvidenceType.Custom,
|
||||
Uri = e.Uri,
|
||||
Digest = e.Digest ?? "sha256:unknown",
|
||||
CollectedAt = e.CollectedAt ?? DateTimeOffset.UtcNow,
|
||||
CollectedAt = e.CollectedAt ?? timeProvider.GetUtcNow(),
|
||||
Snapshot = EvidenceSnapshot.Custom(e.SnapshotType ?? "custom", (e.SnapshotData ?? new Dictionary<string, object>()).ToImmutableDictionary(x => x.Key, x => (object?)x.Value))
|
||||
}).ToArray();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
@@ -407,6 +408,8 @@ public static class RunEndpoints
|
||||
string runId,
|
||||
[FromBody] AddArtifactRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromServices] IGuidProvider guidProvider,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -416,11 +419,11 @@ public static class RunEndpoints
|
||||
{
|
||||
var run = await runService.AddArtifactAsync(tenantId, runId, new RunArtifact
|
||||
{
|
||||
ArtifactId = request.ArtifactId ?? Guid.NewGuid().ToString("N"),
|
||||
ArtifactId = request.ArtifactId ?? guidProvider.NewGuid().ToString("N"),
|
||||
Type = request.Type,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
ContentDigest = request.ContentDigest,
|
||||
ContentSize = request.ContentSize,
|
||||
MediaType = request.MediaType,
|
||||
|
||||
@@ -55,6 +55,12 @@ public sealed class InMemoryAiConsentStore : IAiConsentStore
|
||||
{
|
||||
private readonly Dictionary<string, AiConsentRecord> _consents = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAiConsentStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<AiConsentRecord?> GetConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -64,7 +70,7 @@ public sealed class InMemoryAiConsentStore : IAiConsentStore
|
||||
if (_consents.TryGetValue(key, out var record))
|
||||
{
|
||||
// Check expiration
|
||||
if (record.ExpiresAt.HasValue && record.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
if (record.ExpiresAt.HasValue && record.ExpiresAt.Value < _timeProvider.GetUtcNow())
|
||||
{
|
||||
_consents.Remove(key);
|
||||
return Task.FromResult<AiConsentRecord?>(null);
|
||||
@@ -78,7 +84,7 @@ public sealed class InMemoryAiConsentStore : IAiConsentStore
|
||||
public Task<AiConsentRecord> GrantConsentAsync(string tenantId, string userId, AiConsentGrant grant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(tenantId, userId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = new AiConsentRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
|
||||
@@ -53,11 +53,18 @@ public sealed record AiJustificationResult
|
||||
public sealed class DefaultAiJustificationGenerator : IAiJustificationGenerator
|
||||
{
|
||||
private readonly ILogger<DefaultAiJustificationGenerator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly StellaOps.Determinism.IGuidProvider _guidProvider;
|
||||
private const string ModelVersion = "advisory-ai-v1.2.0";
|
||||
|
||||
public DefaultAiJustificationGenerator(ILogger<DefaultAiJustificationGenerator> logger)
|
||||
public DefaultAiJustificationGenerator(
|
||||
ILogger<DefaultAiJustificationGenerator> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
StellaOps.Determinism.IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? StellaOps.Determinism.SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<AiJustificationResult> GenerateAsync(AiJustificationRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -74,13 +81,13 @@ public sealed class DefaultAiJustificationGenerator : IAiJustificationGenerator
|
||||
|
||||
var result = new AiJustificationResult
|
||||
{
|
||||
JustificationId = $"justify-{Guid.NewGuid():N}",
|
||||
JustificationId = $"justify-{_guidProvider.NewGuid():N}",
|
||||
DraftJustification = justification,
|
||||
SuggestedJustificationType = suggestedType,
|
||||
ConfidenceScore = confidence,
|
||||
EvidenceSuggestions = evidenceSuggestions,
|
||||
ModelVersion = ModelVersion,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
TraceId = request.CorrelationId
|
||||
};
|
||||
|
||||
|
||||
@@ -17,5 +17,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
<!-- Determinism abstractions -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -168,17 +168,19 @@ public sealed record ReplayVerificationResult
|
||||
public sealed class AIArtifactReplayer : IAIArtifactReplayer
|
||||
{
|
||||
private readonly ILlmProvider _provider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AIArtifactReplayer(ILlmProvider provider)
|
||||
public AIArtifactReplayer(ILlmProvider provider, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_provider = provider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ReplayResult> ReplayAsync(
|
||||
AIArtifactReplayManifest manifest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -191,7 +193,7 @@ public sealed class AIArtifactReplayer : IAIArtifactReplayer
|
||||
ReplayedOutput = string.Empty,
|
||||
ReplayedOutputHash = string.Empty,
|
||||
Identical = false,
|
||||
Duration = DateTime.UtcNow - startTime,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime,
|
||||
ErrorMessage = "Replay requires temperature=0 for determinism"
|
||||
};
|
||||
}
|
||||
@@ -205,7 +207,7 @@ public sealed class AIArtifactReplayer : IAIArtifactReplayer
|
||||
ReplayedOutput = string.Empty,
|
||||
ReplayedOutputHash = string.Empty,
|
||||
Identical = false,
|
||||
Duration = DateTime.UtcNow - startTime,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime,
|
||||
ErrorMessage = $"Model {manifest.ModelId} is not available"
|
||||
};
|
||||
}
|
||||
@@ -233,7 +235,7 @@ public sealed class AIArtifactReplayer : IAIArtifactReplayer
|
||||
ReplayedOutput = result.Content,
|
||||
ReplayedOutputHash = replayedHash,
|
||||
Identical = identical,
|
||||
Duration = DateTime.UtcNow - startTime
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -244,7 +246,7 @@ public sealed class AIArtifactReplayer : IAIArtifactReplayer
|
||||
ReplayedOutput = string.Empty,
|
||||
ReplayedOutputHash = string.Empty,
|
||||
Identical = false,
|
||||
Duration = DateTime.UtcNow - startTime,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<ConversationStore> _logger;
|
||||
private readonly ConversationStoreOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -32,11 +33,13 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
public ConversationStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<ConversationStore> logger,
|
||||
ConversationStoreOptions? options = null)
|
||||
ConversationStoreOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options ?? new ConversationStoreOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -217,7 +220,7 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
WHERE updated_at < @cutoff
|
||||
""";
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow - maxAge;
|
||||
var cutoff = _timeProvider.GetUtcNow() - maxAge;
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("cutoff", cutoff);
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -179,7 +180,7 @@ public sealed class FileBasedJobSyncTransport : IJobSyncTransport
|
||||
BundleId = Guid.Parse(bundleIdProp.GetString()!),
|
||||
TenantId = tenantIdProp.GetString()!,
|
||||
SourceNodeId = nodeIdProp.GetString()!,
|
||||
CreatedAt = DateTimeOffset.Parse(createdAtProp.GetString()!),
|
||||
CreatedAt = DateTimeOffset.Parse(createdAtProp.GetString()!, CultureInfo.InvariantCulture),
|
||||
EntryCount = entryCount,
|
||||
SizeBytes = new FileInfo(file).Length
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTraceAttestationService.cs
|
||||
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
|
||||
// Description: Service for generating change trace DSSE attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Scanner.ChangeTrace.Models;
|
||||
|
||||
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
|
||||
using DsseEnvelope = StellaOps.Attestor.ProofChain.Signing.DsseEnvelope;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.ChangeTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating change trace DSSE attestations.
|
||||
/// </summary>
|
||||
public sealed class ChangeTraceAttestationService : IChangeTraceAttestationService
|
||||
{
|
||||
private readonly IProofChainSigner _signer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new change trace attestation service.
|
||||
/// </summary>
|
||||
/// <param name="signer">Proof chain signer for envelope generation.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamp generation.</param>
|
||||
public ChangeTraceAttestationService(
|
||||
IProofChainSigner signer,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DsseEnvelope> GenerateAttestationAsync(
|
||||
ChangeTraceModel trace,
|
||||
ChangeTraceAttestationOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trace);
|
||||
options ??= ChangeTraceAttestationOptions.Default;
|
||||
|
||||
var predicate = MapToPredicate(trace, options);
|
||||
var statement = CreateStatement(trace, predicate);
|
||||
|
||||
return await _signer.SignStatementAsync(
|
||||
statement,
|
||||
SigningKeyProfile.Evidence,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a change trace model to its attestation predicate.
|
||||
/// </summary>
|
||||
private ChangeTracePredicate MapToPredicate(
|
||||
ChangeTraceModel trace,
|
||||
ChangeTraceAttestationOptions options)
|
||||
{
|
||||
var deltas = trace.Deltas
|
||||
.Take(options.MaxDeltas)
|
||||
.Select(d => new ChangeTraceDeltaEntry
|
||||
{
|
||||
Purl = d.Purl,
|
||||
FromVersion = d.FromVersion,
|
||||
ToVersion = d.ToVersion,
|
||||
ChangeType = d.ChangeType.ToString(),
|
||||
Explain = d.Explain.ToString(),
|
||||
SymbolsChanged = d.Evidence.SymbolsChanged,
|
||||
BytesChanged = d.Evidence.BytesChanged,
|
||||
Confidence = d.Evidence.Confidence,
|
||||
TrustDeltaScore = d.TrustDelta?.Score ?? 0,
|
||||
CveIds = d.Evidence.CveIds,
|
||||
Functions = d.Evidence.Functions
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
var proofSteps = trace.Deltas
|
||||
.Where(d => d.TrustDelta is not null)
|
||||
.SelectMany(d => d.TrustDelta!.ProofSteps)
|
||||
.Distinct()
|
||||
.Take(options.MaxProofSteps)
|
||||
.ToImmutableArray();
|
||||
|
||||
var aggregateReachability = AggregateReachabilityImpact(trace.Deltas);
|
||||
var aggregateExploitability = DetermineExploitabilityFromScore(trace.Summary.RiskDelta);
|
||||
|
||||
return new ChangeTracePredicate
|
||||
{
|
||||
FromDigest = trace.Basis.FromScanId ?? trace.Subject.Digest,
|
||||
ToDigest = trace.Basis.ToScanId ?? trace.Subject.Digest,
|
||||
TenantId = options.TenantId,
|
||||
Deltas = deltas,
|
||||
Summary = new ChangeTracePredicateSummary
|
||||
{
|
||||
ChangedPackages = trace.Summary.ChangedPackages,
|
||||
ChangedSymbols = trace.Summary.ChangedSymbols,
|
||||
ChangedBytes = trace.Summary.ChangedBytes,
|
||||
RiskDelta = trace.Summary.RiskDelta,
|
||||
Verdict = trace.Summary.Verdict.ToString().ToLowerInvariant()
|
||||
},
|
||||
TrustDelta = new TrustDeltaRecord
|
||||
{
|
||||
Score = trace.Summary.RiskDelta,
|
||||
BeforeScore = trace.Summary.BeforeRiskScore,
|
||||
AfterScore = trace.Summary.AfterRiskScore,
|
||||
ReachabilityImpact = aggregateReachability.ToString().ToLowerInvariant(),
|
||||
ExploitabilityImpact = aggregateExploitability.ToString().ToLowerInvariant()
|
||||
},
|
||||
ProofSteps = proofSteps,
|
||||
DiffMethods = trace.Basis.DiffMethod,
|
||||
Policies = trace.Basis.Policies,
|
||||
AnalyzedAt = trace.Basis.AnalyzedAt,
|
||||
AlgorithmVersion = trace.Basis.EngineVersion,
|
||||
CommitmentHash = trace.Commitment?.Sha256
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an in-toto statement from the change trace and predicate.
|
||||
/// </summary>
|
||||
private ChangeTraceStatement CreateStatement(
|
||||
ChangeTraceModel trace,
|
||||
ChangeTracePredicate predicate)
|
||||
{
|
||||
var subjectName = trace.Subject.Purl ?? trace.Subject.Name ?? trace.Subject.Digest;
|
||||
var digest = ParseDigest(trace.Subject.Digest);
|
||||
|
||||
return new ChangeTraceStatement
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new Subject
|
||||
{
|
||||
Name = subjectName,
|
||||
Digest = digest
|
||||
}
|
||||
],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a digest string into a dictionary of algorithm:value pairs.
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, string> ParseDigest(string digestString)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
if (string.IsNullOrEmpty(digestString))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle "algorithm:value" format
|
||||
var colonIndex = digestString.IndexOf(':', StringComparison.Ordinal);
|
||||
if (colonIndex > 0)
|
||||
{
|
||||
var algorithm = digestString[..colonIndex];
|
||||
var value = digestString[(colonIndex + 1)..];
|
||||
result[algorithm] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Assume SHA-256 if no algorithm prefix
|
||||
result["sha256"] = digestString;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate reachability impact from multiple deltas.
|
||||
/// </summary>
|
||||
private static ReachabilityImpact AggregateReachabilityImpact(
|
||||
ImmutableArray<PackageDelta> deltas)
|
||||
{
|
||||
// Priority: Introduced > Increased > Reduced > Eliminated > Unchanged
|
||||
if (deltas.Any(d => d.TrustDelta?.ReachabilityImpact == ReachabilityImpact.Introduced))
|
||||
return ReachabilityImpact.Introduced;
|
||||
if (deltas.Any(d => d.TrustDelta?.ReachabilityImpact == ReachabilityImpact.Increased))
|
||||
return ReachabilityImpact.Increased;
|
||||
if (deltas.Any(d => d.TrustDelta?.ReachabilityImpact == ReachabilityImpact.Reduced))
|
||||
return ReachabilityImpact.Reduced;
|
||||
if (deltas.Any(d => d.TrustDelta?.ReachabilityImpact == ReachabilityImpact.Eliminated))
|
||||
return ReachabilityImpact.Eliminated;
|
||||
return ReachabilityImpact.Unchanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determine exploitability impact from overall risk delta score.
|
||||
/// </summary>
|
||||
private static ExploitabilityImpact DetermineExploitabilityFromScore(double riskDelta)
|
||||
{
|
||||
return riskDelta switch
|
||||
{
|
||||
<= -0.5 => ExploitabilityImpact.Eliminated,
|
||||
< -0.3 => ExploitabilityImpact.Down,
|
||||
>= 0.5 => ExploitabilityImpact.Introduced,
|
||||
> 0.3 => ExploitabilityImpact.Up,
|
||||
_ => ExploitabilityImpact.Unchanged
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IChangeTraceAttestationService.cs
|
||||
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
|
||||
// Description: Interface for generating change trace attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.ChangeTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating change trace DSSE attestations.
|
||||
/// </summary>
|
||||
public interface IChangeTraceAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate DSSE envelope for a change trace.
|
||||
/// </summary>
|
||||
/// <param name="trace">The change trace to attest.</param>
|
||||
/// <param name="options">Optional attestation options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>DSSE envelope containing the change trace attestation.</returns>
|
||||
Task<DsseEnvelope> GenerateAttestationAsync(
|
||||
Scanner.ChangeTrace.Models.ChangeTrace trace,
|
||||
ChangeTraceAttestationOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for change trace attestation generation.
|
||||
/// </summary>
|
||||
public sealed record ChangeTraceAttestationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default attestation options.
|
||||
/// </summary>
|
||||
public static readonly ChangeTraceAttestationOptions Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public string TenantId { get; init; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include raw trace in attestation metadata.
|
||||
/// </summary>
|
||||
public bool IncludeRawTrace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of proof steps to include.
|
||||
/// </summary>
|
||||
public int MaxProofSteps { get; init; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of deltas to include in the predicate.
|
||||
/// </summary>
|
||||
public int MaxDeltas { get; init; } = 1000;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTracePredicate.cs
|
||||
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
|
||||
// Description: DSSE predicate for change trace attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for change trace attestations.
|
||||
/// predicateType: stella.ops/changetrace@v1
|
||||
/// </summary>
|
||||
public sealed record ChangeTracePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate type URI for change trace attestations.
|
||||
/// </summary>
|
||||
public const string PredicateTypeUri = "stella.ops/changetrace@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the "from" artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the "to" artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package-level deltas.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deltas")]
|
||||
public ImmutableArray<ChangeTraceDeltaEntry> Deltas { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary of all changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required ChangeTracePredicateSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust delta with proof steps.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trustDelta")]
|
||||
public required TrustDeltaRecord TrustDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable proof steps explaining the verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofSteps")]
|
||||
public ImmutableArray<string> ProofSteps { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Diff methods used (pkg, symbol, byte).
|
||||
/// </summary>
|
||||
[JsonPropertyName("diffMethods")]
|
||||
public ImmutableArray<string> DiffMethods { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Lattice policies applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policies")]
|
||||
public ImmutableArray<string> Policies { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When the analysis was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzedAt")]
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm/engine version for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithmVersion")]
|
||||
public string AlgorithmVersion { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Commitment hash for deterministic verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("commitmentHash")]
|
||||
public string? CommitmentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delta entry within the change trace predicate.
|
||||
/// </summary>
|
||||
public sealed record ChangeTraceDeltaEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) of the changed package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version before the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromVersion")]
|
||||
public required string FromVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version after the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toVersion")]
|
||||
public required string ToVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of change (Added, Removed, Modified, Upgraded, Downgraded, Rebuilt).
|
||||
/// </summary>
|
||||
[JsonPropertyName("changeType")]
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of the change reason.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explain")]
|
||||
public required string Explain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of symbols changed in this package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolsChanged")]
|
||||
public int SymbolsChanged { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes changed in this package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bytesChanged")]
|
||||
public long BytesChanged { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for the change classification (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust delta score for this specific package change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trustDeltaScore")]
|
||||
public double TrustDeltaScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifiers addressed by this change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cveIds")]
|
||||
public ImmutableArray<string> CveIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Function names affected by this change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("functions")]
|
||||
public ImmutableArray<string> Functions { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary within the change trace predicate.
|
||||
/// </summary>
|
||||
public sealed record ChangeTracePredicateSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of packages with changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changedPackages")]
|
||||
public required int ChangedPackages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of symbols with changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changedSymbols")]
|
||||
public required int ChangedSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes changed across all packages.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changedBytes")]
|
||||
public required long ChangedBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated risk delta score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskDelta")]
|
||||
public required double RiskDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verdict (risk_down, neutral, risk_up, inconclusive).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdict")]
|
||||
public required string Verdict { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust delta record within the predicate.
|
||||
/// </summary>
|
||||
public sealed record TrustDeltaRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Overall trust delta score (-1.0 to +1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust score before the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("beforeScore")]
|
||||
public double? BeforeScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust score after the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("afterScore")]
|
||||
public double? AfterScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact on code reachability (unchanged, reduced, increased, eliminated, introduced).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachabilityImpact")]
|
||||
public required string ReachabilityImpact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact on exploitability (unchanged, down, up, eliminated, introduced).
|
||||
/// </summary>
|
||||
[JsonPropertyName("exploitabilityImpact")]
|
||||
public required string ExploitabilityImpact { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTraceStatement.cs
|
||||
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
|
||||
// Description: In-toto statement for change trace attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for change trace attestations.
|
||||
/// Predicate type: stella.ops/changetrace@v1
|
||||
/// </summary>
|
||||
public sealed record ChangeTraceStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => ChangeTracePredicate.PredicateTypeUri;
|
||||
|
||||
/// <summary>
|
||||
/// The change trace predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ChangeTracePredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<ProjectReference Include="..\..\..\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.ChangeTrace\StellaOps.Scanner.ChangeTrace.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTracePredicateTests.cs
|
||||
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
|
||||
// Description: Tests for ChangeTracePredicate serialization.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.ChangeTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ChangeTracePredicate serialization and deserialization.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ChangeTracePredicateTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PredicateTypeUri_IsCorrect()
|
||||
{
|
||||
// Assert
|
||||
ChangeTracePredicate.PredicateTypeUri.Should().Be("stella.ops/changetrace@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_MinimalPredicate_ProducesValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = new ChangeTracePredicate
|
||||
{
|
||||
FromDigest = "sha256:abc123",
|
||||
ToDigest = "sha256:def456",
|
||||
TenantId = "tenant-1",
|
||||
Summary = new ChangeTracePredicateSummary
|
||||
{
|
||||
ChangedPackages = 5,
|
||||
ChangedSymbols = 20,
|
||||
ChangedBytes = 1024,
|
||||
RiskDelta = -0.25,
|
||||
Verdict = "risk_down"
|
||||
},
|
||||
TrustDelta = new TrustDeltaRecord
|
||||
{
|
||||
Score = -0.25,
|
||||
ReachabilityImpact = "reduced",
|
||||
ExploitabilityImpact = "down"
|
||||
},
|
||||
AnalyzedAt = new DateTimeOffset(2026, 1, 12, 14, 30, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().NotBeNullOrEmpty();
|
||||
json.Should().Contain("\"fromDigest\"");
|
||||
json.Should().Contain("\"toDigest\"");
|
||||
json.Should().Contain("\"tenantId\"");
|
||||
json.Should().Contain("\"summary\"");
|
||||
json.Should().Contain("\"trustDelta\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_FullPredicate_PreservesAllData()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = new ChangeTracePredicate
|
||||
{
|
||||
FromDigest = "sha256:abc123",
|
||||
ToDigest = "sha256:def456",
|
||||
TenantId = "tenant-1",
|
||||
Deltas =
|
||||
[
|
||||
new ChangeTraceDeltaEntry
|
||||
{
|
||||
Purl = "pkg:deb/debian/openssl@3.0.9",
|
||||
FromVersion = "3.0.9",
|
||||
ToVersion = "3.0.9-1+deb12u3",
|
||||
ChangeType = "Modified",
|
||||
Explain = "VendorBackport",
|
||||
SymbolsChanged = 10,
|
||||
BytesChanged = 2048,
|
||||
Confidence = 0.95,
|
||||
TrustDeltaScore = -0.3,
|
||||
CveIds = ["CVE-2026-12345"],
|
||||
Functions = ["ssl3_get_record"]
|
||||
}
|
||||
],
|
||||
Summary = new ChangeTracePredicateSummary
|
||||
{
|
||||
ChangedPackages = 1,
|
||||
ChangedSymbols = 10,
|
||||
ChangedBytes = 2048,
|
||||
RiskDelta = -0.3,
|
||||
Verdict = "risk_down"
|
||||
},
|
||||
TrustDelta = new TrustDeltaRecord
|
||||
{
|
||||
Score = -0.3,
|
||||
BeforeScore = 0.5,
|
||||
AfterScore = 0.8,
|
||||
ReachabilityImpact = "reduced",
|
||||
ExploitabilityImpact = "down"
|
||||
},
|
||||
ProofSteps = ["CVE patched", "Function verified"],
|
||||
DiffMethods = ["pkg", "symbol"],
|
||||
Policies = ["lattice:default@v3"],
|
||||
AnalyzedAt = new DateTimeOffset(2026, 1, 12, 14, 30, 0, TimeSpan.Zero),
|
||||
AlgorithmVersion = "1.0.0",
|
||||
CommitmentHash = "a1b2c3d4e5f6"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<ChangeTracePredicate>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.FromDigest.Should().Be(predicate.FromDigest);
|
||||
deserialized.ToDigest.Should().Be(predicate.ToDigest);
|
||||
deserialized.TenantId.Should().Be(predicate.TenantId);
|
||||
deserialized.Deltas.Should().HaveCount(1);
|
||||
deserialized.Deltas[0].Purl.Should().Be("pkg:deb/debian/openssl@3.0.9");
|
||||
deserialized.Summary.ChangedPackages.Should().Be(1);
|
||||
deserialized.TrustDelta.Score.Should().Be(-0.3);
|
||||
deserialized.ProofSteps.Should().HaveCount(2);
|
||||
deserialized.DiffMethods.Should().HaveCount(2);
|
||||
deserialized.Policies.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_DeltaEntry_ContainsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new ChangeTraceDeltaEntry
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
FromVersion = "4.17.20",
|
||||
ToVersion = "4.17.21",
|
||||
ChangeType = "Upgraded",
|
||||
Explain = "UpstreamUpgrade",
|
||||
SymbolsChanged = 5,
|
||||
BytesChanged = 512,
|
||||
Confidence = 0.88,
|
||||
TrustDeltaScore = -0.1,
|
||||
CveIds = ["CVE-2026-00001"],
|
||||
Functions = ["merge", "clone"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(entry, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"purl\"");
|
||||
json.Should().Contain("\"fromVersion\"");
|
||||
json.Should().Contain("\"toVersion\"");
|
||||
json.Should().Contain("\"changeType\"");
|
||||
json.Should().Contain("\"explain\"");
|
||||
json.Should().Contain("\"symbolsChanged\"");
|
||||
json.Should().Contain("\"bytesChanged\"");
|
||||
json.Should().Contain("\"confidence\"");
|
||||
json.Should().Contain("\"trustDeltaScore\"");
|
||||
json.Should().Contain("\"cveIds\"");
|
||||
json.Should().Contain("\"functions\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_Summary_ContainsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new ChangeTracePredicateSummary
|
||||
{
|
||||
ChangedPackages = 3,
|
||||
ChangedSymbols = 25,
|
||||
ChangedBytes = 8192,
|
||||
RiskDelta = 0.15,
|
||||
Verdict = "neutral"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(summary, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"changedPackages\"");
|
||||
json.Should().Contain("\"changedSymbols\"");
|
||||
json.Should().Contain("\"changedBytes\"");
|
||||
json.Should().Contain("\"riskDelta\"");
|
||||
json.Should().Contain("\"verdict\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_TrustDeltaRecord_ContainsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var trustDelta = new TrustDeltaRecord
|
||||
{
|
||||
Score = -0.45,
|
||||
BeforeScore = 0.4,
|
||||
AfterScore = 0.85,
|
||||
ReachabilityImpact = "eliminated",
|
||||
ExploitabilityImpact = "eliminated"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(trustDelta, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"score\"");
|
||||
json.Should().Contain("\"beforeScore\"");
|
||||
json.Should().Contain("\"afterScore\"");
|
||||
json.Should().Contain("\"reachabilityImpact\"");
|
||||
json.Should().Contain("\"exploitabilityImpact\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_EmptyDeltas_ProducesEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = new ChangeTracePredicate
|
||||
{
|
||||
FromDigest = "sha256:abc",
|
||||
ToDigest = "sha256:def",
|
||||
TenantId = "test",
|
||||
Deltas = ImmutableArray<ChangeTraceDeltaEntry>.Empty,
|
||||
Summary = new ChangeTracePredicateSummary
|
||||
{
|
||||
ChangedPackages = 0,
|
||||
ChangedSymbols = 0,
|
||||
ChangedBytes = 0,
|
||||
RiskDelta = 0,
|
||||
Verdict = "neutral"
|
||||
},
|
||||
TrustDelta = new TrustDeltaRecord
|
||||
{
|
||||
Score = 0,
|
||||
ReachabilityImpact = "unchanged",
|
||||
ExploitabilityImpact = "unchanged"
|
||||
},
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"deltas\": []");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_JsonWithMissingOptionalFields_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"fromDigest": "sha256:abc",
|
||||
"toDigest": "sha256:def",
|
||||
"tenantId": "test",
|
||||
"summary": {
|
||||
"changedPackages": 1,
|
||||
"changedSymbols": 5,
|
||||
"changedBytes": 256,
|
||||
"riskDelta": -0.1,
|
||||
"verdict": "risk_down"
|
||||
},
|
||||
"trustDelta": {
|
||||
"score": -0.1,
|
||||
"reachabilityImpact": "reduced",
|
||||
"exploitabilityImpact": "down"
|
||||
},
|
||||
"analyzedAt": "2026-01-12T14:30:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var predicate = JsonSerializer.Deserialize<ChangeTracePredicate>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
predicate.Should().NotBeNull();
|
||||
predicate!.Deltas.Should().BeEmpty();
|
||||
predicate.ProofSteps.Should().BeEmpty();
|
||||
predicate.DiffMethods.Should().BeEmpty();
|
||||
predicate.Policies.Should().BeEmpty();
|
||||
predicate.CommitmentHash.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
namespace StellaOps.Authority.Plugin.Unified;
|
||||
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts an existing IIdentityProviderPlugin to the unified IPlugin and IAuthCapability interfaces.
|
||||
/// This enables gradual migration of Authority plugins to the unified plugin architecture.
|
||||
/// </summary>
|
||||
public sealed class AuthPluginAdapter : IPlugin, IAuthCapability
|
||||
{
|
||||
private readonly IIdentityProviderPlugin _inner;
|
||||
private IPluginContext? _context;
|
||||
private PluginLifecycleState _state = PluginLifecycleState.Discovered;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new adapter for an existing identity provider plugin.
|
||||
/// </summary>
|
||||
/// <param name="inner">The existing identity provider plugin to wrap.</param>
|
||||
public AuthPluginAdapter(IIdentityProviderPlugin inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginInfo Info => new(
|
||||
Id: $"com.stellaops.auth.{_inner.Type}",
|
||||
Name: _inner.Name,
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: $"Authority {_inner.Type} identity provider plugin");
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginCapabilities Capabilities => PluginCapabilities.Auth | PluginCapabilities.Network;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginLifecycleState State => _state;
|
||||
|
||||
#region IAuthCapability
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderType => _inner.Type;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> SupportedMethods
|
||||
{
|
||||
get
|
||||
{
|
||||
var methods = new List<string>();
|
||||
if (_inner.Capabilities.SupportsPassword)
|
||||
methods.Add("password");
|
||||
if (_inner.Capabilities.SupportsMfa)
|
||||
methods.Add("mfa");
|
||||
return methods;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AuthResult> AuthenticateAsync(AuthRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.Method != "password" || string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password))
|
||||
{
|
||||
return AuthResult.Failed("Invalid authentication method or missing credentials");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _inner.Credentials.VerifyPasswordAsync(
|
||||
request.Username,
|
||||
request.Password,
|
||||
ct);
|
||||
|
||||
if (result.Succeeded && result.User != null)
|
||||
{
|
||||
return AuthResult.Succeeded(
|
||||
userId: result.User.SubjectId,
|
||||
roles: result.User.Roles?.ToList());
|
||||
}
|
||||
|
||||
return AuthResult.Failed(result.Message ?? "Authentication failed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AuthResult.Failed(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TokenValidationResult> ValidateTokenAsync(string token, CancellationToken ct)
|
||||
{
|
||||
// Authority plugins don't typically handle token validation directly
|
||||
// This is handled by the Authority web service
|
||||
return Task.FromResult(TokenValidationResult.Invalid("Token validation not supported by this provider"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AuthUserInfo?> GetUserInfoAsync(string userId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _inner.Credentials.FindBySubjectAsync(userId, ct);
|
||||
if (user == null)
|
||||
return null;
|
||||
|
||||
return new AuthUserInfo(
|
||||
Id: user.SubjectId,
|
||||
Username: user.Username,
|
||||
Email: user.Attributes?.GetValueOrDefault("email"),
|
||||
DisplayName: user.DisplayName,
|
||||
Attributes: user.Attributes?.Where(kv => kv.Value != null)
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value!));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AuthGroupInfo>> GetUserGroupsAsync(string userId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get user and extract roles as groups
|
||||
var user = await _inner.Credentials.FindBySubjectAsync(userId, ct);
|
||||
if (user == null)
|
||||
return Array.Empty<AuthGroupInfo>();
|
||||
|
||||
return user.Roles.Select(role => new AuthGroupInfo(role, role, null)).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<AuthGroupInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HasPermissionAsync(string userId, string permission, CancellationToken ct)
|
||||
{
|
||||
var groups = await GetUserGroupsAsync(userId, ct);
|
||||
return groups.Any(g => g.Name.Equals(permission, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SsoInitiation?> InitiateSsoAsync(SsoRequest request, CancellationToken ct)
|
||||
{
|
||||
// SSO is type-specific - LDAP doesn't support it, OIDC/SAML do
|
||||
// This base adapter doesn't support SSO; specialized adapters should override
|
||||
return Task.FromResult<SsoInitiation?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AuthResult> CompleteSsoAsync(SsoCallback callback, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(AuthResult.Failed("SSO not supported by this provider"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IPlugin
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_context = context;
|
||||
_state = PluginLifecycleState.Initializing;
|
||||
|
||||
// The inner plugin is already initialized via the Authority plugin loader
|
||||
// We just need to verify it's working
|
||||
var health = await _inner.CheckHealthAsync(ct);
|
||||
if (health.Status == AuthorityPluginHealthStatus.Unavailable)
|
||||
{
|
||||
_state = PluginLifecycleState.Failed;
|
||||
throw new InvalidOperationException($"Authority plugin health check failed: {health.Message}");
|
||||
}
|
||||
|
||||
_state = PluginLifecycleState.Active;
|
||||
context.Logger.Info("Authority plugin adapter initialized for {PluginName}", _inner.Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _inner.CheckHealthAsync(ct);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
AuthorityPluginHealthStatus.Healthy => HealthCheckResult.Healthy()
|
||||
.WithDetails(result.Details?.Where(kv => kv.Value != null)
|
||||
.ToDictionary(kv => kv.Key, kv => (object)kv.Value!) ?? new Dictionary<string, object>()),
|
||||
AuthorityPluginHealthStatus.Degraded => HealthCheckResult.Degraded(result.Message ?? "Degraded")
|
||||
.WithDetails(result.Details?.Where(kv => kv.Value != null)
|
||||
.ToDictionary(kv => kv.Key, kv => (object)kv.Value!) ?? new Dictionary<string, object>()),
|
||||
_ => HealthCheckResult.Unhealthy(result.Message ?? "Unhealthy")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_state = PluginLifecycleState.Stopped;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Unified plugin adapter for Authority identity provider plugins</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Plugin\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
|
||||
public interface IAuthorityInMemoryIdGenerator
|
||||
@@ -7,5 +9,12 @@ public interface IAuthorityInMemoryIdGenerator
|
||||
|
||||
public sealed class GuidAuthorityInMemoryIdGenerator : IAuthorityInMemoryIdGenerator
|
||||
{
|
||||
public string NextId() => Guid.NewGuid().ToString("N");
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public GuidAuthorityInMemoryIdGenerator(IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public string NextId() => _guidProvider.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Core\StellaOps.Authority.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ public sealed class GoldenSetController : ControllerBase
|
||||
};
|
||||
|
||||
// Validate
|
||||
var validationResult = _validator.Validate(definition);
|
||||
var validationResult = await _validator.ValidateAsync(definition, null, ct);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
return BadRequest(CreateProblem(
|
||||
|
||||
@@ -17,18 +17,33 @@ public sealed class DeltaSignatureMatcher : IDeltaSignatureMatcher
|
||||
{
|
||||
private readonly DisassemblyService _disassemblyService;
|
||||
private readonly NormalizationService _normalizationService;
|
||||
private readonly ISymbolChangeTracer _changeTracer;
|
||||
private readonly ILogger<DeltaSignatureMatcher> _logger;
|
||||
|
||||
public DeltaSignatureMatcher(
|
||||
DisassemblyService disassemblyService,
|
||||
NormalizationService normalizationService,
|
||||
ISymbolChangeTracer changeTracer,
|
||||
ILogger<DeltaSignatureMatcher> logger)
|
||||
{
|
||||
_disassemblyService = disassemblyService;
|
||||
_normalizationService = normalizationService;
|
||||
_changeTracer = changeTracer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy constructor for backward compatibility.
|
||||
/// Creates an internal SymbolChangeTracer instance.
|
||||
/// </summary>
|
||||
public DeltaSignatureMatcher(
|
||||
DisassemblyService disassemblyService,
|
||||
NormalizationService normalizationService,
|
||||
ILogger<DeltaSignatureMatcher> logger)
|
||||
: this(disassemblyService, normalizationService, new SymbolChangeTracer(), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MatchResult>> MatchAsync(
|
||||
Stream binaryStream,
|
||||
@@ -329,6 +344,58 @@ public sealed class DeltaSignatureMatcher : IDeltaSignatureMatcher
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DeltaComparisonResult> CompareSignaturesAsync(
|
||||
DeltaSignature fromSignature,
|
||||
DeltaSignature toSignature,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromSignature);
|
||||
ArgumentNullException.ThrowIfNull(toSignature);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Comparing signatures: {From} ({FromSymbols} symbols) -> {To} ({ToSymbols} symbols)",
|
||||
fromSignature.SignatureId,
|
||||
fromSignature.Symbols.Length,
|
||||
toSignature.SignatureId,
|
||||
toSignature.Symbols.Length);
|
||||
|
||||
var symbolResults = _changeTracer.CompareAllSymbols(fromSignature, toSignature);
|
||||
|
||||
var summary = new DeltaComparisonSummary
|
||||
{
|
||||
TotalSymbols = symbolResults.Count,
|
||||
UnchangedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Unchanged),
|
||||
AddedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Added),
|
||||
RemovedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Removed),
|
||||
ModifiedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Modified),
|
||||
PatchedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Patched),
|
||||
AverageConfidence = symbolResults.Count > 0
|
||||
? symbolResults.Average(r => r.Confidence)
|
||||
: 0.0,
|
||||
TotalSizeDelta = symbolResults.Sum(r => r.SizeDelta)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Comparison complete: {Total} symbols, {Unchanged} unchanged, {Added} added, {Removed} removed, {Modified} modified, {Patched} patched",
|
||||
summary.TotalSymbols,
|
||||
summary.UnchangedSymbols,
|
||||
summary.AddedSymbols,
|
||||
summary.RemovedSymbols,
|
||||
summary.ModifiedSymbols,
|
||||
summary.PatchedSymbols);
|
||||
|
||||
var result = new DeltaComparisonResult
|
||||
{
|
||||
FromSignatureId = fromSignature.SignatureId,
|
||||
ToSignatureId = toSignature.SignatureId,
|
||||
SymbolResults = [.. symbolResults],
|
||||
Summary = summary
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static byte[] GetNormalizedBytes(NormalizedFunction normalized)
|
||||
{
|
||||
var totalSize = normalized.Instructions.Sum(i => i.NormalizedBytes.Length);
|
||||
|
||||
@@ -35,4 +35,16 @@ public interface IDeltaSignatureMatcher
|
||||
string symbolHash,
|
||||
string symbolName,
|
||||
IEnumerable<DeltaSignature> signatures);
|
||||
|
||||
/// <summary>
|
||||
/// Compare two delta signatures and return detailed change information.
|
||||
/// </summary>
|
||||
/// <param name="fromSignature">The "before" signature.</param>
|
||||
/// <param name="toSignature">The "after" signature.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Detailed comparison result with symbol-level changes.</returns>
|
||||
Task<DeltaComparisonResult> CompareSignaturesAsync(
|
||||
DeltaSignature fromSignature,
|
||||
DeltaSignature toSignature,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig;
|
||||
|
||||
/// <summary>
|
||||
/// Service for detailed symbol comparison between binary versions.
|
||||
/// </summary>
|
||||
public interface ISymbolChangeTracer
|
||||
{
|
||||
/// <summary>
|
||||
/// Compare two symbol signatures and compute detailed change metrics.
|
||||
/// </summary>
|
||||
/// <param name="fromSymbol">Symbol from the "before" version (null if added).</param>
|
||||
/// <param name="toSymbol">Symbol from the "after" version (null if removed).</param>
|
||||
/// <returns>Detailed symbol match result with change tracking.</returns>
|
||||
SymbolMatchResult CompareSymbols(
|
||||
SymbolSignature? fromSymbol,
|
||||
SymbolSignature? toSymbol);
|
||||
|
||||
/// <summary>
|
||||
/// Compare all symbols between two delta signatures.
|
||||
/// </summary>
|
||||
/// <param name="fromSignature">The "before" delta signature.</param>
|
||||
/// <param name="toSignature">The "after" delta signature.</param>
|
||||
/// <returns>List of symbol comparison results.</returns>
|
||||
IReadOnlyList<SymbolMatchResult> CompareAllSymbols(
|
||||
DeltaSignature fromSignature,
|
||||
DeltaSignature toSignature);
|
||||
}
|
||||
@@ -72,6 +72,11 @@ public sealed record DeltaSignatureRequest
|
||||
/// </summary>
|
||||
public sealed record DeltaSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this signature.
|
||||
/// </summary>
|
||||
public string SignatureId { get; init; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Schema identifier for this signature format.
|
||||
/// </summary>
|
||||
@@ -278,6 +283,79 @@ public sealed record SymbolMatchResult
|
||||
/// Match confidence (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
// ====== CHANGE TRACKING FIELDS ======
|
||||
|
||||
/// <summary>
|
||||
/// Type of change detected.
|
||||
/// </summary>
|
||||
public SymbolChangeType ChangeType { get; init; } = SymbolChangeType.Unchanged;
|
||||
|
||||
/// <summary>
|
||||
/// Size delta in bytes (positive = larger, negative = smaller).
|
||||
/// </summary>
|
||||
public int SizeDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CFG basic block count delta (if available).
|
||||
/// </summary>
|
||||
public int? CfgBlockDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indices of chunks that matched (for partial match analysis).
|
||||
/// </summary>
|
||||
public ImmutableArray<int> MatchedChunkIndices { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation of the change.
|
||||
/// </summary>
|
||||
public string? ChangeExplanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the "from" version (before change).
|
||||
/// </summary>
|
||||
public string? FromHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the "to" version (after change).
|
||||
/// </summary>
|
||||
public string? ToHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method used for matching (CFGHash, InstructionHash, SemanticHash, ChunkHash).
|
||||
/// </summary>
|
||||
public string? MatchMethod { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of symbol change detected.
|
||||
/// </summary>
|
||||
public enum SymbolChangeType
|
||||
{
|
||||
/// <summary>
|
||||
/// No change detected.
|
||||
/// </summary>
|
||||
Unchanged,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol was added (not present in "from" version).
|
||||
/// </summary>
|
||||
Added,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol was removed (not present in "to" version).
|
||||
/// </summary>
|
||||
Removed,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol was modified (hash changed).
|
||||
/// </summary>
|
||||
Modified,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol was patched (security fix applied, verified).
|
||||
/// </summary>
|
||||
Patched
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -310,3 +388,75 @@ public sealed record AuthoringResult
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of comparing two delta signatures.
|
||||
/// </summary>
|
||||
public sealed record DeltaComparisonResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifier for the "from" signature.
|
||||
/// </summary>
|
||||
public required string FromSignatureId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier for the "to" signature.
|
||||
/// </summary>
|
||||
public required string ToSignatureId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual symbol comparison results.
|
||||
/// </summary>
|
||||
public ImmutableArray<SymbolMatchResult> SymbolResults { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the comparison.
|
||||
/// </summary>
|
||||
public required DeltaComparisonSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a delta comparison between two signatures.
|
||||
/// </summary>
|
||||
public sealed record DeltaComparisonSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of symbols compared.
|
||||
/// </summary>
|
||||
public int TotalSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unchanged symbols.
|
||||
/// </summary>
|
||||
public int UnchangedSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of added symbols.
|
||||
/// </summary>
|
||||
public int AddedSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of removed symbols.
|
||||
/// </summary>
|
||||
public int RemovedSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of modified symbols.
|
||||
/// </summary>
|
||||
public int ModifiedSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of patched symbols (security fixes).
|
||||
/// </summary>
|
||||
public int PatchedSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Average confidence across all symbol comparisons.
|
||||
/// </summary>
|
||||
public double AverageConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size delta in bytes.
|
||||
/// </summary>
|
||||
public int TotalSizeDelta { get; init; }
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ public static class ServiceCollectionExtensions
|
||||
logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<ISymbolChangeTracer, SymbolChangeTracer>();
|
||||
services.AddSingleton<IDeltaSignatureMatcher, DeltaSignatureMatcher>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig;
|
||||
|
||||
/// <summary>
|
||||
/// Service for detailed symbol comparison between binary versions.
|
||||
/// Determines change type, similarity, and generates explanations.
|
||||
/// </summary>
|
||||
public sealed class SymbolChangeTracer : ISymbolChangeTracer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SymbolMatchResult CompareSymbols(
|
||||
SymbolSignature? fromSymbol,
|
||||
SymbolSignature? toSymbol)
|
||||
{
|
||||
// Case 1: Symbol added
|
||||
if (fromSymbol is null && toSymbol is not null)
|
||||
{
|
||||
return new SymbolMatchResult
|
||||
{
|
||||
SymbolName = toSymbol.Name,
|
||||
ExactMatch = false,
|
||||
Confidence = 1.0,
|
||||
ChangeType = SymbolChangeType.Added,
|
||||
SizeDelta = toSymbol.SizeBytes,
|
||||
ToHash = toSymbol.HashHex,
|
||||
ChangeExplanation = "Symbol added in new version"
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Symbol removed
|
||||
if (fromSymbol is not null && toSymbol is null)
|
||||
{
|
||||
return new SymbolMatchResult
|
||||
{
|
||||
SymbolName = fromSymbol.Name,
|
||||
ExactMatch = false,
|
||||
Confidence = 1.0,
|
||||
ChangeType = SymbolChangeType.Removed,
|
||||
SizeDelta = -fromSymbol.SizeBytes,
|
||||
FromHash = fromSymbol.HashHex,
|
||||
ChangeExplanation = "Symbol removed in new version"
|
||||
};
|
||||
}
|
||||
|
||||
// Case 3: Both exist - compare
|
||||
if (fromSymbol is not null && toSymbol is not null)
|
||||
{
|
||||
return CompareExistingSymbols(fromSymbol, toSymbol);
|
||||
}
|
||||
|
||||
// Case 4: Both null (shouldn't happen)
|
||||
throw new ArgumentException("Both symbols cannot be null");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<SymbolMatchResult> CompareAllSymbols(
|
||||
DeltaSignature fromSignature,
|
||||
DeltaSignature toSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromSignature);
|
||||
ArgumentNullException.ThrowIfNull(toSignature);
|
||||
|
||||
var fromSymbols = fromSignature.Symbols
|
||||
.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
var toSymbols = toSignature.Symbols
|
||||
.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
|
||||
var allNames = fromSymbols.Keys
|
||||
.Union(toSymbols.Keys, StringComparer.Ordinal)
|
||||
.OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
var results = new List<SymbolMatchResult>();
|
||||
|
||||
foreach (var name in allNames)
|
||||
{
|
||||
fromSymbols.TryGetValue(name, out var fromSymbol);
|
||||
toSymbols.TryGetValue(name, out var toSymbol);
|
||||
|
||||
var result = CompareSymbols(fromSymbol, toSymbol);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static SymbolMatchResult CompareExistingSymbols(
|
||||
SymbolSignature from,
|
||||
SymbolSignature to)
|
||||
{
|
||||
var exactMatch = string.Equals(from.HashHex, to.HashHex, StringComparison.OrdinalIgnoreCase);
|
||||
var sizeDelta = to.SizeBytes - from.SizeBytes;
|
||||
var cfgDelta = (from.CfgBbCount.HasValue && to.CfgBbCount.HasValue)
|
||||
? to.CfgBbCount.Value - from.CfgBbCount.Value
|
||||
: (int?)null;
|
||||
|
||||
if (exactMatch)
|
||||
{
|
||||
return new SymbolMatchResult
|
||||
{
|
||||
SymbolName = from.Name,
|
||||
ExactMatch = true,
|
||||
Confidence = 1.0,
|
||||
ChangeType = SymbolChangeType.Unchanged,
|
||||
SizeDelta = 0,
|
||||
FromHash = from.HashHex,
|
||||
ToHash = to.HashHex,
|
||||
MatchMethod = "ExactHash",
|
||||
ChangeExplanation = "No change detected"
|
||||
};
|
||||
}
|
||||
|
||||
// Compute chunk matches
|
||||
var fromChunks = from.Chunks ?? [];
|
||||
var toChunks = to.Chunks ?? [];
|
||||
var (chunksMatched, matchedIndices) = CompareChunks(fromChunks, toChunks);
|
||||
var chunkSimilarity = fromChunks.Length > 0
|
||||
? (double)chunksMatched / fromChunks.Length
|
||||
: 0.0;
|
||||
|
||||
// Determine change type and confidence
|
||||
var (changeType, confidence, explanation, method) = DetermineChange(
|
||||
from, to, chunkSimilarity, cfgDelta);
|
||||
|
||||
return new SymbolMatchResult
|
||||
{
|
||||
SymbolName = from.Name,
|
||||
ExactMatch = false,
|
||||
ChunksMatched = chunksMatched,
|
||||
ChunksTotal = Math.Max(fromChunks.Length, toChunks.Length),
|
||||
Confidence = confidence,
|
||||
ChangeType = changeType,
|
||||
SizeDelta = sizeDelta,
|
||||
CfgBlockDelta = cfgDelta,
|
||||
MatchedChunkIndices = matchedIndices,
|
||||
FromHash = from.HashHex,
|
||||
ToHash = to.HashHex,
|
||||
MatchMethod = method,
|
||||
ChangeExplanation = explanation
|
||||
};
|
||||
}
|
||||
|
||||
private static (int matched, ImmutableArray<int> indices) CompareChunks(
|
||||
ImmutableArray<ChunkHash> fromChunks,
|
||||
ImmutableArray<ChunkHash> toChunks)
|
||||
{
|
||||
if (fromChunks.Length == 0 || toChunks.Length == 0)
|
||||
{
|
||||
return (0, []);
|
||||
}
|
||||
|
||||
var toChunkSet = toChunks
|
||||
.Select(c => c.HashHex)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matchedIndices = new List<int>();
|
||||
var matched = 0;
|
||||
|
||||
for (var i = 0; i < fromChunks.Length; i++)
|
||||
{
|
||||
if (toChunkSet.Contains(fromChunks[i].HashHex))
|
||||
{
|
||||
matched++;
|
||||
matchedIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return (matched, matchedIndices.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static (SymbolChangeType type, double confidence, string explanation, string method)
|
||||
DetermineChange(
|
||||
SymbolSignature from,
|
||||
SymbolSignature to,
|
||||
double chunkSimilarity,
|
||||
int? cfgDelta)
|
||||
{
|
||||
// High chunk similarity with CFG change = likely patch
|
||||
if (chunkSimilarity >= 0.85 && cfgDelta.HasValue && Math.Abs(cfgDelta.Value) <= 5)
|
||||
{
|
||||
return (
|
||||
SymbolChangeType.Patched,
|
||||
Math.Min(0.95, chunkSimilarity),
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Function patched: {0} basic blocks changed",
|
||||
Math.Abs(cfgDelta.Value)),
|
||||
"CFGHash+ChunkMatch"
|
||||
);
|
||||
}
|
||||
|
||||
// High chunk similarity = minor modification
|
||||
if (chunkSimilarity >= 0.7)
|
||||
{
|
||||
return (
|
||||
SymbolChangeType.Modified,
|
||||
chunkSimilarity,
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Function modified: {0:P0} of code changed",
|
||||
1 - chunkSimilarity),
|
||||
"ChunkMatch"
|
||||
);
|
||||
}
|
||||
|
||||
// Semantic match check (if available)
|
||||
if (!string.IsNullOrEmpty(from.SemanticHashHex) &&
|
||||
!string.IsNullOrEmpty(to.SemanticHashHex))
|
||||
{
|
||||
var semanticMatch = string.Equals(
|
||||
from.SemanticHashHex, to.SemanticHashHex,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (semanticMatch)
|
||||
{
|
||||
return (
|
||||
SymbolChangeType.Modified,
|
||||
0.80,
|
||||
"Function semantically equivalent (compiler variation)",
|
||||
"SemanticHash"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Low similarity = significant modification
|
||||
return (
|
||||
SymbolChangeType.Modified,
|
||||
Math.Max(0.4, chunkSimilarity),
|
||||
"Function significantly modified",
|
||||
"ChunkMatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.BinaryIndex.Disassembly;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Ghidra;
|
||||
|
||||
@@ -29,6 +30,7 @@ public sealed class GhidraDisassemblyPlugin : IDisassemblyPlugin, IDisposable
|
||||
private readonly GhidraOptions _options;
|
||||
private readonly ILogger<GhidraDisassemblyPlugin> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private bool _disposed;
|
||||
|
||||
private static readonly DisassemblyCapabilities s_capabilities = new()
|
||||
@@ -74,16 +76,19 @@ public sealed class GhidraDisassemblyPlugin : IDisassemblyPlugin, IDisposable
|
||||
/// <param name="options">Ghidra options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <param name="guidProvider">GUID provider for deterministic IDs.</param>
|
||||
public GhidraDisassemblyPlugin(
|
||||
IGhidraService ghidraService,
|
||||
IOptions<GhidraOptions> options,
|
||||
ILogger<GhidraDisassemblyPlugin> logger,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_ghidraService = ghidraService ?? throw new ArgumentNullException(nameof(ghidraService));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -297,7 +302,7 @@ public sealed class GhidraDisassemblyPlugin : IDisassemblyPlugin, IDisposable
|
||||
// Write bytes to temp file
|
||||
var tempPath = Path.Combine(
|
||||
_options.WorkDir,
|
||||
$"disasm_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{Guid.NewGuid():N}.bin");
|
||||
$"disasm_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{_guidProvider.NewGuid():N}.bin");
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Ghidra;
|
||||
|
||||
@@ -18,6 +19,8 @@ public sealed class GhidraHeadlessManager : IAsyncDisposable
|
||||
{
|
||||
private readonly GhidraOptions _options;
|
||||
private readonly ILogger<GhidraHeadlessManager> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -26,12 +29,18 @@ public sealed class GhidraHeadlessManager : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="options">Ghidra configuration options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider for deterministic time.</param>
|
||||
/// <param name="guidProvider">GUID provider for deterministic IDs.</param>
|
||||
public GhidraHeadlessManager(
|
||||
IOptions<GhidraOptions> options,
|
||||
ILogger<GhidraHeadlessManager> logger)
|
||||
ILogger<GhidraHeadlessManager> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_semaphore = new SemaphoreSlim(_options.MaxConcurrentInstances, _options.MaxConcurrentInstances);
|
||||
|
||||
EnsureWorkDirectoryExists();
|
||||
@@ -180,7 +189,7 @@ public sealed class GhidraHeadlessManager : IAsyncDisposable
|
||||
{
|
||||
var projectDir = Path.Combine(
|
||||
_options.WorkDir,
|
||||
$"project_{DateTime.UtcNow:yyyyMMddHHmmssfff}_{Guid.NewGuid():N}");
|
||||
$"project_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{_guidProvider.NewGuid():N}");
|
||||
|
||||
Directory.CreateDirectory(projectDir);
|
||||
_logger.LogDebug("Created temp project directory: {Path}", projectDir);
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Ghidra;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class GhidraService : IGhidraService, IAsyncDisposable
|
||||
private readonly GhidraOptions _options;
|
||||
private readonly ILogger<GhidraService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new GhidraService.
|
||||
@@ -33,16 +35,19 @@ public sealed class GhidraService : IGhidraService, IAsyncDisposable
|
||||
/// <param name="options">Ghidra options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <param name="guidProvider">GUID provider for deterministic IDs.</param>
|
||||
public GhidraService(
|
||||
GhidraHeadlessManager headlessManager,
|
||||
IOptions<GhidraOptions> options,
|
||||
ILogger<GhidraService> logger,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_headlessManager = headlessManager;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -56,7 +61,7 @@ public sealed class GhidraService : IGhidraService, IAsyncDisposable
|
||||
// Write stream to temp file
|
||||
var tempPath = Path.Combine(
|
||||
_options.WorkDir,
|
||||
$"binary_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{Guid.NewGuid():N}.bin");
|
||||
$"binary_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{_guidProvider.NewGuid():N}.bin");
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Ghidra;
|
||||
|
||||
@@ -28,6 +29,7 @@ public sealed class GhidriffBridge : IGhidriffBridge
|
||||
private readonly GhidraOptions _ghidraOptions;
|
||||
private readonly ILogger<GhidriffBridge> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new GhidriffBridge.
|
||||
@@ -36,16 +38,19 @@ public sealed class GhidriffBridge : IGhidriffBridge
|
||||
/// <param name="ghidraOptions">Ghidra options for path configuration.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider.</param>
|
||||
/// <param name="guidProvider">GUID provider for deterministic IDs.</param>
|
||||
public GhidriffBridge(
|
||||
IOptions<GhidriffOptions> options,
|
||||
IOptions<GhidraOptions> ghidraOptions,
|
||||
ILogger<GhidriffBridge> logger,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_ghidraOptions = ghidraOptions.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
|
||||
EnsureWorkDirectoryExists();
|
||||
}
|
||||
@@ -212,7 +217,7 @@ public sealed class GhidriffBridge : IGhidriffBridge
|
||||
{
|
||||
var outputDir = Path.Combine(
|
||||
_options.WorkDir,
|
||||
$"diff_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{Guid.NewGuid():N}");
|
||||
$"diff_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{_guidProvider.NewGuid():N}");
|
||||
|
||||
Directory.CreateDirectory(outputDir);
|
||||
return outputDir;
|
||||
@@ -523,7 +528,7 @@ public sealed class GhidriffBridge : IGhidriffBridge
|
||||
{
|
||||
var path = Path.Combine(
|
||||
_options.WorkDir,
|
||||
$"{prefix}_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{Guid.NewGuid():N}.bin");
|
||||
$"{prefix}_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{_guidProvider.NewGuid():N}.bin");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Ghidra;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class VersionTrackingService : IVersionTrackingService
|
||||
private readonly GhidraOptions _options;
|
||||
private readonly ILogger<VersionTrackingService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new VersionTrackingService.
|
||||
@@ -33,16 +35,19 @@ public sealed class VersionTrackingService : IVersionTrackingService
|
||||
/// <param name="options">Ghidra options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider.</param>
|
||||
/// <param name="guidProvider">GUID provider for deterministic IDs.</param>
|
||||
public VersionTrackingService(
|
||||
GhidraHeadlessManager headlessManager,
|
||||
IOptions<GhidraOptions> options,
|
||||
ILogger<VersionTrackingService> logger,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_headlessManager = headlessManager;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -343,7 +348,7 @@ public sealed class VersionTrackingService : IVersionTrackingService
|
||||
{
|
||||
var path = Path.Combine(
|
||||
_options.WorkDir,
|
||||
$"{prefix}_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{Guid.NewGuid():N}.bin");
|
||||
$"{prefix}_{_timeProvider.GetUtcNow():yyyyMMddHHmmssfff}_{_guidProvider.NewGuid():N}.bin");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Disassembly.Abstractions\StellaOps.BinaryIndex.Disassembly.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Contracts\StellaOps.BinaryIndex.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.BinaryIndex.DeltaSig;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Persistence.Repositories;
|
||||
|
||||
@@ -17,6 +18,8 @@ public sealed class DeltaSignatureRepository : IDeltaSignatureRepository
|
||||
{
|
||||
private readonly BinaryIndexDbContext _dbContext;
|
||||
private readonly ILogger<DeltaSignatureRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
@@ -26,10 +29,14 @@ public sealed class DeltaSignatureRepository : IDeltaSignatureRepository
|
||||
|
||||
public DeltaSignatureRepository(
|
||||
BinaryIndexDbContext dbContext,
|
||||
ILogger<DeltaSignatureRepository> logger)
|
||||
ILogger<DeltaSignatureRepository> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -59,8 +66,8 @@ public sealed class DeltaSignatureRepository : IDeltaSignatureRepository
|
||||
RETURNING id, created_at, updated_at
|
||||
""";
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var id = entity.Id != Guid.Empty ? entity.Id : Guid.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = entity.Id != Guid.Empty ? entity.Id : _guidProvider.NewGuid();
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
@@ -362,7 +369,7 @@ public sealed class DeltaSignatureRepository : IDeltaSignatureRepository
|
||||
RETURNING updated_at
|
||||
""";
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
|
||||
@@ -3,6 +3,8 @@ using Dapper;
|
||||
using StellaOps.BinaryIndex.Fingerprints;
|
||||
using StellaOps.BinaryIndex.Fingerprints.Models;
|
||||
using System.Text.Json;
|
||||
using IGuidProvider = StellaOps.Determinism.IGuidProvider;
|
||||
using SystemGuidProvider = StellaOps.Determinism.SystemGuidProvider;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Persistence.Repositories;
|
||||
|
||||
@@ -12,11 +14,13 @@ namespace StellaOps.BinaryIndex.Persistence.Repositories;
|
||||
public sealed class FingerprintRepository : IFingerprintRepository
|
||||
{
|
||||
private readonly BinaryIndexDbContext _dbContext;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public FingerprintRepository(BinaryIndexDbContext dbContext)
|
||||
public FingerprintRepository(BinaryIndexDbContext dbContext, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<VulnFingerprint> CreateAsync(VulnFingerprint fingerprint, CancellationToken ct = default)
|
||||
@@ -42,7 +46,7 @@ public sealed class FingerprintRepository : IFingerprintRepository
|
||||
sql,
|
||||
new
|
||||
{
|
||||
Id = fingerprint.Id != Guid.Empty ? fingerprint.Id : Guid.NewGuid(),
|
||||
Id = fingerprint.Id != Guid.Empty ? fingerprint.Id : _guidProvider.NewGuid(),
|
||||
fingerprint.CveId,
|
||||
fingerprint.Component,
|
||||
fingerprint.Purl,
|
||||
@@ -256,10 +260,12 @@ public sealed class FingerprintRepository : IFingerprintRepository
|
||||
public sealed class FingerprintMatchRepository : IFingerprintMatchRepository
|
||||
{
|
||||
private readonly BinaryIndexDbContext _dbContext;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public FingerprintMatchRepository(BinaryIndexDbContext dbContext)
|
||||
public FingerprintMatchRepository(BinaryIndexDbContext dbContext, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<FingerprintMatch> CreateAsync(FingerprintMatch match, CancellationToken ct = default)
|
||||
@@ -284,7 +290,7 @@ public sealed class FingerprintMatchRepository : IFingerprintMatchRepository
|
||||
sql,
|
||||
new
|
||||
{
|
||||
Id = match.Id != Guid.Empty ? match.Id : Guid.NewGuid(),
|
||||
Id = match.Id != Guid.Empty ? match.Id : _guidProvider.NewGuid(),
|
||||
match.ScanId,
|
||||
MatchType = match.Type.ToString().ToLowerInvariant(),
|
||||
match.BinaryKey,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.FixIndex\StellaOps.BinaryIndex.FixIndex.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Fingerprints\StellaOps.BinaryIndex.Fingerprints.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
</PackageReference> <PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DeltaSignatureMatcher.CompareSignaturesAsync via SymbolChangeTracer.
|
||||
/// Note: CompareSignaturesAsync only requires ISymbolChangeTracer, not disassembly services.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ExtendedMatcherTests
|
||||
{
|
||||
private readonly SymbolChangeTracer _changeTracer;
|
||||
|
||||
public ExtendedMatcherTests()
|
||||
{
|
||||
_changeTracer = new SymbolChangeTracer();
|
||||
}
|
||||
|
||||
// Helper to directly test the comparison logic that CompareSignaturesAsync uses
|
||||
private DeltaComparisonResult CompareSignatures(DeltaSignature from, DeltaSignature to)
|
||||
{
|
||||
var symbolResults = _changeTracer.CompareAllSymbols(from, to);
|
||||
|
||||
var summary = new DeltaComparisonSummary
|
||||
{
|
||||
TotalSymbols = symbolResults.Count,
|
||||
UnchangedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Unchanged),
|
||||
AddedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Added),
|
||||
RemovedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Removed),
|
||||
ModifiedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Modified),
|
||||
PatchedSymbols = symbolResults.Count(r => r.ChangeType == SymbolChangeType.Patched),
|
||||
AverageConfidence = symbolResults.Count > 0
|
||||
? symbolResults.Average(r => r.Confidence)
|
||||
: 0.0,
|
||||
TotalSizeDelta = symbolResults.Sum(r => r.SizeDelta)
|
||||
};
|
||||
|
||||
return new DeltaComparisonResult
|
||||
{
|
||||
FromSignatureId = from.SignatureId,
|
||||
ToSignatureId = to.SignatureId,
|
||||
SymbolResults = [.. symbolResults],
|
||||
Summary = summary
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_IdenticalSignatures_ReturnsAllUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new[]
|
||||
{
|
||||
CreateSymbol("func1", "sha256:abc", 100),
|
||||
CreateSymbol("func2", "sha256:def", 200)
|
||||
};
|
||||
|
||||
var fromSig = CreateSignature("from-1", symbols);
|
||||
var toSig = CreateSignature("to-1", symbols);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.FromSignatureId.Should().Be("from-1");
|
||||
result.ToSignatureId.Should().Be("to-1");
|
||||
result.Summary.TotalSymbols.Should().Be(2);
|
||||
result.Summary.UnchangedSymbols.Should().Be(2);
|
||||
result.Summary.AddedSymbols.Should().Be(0);
|
||||
result.Summary.RemovedSymbols.Should().Be(0);
|
||||
result.Summary.ModifiedSymbols.Should().Be(0);
|
||||
result.Summary.AverageConfidence.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_AddedSymbols_CountsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var fromSig = CreateSignature("from", [CreateSymbol("existing", "sha256:a", 100)]);
|
||||
var toSig = CreateSignature("to",
|
||||
[
|
||||
CreateSymbol("existing", "sha256:a", 100),
|
||||
CreateSymbol("new1", "sha256:b", 200),
|
||||
CreateSymbol("new2", "sha256:c", 300)
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.Summary.AddedSymbols.Should().Be(2);
|
||||
result.Summary.UnchangedSymbols.Should().Be(1);
|
||||
result.Summary.TotalSymbols.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_RemovedSymbols_CountsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var fromSig = CreateSignature("from",
|
||||
[
|
||||
CreateSymbol("staying", "sha256:a", 100),
|
||||
CreateSymbol("removed1", "sha256:b", 200),
|
||||
CreateSymbol("removed2", "sha256:c", 300)
|
||||
]);
|
||||
var toSig = CreateSignature("to", [CreateSymbol("staying", "sha256:a", 100)]);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.Summary.RemovedSymbols.Should().Be(2);
|
||||
result.Summary.UnchangedSymbols.Should().Be(1);
|
||||
result.Summary.TotalSymbols.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_ModifiedSymbols_CountsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var fromSig = CreateSignature("from",
|
||||
[
|
||||
CreateSymbol("unchanged", "sha256:same", 100),
|
||||
CreateSymbol("modified", "sha256:old", 200)
|
||||
]);
|
||||
var toSig = CreateSignature("to",
|
||||
[
|
||||
CreateSymbol("unchanged", "sha256:same", 100),
|
||||
CreateSymbol("modified", "sha256:new", 220)
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.Summary.ModifiedSymbols.Should().Be(1);
|
||||
result.Summary.UnchangedSymbols.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_CalculatesTotalSizeDelta()
|
||||
{
|
||||
// Arrange
|
||||
var fromSig = CreateSignature("from",
|
||||
[
|
||||
CreateSymbol("func1", "sha256:a", 100),
|
||||
CreateSymbol("func2", "sha256:b", 200)
|
||||
]);
|
||||
var toSig = CreateSignature("to",
|
||||
[
|
||||
CreateSymbol("func1", "sha256:c", 150), // +50
|
||||
CreateSymbol("func2", "sha256:d", 180) // -20
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert - Total delta should be +50 - 20 = +30
|
||||
result.Summary.TotalSizeDelta.Should().Be(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_MixedChanges_SummaryIsComplete()
|
||||
{
|
||||
// Arrange
|
||||
var fromSig = CreateSignature("from",
|
||||
[
|
||||
CreateSymbol("unchanged", "sha256:same", 100),
|
||||
CreateSymbol("modified", "sha256:old", 200),
|
||||
CreateSymbol("removed", "sha256:gone", 150)
|
||||
]);
|
||||
var toSig = CreateSignature("to",
|
||||
[
|
||||
CreateSymbol("unchanged", "sha256:same", 100),
|
||||
CreateSymbol("modified", "sha256:new", 220),
|
||||
CreateSymbol("added", "sha256:brand-new", 300)
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.Summary.TotalSymbols.Should().Be(4);
|
||||
result.Summary.UnchangedSymbols.Should().Be(1);
|
||||
result.Summary.ModifiedSymbols.Should().Be(1);
|
||||
result.Summary.AddedSymbols.Should().Be(1);
|
||||
result.Summary.RemovedSymbols.Should().Be(1);
|
||||
result.Summary.PatchedSymbols.Should().Be(0);
|
||||
result.SymbolResults.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_PatchedSymbols_CountsCorrectly()
|
||||
{
|
||||
// Arrange - High chunk similarity (>= 85%) with CFG change -> 6/7 = 86%
|
||||
var fromSig = CreateSignature("from",
|
||||
[
|
||||
CreateSymbolWithChunks("patched", "sha256:before", 350, 10,
|
||||
[("sha256:1", 0, 50), ("sha256:2", 50, 50), ("sha256:3", 100, 50), ("sha256:4", 150, 50),
|
||||
("sha256:5", 200, 50), ("sha256:6", 250, 50), ("sha256:7", 300, 50)])
|
||||
]);
|
||||
var toSig = CreateSignature("to",
|
||||
[
|
||||
CreateSymbolWithChunks("patched", "sha256:after", 360, 12,
|
||||
[("sha256:1", 0, 50), ("sha256:2", 50, 50), ("sha256:3", 100, 50), ("sha256:4", 150, 50),
|
||||
("sha256:5", 200, 50), ("sha256:6", 250, 50), ("sha256:new", 300, 60)])
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.Summary.PatchedSymbols.Should().Be(1);
|
||||
result.Summary.ModifiedSymbols.Should().Be(0); // Patched is separate from Modified
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_EmptySignatures_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var fromSig = CreateSignature("from", []);
|
||||
var toSig = CreateSignature("to", []);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.Summary.TotalSymbols.Should().Be(0);
|
||||
result.SymbolResults.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSignaturesAsync_SymbolResultsContainDetailedInfo()
|
||||
{
|
||||
// Arrange
|
||||
var fromSig = CreateSignature("from", [CreateSymbol("func", "sha256:old", 100)]);
|
||||
var toSig = CreateSignature("to", [CreateSymbol("func", "sha256:new", 120)]);
|
||||
|
||||
// Act
|
||||
var result = CompareSignatures(fromSig, toSig);
|
||||
|
||||
// Assert
|
||||
result.SymbolResults.Should().HaveCount(1);
|
||||
var symbolResult = result.SymbolResults[0];
|
||||
symbolResult.SymbolName.Should().Be("func");
|
||||
symbolResult.FromHash.Should().Be("sha256:old");
|
||||
symbolResult.ToHash.Should().Be("sha256:new");
|
||||
symbolResult.SizeDelta.Should().Be(20);
|
||||
symbolResult.ChangeExplanation.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static SymbolSignature CreateSymbol(string name, string hash, int size, int? cfgCount = null)
|
||||
{
|
||||
return new SymbolSignature
|
||||
{
|
||||
Name = name,
|
||||
HashAlg = "sha256",
|
||||
HashHex = hash,
|
||||
SizeBytes = size,
|
||||
CfgBbCount = cfgCount
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolSignature CreateSymbolWithChunks(
|
||||
string name, string hash, int size, int? cfgCount,
|
||||
(string hash, int offset, int size)[] chunks)
|
||||
{
|
||||
return new SymbolSignature
|
||||
{
|
||||
Name = name,
|
||||
HashAlg = "sha256",
|
||||
HashHex = hash,
|
||||
SizeBytes = size,
|
||||
CfgBbCount = cfgCount,
|
||||
Chunks = chunks.Select(c => new ChunkHash(c.offset, c.size, c.hash)).ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static DeltaSignature CreateSignature(string id, SymbolSignature[] symbols)
|
||||
{
|
||||
return new DeltaSignature
|
||||
{
|
||||
SignatureId = id,
|
||||
Cve = "CVE-2026-0001",
|
||||
Package = new PackageRef("testpkg", null),
|
||||
Target = new TargetRef("x86_64", "gnu"),
|
||||
Normalization = new NormalizationRef("test", "1.0", []),
|
||||
SignatureState = "vulnerable",
|
||||
Symbols = symbols.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SymbolChangeTracer.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SymbolChangeTracerTests
|
||||
{
|
||||
private readonly SymbolChangeTracer _tracer;
|
||||
|
||||
public SymbolChangeTracerTests()
|
||||
{
|
||||
_tracer = new SymbolChangeTracer();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_AddedSymbol_ReturnsAddedChangeType()
|
||||
{
|
||||
// Arrange
|
||||
var toSymbol = CreateSymbol("new_function", "sha256:abc123", 512);
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(null, toSymbol);
|
||||
|
||||
// Assert
|
||||
result.ChangeType.Should().Be(SymbolChangeType.Added);
|
||||
result.SymbolName.Should().Be("new_function");
|
||||
result.SizeDelta.Should().Be(512);
|
||||
result.ToHash.Should().Be("sha256:abc123");
|
||||
result.FromHash.Should().BeNull();
|
||||
result.Confidence.Should().Be(1.0);
|
||||
result.ChangeExplanation.Should().Contain("added");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_RemovedSymbol_ReturnsRemovedChangeType()
|
||||
{
|
||||
// Arrange
|
||||
var fromSymbol = CreateSymbol("old_function", "sha256:def456", 256);
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(fromSymbol, null);
|
||||
|
||||
// Assert
|
||||
result.ChangeType.Should().Be(SymbolChangeType.Removed);
|
||||
result.SymbolName.Should().Be("old_function");
|
||||
result.SizeDelta.Should().Be(-256);
|
||||
result.FromHash.Should().Be("sha256:def456");
|
||||
result.ToHash.Should().BeNull();
|
||||
result.Confidence.Should().Be(1.0);
|
||||
result.ChangeExplanation.Should().Contain("removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_UnchangedSymbol_ReturnsUnchangedChangeType()
|
||||
{
|
||||
// Arrange
|
||||
var fromSymbol = CreateSymbol("stable_function", "sha256:same", 128);
|
||||
var toSymbol = CreateSymbol("stable_function", "sha256:same", 128);
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(fromSymbol, toSymbol);
|
||||
|
||||
// Assert
|
||||
result.ChangeType.Should().Be(SymbolChangeType.Unchanged);
|
||||
result.ExactMatch.Should().BeTrue();
|
||||
result.SizeDelta.Should().Be(0);
|
||||
result.Confidence.Should().Be(1.0);
|
||||
result.MatchMethod.Should().Be("ExactHash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_ModifiedSymbol_ReturnsModifiedChangeType()
|
||||
{
|
||||
// Arrange
|
||||
var fromSymbol = CreateSymbol("func", "sha256:before", 100);
|
||||
var toSymbol = CreateSymbol("func", "sha256:after", 120);
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(fromSymbol, toSymbol);
|
||||
|
||||
// Assert
|
||||
result.ChangeType.Should().Be(SymbolChangeType.Modified);
|
||||
result.ExactMatch.Should().BeFalse();
|
||||
result.SizeDelta.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_HighChunkSimilarityWithCfgChange_ReturnsPatchedChangeType()
|
||||
{
|
||||
// Arrange - >= 85% chunk similarity (6/7 = 86%) with small CFG change indicates patch
|
||||
var fromSymbol = CreateSymbolWithChunks("patched_func", "sha256:before", 350, 10,
|
||||
[("sha256:chunk1", 0, 50), ("sha256:chunk2", 50, 50), ("sha256:chunk3", 100, 50),
|
||||
("sha256:chunk4", 150, 50), ("sha256:chunk5", 200, 50), ("sha256:chunk6", 250, 50), ("sha256:chunk7", 300, 50)]);
|
||||
var toSymbol = CreateSymbolWithChunks("patched_func", "sha256:after", 360, 12,
|
||||
[("sha256:chunk1", 0, 50), ("sha256:chunk2", 50, 50), ("sha256:chunk3", 100, 50),
|
||||
("sha256:chunk4", 150, 50), ("sha256:chunk5", 200, 50), ("sha256:chunk6", 250, 50), ("sha256:newchunk", 300, 60)]);
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(fromSymbol, toSymbol);
|
||||
|
||||
// Assert
|
||||
result.ChangeType.Should().Be(SymbolChangeType.Patched);
|
||||
result.CfgBlockDelta.Should().Be(2);
|
||||
result.ChangeExplanation.Should().Contain("patched");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_HighChunkSimilarity_ReturnsModifiedWithHighConfidence()
|
||||
{
|
||||
// Arrange - 75% chunk similarity
|
||||
var fromSymbol = CreateSymbolWithChunks("func", "sha256:before", 200, null,
|
||||
[("sha256:chunk1", 0, 50), ("sha256:chunk2", 50, 50), ("sha256:chunk3", 100, 50), ("sha256:chunk4", 150, 50)]);
|
||||
var toSymbol = CreateSymbolWithChunks("func", "sha256:after", 200, null,
|
||||
[("sha256:chunk1", 0, 50), ("sha256:chunk2", 50, 50), ("sha256:chunk3", 100, 50), ("sha256:new", 150, 50)]);
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(fromSymbol, toSymbol);
|
||||
|
||||
// Assert
|
||||
result.ChangeType.Should().Be(SymbolChangeType.Modified);
|
||||
result.ChunksMatched.Should().Be(3);
|
||||
result.ChunksTotal.Should().Be(4);
|
||||
result.Confidence.Should().BeGreaterThanOrEqualTo(0.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_SemanticMatch_ReturnsModifiedWithSemanticMethod()
|
||||
{
|
||||
// Arrange - Different hash but same semantic fingerprint
|
||||
var fromSymbol = CreateSymbolWithSemantic("func", "sha256:before", 200, "sha256:semantic1");
|
||||
var toSymbol = CreateSymbolWithSemantic("func", "sha256:after", 200, "sha256:semantic1");
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(fromSymbol, toSymbol);
|
||||
|
||||
// Assert
|
||||
result.ChangeType.Should().Be(SymbolChangeType.Modified);
|
||||
result.MatchMethod.Should().Be("SemanticHash");
|
||||
result.Confidence.Should().BeGreaterThanOrEqualTo(0.8);
|
||||
result.ChangeExplanation.Should().Contain("semantically equivalent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_BothNull_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
var action = () => _tracer.CompareSymbols(null, null);
|
||||
action.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareAllSymbols_MixedChanges_ReturnsCorrectResults()
|
||||
{
|
||||
// Arrange
|
||||
var fromSignature = CreateSignature("sig-from",
|
||||
[
|
||||
CreateSymbol("unchanged", "sha256:same", 100),
|
||||
CreateSymbol("modified", "sha256:old", 200),
|
||||
CreateSymbol("removed", "sha256:gone", 150)
|
||||
]);
|
||||
|
||||
var toSignature = CreateSignature("sig-to",
|
||||
[
|
||||
CreateSymbol("unchanged", "sha256:same", 100),
|
||||
CreateSymbol("modified", "sha256:new", 220),
|
||||
CreateSymbol("added", "sha256:new", 300)
|
||||
]);
|
||||
|
||||
// Act
|
||||
var results = _tracer.CompareAllSymbols(fromSignature, toSignature);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(4);
|
||||
results.Should().ContainSingle(r => r.ChangeType == SymbolChangeType.Unchanged);
|
||||
results.Should().ContainSingle(r => r.ChangeType == SymbolChangeType.Modified);
|
||||
results.Should().ContainSingle(r => r.ChangeType == SymbolChangeType.Added);
|
||||
results.Should().ContainSingle(r => r.ChangeType == SymbolChangeType.Removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareAllSymbols_ResultsAreSortedByName()
|
||||
{
|
||||
// Arrange
|
||||
var fromSignature = CreateSignature("from", [CreateSymbol("z_func", "sha256:a", 100)]);
|
||||
var toSignature = CreateSignature("to", [CreateSymbol("a_func", "sha256:b", 100)]);
|
||||
|
||||
// Act
|
||||
var results = _tracer.CompareAllSymbols(fromSignature, toSignature);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results[0].SymbolName.Should().Be("a_func");
|
||||
results[1].SymbolName.Should().Be("z_func");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareSymbols_RecordsMatchedChunkIndices()
|
||||
{
|
||||
// Arrange
|
||||
var fromSymbol = CreateSymbolWithChunks("func", "sha256:before", 200, null,
|
||||
[("sha256:a", 0, 50), ("sha256:b", 50, 50), ("sha256:c", 100, 50), ("sha256:d", 150, 50)]);
|
||||
var toSymbol = CreateSymbolWithChunks("func", "sha256:after", 200, null,
|
||||
[("sha256:a", 0, 50), ("sha256:x", 50, 50), ("sha256:c", 100, 50), ("sha256:y", 150, 50)]);
|
||||
|
||||
// Act
|
||||
var result = _tracer.CompareSymbols(fromSymbol, toSymbol);
|
||||
|
||||
// Assert
|
||||
result.MatchedChunkIndices.Should().BeEquivalentTo([0, 2]);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static SymbolSignature CreateSymbol(string name, string hash, int size, int? cfgCount = null)
|
||||
{
|
||||
return new SymbolSignature
|
||||
{
|
||||
Name = name,
|
||||
HashAlg = "sha256",
|
||||
HashHex = hash,
|
||||
SizeBytes = size,
|
||||
CfgBbCount = cfgCount
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolSignature CreateSymbolWithChunks(
|
||||
string name, string hash, int size, int? cfgCount,
|
||||
(string hash, int offset, int size)[] chunks)
|
||||
{
|
||||
return new SymbolSignature
|
||||
{
|
||||
Name = name,
|
||||
HashAlg = "sha256",
|
||||
HashHex = hash,
|
||||
SizeBytes = size,
|
||||
CfgBbCount = cfgCount,
|
||||
Chunks = chunks.Select(c => new ChunkHash(c.offset, c.size, c.hash)).ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolSignature CreateSymbolWithSemantic(string name, string hash, int size, string semanticHash)
|
||||
{
|
||||
return new SymbolSignature
|
||||
{
|
||||
Name = name,
|
||||
HashAlg = "sha256",
|
||||
HashHex = hash,
|
||||
SizeBytes = size,
|
||||
SemanticHashHex = semanticHash
|
||||
};
|
||||
}
|
||||
|
||||
private static DeltaSignature CreateSignature(string id, SymbolSignature[] symbols)
|
||||
{
|
||||
return new DeltaSignature
|
||||
{
|
||||
SignatureId = id,
|
||||
Cve = "CVE-2026-0001",
|
||||
Package = new PackageRef("testpkg", null),
|
||||
Target = new TargetRef("x86_64", "gnu"),
|
||||
Normalization = new NormalizationRef("test", "1.0", []),
|
||||
SignatureState = "vulnerable",
|
||||
Symbols = symbols.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ public static class AttestCommandGroup
|
||||
attest.Add(FixChainCommandGroup.BuildFixChainCommand(verboseOption, cancellationToken));
|
||||
attest.Add(FixChainCommandGroup.BuildFixChainVerifyCommand(verboseOption, cancellationToken));
|
||||
|
||||
// Patch attestation command (Sprint 20260111_001_005)
|
||||
attest.Add(PatchAttestCommandGroup.BuildPatchAttestCommand(verboseOption, cancellationToken));
|
||||
|
||||
return attest;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.Binary;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Binary;
|
||||
|
||||
|
||||
450
src/Cli/StellaOps.Cli/Commands/ChangeTraceCommandGroup.cs
Normal file
450
src/Cli/StellaOps.Cli/Commands/ChangeTraceCommandGroup.cs
Normal file
@@ -0,0 +1,450 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTraceCommandGroup.cs
|
||||
// Sprint: SPRINT_20260112_200_006_CLI_commands
|
||||
// Description: CLI commands for building, exporting, and verifying change traces.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.ChangeTrace.Builder;
|
||||
using StellaOps.Scanner.ChangeTrace.CycloneDx;
|
||||
using StellaOps.Scanner.ChangeTrace.Models;
|
||||
using StellaOps.Scanner.ChangeTrace.Validation;
|
||||
|
||||
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for building, exporting, and verifying change traces.
|
||||
/// </summary>
|
||||
public static class ChangeTraceCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the change-trace command group.
|
||||
/// </summary>
|
||||
public static Command BuildChangeTraceCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var changeTrace = new Command("change-trace", "Build and export change traces between scans");
|
||||
|
||||
changeTrace.Add(BuildBuildCommand(services, verboseOption, cancellationToken));
|
||||
changeTrace.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
changeTrace.Add(BuildVerifyCommand(verboseOption, cancellationToken));
|
||||
|
||||
return changeTrace;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'build' subcommand for creating change traces.
|
||||
/// </summary>
|
||||
private static Command BuildBuildCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fromOption = new Option<string>("--from") { Description = "Source scan ID or binary file path", Required = true };
|
||||
var toOption = new Option<string>("--to") { Description = "Target scan ID or binary file path", Required = true };
|
||||
var includeByteOption = new Option<bool>("--include-byte-diff") { Description = "Include byte-level diffing (slower, more detailed)" };
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" }) { Description = "Output file path (default: stdout)" };
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" }) { Description = "Output format: json, table, summary" };
|
||||
formatOption.SetDefaultValue("json");
|
||||
|
||||
var build = new Command("build", "Build a change trace comparing two scans or binaries");
|
||||
build.Add(fromOption);
|
||||
build.Add(toOption);
|
||||
build.Add(includeByteOption);
|
||||
build.Add(outputOption);
|
||||
build.Add(formatOption);
|
||||
build.Add(verboseOption);
|
||||
|
||||
build.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var from = parseResult.GetValue(fromOption) ?? string.Empty;
|
||||
var to = parseResult.GetValue(toOption) ?? string.Empty;
|
||||
var includeByteDiff = parseResult.GetValue(includeByteOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
try
|
||||
{
|
||||
// Build the change trace
|
||||
var builder = services.GetService(typeof(IChangeTraceBuilder)) as IChangeTraceBuilder
|
||||
?? new ChangeTraceBuilder(NullLogger<ChangeTraceBuilder>.Instance, TimeProvider.System);
|
||||
|
||||
var options = new ChangeTraceBuilderOptions
|
||||
{
|
||||
IncludeByteDiff = includeByteDiff
|
||||
};
|
||||
|
||||
ChangeTraceModel trace;
|
||||
|
||||
// Check if inputs are files or scan IDs
|
||||
if (File.Exists(from) && File.Exists(to))
|
||||
{
|
||||
// Binary file comparison
|
||||
trace = await builder.FromBinaryComparisonAsync(from, to, options, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scan ID comparison
|
||||
trace = await builder.FromScanComparisonAsync(from, to, options, cancellationToken);
|
||||
}
|
||||
|
||||
// Format output
|
||||
var result = format.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => JsonSerializer.Serialize(trace, JsonOptions),
|
||||
"table" => FormatAsTable(trace),
|
||||
"summary" => FormatAsSummary(trace),
|
||||
_ => JsonSerializer.Serialize(trace, JsonOptions)
|
||||
};
|
||||
|
||||
// Write output
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
await File.WriteAllTextAsync(output, result, cancellationToken);
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Change trace written to {output}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(result);
|
||||
}
|
||||
|
||||
// Return exit code based on verdict
|
||||
return trace.Summary.Verdict switch
|
||||
{
|
||||
ChangeTraceVerdict.RiskDown => ChangeTraceExitCodes.Success,
|
||||
ChangeTraceVerdict.Neutral => ChangeTraceExitCodes.Success,
|
||||
ChangeTraceVerdict.RiskUp => ChangeTraceExitCodes.RiskUp,
|
||||
_ => ChangeTraceExitCodes.Inconclusive
|
||||
};
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found - {ex.FileName}");
|
||||
return ChangeTraceExitCodes.FileNotFound;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Invalid JSON - {ex.Message}");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return ChangeTraceExitCodes.Error;
|
||||
}
|
||||
});
|
||||
|
||||
return build;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'export' subcommand for exporting change traces.
|
||||
/// </summary>
|
||||
private static Command BuildExportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputOption = new Option<string>("--input", new[] { "-i" }) { Description = "Input change trace JSON file", Required = true };
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" }) { Description = "Export format: json, cyclonedx, bundle" };
|
||||
formatOption.SetDefaultValue("json");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" }) { Description = "Output file path" };
|
||||
var cdxEmbeddedOption = new Option<bool>("--cdx-embedded") { Description = "Embed in CycloneDX as component-evidence extension" };
|
||||
var cdxBomOption = new Option<string?>("--cdx-bom") { Description = "Existing CycloneDX BOM to embed the trace in" };
|
||||
|
||||
var export = new Command("export", "Export a change trace in various formats");
|
||||
export.Add(inputOption);
|
||||
export.Add(formatOption);
|
||||
export.Add(outputOption);
|
||||
export.Add(cdxEmbeddedOption);
|
||||
export.Add(cdxBomOption);
|
||||
export.Add(verboseOption);
|
||||
|
||||
export.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var input = parseResult.GetValue(inputOption) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var cdxEmbedded = parseResult.GetValue(cdxEmbeddedOption);
|
||||
var cdxBom = parseResult.GetValue(cdxBomOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(input))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Input file not found - {input}");
|
||||
return ChangeTraceExitCodes.FileNotFound;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(input, cancellationToken);
|
||||
var trace = JsonSerializer.Deserialize<ChangeTraceModel>(content, JsonOptions);
|
||||
|
||||
if (trace is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: Failed to parse change trace");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
|
||||
string result;
|
||||
string defaultExtension;
|
||||
|
||||
if (cdxEmbedded || format.Equals("cyclonedx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var evidenceExtension = new ChangeTraceEvidenceExtension(TimeProvider.System);
|
||||
|
||||
if (!string.IsNullOrEmpty(cdxBom) && File.Exists(cdxBom))
|
||||
{
|
||||
// Embed in existing BOM
|
||||
var bomContent = await File.ReadAllTextAsync(cdxBom, cancellationToken);
|
||||
using var bomDoc = JsonDocument.Parse(bomContent);
|
||||
using var resultDoc = evidenceExtension.EmbedInCycloneDx(bomDoc, trace);
|
||||
result = JsonSerializer.Serialize(resultDoc, JsonOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standalone export
|
||||
using var resultDoc = evidenceExtension.ExportAsStandalone(trace);
|
||||
result = JsonSerializer.Serialize(resultDoc, JsonOptions);
|
||||
}
|
||||
|
||||
defaultExtension = ".cdx.json";
|
||||
}
|
||||
else
|
||||
{
|
||||
result = format.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => JsonSerializer.Serialize(trace, JsonOptions),
|
||||
"table" => FormatAsTable(trace),
|
||||
"summary" => FormatAsSummary(trace),
|
||||
_ => JsonSerializer.Serialize(trace, JsonOptions)
|
||||
};
|
||||
|
||||
defaultExtension = format.Equals("json", StringComparison.OrdinalIgnoreCase)
|
||||
? ".cdxchange.json"
|
||||
: ".txt";
|
||||
}
|
||||
|
||||
// Write output
|
||||
var outputPath = output ?? $"trace-export{defaultExtension}";
|
||||
await File.WriteAllTextAsync(outputPath, result, cancellationToken);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Exported to {outputPath}");
|
||||
}
|
||||
|
||||
return ChangeTraceExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return ChangeTraceExitCodes.Error;
|
||||
}
|
||||
});
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'verify' subcommand for verifying change trace files.
|
||||
/// </summary>
|
||||
private static Command BuildVerifyCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileArg = new Argument<string>("file")
|
||||
{
|
||||
Description = "Path to change trace file (.cdxchange.json)"
|
||||
};
|
||||
|
||||
var strictOption = new Option<bool>("--strict")
|
||||
{
|
||||
Description = "Fail on any warnings"
|
||||
};
|
||||
|
||||
var verify = new Command("verify", "Verify a change trace file");
|
||||
verify.Add(fileArg);
|
||||
verify.Add(strictOption);
|
||||
verify.Add(verboseOption);
|
||||
|
||||
verify.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var file = parseResult.GetValue(fileArg) ?? string.Empty;
|
||||
var strict = parseResult.GetValue(strictOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found - {file}");
|
||||
return ChangeTraceExitCodes.FileNotFound;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
|
||||
// Validate JSON structure
|
||||
ChangeTraceModel? trace;
|
||||
try
|
||||
{
|
||||
trace = JsonSerializer.Deserialize<ChangeTraceModel>(content, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Invalid JSON - {ex.Message}");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
|
||||
if (trace is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: Failed to parse change trace");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
|
||||
// Validate trace
|
||||
var validator = new ChangeTraceValidator();
|
||||
var result = validator.Validate(trace);
|
||||
|
||||
// Display results
|
||||
if (result.Errors.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine("Errors:");
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
Console.Error.WriteLine($" - {error}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Warnings.Count > 0)
|
||||
{
|
||||
Console.WriteLine("Warnings:");
|
||||
foreach (var warning in result.Warnings)
|
||||
{
|
||||
Console.WriteLine($" - {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.IsValid && (!strict || result.Warnings.Count == 0))
|
||||
{
|
||||
Console.WriteLine("Change trace is valid");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(FormatAsSummary(trace));
|
||||
}
|
||||
|
||||
return ChangeTraceExitCodes.Success;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("Change trace validation failed");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return ChangeTraceExitCodes.Error;
|
||||
}
|
||||
});
|
||||
|
||||
return verify;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format a change trace as a table.
|
||||
/// </summary>
|
||||
private static string FormatAsTable(ChangeTraceModel trace)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
string.Format("{0,-50} {1,-15} {2,-15} {3,-15} {4,-10}",
|
||||
"Component", "From", "To", "Change Type", "Trust Delta"),
|
||||
new string('-', 105)
|
||||
};
|
||||
|
||||
foreach (var delta in trace.Deltas)
|
||||
{
|
||||
var trustDelta = delta.TrustDelta?.Score ?? 0;
|
||||
var trustSign = trustDelta < 0 ? "" : (trustDelta > 0 ? "+" : " ");
|
||||
|
||||
lines.Add(string.Format("{0,-50} {1,-15} {2,-15} {3,-15} {4}{5:0.00}",
|
||||
TruncatePurl(delta.Purl, 50),
|
||||
TruncateVersion(delta.FromVersion, 15),
|
||||
TruncateVersion(delta.ToVersion, 15),
|
||||
delta.ChangeType.ToString(),
|
||||
trustSign,
|
||||
trustDelta));
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format a change trace as a summary.
|
||||
/// </summary>
|
||||
private static string FormatAsSummary(ChangeTraceModel trace)
|
||||
{
|
||||
var trustDelta = trace.Summary.RiskDelta;
|
||||
var trustSign = trustDelta < 0 ? "" : (trustDelta > 0 ? "+" : " ");
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"Change Trace: {trace.Subject.Digest}",
|
||||
$"Generated: {trace.Basis.AnalyzedAt:O}",
|
||||
$"Packages Changed: {trace.Summary.ChangedPackages}",
|
||||
$"Symbols Changed: {trace.Summary.ChangedSymbols}",
|
||||
$"Bytes Changed: {trace.Summary.ChangedBytes:N0}",
|
||||
$"Trust Delta: {trustSign}{trustDelta:0.00}",
|
||||
$"Verdict: {trace.Summary.Verdict}"
|
||||
};
|
||||
|
||||
if (trace.Commitment is not null)
|
||||
{
|
||||
lines.Add($"Commitment: {trace.Commitment.Sha256}");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string TruncatePurl(string purl, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl) || purl.Length <= maxLength)
|
||||
return purl ?? "-";
|
||||
|
||||
return purl[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
private static string TruncateVersion(string? version, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return "-";
|
||||
|
||||
if (version.Length <= maxLength)
|
||||
return version;
|
||||
|
||||
return version[..(maxLength - 3)] + "...";
|
||||
}
|
||||
}
|
||||
49
src/Cli/StellaOps.Cli/Commands/ChangeTraceExitCodes.cs
Normal file
49
src/Cli/StellaOps.Cli/Commands/ChangeTraceExitCodes.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTraceExitCodes.cs
|
||||
// Sprint: SPRINT_20260112_200_006_CLI_commands
|
||||
// Description: Exit codes for change-trace CLI commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Exit codes for change-trace CLI commands.
|
||||
/// Designed for CI/CD pipeline integration.
|
||||
/// </summary>
|
||||
public static class ChangeTraceExitCodes
|
||||
{
|
||||
/// <summary>
|
||||
/// Operation completed successfully (or risk_down/neutral verdict).
|
||||
/// </summary>
|
||||
public const int Success = 0;
|
||||
|
||||
/// <summary>
|
||||
/// General error (file not found, validation failed, etc.).
|
||||
/// </summary>
|
||||
public const int Error = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Risk up verdict - trust delta indicates increased risk.
|
||||
/// </summary>
|
||||
public const int RiskUp = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Inconclusive - unable to determine verdict.
|
||||
/// </summary>
|
||||
public const int Inconclusive = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Input file not found.
|
||||
/// </summary>
|
||||
public const int FileNotFound = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Validation failed.
|
||||
/// </summary>
|
||||
public const int ValidationFailed = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Service not available.
|
||||
/// </summary>
|
||||
public const int ServiceUnavailable = 6;
|
||||
}
|
||||
@@ -135,6 +135,9 @@ internal static class CommandFactory
|
||||
root.Add(GoldenSet.GoldenSetCommandGroup.BuildGoldenCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(GoldenSet.VerifyFixCommandGroup.BuildVerifyFixCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260112_200_006_CLI - Change Trace Commands
|
||||
root.Add(ChangeTraceCommandGroup.BuildChangeTraceCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Add scan graph subcommand to existing scan command
|
||||
var scanCommand = root.Children.OfType<Command>().FirstOrDefault(c => c.Name == "scan");
|
||||
if (scanCommand is not null)
|
||||
@@ -417,6 +420,10 @@ internal static class CommandFactory
|
||||
var recipe = LayerSbomCommandGroup.BuildRecipeCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(recipe);
|
||||
|
||||
// Patch verification command (Sprint: SPRINT_20260111_001_004_CLI_verify_patches)
|
||||
var verifyPatches = PatchVerifyCommandGroup.BuildVerifyPatchesCommand(services, verboseOption, cancellationToken);
|
||||
scan.Add(verifyPatches);
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
|
||||
@@ -2971,7 +2971,7 @@ internal static partial class CommandHandlers
|
||||
|
||||
try
|
||||
{
|
||||
await TenantProfileStore.SetActiveTenantAsync(normalizedTenant, displayName, cancellationToken).ConfigureAwait(false);
|
||||
await TenantProfileStore.SetActiveTenantAsync(normalizedTenant, displayName, asOf: null, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Active tenant set to '{TenantId}'.", normalizedTenant);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(displayName))
|
||||
@@ -3043,7 +3043,7 @@ internal static partial class CommandHandlers
|
||||
|
||||
try
|
||||
{
|
||||
await TenantProfileStore.ClearActiveTenantAsync(cancellationToken).ConfigureAwait(false);
|
||||
await TenantProfileStore.ClearActiveTenantAsync(asOf: null, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine("Active tenant cleared.");
|
||||
Console.WriteLine("Subsequent commands will require --tenant or STELLAOPS_TENANT environment variable.");
|
||||
}
|
||||
|
||||
580
src/Cli/StellaOps.Cli/Commands/PatchAttestCommandGroup.cs
Normal file
580
src/Cli/StellaOps.Cli/Commands/PatchAttestCommandGroup.cs
Normal file
@@ -0,0 +1,580 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatchAttestCommandGroup.cs
|
||||
// Sprint: SPRINT_20260111_001_005_CLI_attest_patch
|
||||
// Task: Patch attestation command for creating DSSE-signed patch evidence
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Feedser.BinaryAnalysis.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for patch attestation operations.
|
||||
/// Creates DSSE-signed attestations from before/after binary analysis.
|
||||
/// </summary>
|
||||
public static class PatchAttestCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'attest patch' command.
|
||||
/// Creates a patch verification attestation from before/after binaries.
|
||||
/// </summary>
|
||||
public static Command BuildPatchAttestCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var cveOption = new Option<string>("--cve", "-c")
|
||||
{
|
||||
Description = "CVE identifier being attested (e.g., CVE-2024-1234)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var fromOption = new Option<FileInfo>("--from", "-f")
|
||||
{
|
||||
Description = "Path to vulnerable binary (before patch)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var toOption = new Option<FileInfo>("--to", "-t")
|
||||
{
|
||||
Description = "Path to patched binary (after patch)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var outputOption = new Option<FileInfo?>("--out", "-o")
|
||||
{
|
||||
Description = "Output DSSE envelope file (prints to stdout if not specified)"
|
||||
};
|
||||
|
||||
var purlOption = new Option<string?>("--purl", "-p")
|
||||
{
|
||||
Description = "Package URL for the component (e.g., pkg:rpm/openssl@1.1.1k-123.el8)"
|
||||
};
|
||||
|
||||
var keyOption = new Option<string?>("--key", "-k")
|
||||
{
|
||||
Description = "Path to private key for signing (PEM or PKCS#8)"
|
||||
};
|
||||
|
||||
var keylessOption = new Option<bool>("--sign-keyless")
|
||||
{
|
||||
Description = "Use Sigstore keyless signing (OIDC)"
|
||||
};
|
||||
|
||||
var noSignOption = new Option<bool>("--no-sign")
|
||||
{
|
||||
Description = "Skip signing (output unsigned attestation payload)"
|
||||
};
|
||||
|
||||
var noRekorOption = new Option<bool>("--no-rekor")
|
||||
{
|
||||
Description = "Skip Rekor transparency log publication"
|
||||
};
|
||||
|
||||
var publishOption = new Option<bool>("--publish")
|
||||
{
|
||||
Description = "Publish attestation to Authority service"
|
||||
};
|
||||
|
||||
var manifestOption = new Option<FileInfo?>("--manifest", "-m")
|
||||
{
|
||||
Description = "Patch manifest file for batch attestation (YAML)"
|
||||
};
|
||||
|
||||
var outDirOption = new Option<DirectoryInfo?>("--out-dir")
|
||||
{
|
||||
Description = "Output directory for batch attestations"
|
||||
};
|
||||
|
||||
var issuerOption = new Option<string?>("--issuer")
|
||||
{
|
||||
Description = "Issuer identifier for the attestation"
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description")
|
||||
{
|
||||
Description = "Human-readable description of the patch"
|
||||
};
|
||||
|
||||
var patch = new Command("patch", "Create DSSE-signed patch verification attestation")
|
||||
{
|
||||
cveOption,
|
||||
fromOption,
|
||||
toOption,
|
||||
outputOption,
|
||||
purlOption,
|
||||
keyOption,
|
||||
keylessOption,
|
||||
noSignOption,
|
||||
noRekorOption,
|
||||
publishOption,
|
||||
manifestOption,
|
||||
outDirOption,
|
||||
issuerOption,
|
||||
descriptionOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
patch.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var cve = parseResult.GetValue(cveOption) ?? string.Empty;
|
||||
var from = parseResult.GetValue(fromOption)!;
|
||||
var to = parseResult.GetValue(toOption)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var purl = parseResult.GetValue(purlOption);
|
||||
var keyPath = parseResult.GetValue(keyOption);
|
||||
var keyless = parseResult.GetValue(keylessOption);
|
||||
var noSign = parseResult.GetValue(noSignOption);
|
||||
var noRekor = parseResult.GetValue(noRekorOption);
|
||||
var publish = parseResult.GetValue(publishOption);
|
||||
var manifest = parseResult.GetValue(manifestOption);
|
||||
var outDir = parseResult.GetValue(outDirOption);
|
||||
var issuer = parseResult.GetValue(issuerOption);
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await ExecutePatchAttestAsync(
|
||||
cve,
|
||||
from,
|
||||
to,
|
||||
output,
|
||||
purl,
|
||||
keyPath,
|
||||
keyless,
|
||||
noSign,
|
||||
noRekor,
|
||||
publish,
|
||||
manifest,
|
||||
outDir,
|
||||
issuer,
|
||||
description,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecutePatchAttestAsync(
|
||||
string cve,
|
||||
FileInfo fromFile,
|
||||
FileInfo toFile,
|
||||
FileInfo? outputFile,
|
||||
string? purl,
|
||||
string? keyPath,
|
||||
bool keyless,
|
||||
bool noSign,
|
||||
bool noRekor,
|
||||
bool publish,
|
||||
FileInfo? manifest,
|
||||
DirectoryInfo? outDir,
|
||||
string? issuer,
|
||||
string? description,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate input files
|
||||
if (!fromFile.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Vulnerable binary not found: {fromFile.FullName}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!toFile.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Patched binary not found: {toFile.FullName}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine("Creating patch attestation...");
|
||||
Console.WriteLine($" CVE: {cve}");
|
||||
Console.WriteLine($" From (vulnerable): {fromFile.FullName}");
|
||||
Console.WriteLine($" To (patched): {toFile.FullName}");
|
||||
if (purl is not null)
|
||||
Console.WriteLine($" PURL: {purl}");
|
||||
if (outputFile is not null)
|
||||
Console.WriteLine($" Output: {outputFile.FullName}");
|
||||
Console.WriteLine($" Sign: {(noSign ? "disabled" : (keyless ? "keyless" : (keyPath is not null ? keyPath : "default")))}");
|
||||
Console.WriteLine($" Rekor: {(noRekor ? "disabled" : "enabled")}");
|
||||
}
|
||||
|
||||
// Read binary files
|
||||
var fromBytes = await File.ReadAllBytesAsync(fromFile.FullName, ct);
|
||||
var toBytes = await File.ReadAllBytesAsync(toFile.FullName, ct);
|
||||
|
||||
// Compute digests
|
||||
var fromDigest = ComputeSha256(fromBytes);
|
||||
var toDigest = ComputeSha256(toBytes);
|
||||
|
||||
// Extract basic binary information
|
||||
var fromSize = fromBytes.Length;
|
||||
var toSize = toBytes.Length;
|
||||
|
||||
// Compute simple section fingerprints (placeholder - real impl would use IBinaryFingerprinter)
|
||||
var sectionFingerprints = ComputeSimpleSectionFingerprints(fromBytes, toBytes);
|
||||
|
||||
// Build attestation predicate
|
||||
var attestedAt = DateTimeOffset.UtcNow;
|
||||
var predicate = new PatchVerificationPredicateDto
|
||||
{
|
||||
Cve = cve,
|
||||
VulnerableBinaryDigest = $"sha256:{fromDigest}",
|
||||
PatchedBinaryDigest = $"sha256:{toDigest}",
|
||||
VulnerableBinaryPath = fromFile.Name,
|
||||
PatchedBinaryPath = toFile.Name,
|
||||
Purl = purl,
|
||||
Fingerprints = new PatchFingerprintsDto
|
||||
{
|
||||
Sections = sectionFingerprints.Sections.ToList(),
|
||||
Functions = sectionFingerprints.Functions?.ToList(),
|
||||
Deltas = sectionFingerprints.Deltas?.ToList()
|
||||
},
|
||||
Issuer = issuer,
|
||||
Description = description,
|
||||
AttestedAt = attestedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
AttestorVersion = "1.0.0"
|
||||
};
|
||||
|
||||
// Build in-toto statement
|
||||
var statement = new InTotoStatementDto
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v0.1",
|
||||
PredicateType = "https://stellaops.org/patch-verification/v1",
|
||||
Subject = new List<SubjectDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = toFile.Name,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = toDigest
|
||||
}
|
||||
}
|
||||
},
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
// Serialize statement
|
||||
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
if (noSign)
|
||||
{
|
||||
// Output unsigned statement
|
||||
if (outputFile is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile.FullName, statementJson, ct);
|
||||
Console.WriteLine($"Unsigned attestation written to {outputFile.FullName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(statementJson);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Create DSSE envelope (placeholder - real impl would use actual signing)
|
||||
var envelope = CreateDsseEnvelope(statementJson, keyPath, keyless);
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
|
||||
if (outputFile is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile.FullName, envelopeJson, ct);
|
||||
Console.WriteLine($"DSSE attestation written to {outputFile.FullName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(envelopeJson);
|
||||
}
|
||||
|
||||
if (publish)
|
||||
{
|
||||
Console.WriteLine("[yellow]Warning:[/] --publish not yet implemented. Use 'stella attest attach' to publish.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Attestation Summary:");
|
||||
Console.WriteLine($" CVE: {cve}");
|
||||
Console.WriteLine($" Vulnerable digest: sha256:{fromDigest[..16]}...");
|
||||
Console.WriteLine($" Patched digest: sha256:{toDigest[..16]}...");
|
||||
Console.WriteLine($" Section fingerprints: {sectionFingerprints.Sections.Count}");
|
||||
Console.WriteLine($" Attested at: {attestedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error creating patch attestation: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<SectionFingerprintDto> Sections, IReadOnlyList<FunctionFingerprintDto>? Functions, IReadOnlyList<DeltaDto>? Deltas)
|
||||
ComputeSimpleSectionFingerprints(byte[] fromBytes, byte[] toBytes)
|
||||
{
|
||||
// This is a simplified implementation that creates section-level fingerprints
|
||||
// Real implementation would use IBinaryFingerprinter from Feedser.BinaryAnalysis
|
||||
|
||||
var sections = new List<SectionFingerprintDto>();
|
||||
|
||||
// Create a simple fingerprint based on file sections
|
||||
// In reality, we'd parse ELF/PE headers and extract actual sections
|
||||
var chunkSize = 4096;
|
||||
var fromChunks = (int)Math.Ceiling(fromBytes.Length / (double)chunkSize);
|
||||
var toChunks = (int)Math.Ceiling(toBytes.Length / (double)chunkSize);
|
||||
|
||||
// Compare chunks to identify changed sections
|
||||
for (int i = 0; i < Math.Max(fromChunks, toChunks); i++)
|
||||
{
|
||||
var fromStart = i * chunkSize;
|
||||
var toStart = i * chunkSize;
|
||||
|
||||
byte[]? fromChunk = fromStart < fromBytes.Length
|
||||
? fromBytes.Skip(fromStart).Take(Math.Min(chunkSize, fromBytes.Length - fromStart)).ToArray()
|
||||
: null;
|
||||
|
||||
byte[]? toChunk = toStart < toBytes.Length
|
||||
? toBytes.Skip(toStart).Take(Math.Min(chunkSize, toBytes.Length - toStart)).ToArray()
|
||||
: null;
|
||||
|
||||
var status = (fromChunk, toChunk) switch
|
||||
{
|
||||
(null, not null) => "added",
|
||||
(not null, null) => "removed",
|
||||
(not null, not null) when !fromChunk.SequenceEqual(toChunk) => "modified",
|
||||
_ => "unchanged"
|
||||
};
|
||||
|
||||
if (status != "unchanged")
|
||||
{
|
||||
sections.Add(new SectionFingerprintDto
|
||||
{
|
||||
Name = $".section_{i}",
|
||||
Offset = (ulong)(i * chunkSize),
|
||||
VulnerableHash = fromChunk is not null ? ComputeSha256(fromChunk)[..16] : null,
|
||||
PatchedHash = toChunk is not null ? ComputeSha256(toChunk)[..16] : null,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no sections differ, add a summary section
|
||||
if (sections.Count == 0)
|
||||
{
|
||||
sections.Add(new SectionFingerprintDto
|
||||
{
|
||||
Name = ".text",
|
||||
Offset = 0,
|
||||
VulnerableHash = ComputeSha256(fromBytes)[..16],
|
||||
PatchedHash = ComputeSha256(toBytes)[..16],
|
||||
Status = "identical"
|
||||
});
|
||||
}
|
||||
|
||||
return (sections, null, null);
|
||||
}
|
||||
|
||||
private static DsseEnvelopeDto CreateDsseEnvelope(string payload, string? keyPath, bool keyless)
|
||||
{
|
||||
// This is a placeholder implementation
|
||||
// Real implementation would use actual DSSE signing via Attestor.Envelope
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
// Create placeholder signature
|
||||
// In production, this would use cryptographic signing
|
||||
var signatureData = $"placeholder-sig-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||
var signatureBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(signatureData));
|
||||
|
||||
var keyId = keyless
|
||||
? "sigstore-keyless"
|
||||
: keyPath ?? "local-key";
|
||||
|
||||
return new DsseEnvelopeDto
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures = new List<DsseSignatureDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
KeyId = keyId,
|
||||
Sig = signatureBase64
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record InTotoStatementDto
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required List<SubjectDto> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required PatchVerificationPredicateDto Predicate { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SubjectDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed record PatchVerificationPredicateDto
|
||||
{
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableBinaryDigest")]
|
||||
public required string VulnerableBinaryDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedBinaryDigest")]
|
||||
public required string PatchedBinaryDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableBinaryPath")]
|
||||
public string? VulnerableBinaryPath { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedBinaryPath")]
|
||||
public string? PatchedBinaryPath { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprints")]
|
||||
public required PatchFingerprintsDto Fingerprints { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("attestedAt")]
|
||||
public required string AttestedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("attestorVersion")]
|
||||
public required string AttestorVersion { get; init; }
|
||||
}
|
||||
|
||||
private sealed record PatchFingerprintsDto
|
||||
{
|
||||
[JsonPropertyName("sections")]
|
||||
public required List<SectionFingerprintDto> Sections { get; init; }
|
||||
|
||||
[JsonPropertyName("functions")]
|
||||
public List<FunctionFingerprintDto>? Functions { get; init; }
|
||||
|
||||
[JsonPropertyName("deltas")]
|
||||
public List<DeltaDto>? Deltas { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SectionFingerprintDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public ulong Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableHash")]
|
||||
public string? VulnerableHash { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedHash")]
|
||||
public string? PatchedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
private sealed record FunctionFingerprintDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("address")]
|
||||
public ulong Address { get; init; }
|
||||
|
||||
[JsonPropertyName("cfgHash")]
|
||||
public string? CfgHash { get; init; }
|
||||
|
||||
[JsonPropertyName("instructionHash")]
|
||||
public string? InstructionHash { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DeltaDto
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("location")]
|
||||
public required string Location { get; init; }
|
||||
|
||||
[JsonPropertyName("before")]
|
||||
public string? Before { get; init; }
|
||||
|
||||
[JsonPropertyName("after")]
|
||||
public string? After { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DsseEnvelopeDto
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required List<DsseSignatureDto> Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DsseSignatureDto
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
461
src/Cli/StellaOps.Cli/Commands/PatchVerifyCommandGroup.cs
Normal file
461
src/Cli/StellaOps.Cli/Commands/PatchVerifyCommandGroup.cs
Normal file
@@ -0,0 +1,461 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatchVerifyCommandGroup.cs
|
||||
// Sprint: SPRINT_20260111_001_004_CLI_verify_patches
|
||||
// Task: CLI integration for patch verification
|
||||
// Description: CLI commands for patch verification under scan command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.PatchVerification;
|
||||
using StellaOps.Scanner.PatchVerification.Models;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for patch verification operations under the scan command.
|
||||
/// Implements `stella scan verify-patches` for on-demand patch verification.
|
||||
/// </summary>
|
||||
public static class PatchVerifyCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the verify-patches command for scan command group.
|
||||
/// </summary>
|
||||
public static Command BuildVerifyPatchesCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdOption = new Option<string?>("--scan-id", "-s")
|
||||
{
|
||||
Description = "Scan ID to verify patches for (retrieves CVEs from existing scan)"
|
||||
};
|
||||
|
||||
var cveOption = new Option<string[]>("--cve", "-c")
|
||||
{
|
||||
Description = "Specific CVE IDs to verify (comma-separated or multiple --cve flags)",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var binaryPathOption = new Option<string?>("--binary", "-b")
|
||||
{
|
||||
Description = "Path to binary file to verify"
|
||||
};
|
||||
|
||||
var imageOption = new Option<string?>("--image", "-i")
|
||||
{
|
||||
Description = "OCI image reference to verify patches in"
|
||||
};
|
||||
|
||||
var confidenceThresholdOption = new Option<double>("--confidence-threshold")
|
||||
{
|
||||
Description = "Minimum confidence threshold (0.0-1.0, default: 0.7)"
|
||||
};
|
||||
confidenceThresholdOption.SetDefaultValue(0.7);
|
||||
|
||||
var similarityThresholdOption = new Option<double>("--similarity-threshold")
|
||||
{
|
||||
Description = "Minimum similarity threshold for fingerprint match (0.0-1.0, default: 0.85)"
|
||||
};
|
||||
similarityThresholdOption.SetDefaultValue(0.85);
|
||||
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output format: table (default), json, summary"
|
||||
};
|
||||
outputOption.SetDefaultValue("table");
|
||||
|
||||
var outputFileOption = new Option<string?>("--output-file", "-f")
|
||||
{
|
||||
Description = "Write output to file instead of stdout"
|
||||
};
|
||||
|
||||
var includeEvidenceOption = new Option<bool>("--include-evidence")
|
||||
{
|
||||
Description = "Include detailed fingerprint evidence in output"
|
||||
};
|
||||
|
||||
var verifyPatches = new Command("verify-patches", "Verify that security patches are present in binaries")
|
||||
{
|
||||
scanIdOption,
|
||||
cveOption,
|
||||
binaryPathOption,
|
||||
imageOption,
|
||||
confidenceThresholdOption,
|
||||
similarityThresholdOption,
|
||||
outputOption,
|
||||
outputFileOption,
|
||||
includeEvidenceOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
verifyPatches.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(scanIdOption);
|
||||
var cves = parseResult.GetValue(cveOption) ?? Array.Empty<string>();
|
||||
var binaryPath = parseResult.GetValue(binaryPathOption);
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var confidenceThreshold = parseResult.GetValue(confidenceThresholdOption);
|
||||
var similarityThreshold = parseResult.GetValue(similarityThresholdOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var outputFile = parseResult.GetValue(outputFileOption);
|
||||
var includeEvidence = parseResult.GetValue(includeEvidenceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleVerifyPatchesAsync(
|
||||
services,
|
||||
scanId,
|
||||
cves,
|
||||
binaryPath,
|
||||
image,
|
||||
confidenceThreshold,
|
||||
similarityThreshold,
|
||||
output,
|
||||
outputFile,
|
||||
includeEvidence,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return verifyPatches;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleVerifyPatchesAsync(
|
||||
IServiceProvider services,
|
||||
string? scanId,
|
||||
string[] cves,
|
||||
string? binaryPath,
|
||||
string? image,
|
||||
double confidenceThreshold,
|
||||
double similarityThreshold,
|
||||
string output,
|
||||
string? outputFile,
|
||||
bool includeEvidence,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(PatchVerifyCommandGroup));
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
// Validate input
|
||||
if (string.IsNullOrWhiteSpace(scanId) && cves.Length == 0)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Either --scan-id or at least one --cve must be specified.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(binaryPath) && string.IsNullOrWhiteSpace(image) && string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Either --binary, --image, or --scan-id must be specified.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine("[dim]Patch Verification Options:[/]");
|
||||
if (!string.IsNullOrWhiteSpace(scanId))
|
||||
console.MarkupLine($"[dim] Scan ID: {scanId}[/]");
|
||||
if (cves.Length > 0)
|
||||
console.MarkupLine($"[dim] CVEs: {string.Join(", ", cves)}[/]");
|
||||
if (!string.IsNullOrWhiteSpace(binaryPath))
|
||||
console.MarkupLine($"[dim] Binary: {binaryPath}[/]");
|
||||
if (!string.IsNullOrWhiteSpace(image))
|
||||
console.MarkupLine($"[dim] Image: {image}[/]");
|
||||
console.MarkupLine($"[dim] Confidence threshold: {confidenceThreshold:P0}[/]");
|
||||
console.MarkupLine($"[dim] Similarity threshold: {similarityThreshold:P0}[/]");
|
||||
}
|
||||
|
||||
// Get the patch verification orchestrator
|
||||
var orchestrator = scope.ServiceProvider.GetService<IPatchVerificationOrchestrator>();
|
||||
if (orchestrator is null)
|
||||
{
|
||||
console.MarkupLine("[yellow]Warning:[/] Patch verification service not available.");
|
||||
console.MarkupLine("[dim]Patch verification requires the Scanner.PatchVerification library to be configured.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Create verification options
|
||||
var options = new PatchVerificationOptions
|
||||
{
|
||||
MinConfidenceThreshold = confidenceThreshold,
|
||||
MinSimilarityThreshold = similarityThreshold
|
||||
};
|
||||
|
||||
// Perform verification
|
||||
PatchVerificationResult? result = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
// TODO: Fetch CVEs and binary paths from scan results via backend API
|
||||
// For now, show a placeholder message
|
||||
console.MarkupLine($"[dim]Fetching scan results for {scanId}...[/]");
|
||||
|
||||
// This would normally fetch from the backend
|
||||
var context = new PatchVerificationContext
|
||||
{
|
||||
ScanId = scanId,
|
||||
TenantId = "default",
|
||||
ImageDigest = "sha256:placeholder",
|
||||
ArtifactPurl = "pkg:oci/placeholder",
|
||||
CveIds = cves.Length > 0 ? cves : new[] { "CVE-2024-0001" },
|
||||
BinaryPaths = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
result = await orchestrator.VerifyAsync(context, ct);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(binaryPath))
|
||||
{
|
||||
// Verify single binary
|
||||
if (!File.Exists(binaryPath))
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Binary file not found: {binaryPath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var evidenceList = new List<PatchVerificationEvidence>();
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
var evidence = await orchestrator.VerifySingleAsync(
|
||||
cve,
|
||||
binaryPath,
|
||||
"pkg:generic/binary",
|
||||
options,
|
||||
ct);
|
||||
evidenceList.Add(evidence);
|
||||
}
|
||||
|
||||
result = new PatchVerificationResult
|
||||
{
|
||||
ScanId = $"cli-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}",
|
||||
Evidence = evidenceList,
|
||||
PatchedCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.Verified)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
UnpatchedCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.NotPatched)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
InconclusiveCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.Inconclusive)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
NoPatchDataCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.NoPatchData)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Image-based verification not yet implemented. Use --binary instead.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Verification failed to produce results.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Output results
|
||||
var outputText = output.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => FormatJsonOutput(result, includeEvidence),
|
||||
"summary" => FormatSummaryOutput(result),
|
||||
_ => FormatTableOutput(result, includeEvidence, console)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputFile))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile, outputText, ct);
|
||||
console.MarkupLine($"[green]Output written to {outputFile}[/]");
|
||||
}
|
||||
else if (output.ToLowerInvariant() != "table")
|
||||
{
|
||||
console.WriteLine(outputText);
|
||||
}
|
||||
|
||||
// Return exit code based on results
|
||||
if (result.UnpatchedCves.Count > 0)
|
||||
{
|
||||
return 2; // Unpatched vulnerabilities found
|
||||
}
|
||||
|
||||
if (result.InconclusiveCves.Count > 0 && result.PatchedCves.Count == 0)
|
||||
{
|
||||
return 3; // Only inconclusive results
|
||||
}
|
||||
|
||||
return 0; // Success
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Patch verification failed");
|
||||
console.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatJsonOutput(PatchVerificationResult result, bool includeEvidence)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
scanId = result.ScanId,
|
||||
verifiedAt = result.VerifiedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
verifierVersion = result.VerifierVersion,
|
||||
summary = new
|
||||
{
|
||||
totalCves = result.Evidence.Count,
|
||||
patched = result.PatchedCves.Count,
|
||||
unpatched = result.UnpatchedCves.Count,
|
||||
inconclusive = result.InconclusiveCves.Count,
|
||||
noPatchData = result.NoPatchDataCves.Count
|
||||
},
|
||||
patchedCves = result.PatchedCves,
|
||||
unpatchedCves = result.UnpatchedCves,
|
||||
inconclusiveCves = result.InconclusiveCves,
|
||||
noPatchDataCves = result.NoPatchDataCves,
|
||||
evidence = includeEvidence ? result.Evidence.Select(e => new
|
||||
{
|
||||
evidenceId = e.EvidenceId,
|
||||
cveId = e.CveId,
|
||||
binaryPath = e.BinaryPath,
|
||||
status = e.Status.ToString(),
|
||||
similarity = e.Similarity,
|
||||
confidence = e.Confidence,
|
||||
method = e.Method.ToString(),
|
||||
reason = e.Reason,
|
||||
trustScore = e.ComputeTrustScore(),
|
||||
verifiedAt = e.VerifiedAt.ToString("O", CultureInfo.InvariantCulture)
|
||||
}) : null
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(output, JsonOptions);
|
||||
}
|
||||
|
||||
private static string FormatSummaryOutput(PatchVerificationResult result)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("Patch Verification Summary");
|
||||
sb.AppendLine("==========================");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Scan ID: {result.ScanId}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Verified at: {result.VerifiedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Verifier version: {result.VerifierVersion}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Total CVEs checked: {result.Evidence.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Patched: {result.PatchedCves.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Unpatched: {result.UnpatchedCves.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Inconclusive: {result.InconclusiveCves.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" No patch data: {result.NoPatchDataCves.Count}");
|
||||
|
||||
if (result.PatchedCves.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Patched CVEs:");
|
||||
foreach (var cve in result.PatchedCves.OrderBy(c => c))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" [PATCHED] {cve}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.UnpatchedCves.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Unpatched CVEs:");
|
||||
foreach (var cve in result.UnpatchedCves.OrderBy(c => c))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" [UNPATCHED] {cve}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatTableOutput(PatchVerificationResult result, bool includeEvidence, IAnsiConsole console)
|
||||
{
|
||||
// Header
|
||||
var header = new Panel(new Markup($"[bold]Patch Verification Results[/] - {result.ScanId}"))
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
console.Write(header);
|
||||
|
||||
// Summary table
|
||||
var summaryTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Summary[/]")
|
||||
.AddColumn("Metric")
|
||||
.AddColumn("Count");
|
||||
|
||||
summaryTable.AddRow("Total CVEs", result.Evidence.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[green]Patched[/]", result.PatchedCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[red]Unpatched[/]", result.UnpatchedCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[yellow]Inconclusive[/]", result.InconclusiveCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[dim]No patch data[/]", result.NoPatchDataCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
console.Write(summaryTable);
|
||||
|
||||
// Evidence table
|
||||
if (result.Evidence.Count > 0)
|
||||
{
|
||||
console.WriteLine();
|
||||
var evidenceTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Verification Evidence[/]")
|
||||
.AddColumn("CVE")
|
||||
.AddColumn("Status")
|
||||
.AddColumn("Similarity")
|
||||
.AddColumn("Confidence")
|
||||
.AddColumn("Method")
|
||||
.AddColumn("Trust Score");
|
||||
|
||||
foreach (var evidence in result.Evidence.OrderBy(e => e.CveId))
|
||||
{
|
||||
var statusColor = evidence.Status switch
|
||||
{
|
||||
PatchVerificationStatus.Verified => "green",
|
||||
PatchVerificationStatus.NotPatched => "red",
|
||||
PatchVerificationStatus.PartialMatch => "yellow",
|
||||
PatchVerificationStatus.Inconclusive => "yellow",
|
||||
_ => "dim"
|
||||
};
|
||||
|
||||
evidenceTable.AddRow(
|
||||
evidence.CveId,
|
||||
$"[{statusColor}]{evidence.Status}[/]",
|
||||
evidence.Similarity.ToString("P0", CultureInfo.InvariantCulture),
|
||||
evidence.Confidence.ToString("P0", CultureInfo.InvariantCulture),
|
||||
evidence.Method.ToString(),
|
||||
evidence.ComputeTrustScore().ToString("P0", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
console.Write(evidenceTable);
|
||||
}
|
||||
|
||||
// Verified timestamp
|
||||
console.WriteLine();
|
||||
console.MarkupLine($"[dim]Verified at: {result.VerifiedAt:yyyy-MM-dd HH:mm:ss} UTC[/]");
|
||||
console.MarkupLine($"[dim]Verifier version: {result.VerifierVersion}[/]");
|
||||
|
||||
return string.Empty; // Table output is written directly to console
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
using StellaOps.ExportCenter.Core.EvidenceCache;
|
||||
using StellaOps.Verdict;
|
||||
using StellaOps.Scanner.PatchVerification.DependencyInjection;
|
||||
#if DEBUG || STELLAOPS_ENABLE_SIMULATOR
|
||||
using StellaOps.Cryptography.Plugin.SimRemote.DependencyInjection;
|
||||
#endif
|
||||
@@ -321,6 +322,9 @@ internal static class Program
|
||||
// CLI-CRYPTO-4100-001: Crypto profile validator
|
||||
services.AddSingleton<CryptoProfileValidator>();
|
||||
|
||||
// CLI-PATCHVERIFY-001-004: Patch verification services (SPRINT_20260111_001_004)
|
||||
services.AddPatchVerification();
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
|
||||
@@ -30,11 +30,16 @@ internal sealed class AttestationReader : IAttestationReader
|
||||
|
||||
private readonly ILogger<AttestationReader> _logger;
|
||||
private readonly IForensicVerifier _verifier;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationReader(ILogger<AttestationReader> logger, IForensicVerifier verifier)
|
||||
public AttestationReader(
|
||||
ILogger<AttestationReader> logger,
|
||||
IForensicVerifier verifier,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<AttestationShowResult> ReadAttestationAsync(
|
||||
@@ -127,7 +132,7 @@ internal sealed class AttestationReader : IAttestationReader
|
||||
if (matchingRoot is not null)
|
||||
{
|
||||
var isValid = VerifySignature(envelope, sig, matchingRoot);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
private readonly IStellaOpsTokenClient _tokenClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<OrchestratorClient> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -37,12 +38,14 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptions<StellaOpsCliOptions> options,
|
||||
ILogger<OrchestratorClient> logger)
|
||||
ILogger<OrchestratorClient> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SourceListResponse> ListSourcesAsync(
|
||||
@@ -171,7 +174,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
SourceId = request.SourceId,
|
||||
Reachable = false,
|
||||
ErrorMessage = $"Failed to test source: {response.StatusCode} - {errorContent}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,7 +184,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = true,
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,18 @@ internal sealed partial class PromotionAssembler : IPromotionAssembler
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ILogger<PromotionAssembler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PromotionAssembler(HttpClient httpClient, ICryptoHash cryptoHash, ILogger<PromotionAssembler> logger)
|
||||
public PromotionAssembler(
|
||||
HttpClient httpClient,
|
||||
ICryptoHash cryptoHash,
|
||||
ILogger<PromotionAssembler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PromotionAssembleResult> AssembleAsync(
|
||||
@@ -171,7 +177,7 @@ internal sealed partial class PromotionAssembler : IPromotionAssembler
|
||||
From = request.FromEnvironment,
|
||||
To = request.ToEnvironment,
|
||||
Actor = request.Actor ?? Environment.UserName,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Pipeline = request.Pipeline,
|
||||
Ticket = request.Ticket,
|
||||
Notes = request.Notes
|
||||
@@ -527,7 +533,7 @@ internal sealed partial class PromotionAssembler : IPromotionAssembler
|
||||
RekorEntry = rekorEntry,
|
||||
AuditId = auditId,
|
||||
SignerKeyId = signerKeyId,
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = _timeProvider.GetUtcNow(),
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ namespace StellaOps.Cli.Services;
|
||||
internal sealed class ScannerExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ILogger<ScannerExecutor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger)
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ScannerExecutionResult> RunAsync(
|
||||
@@ -47,7 +49,7 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
: Path.GetFullPath(resultsDirectory);
|
||||
|
||||
Directory.CreateDirectory(resultsDirectory);
|
||||
var executionTimestamp = DateTimeOffset.UtcNow;
|
||||
var executionTimestamp = _timeProvider.GetUtcNow();
|
||||
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -92,7 +94,7 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var completionTimestamp = DateTimeOffset.UtcNow;
|
||||
var completionTimestamp = _timeProvider.GetUtcNow();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
@@ -279,9 +281,9 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
return newest ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePlaceholderResult(string resultsDirectory)
|
||||
private string CreatePlaceholderResult(string resultsDirectory)
|
||||
{
|
||||
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
var fileName = $"scan-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
|
||||
return path;
|
||||
|
||||
@@ -94,25 +94,29 @@ internal static class TenantProfileStore
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task SetActiveTenantAsync(string tenantId, string? displayName = null, CancellationToken cancellationToken = default)
|
||||
public static async Task SetActiveTenantAsync(
|
||||
string tenantId,
|
||||
string? displayName = null,
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
ActiveTenant = tenantId?.Trim().ToLowerInvariant(),
|
||||
ActiveTenantDisplayName = displayName?.Trim(),
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
LastUpdated = asOf ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await SaveAsync(profile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task ClearActiveTenantAsync(CancellationToken cancellationToken = default)
|
||||
public static async Task ClearActiveTenantAsync(DateTimeOffset? asOf = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
ActiveTenant = null,
|
||||
ActiveTenantDisplayName = null,
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
LastUpdated = asOf ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await SaveAsync(profile, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
|
||||
<!-- GitHub Code Scanning Integration (SPRINT_20260109_010_002) -->
|
||||
<ProjectReference Include="../../Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||
<!-- Patch Verification (SPRINT_20260111_001_004) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.PatchVerification/StellaOps.Scanner.PatchVerification.csproj" />
|
||||
<!-- Change Trace (SPRINT_20260112_200_006) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.ChangeTrace/StellaOps.Scanner.ChangeTrace.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- GOST Crypto Plugins (Russia distribution) -->
|
||||
|
||||
@@ -4449,7 +4449,7 @@ spec:
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) => null;
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
Assert.Equal(DevPortalVerifyExitCode.Success, result.ExitCode);
|
||||
Assert.Equal("a1b2c3d4-e5f6-7890-abcd-ef1234567890", result.BundleId);
|
||||
Assert.NotNull(result.RootHash);
|
||||
Assert.True(result.RootHash!.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.StartsWith("sha256:", result.RootHash!, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(1, result.Entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
namespace StellaOps.Concelier.Plugin.Unified;
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using UnifiedFeedCapability = StellaOps.Plugin.Abstractions.Capabilities.IFeedCapability;
|
||||
using LegacyFeedConnector = StellaOps.Plugin.IFeedConnector;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts an existing IConnectorPlugin to the unified IPlugin and IFeedCapability interfaces.
|
||||
/// This enables gradual migration of Concelier connectors to the unified plugin architecture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The adapter bridges the Concelier-specific IConnectorPlugin/IFeedConnector interfaces to
|
||||
/// the Plugin.Abstractions IFeedCapability interface. The underlying connector operations
|
||||
/// are delegated to the wrapped connector.
|
||||
/// </remarks>
|
||||
public sealed class FeedPluginAdapter : IPlugin, UnifiedFeedCapability
|
||||
{
|
||||
private readonly IConnectorPlugin _plugin;
|
||||
private readonly FeedType _feedType;
|
||||
private readonly IReadOnlyList<string> _ecosystems;
|
||||
private IPluginContext? _context;
|
||||
private IServiceProvider? _serviceProvider;
|
||||
private LegacyFeedConnector? _connector;
|
||||
private PluginLifecycleState _state = PluginLifecycleState.Discovered;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new adapter for an existing connector plugin.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The existing connector plugin to wrap.</param>
|
||||
/// <param name="feedType">The feed type classification.</param>
|
||||
/// <param name="ecosystems">Ecosystems covered by this feed.</param>
|
||||
public FeedPluginAdapter(
|
||||
IConnectorPlugin plugin,
|
||||
FeedType feedType,
|
||||
IReadOnlyList<string> ecosystems)
|
||||
{
|
||||
_plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
|
||||
_feedType = feedType;
|
||||
_ecosystems = ecosystems ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
#region IPlugin
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginInfo Info => new(
|
||||
Id: $"com.stellaops.feed.{_plugin.Name}",
|
||||
Name: _plugin.Name,
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: $"Vulnerability feed connector for {_plugin.Name}");
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginCapabilities Capabilities => PluginCapabilities.Feed | PluginCapabilities.Network;
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginLifecycleState State => _state;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_context = context;
|
||||
_state = PluginLifecycleState.Initializing;
|
||||
|
||||
try
|
||||
{
|
||||
// Get service provider from context
|
||||
// Note: This would require IPluginContext to expose the service provider
|
||||
// For now we create a minimal service provider
|
||||
_serviceProvider = CreateServiceProvider(context);
|
||||
|
||||
if (!_plugin.IsAvailable(_serviceProvider))
|
||||
{
|
||||
_state = PluginLifecycleState.Failed;
|
||||
throw new InvalidOperationException(
|
||||
$"Feed connector '{_plugin.Name}' is not available.");
|
||||
}
|
||||
|
||||
// Create the actual connector
|
||||
_connector = _plugin.Create(_serviceProvider);
|
||||
|
||||
_state = PluginLifecycleState.Active;
|
||||
context.Logger.Info("Feed connector adapter initialized for {FeedId}", _plugin.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_state = PluginLifecycleState.Failed;
|
||||
context.Logger.Error(ex, "Failed to initialize feed connector {FeedId}", _plugin.Name);
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_state != PluginLifecycleState.Active)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"Connector is in state {_state}");
|
||||
}
|
||||
|
||||
if (_connector == null)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Connector not initialized");
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy()
|
||||
.WithDetails(new Dictionary<string, object>
|
||||
{
|
||||
["feedId"] = FeedId,
|
||||
["feedType"] = _feedType.ToString(),
|
||||
["ecosystems"] = string.Join(", ", _ecosystems),
|
||||
["sourceName"] = _connector.SourceName
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_connector is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
return asyncDisposable.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_connector is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
_connector = null;
|
||||
_state = PluginLifecycleState.Stopped;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IConnectorCapability
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ConnectorType => $"feed.{_plugin.Name}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => _plugin.Name;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(CancellationToken ct)
|
||||
{
|
||||
if (_state != PluginLifecycleState.Active)
|
||||
{
|
||||
return ConnectionTestResult.Failed($"Connector is in state {_state}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// For feed connectors, we can't easily test connection without a full fetch
|
||||
// Return success if the connector is initialized
|
||||
return ConnectionTestResult.Succeeded();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConnectionTestResult.Failed(ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConnectionInfo> GetConnectionInfoAsync(CancellationToken ct)
|
||||
{
|
||||
return new ConnectionInfo(
|
||||
EndpointUrl: $"feed://{_plugin.Name}",
|
||||
AuthenticatedAs: null,
|
||||
ConnectedSince: null,
|
||||
Metadata: new Dictionary<string, object>
|
||||
{
|
||||
["feedType"] = _feedType.ToString(),
|
||||
["ecosystems"] = _ecosystems
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IFeedCapability
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FeedId => _plugin.Name;
|
||||
|
||||
/// <inheritdoc />
|
||||
public FeedType FeedType => _feedType;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> CoveredEcosystems => _ecosystems;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FeedMetadata> GetMetadataAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
|
||||
// The legacy IFeedConnector doesn't have direct metadata access
|
||||
// Return basic metadata from the plugin
|
||||
return new FeedMetadata(
|
||||
FeedId: FeedId,
|
||||
LastModified: null,
|
||||
TotalEntries: null,
|
||||
SchemaVersion: null,
|
||||
Description: $"Vulnerability feed from {FeedId}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<FeedEntry> FetchEntriesAsync(
|
||||
DateTimeOffset? since,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
|
||||
// The legacy IFeedConnector uses FetchAsync/ParseAsync/MapAsync pipeline
|
||||
// which doesn't directly stream entries. We document this limitation.
|
||||
return EmptyAsyncEnumerable<FeedEntry>.ThrowNotSupported(
|
||||
$"Direct entry streaming is not supported for {FeedId}. " +
|
||||
"Use the legacy FetchAsync/ParseAsync/MapAsync pipeline via the connector directly.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FeedEntry?> GetEntryAsync(string id, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
|
||||
// The legacy connector doesn't support individual entry lookup
|
||||
throw new NotSupportedException(
|
||||
$"Individual entry lookup is not supported for {FeedId}. " +
|
||||
"Use the legacy pipeline for bulk data processing.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetRawEntryAsync(string id, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
|
||||
// Not supported by legacy connector
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Operations
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying connector for extended operations.
|
||||
/// </summary>
|
||||
public LegacyFeedConnector? Connector => _connector;
|
||||
|
||||
/// <summary>
|
||||
/// Executes the fetch phase of the legacy pipeline.
|
||||
/// </summary>
|
||||
public async Task FetchAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
await _connector!.FetchAsync(_serviceProvider!, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the parse phase of the legacy pipeline.
|
||||
/// </summary>
|
||||
public async Task ParseAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
await _connector!.ParseAsync(_serviceProvider!, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the map phase of the legacy pipeline.
|
||||
/// </summary>
|
||||
public async Task MapAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
await _connector!.MapAsync(_serviceProvider!, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the full pipeline (fetch, parse, map).
|
||||
/// </summary>
|
||||
public async Task RunPipelineAsync(CancellationToken ct)
|
||||
{
|
||||
await FetchAsync(ct);
|
||||
await ParseAsync(ct);
|
||||
await MapAsync(ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void EnsureActive()
|
||||
{
|
||||
if (_state != PluginLifecycleState.Active || _connector == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Feed connector '{_plugin.Name}' is not active (state: {_state})");
|
||||
}
|
||||
}
|
||||
|
||||
private IServiceProvider CreateServiceProvider(IPluginContext context)
|
||||
{
|
||||
// The adapter needs to provide services that the legacy connector expects
|
||||
// For now, return a minimal provider that wraps the context
|
||||
return new MinimalServiceProvider(context);
|
||||
}
|
||||
|
||||
private sealed class MinimalServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly IPluginContext _context;
|
||||
|
||||
public MinimalServiceProvider(IPluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
if (serviceType == typeof(IPluginContext))
|
||||
return _context;
|
||||
|
||||
if (serviceType == typeof(IPluginLogger))
|
||||
return _context.Logger;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for creating async enumerables that throw exceptions.
|
||||
/// </summary>
|
||||
internal static class EmptyAsyncEnumerable<T>
|
||||
{
|
||||
public static IAsyncEnumerable<T> ThrowNotSupported(string message)
|
||||
{
|
||||
return new ThrowingAsyncEnumerable(message);
|
||||
}
|
||||
|
||||
private sealed class ThrowingAsyncEnumerable : IAsyncEnumerable<T>
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public ThrowingAsyncEnumerable(string message) => _message = message;
|
||||
|
||||
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken ct) =>
|
||||
new ThrowingAsyncEnumerator(_message);
|
||||
}
|
||||
|
||||
private sealed class ThrowingAsyncEnumerator : IAsyncEnumerator<T>
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public ThrowingAsyncEnumerator(string message) => _message = message;
|
||||
|
||||
public T Current => throw new NotSupportedException(_message);
|
||||
|
||||
public ValueTask<bool> MoveNextAsync() =>
|
||||
throw new NotSupportedException(_message);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
namespace StellaOps.Concelier.Plugin.Unified;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using UnifiedFeedCapability = StellaOps.Plugin.Abstractions.Capabilities.IFeedCapability;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating unified feed plugin adapters from existing Concelier connectors.
|
||||
/// </summary>
|
||||
public sealed class FeedPluginAdapterFactory
|
||||
{
|
||||
private readonly IEnumerable<IConnectorPlugin> _plugins;
|
||||
private readonly Dictionary<string, FeedPluginAdapter> _adapters = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Known feed types for each connector.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, FeedType> KnownFeedTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// CVE databases
|
||||
["nvd"] = FeedType.Database,
|
||||
["cve"] = FeedType.Database,
|
||||
["osv"] = FeedType.Database,
|
||||
["ghsa"] = FeedType.Advisory,
|
||||
|
||||
// OVAL feeds (distro-specific)
|
||||
["redhat"] = FeedType.Oval,
|
||||
["ubuntu"] = FeedType.Oval,
|
||||
["debian"] = FeedType.Oval,
|
||||
["suse"] = FeedType.Oval,
|
||||
["alpine"] = FeedType.EcosystemSpecific,
|
||||
|
||||
// Vendor advisories
|
||||
["msrc"] = FeedType.Advisory,
|
||||
["cisco"] = FeedType.Advisory,
|
||||
["adobe"] = FeedType.Advisory,
|
||||
["apple"] = FeedType.Advisory,
|
||||
["oracle"] = FeedType.Advisory,
|
||||
["vmware"] = FeedType.Advisory,
|
||||
["chromium"] = FeedType.Advisory,
|
||||
|
||||
// Government/CERT feeds
|
||||
["kev"] = FeedType.Advisory,
|
||||
["certfr"] = FeedType.Advisory,
|
||||
["certbund"] = FeedType.Advisory,
|
||||
["certcc"] = FeedType.Advisory,
|
||||
["certin"] = FeedType.Advisory,
|
||||
["acsc"] = FeedType.Advisory,
|
||||
["cccs"] = FeedType.Advisory,
|
||||
["jvn"] = FeedType.Advisory,
|
||||
["kisa"] = FeedType.Advisory,
|
||||
|
||||
// Russian feeds
|
||||
["rubdu"] = FeedType.Database,
|
||||
["runkcki"] = FeedType.Advisory,
|
||||
|
||||
// ICS feeds
|
||||
["icscisa"] = FeedType.Advisory,
|
||||
["kaspersky"] = FeedType.Advisory,
|
||||
|
||||
// Mirror/EPSS
|
||||
["stellaops-mirror"] = FeedType.Database,
|
||||
["epss"] = FeedType.EcosystemSpecific
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Known ecosystems for each connector.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string[]> KnownEcosystems = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["nvd"] = new[] { "all" },
|
||||
["cve"] = new[] { "all" },
|
||||
["osv"] = new[] { "npm", "pypi", "crates.io", "go", "maven", "rubygems", "nuget" },
|
||||
["ghsa"] = new[] { "npm", "pypi", "go", "maven", "rubygems", "nuget", "composer" },
|
||||
["redhat"] = new[] { "rpm:redhat" },
|
||||
["ubuntu"] = new[] { "deb:ubuntu" },
|
||||
["debian"] = new[] { "deb:debian" },
|
||||
["suse"] = new[] { "rpm:suse" },
|
||||
["alpine"] = new[] { "apk:alpine" },
|
||||
["kev"] = new[] { "all" },
|
||||
["epss"] = new[] { "all" }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new factory instance.
|
||||
/// </summary>
|
||||
/// <param name="plugins">The available connector plugins.</param>
|
||||
public FeedPluginAdapterFactory(IEnumerable<IConnectorPlugin> plugins)
|
||||
{
|
||||
_plugins = plugins ?? throw new ArgumentNullException(nameof(plugins));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available unified feed plugins.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">Service provider for availability checking.</param>
|
||||
/// <returns>List of unified feed plugins.</returns>
|
||||
public IReadOnlyList<IPlugin> GetAllPlugins(IServiceProvider serviceProvider)
|
||||
{
|
||||
var result = new List<IPlugin>();
|
||||
|
||||
foreach (var plugin in _plugins)
|
||||
{
|
||||
if (plugin.IsAvailable(serviceProvider))
|
||||
{
|
||||
var adapter = GetOrCreateAdapter(plugin);
|
||||
if (adapter != null)
|
||||
{
|
||||
result.Add(adapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a unified feed plugin by feed ID.
|
||||
/// </summary>
|
||||
/// <param name="feedId">Feed identifier (e.g., "nvd", "osv", "ghsa").</param>
|
||||
/// <returns>Unified feed plugin, or null if not found.</returns>
|
||||
public IPlugin? GetPlugin(string feedId)
|
||||
{
|
||||
var plugin = _plugins.FirstOrDefault(p =>
|
||||
p.Name.Equals(feedId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (plugin == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetOrCreateAdapter(plugin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the feed capability for a connector.
|
||||
/// </summary>
|
||||
/// <param name="feedId">Feed identifier.</param>
|
||||
/// <returns>Feed capability, or null if not found.</returns>
|
||||
public UnifiedFeedCapability? GetCapability(string feedId)
|
||||
{
|
||||
return GetPlugin(feedId) as UnifiedFeedCapability;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available feed IDs.
|
||||
/// </summary>
|
||||
/// <returns>List of feed IDs.</returns>
|
||||
public IReadOnlyList<string> GetAvailableFeedIds()
|
||||
{
|
||||
return _plugins.Select(p => p.Name).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets feed plugins by type.
|
||||
/// </summary>
|
||||
/// <param name="feedType">Feed type to filter by.</param>
|
||||
/// <param name="serviceProvider">Service provider for availability checking.</param>
|
||||
/// <returns>List of plugins matching the feed type.</returns>
|
||||
public IReadOnlyList<IPlugin> GetPluginsByType(FeedType feedType, IServiceProvider serviceProvider)
|
||||
{
|
||||
var result = new List<IPlugin>();
|
||||
|
||||
foreach (var plugin in _plugins)
|
||||
{
|
||||
if (!plugin.IsAvailable(serviceProvider))
|
||||
continue;
|
||||
|
||||
var type = KnownFeedTypes.TryGetValue(plugin.Name, out var t)
|
||||
? t
|
||||
: FeedType.Database;
|
||||
|
||||
if (type == feedType)
|
||||
{
|
||||
var adapter = GetOrCreateAdapter(plugin);
|
||||
if (adapter != null)
|
||||
{
|
||||
result.Add(adapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private FeedPluginAdapter? GetOrCreateAdapter(IConnectorPlugin plugin)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_adapters.TryGetValue(plugin.Name, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var feedType = KnownFeedTypes.TryGetValue(plugin.Name, out var ft)
|
||||
? ft
|
||||
: FeedType.Database;
|
||||
|
||||
var ecosystems = KnownEcosystems.TryGetValue(plugin.Name, out var eco)
|
||||
? eco
|
||||
: Array.Empty<string>();
|
||||
|
||||
var adapter = new FeedPluginAdapter(plugin, feedType, ecosystems);
|
||||
_adapters[plugin.Name] = adapter;
|
||||
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering unified feed plugin services.
|
||||
/// </summary>
|
||||
public static class FeedPluginAdapterExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds unified feed plugin adapter services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnifiedFeedPlugins(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<FeedPluginAdapterFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Unified plugin adapter for Concelier feed connectors</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\..\Plugin\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -12,6 +12,12 @@ public sealed class InMemoryOrchestratorRegistryStore : IOrchestratorRegistrySto
|
||||
private readonly ConcurrentDictionary<(string Tenant, string ConnectorId, Guid RunId), List<OrchestratorHeartbeatRecord>> _heartbeats = new();
|
||||
private readonly ConcurrentDictionary<(string Tenant, string ConnectorId, Guid RunId), List<OrchestratorCommandRecord>> _commands = new();
|
||||
private readonly ConcurrentDictionary<(string Tenant, string ConnectorId, Guid RunId), OrchestratorRunManifest> _manifests = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryOrchestratorRegistryStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpsertAsync(OrchestratorRegistryRecord record, CancellationToken cancellationToken)
|
||||
@@ -99,7 +105,7 @@ public sealed class InMemoryOrchestratorRegistryStore : IOrchestratorRegistrySto
|
||||
|
||||
lock (commands)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var pending = commands
|
||||
.Where(c => (afterSequence is null || c.Sequence > afterSequence)
|
||||
&& (c.ExpiresAt is null || c.ExpiresAt > now))
|
||||
|
||||
@@ -19,8 +19,11 @@ public sealed record TenantScope(
|
||||
/// <summary>
|
||||
/// Validates that the tenant scope is well-formed.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
/// <param name="asOf">The time to check expiry against. Defaults to current UTC time.</param>
|
||||
public void Validate(DateTimeOffset? asOf = null)
|
||||
{
|
||||
var now = asOf ?? DateTimeOffset.UtcNow;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
throw new TenantScopeException("auth/tenant-scope-missing", "TenantId is required");
|
||||
@@ -41,7 +44,7 @@ public sealed record TenantScope(
|
||||
throw new TenantScopeException("auth/tenant-scope-missing", "Required concelier scope missing");
|
||||
}
|
||||
|
||||
if (ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
if (ExpiresAt <= now)
|
||||
{
|
||||
throw new TenantScopeException("auth/token-expired", "Token has expired");
|
||||
}
|
||||
|
||||
@@ -21,17 +21,20 @@ public sealed class BundleExportService : IBundleExportService
|
||||
private readonly IBundleSigner _signer;
|
||||
private readonly FederationOptions _options;
|
||||
private readonly ILogger<BundleExportService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public BundleExportService(
|
||||
IDeltaQueryService deltaQuery,
|
||||
IBundleSigner signer,
|
||||
IOptions<FederationOptions> options,
|
||||
ILogger<BundleExportService> logger)
|
||||
ILogger<BundleExportService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_deltaQuery = deltaQuery;
|
||||
_signer = signer;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -164,7 +167,7 @@ public sealed class BundleExportService : IBundleExportService
|
||||
await WriteEntryAsync(tarWriter, "deletions.ndjson", deletionBuffer, ct);
|
||||
|
||||
// Generate new cursor
|
||||
var exportCursor = CursorFormat.Create(DateTimeOffset.UtcNow);
|
||||
var exportCursor = CursorFormat.Create(_timeProvider.GetUtcNow());
|
||||
|
||||
// Build manifest
|
||||
var manifest = new BundleManifest
|
||||
@@ -173,7 +176,7 @@ public sealed class BundleExportService : IBundleExportService
|
||||
SiteId = _options.SiteId,
|
||||
ExportCursor = exportCursor,
|
||||
SinceCursor = sinceCursor,
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
ExportedAt = _timeProvider.GetUtcNow(),
|
||||
Counts = new BundleCounts
|
||||
{
|
||||
Canonicals = canonicalCount,
|
||||
|
||||
@@ -14,13 +14,16 @@ public sealed class DeltaQueryService : IDeltaQueryService
|
||||
{
|
||||
private readonly ICanonicalAdvisoryStore _canonicalStore;
|
||||
private readonly ILogger<DeltaQueryService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeltaQueryService(
|
||||
ICanonicalAdvisoryStore canonicalStore,
|
||||
ILogger<DeltaQueryService> logger)
|
||||
ILogger<DeltaQueryService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_canonicalStore = canonicalStore;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -32,7 +35,7 @@ public sealed class DeltaQueryService : IDeltaQueryService
|
||||
options ??= new DeltaQueryOptions();
|
||||
|
||||
var sinceTimestamp = ParseCursor(sinceCursor);
|
||||
var newCursor = CursorFormat.Create(DateTimeOffset.UtcNow);
|
||||
var newCursor = CursorFormat.Create(_timeProvider.GetUtcNow());
|
||||
|
||||
_logger.LogInformation(
|
||||
"Querying changes since {Cursor} (timestamp: {Since})",
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
namespace StellaOps.Cryptography.Plugin.Eidas;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Org.BouncyCastle.X509;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using X509Certificate = Org.BouncyCastle.X509.X509Certificate;
|
||||
|
||||
/// <summary>
|
||||
/// eIDAS cryptography plugin for EU qualified electronic signatures.
|
||||
/// Implements ETSI TS 119 312 compliant signature operations.
|
||||
/// </summary>
|
||||
public sealed class EidasPlugin : CryptoPluginBase
|
||||
{
|
||||
private EidasOptions? _options;
|
||||
private X509Certificate2? _signingCertificate;
|
||||
private X509Certificate? _bcCertificate;
|
||||
private RSA? _privateKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PluginInfo Info => new(
|
||||
Id: "com.stellaops.crypto.eidas",
|
||||
Name: "eIDAS Cryptography Provider",
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: "EU eIDAS qualified electronic signatures (ETSI TS 119 312)",
|
||||
LicenseId: "AGPL-3.0-or-later");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> SupportedAlgorithms => new[]
|
||||
{
|
||||
"eIDAS-RSA-SHA256",
|
||||
"eIDAS-RSA-SHA384",
|
||||
"eIDAS-RSA-SHA512",
|
||||
"eIDAS-ECDSA-SHA256",
|
||||
"eIDAS-ECDSA-SHA384",
|
||||
"eIDAS-CAdES-BES",
|
||||
"eIDAS-XAdES-BES"
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_options = context.Configuration.Bind<EidasOptions>() ?? new EidasOptions();
|
||||
|
||||
if (!string.IsNullOrEmpty(_options.CertificatePath))
|
||||
{
|
||||
LoadCertificate(_options.CertificatePath, _options.CertificatePassword);
|
||||
Context?.Logger.Info("eIDAS provider initialized with certificate from {Path}", _options.CertificatePath);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_options.CertificateThumbprint))
|
||||
{
|
||||
LoadCertificateFromStore(_options.CertificateThumbprint, _options.CertificateStoreLocation);
|
||||
Context?.Logger.Info("eIDAS provider initialized with certificate from store");
|
||||
}
|
||||
else
|
||||
{
|
||||
Context?.Logger.Warning("eIDAS provider initialized without certificate - signing operations will fail");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanHandle(CryptoOperation operation, string algorithm)
|
||||
{
|
||||
return algorithm.StartsWith("eIDAS", StringComparison.OrdinalIgnoreCase) &&
|
||||
SupportedAlgorithms.Contains(algorithm, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_signingCertificate == null || _privateKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No signing certificate configured.");
|
||||
}
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
byte[] signature;
|
||||
|
||||
if (algorithm.Contains("CAdES", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
signature = CreateCadesSignature(data.ToArray());
|
||||
}
|
||||
else if (algorithm.Contains("ECDSA", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new NotSupportedException("ECDSA not yet implemented for eIDAS. Use RSA algorithms.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard RSA signature
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
signature = _privateKey.SignData(data.ToArray(), hashAlgorithm, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
Context?.Logger.Debug("Signed {DataLength} bytes with {Algorithm}", data.Length, algorithm);
|
||||
|
||||
return Task.FromResult(signature);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
bool isValid;
|
||||
|
||||
if (algorithm.Contains("CAdES", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
isValid = VerifyCadesSignature(data.ToArray(), signature.ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Load verification certificate
|
||||
X509Certificate2 verificationCert;
|
||||
if (!string.IsNullOrEmpty(options.CertificateChain))
|
||||
{
|
||||
verificationCert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(options.CertificateChain));
|
||||
}
|
||||
else if (_signingCertificate != null)
|
||||
{
|
||||
verificationCert = _signingCertificate;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("No verification certificate available.");
|
||||
}
|
||||
|
||||
using var rsa = verificationCert.GetRSAPublicKey();
|
||||
if (rsa == null)
|
||||
{
|
||||
throw new InvalidOperationException("Certificate does not contain RSA public key.");
|
||||
}
|
||||
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
isValid = rsa.VerifyData(data.ToArray(), signature.ToArray(), hashAlgorithm, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
Context?.Logger.Debug("Verified signature: {IsValid}", isValid);
|
||||
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_signingCertificate == null)
|
||||
{
|
||||
throw new InvalidOperationException("No certificate configured for encryption.");
|
||||
}
|
||||
|
||||
using var rsa = _signingCertificate.GetRSAPublicKey()
|
||||
?? throw new InvalidOperationException("Certificate does not contain RSA public key.");
|
||||
|
||||
var encrypted = rsa.Encrypt(data.ToArray(), RSAEncryptionPadding.OaepSHA256);
|
||||
|
||||
Context?.Logger.Debug("Encrypted {DataLength} bytes", data.Length);
|
||||
|
||||
return Task.FromResult(encrypted);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_privateKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No private key configured for decryption.");
|
||||
}
|
||||
|
||||
var decrypted = _privateKey.Decrypt(data.ToArray(), RSAEncryptionPadding.OaepSHA256);
|
||||
|
||||
Context?.Logger.Debug("Decrypted {DataLength} bytes", data.Length);
|
||||
|
||||
return Task.FromResult(decrypted);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
byte[] hash = algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("512") => SHA512.HashData(data.Span),
|
||||
var a when a.Contains("384") => SHA384.HashData(data.Span),
|
||||
_ => SHA256.HashData(data.Span)
|
||||
};
|
||||
|
||||
Context?.Logger.Debug("Computed hash of {DataLength} bytes", data.Length);
|
||||
|
||||
return Task.FromResult(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
_privateKey?.Dispose();
|
||||
_signingCertificate?.Dispose();
|
||||
_privateKey = null;
|
||||
_signingCertificate = null;
|
||||
_bcCertificate = null;
|
||||
State = PluginLifecycleState.Stopped;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private void LoadCertificate(string path, string? password)
|
||||
{
|
||||
_signingCertificate = X509CertificateLoader.LoadPkcs12FromFile(path, password, X509KeyStorageFlags.Exportable);
|
||||
_privateKey = _signingCertificate.GetRSAPrivateKey();
|
||||
_bcCertificate = DotNetUtilities.FromX509Certificate(_signingCertificate);
|
||||
}
|
||||
|
||||
private void LoadCertificateFromStore(string thumbprint, StoreLocation storeLocation)
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, storeLocation);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
var certs = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false);
|
||||
if (certs.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Certificate with thumbprint {thumbprint} not found in store.");
|
||||
}
|
||||
|
||||
_signingCertificate = certs[0];
|
||||
_privateKey = _signingCertificate.GetRSAPrivateKey();
|
||||
_bcCertificate = DotNetUtilities.FromX509Certificate(_signingCertificate);
|
||||
}
|
||||
|
||||
private byte[] CreateCadesSignature(byte[] data)
|
||||
{
|
||||
if (_signingCertificate == null || _privateKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("Certificate not loaded for CAdES signing.");
|
||||
}
|
||||
|
||||
// Simplified CAdES-BES: Use .NET CMS for signing
|
||||
// For full CAdES compliance, a dedicated library like iText or DSS should be used
|
||||
var contentInfo = new System.Security.Cryptography.Pkcs.ContentInfo(data);
|
||||
var signedCms = new System.Security.Cryptography.Pkcs.SignedCms(contentInfo, detached: true);
|
||||
var signer = new System.Security.Cryptography.Pkcs.CmsSigner(_signingCertificate)
|
||||
{
|
||||
DigestAlgorithm = new Oid("2.16.840.1.101.3.4.2.1") // SHA-256
|
||||
};
|
||||
signedCms.ComputeSignature(signer);
|
||||
|
||||
return signedCms.Encode();
|
||||
}
|
||||
|
||||
private bool VerifyCadesSignature(byte[] data, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use .NET CMS verification
|
||||
var contentInfo = new System.Security.Cryptography.Pkcs.ContentInfo(data);
|
||||
var signedCms = new System.Security.Cryptography.Pkcs.SignedCms(contentInfo, detached: true);
|
||||
signedCms.Decode(signature);
|
||||
signedCms.CheckSignature(verifySignatureOnly: true);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static HashAlgorithmName GetHashAlgorithmName(string algorithm)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("SHA512") => HashAlgorithmName.SHA512,
|
||||
var a when a.Contains("SHA384") => HashAlgorithmName.SHA384,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for eIDAS cryptography plugin.
|
||||
/// </summary>
|
||||
public sealed class EidasOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to PKCS#12/PFX certificate file.
|
||||
/// </summary>
|
||||
public string? CertificatePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for the certificate file.
|
||||
/// </summary>
|
||||
public string? CertificatePassword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate thumbprint for loading from Windows certificate store.
|
||||
/// </summary>
|
||||
public string? CertificateThumbprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate store location (CurrentUser or LocalMachine).
|
||||
/// </summary>
|
||||
public StoreLocation CertificateStoreLocation { get; init; } = StoreLocation.CurrentUser;
|
||||
|
||||
/// <summary>
|
||||
/// Trusted timestamp authority URL for qualified signatures.
|
||||
/// </summary>
|
||||
public string? TimestampAuthorityUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate certificate chain during operations.
|
||||
/// </summary>
|
||||
public bool ValidateCertificateChain { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin\StellaOps.Cryptography.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="plugin.yaml" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,50 @@
|
||||
plugin:
|
||||
id: com.stellaops.crypto.eidas
|
||||
name: eIDAS Cryptography Provider
|
||||
version: 1.0.0
|
||||
vendor: Stella Ops
|
||||
description: EU eIDAS qualified electronic signatures (ETSI TS 119 312)
|
||||
license: AGPL-3.0-or-later
|
||||
|
||||
entryPoint: StellaOps.Cryptography.Plugin.Eidas.EidasPlugin
|
||||
|
||||
minPlatformVersion: 1.0.0
|
||||
|
||||
capabilities:
|
||||
- type: crypto
|
||||
id: eidas
|
||||
algorithms:
|
||||
- eIDAS-RSA-SHA256
|
||||
- eIDAS-RSA-SHA384
|
||||
- eIDAS-RSA-SHA512
|
||||
- eIDAS-ECDSA-SHA256
|
||||
- eIDAS-ECDSA-SHA384
|
||||
- eIDAS-CAdES-BES
|
||||
- eIDAS-XAdES-BES
|
||||
|
||||
configSchema:
|
||||
type: object
|
||||
properties:
|
||||
certificatePath:
|
||||
type: string
|
||||
description: Path to PKCS#12/PFX certificate file
|
||||
certificatePassword:
|
||||
type: string
|
||||
description: Password for the certificate file
|
||||
certificateThumbprint:
|
||||
type: string
|
||||
description: Certificate thumbprint for Windows certificate store
|
||||
certificateStoreLocation:
|
||||
type: string
|
||||
enum: [CurrentUser, LocalMachine]
|
||||
default: CurrentUser
|
||||
description: Certificate store location
|
||||
timestampAuthorityUrl:
|
||||
type: string
|
||||
format: uri
|
||||
description: Trusted timestamp authority URL
|
||||
validateCertificateChain:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Validate certificate chain during operations
|
||||
required: []
|
||||
@@ -0,0 +1,431 @@
|
||||
namespace StellaOps.Cryptography.Plugin.Fips;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// FIPS 140-2 compliant cryptography plugin.
|
||||
/// Uses .NET's FIPS-validated cryptographic implementations.
|
||||
/// </summary>
|
||||
public sealed class FipsPlugin : CryptoPluginBase
|
||||
{
|
||||
private FipsOptions? _options;
|
||||
private RSA? _rsaKey;
|
||||
private ECDsa? _ecdsaKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PluginInfo Info => new(
|
||||
Id: "com.stellaops.crypto.fips",
|
||||
Name: "FIPS 140-2 Cryptography Provider",
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: "US FIPS 140-2 compliant cryptographic algorithms",
|
||||
LicenseId: "AGPL-3.0-or-later");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> SupportedAlgorithms => new[]
|
||||
{
|
||||
"RSA-SHA256",
|
||||
"RSA-SHA384",
|
||||
"RSA-SHA512",
|
||||
"RSA-PSS-SHA256",
|
||||
"RSA-PSS-SHA384",
|
||||
"RSA-PSS-SHA512",
|
||||
"ECDSA-P256-SHA256",
|
||||
"ECDSA-P384-SHA384",
|
||||
"ECDSA-P521-SHA512",
|
||||
"AES-128-CBC",
|
||||
"AES-256-CBC",
|
||||
"AES-128-GCM",
|
||||
"AES-256-GCM",
|
||||
"SHA-256",
|
||||
"SHA-384",
|
||||
"SHA-512",
|
||||
"HMAC-SHA256",
|
||||
"HMAC-SHA384",
|
||||
"HMAC-SHA512"
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_options = context.Configuration.Bind<FipsOptions>() ?? new FipsOptions();
|
||||
|
||||
// Verify FIPS mode if required
|
||||
if (_options.RequireFipsMode && !IsFipsModeEnabled())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"FIPS mode is required but not enabled. Set CryptoConfig.AllowOnlyFipsAlgorithms = true.");
|
||||
}
|
||||
|
||||
// Initialize RSA key
|
||||
if (!string.IsNullOrEmpty(_options.RsaKeyXml))
|
||||
{
|
||||
_rsaKey = RSA.Create();
|
||||
_rsaKey.FromXmlString(_options.RsaKeyXml);
|
||||
Context?.Logger.Info("FIPS provider initialized with configured RSA key");
|
||||
}
|
||||
else if (_options.GenerateKeysOnInit)
|
||||
{
|
||||
_rsaKey = RSA.Create(_options.RsaKeySize);
|
||||
Context?.Logger.Info("FIPS provider initialized with generated {KeySize}-bit RSA key", _options.RsaKeySize);
|
||||
}
|
||||
|
||||
// Initialize ECDSA key
|
||||
if (!string.IsNullOrEmpty(_options.EcdsaKeyXml))
|
||||
{
|
||||
_ecdsaKey = ECDsa.Create();
|
||||
_ecdsaKey.FromXmlString(_options.EcdsaKeyXml);
|
||||
}
|
||||
else if (_options.GenerateKeysOnInit)
|
||||
{
|
||||
_ecdsaKey = ECDsa.Create(GetECCurve(_options.EcdsaCurve));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanHandle(CryptoOperation operation, string algorithm)
|
||||
{
|
||||
return SupportedAlgorithms.Contains(algorithm, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
byte[] signature;
|
||||
|
||||
if (algorithm.StartsWith("ECDSA", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_ecdsaKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No ECDSA key available.");
|
||||
}
|
||||
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
signature = _ecdsaKey.SignData(data.ToArray(), hashAlgorithm);
|
||||
}
|
||||
else if (algorithm.Contains("PSS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_rsaKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No RSA key available.");
|
||||
}
|
||||
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
signature = _rsaKey.SignData(data.ToArray(), hashAlgorithm, RSASignaturePadding.Pss);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_rsaKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No RSA key available.");
|
||||
}
|
||||
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
signature = _rsaKey.SignData(data.ToArray(), hashAlgorithm, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
Context?.Logger.Debug("Signed {DataLength} bytes with {Algorithm}", data.Length, algorithm);
|
||||
|
||||
return Task.FromResult(signature);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
bool isValid;
|
||||
|
||||
if (algorithm.StartsWith("ECDSA", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_ecdsaKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No ECDSA key available.");
|
||||
}
|
||||
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
isValid = _ecdsaKey.VerifyData(data.ToArray(), signature.ToArray(), hashAlgorithm);
|
||||
}
|
||||
else if (algorithm.Contains("PSS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_rsaKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No RSA key available.");
|
||||
}
|
||||
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
isValid = _rsaKey.VerifyData(data.ToArray(), signature.ToArray(), hashAlgorithm, RSASignaturePadding.Pss);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_rsaKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No RSA key available.");
|
||||
}
|
||||
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
isValid = _rsaKey.VerifyData(data.ToArray(), signature.ToArray(), hashAlgorithm, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
Context?.Logger.Debug("Verified signature: {IsValid}", isValid);
|
||||
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
var keyBytes = GetSymmetricKey(options.KeyId, algorithm);
|
||||
|
||||
if (algorithm.Contains("GCM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(AesGcmEncrypt(data.ToArray(), keyBytes, options.Iv, options.Aad));
|
||||
}
|
||||
else
|
||||
{
|
||||
return Task.FromResult(AesCbcEncrypt(data.ToArray(), keyBytes, options.Iv));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
var keyBytes = GetSymmetricKey(options.KeyId, algorithm);
|
||||
|
||||
if (algorithm.Contains("GCM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(AesGcmDecrypt(data.ToArray(), keyBytes, options.Iv, options.Aad));
|
||||
}
|
||||
else
|
||||
{
|
||||
return Task.FromResult(AesCbcDecrypt(data.ToArray(), keyBytes, options.Iv));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
byte[] hash;
|
||||
|
||||
if (algorithm.Contains("HMAC", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// For HMAC, we need a key - use a default for demonstration
|
||||
var hmacKey = System.Text.Encoding.UTF8.GetBytes("default-hmac-key");
|
||||
hash = algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("512") => HMACSHA512.HashData(hmacKey, data.Span),
|
||||
var a when a.Contains("384") => HMACSHA384.HashData(hmacKey, data.Span),
|
||||
_ => HMACSHA256.HashData(hmacKey, data.Span)
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
hash = algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("512") => SHA512.HashData(data.Span),
|
||||
var a when a.Contains("384") => SHA384.HashData(data.Span),
|
||||
_ => SHA256.HashData(data.Span)
|
||||
};
|
||||
}
|
||||
|
||||
Context?.Logger.Debug("Computed {Algorithm} hash of {DataLength} bytes", algorithm, data.Length);
|
||||
|
||||
return Task.FromResult(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
_rsaKey?.Dispose();
|
||||
_ecdsaKey?.Dispose();
|
||||
_rsaKey = null;
|
||||
_ecdsaKey = null;
|
||||
State = PluginLifecycleState.Stopped;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool IsFipsModeEnabled()
|
||||
{
|
||||
// Check if FIPS mode is enabled at OS level
|
||||
try
|
||||
{
|
||||
// This will throw if FIPS mode is required but algorithm is not FIPS-compliant
|
||||
using var _ = SHA256.Create();
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ECCurve GetECCurve(string curveName)
|
||||
{
|
||||
return curveName.ToUpperInvariant() switch
|
||||
{
|
||||
"P-521" or "SECP521R1" => ECCurve.NamedCurves.nistP521,
|
||||
"P-384" or "SECP384R1" => ECCurve.NamedCurves.nistP384,
|
||||
_ => ECCurve.NamedCurves.nistP256
|
||||
};
|
||||
}
|
||||
|
||||
private static HashAlgorithmName GetHashAlgorithmName(string algorithm)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("512") => HashAlgorithmName.SHA512,
|
||||
var a when a.Contains("384") => HashAlgorithmName.SHA384,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] GetSymmetricKey(string keyId, string algorithm)
|
||||
{
|
||||
// Determine key size from algorithm
|
||||
var keySize = algorithm.Contains("256") ? 32 : 16;
|
||||
|
||||
// Derive key from key ID using SHA-256
|
||||
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(keyId));
|
||||
return hash.Take(keySize).ToArray();
|
||||
}
|
||||
|
||||
private static byte[] AesCbcEncrypt(byte[] data, byte[] key, byte[]? iv)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
iv ??= aes.IV;
|
||||
aes.IV = iv;
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var encrypted = encryptor.TransformFinalBlock(data, 0, data.Length);
|
||||
|
||||
// Prepend IV to ciphertext
|
||||
var result = new byte[iv.Length + encrypted.Length];
|
||||
Array.Copy(iv, 0, result, 0, iv.Length);
|
||||
Array.Copy(encrypted, 0, result, iv.Length, encrypted.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] AesCbcDecrypt(byte[] data, byte[] key, byte[]? iv)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
if (iv == null)
|
||||
{
|
||||
iv = data.Take(16).ToArray();
|
||||
data = data.Skip(16).ToArray();
|
||||
}
|
||||
aes.IV = iv;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
return decryptor.TransformFinalBlock(data, 0, data.Length);
|
||||
}
|
||||
|
||||
private static byte[] AesGcmEncrypt(byte[] data, byte[] key, byte[]? iv, byte[]? aad)
|
||||
{
|
||||
iv ??= RandomNumberGenerator.GetBytes(12);
|
||||
var tag = new byte[16];
|
||||
|
||||
using var aesGcm = new AesGcm(key, 16);
|
||||
var ciphertext = new byte[data.Length];
|
||||
aesGcm.Encrypt(iv, data, ciphertext, tag, aad);
|
||||
|
||||
// Format: IV (12) + Tag (16) + Ciphertext
|
||||
var result = new byte[iv.Length + tag.Length + ciphertext.Length];
|
||||
Array.Copy(iv, 0, result, 0, iv.Length);
|
||||
Array.Copy(tag, 0, result, iv.Length, tag.Length);
|
||||
Array.Copy(ciphertext, 0, result, iv.Length + tag.Length, ciphertext.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] AesGcmDecrypt(byte[] data, byte[] key, byte[]? iv, byte[]? aad)
|
||||
{
|
||||
if (iv == null)
|
||||
{
|
||||
iv = data.Take(12).ToArray();
|
||||
var tag = data.Skip(12).Take(16).ToArray();
|
||||
var ciphertext = data.Skip(28).ToArray();
|
||||
|
||||
using var aesGcm = new AesGcm(key, 16);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
aesGcm.Decrypt(iv, ciphertext, tag, plaintext, aad);
|
||||
return plaintext;
|
||||
}
|
||||
else
|
||||
{
|
||||
var tag = data.Take(16).ToArray();
|
||||
var ciphertext = data.Skip(16).ToArray();
|
||||
|
||||
using var aesGcm = new AesGcm(key, 16);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
aesGcm.Decrypt(iv, ciphertext, tag, plaintext, aad);
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FIPS cryptography plugin.
|
||||
/// </summary>
|
||||
public sealed class FipsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Require FIPS mode to be enabled at OS level.
|
||||
/// </summary>
|
||||
public bool RequireFipsMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// RSA key in XML format.
|
||||
/// </summary>
|
||||
public string? RsaKeyXml { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// RSA key size in bits (2048, 3072, 4096).
|
||||
/// </summary>
|
||||
public int RsaKeySize { get; init; } = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// ECDSA key in XML format.
|
||||
/// </summary>
|
||||
public string? EcdsaKeyXml { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ECDSA curve name (P-256, P-384, P-521).
|
||||
/// </summary>
|
||||
public string EcdsaCurve { get; init; } = "P-256";
|
||||
|
||||
/// <summary>
|
||||
/// Generate keys on initialization if not configured.
|
||||
/// </summary>
|
||||
public bool GenerateKeysOnInit { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin\StellaOps.Cryptography.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="plugin.yaml" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,64 @@
|
||||
plugin:
|
||||
id: com.stellaops.crypto.fips
|
||||
name: FIPS 140-2 Cryptography Provider
|
||||
version: 1.0.0
|
||||
vendor: Stella Ops
|
||||
description: US FIPS 140-2 compliant cryptographic algorithms
|
||||
license: AGPL-3.0-or-later
|
||||
|
||||
entryPoint: StellaOps.Cryptography.Plugin.Fips.FipsPlugin
|
||||
|
||||
minPlatformVersion: 1.0.0
|
||||
|
||||
capabilities:
|
||||
- type: crypto
|
||||
id: fips
|
||||
algorithms:
|
||||
- RSA-SHA256
|
||||
- RSA-SHA384
|
||||
- RSA-SHA512
|
||||
- RSA-PSS-SHA256
|
||||
- RSA-PSS-SHA384
|
||||
- RSA-PSS-SHA512
|
||||
- ECDSA-P256-SHA256
|
||||
- ECDSA-P384-SHA384
|
||||
- ECDSA-P521-SHA512
|
||||
- AES-128-CBC
|
||||
- AES-256-CBC
|
||||
- AES-128-GCM
|
||||
- AES-256-GCM
|
||||
- SHA-256
|
||||
- SHA-384
|
||||
- SHA-512
|
||||
- HMAC-SHA256
|
||||
- HMAC-SHA384
|
||||
- HMAC-SHA512
|
||||
|
||||
configSchema:
|
||||
type: object
|
||||
properties:
|
||||
requireFipsMode:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Require FIPS mode to be enabled at OS level
|
||||
rsaKeyXml:
|
||||
type: string
|
||||
description: RSA key in XML format
|
||||
rsaKeySize:
|
||||
type: integer
|
||||
enum: [2048, 3072, 4096]
|
||||
default: 2048
|
||||
description: RSA key size in bits
|
||||
ecdsaKeyXml:
|
||||
type: string
|
||||
description: ECDSA key in XML format
|
||||
ecdsaCurve:
|
||||
type: string
|
||||
enum: [P-256, P-384, P-521]
|
||||
default: P-256
|
||||
description: ECDSA curve name
|
||||
generateKeysOnInit:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Generate keys on initialization
|
||||
required: []
|
||||
@@ -0,0 +1,342 @@
|
||||
namespace StellaOps.Cryptography.Plugin.Gost;
|
||||
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Engines;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Math;
|
||||
using Org.BouncyCastle.Math.EC;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// GOST cryptography plugin providing Russian Federal cryptographic standards.
|
||||
/// Implements GOST R 34.10-2012 (signatures) and GOST R 34.11-2012 (hashes).
|
||||
/// </summary>
|
||||
public sealed class GostPlugin : CryptoPluginBase
|
||||
{
|
||||
private GostOptions? _options;
|
||||
private AsymmetricCipherKeyPair? _keyPair;
|
||||
private readonly SecureRandom _random = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PluginInfo Info => new(
|
||||
Id: "com.stellaops.crypto.gost",
|
||||
Name: "GOST Cryptography Provider",
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: "Russian GOST R 34.10-2012 and R 34.11-2012 cryptographic algorithms",
|
||||
LicenseId: "AGPL-3.0-or-later");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> SupportedAlgorithms => new[]
|
||||
{
|
||||
"GOST-R34.10-2012-256",
|
||||
"GOST-R34.10-2012-512",
|
||||
"GOST-R34.11-2012-256",
|
||||
"GOST-R34.11-2012-512",
|
||||
"GOST-28147-89"
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_options = context.Configuration.Bind<GostOptions>() ?? new GostOptions();
|
||||
|
||||
// Generate or load key pair if configured
|
||||
if (!string.IsNullOrEmpty(_options.PrivateKeyBase64))
|
||||
{
|
||||
// Load existing key - implementation depends on key format
|
||||
Context?.Logger.Info("GOST provider initialized with configured key");
|
||||
}
|
||||
else if (_options.GenerateKeyOnInit)
|
||||
{
|
||||
// Generate new GOST-R34.10-2012-256 key pair
|
||||
_keyPair = GenerateGost2012KeyPair(_options.KeySize);
|
||||
Context?.Logger.Info("GOST provider initialized with generated {KeySize}-bit key", _options.KeySize);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanHandle(CryptoOperation operation, string algorithm)
|
||||
{
|
||||
return algorithm.StartsWith("GOST", StringComparison.OrdinalIgnoreCase) &&
|
||||
SupportedAlgorithms.Contains(algorithm, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_keyPair == null)
|
||||
{
|
||||
throw new InvalidOperationException("No signing key available. Configure a key or enable GenerateKeyOnInit.");
|
||||
}
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
var digestBits = algorithm.Contains("512") ? 512 : 256;
|
||||
|
||||
// Create GOST R 34.11-2012 digest
|
||||
var digest = CreateGost2012Digest(digestBits);
|
||||
|
||||
// Create GOST R 34.10-2012 signer
|
||||
var signer = new ECGost3410Signer();
|
||||
signer.Init(true, _keyPair.Private);
|
||||
|
||||
// Hash the data
|
||||
digest.BlockUpdate(data.Span.ToArray(), 0, data.Length);
|
||||
var hash = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(hash, 0);
|
||||
|
||||
// Sign the hash
|
||||
var signature = signer.GenerateSignature(hash);
|
||||
var sigBytes = EncodeSignature(signature);
|
||||
|
||||
Context?.Logger.Debug("Signed {DataLength} bytes with {Algorithm}", data.Length, algorithm);
|
||||
|
||||
return Task.FromResult(sigBytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_keyPair == null)
|
||||
{
|
||||
throw new InvalidOperationException("No verification key available.");
|
||||
}
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
var digestBits = algorithm.Contains("512") ? 512 : 256;
|
||||
|
||||
// Create GOST R 34.11-2012 digest
|
||||
var digest = CreateGost2012Digest(digestBits);
|
||||
|
||||
// Create GOST R 34.10-2012 verifier
|
||||
var verifier = new ECGost3410Signer();
|
||||
verifier.Init(false, _keyPair.Public);
|
||||
|
||||
// Hash the data
|
||||
digest.BlockUpdate(data.Span.ToArray(), 0, data.Length);
|
||||
var hash = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(hash, 0);
|
||||
|
||||
// Decode and verify signature
|
||||
var sigComponents = DecodeSignature(signature.ToArray());
|
||||
var isValid = verifier.VerifySignature(hash, sigComponents[0], sigComponents[1]);
|
||||
|
||||
Context?.Logger.Debug("Verified signature: {IsValid}", isValid);
|
||||
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!options.Algorithm.Contains("28147", StringComparison.Ordinal))
|
||||
{
|
||||
throw new NotSupportedException($"Encryption algorithm {options.Algorithm} not supported. Use GOST-28147-89.");
|
||||
}
|
||||
|
||||
// GOST 28147-89 block cipher encryption
|
||||
var engine = new Gost28147Engine();
|
||||
var keyBytes = GetEncryptionKey(options.KeyId);
|
||||
engine.Init(true, new KeyParameter(keyBytes));
|
||||
|
||||
var encrypted = ProcessBlocks(engine, data.ToArray());
|
||||
|
||||
Context?.Logger.Debug("Encrypted {DataLength} bytes with GOST-28147-89", data.Length);
|
||||
|
||||
return Task.FromResult(encrypted);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// GOST 28147-89 block cipher decryption
|
||||
var engine = new Gost28147Engine();
|
||||
var keyBytes = GetEncryptionKey(options.KeyId);
|
||||
engine.Init(false, new KeyParameter(keyBytes));
|
||||
|
||||
var decrypted = ProcessBlocks(engine, data.ToArray());
|
||||
|
||||
Context?.Logger.Debug("Decrypted {DataLength} bytes with GOST-28147-89", data.Length);
|
||||
|
||||
return Task.FromResult(decrypted);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var digestBits = algorithm.Contains("512") ? 512 : 256;
|
||||
var digest = CreateGost2012Digest(digestBits);
|
||||
|
||||
digest.BlockUpdate(data.Span.ToArray(), 0, data.Length);
|
||||
var hash = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(hash, 0);
|
||||
|
||||
Context?.Logger.Debug("Computed {Algorithm} hash of {DataLength} bytes", algorithm, data.Length);
|
||||
|
||||
return Task.FromResult(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
_keyPair = null;
|
||||
State = PluginLifecycleState.Stopped;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static IDigest CreateGost2012Digest(int bits)
|
||||
{
|
||||
return bits switch
|
||||
{
|
||||
256 => new Gost3411_2012_256Digest(),
|
||||
512 => new Gost3411_2012_512Digest(),
|
||||
_ => throw new ArgumentException($"Unsupported digest size: {bits}")
|
||||
};
|
||||
}
|
||||
|
||||
private AsymmetricCipherKeyPair GenerateGost2012KeyPair(int keySize)
|
||||
{
|
||||
// GOST R 34.10-2012 uses specific elliptic curve parameters
|
||||
// For 256-bit: id-tc26-gost-3410-2012-256-paramSetA
|
||||
// For 512-bit: id-tc26-gost-3410-2012-512-paramSetA
|
||||
|
||||
var generator = new ECKeyPairGenerator("ECGOST3410");
|
||||
var domainParams = GetGost2012DomainParameters(keySize);
|
||||
var keyGenParams = new ECKeyGenerationParameters(domainParams, _random);
|
||||
generator.Init(keyGenParams);
|
||||
|
||||
return generator.GenerateKeyPair();
|
||||
}
|
||||
|
||||
private static ECDomainParameters GetGost2012DomainParameters(int keySize)
|
||||
{
|
||||
// Simplified: use predefined GOST parameters
|
||||
// In production, load from OID: 1.2.643.7.1.2.1.1.1 (256-bit) or 1.2.643.7.1.2.1.2.1 (512-bit)
|
||||
|
||||
if (keySize == 256)
|
||||
{
|
||||
// id-tc26-gost-3410-2012-256-paramSetA
|
||||
var p = new BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFD97", 16);
|
||||
var a = new BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFD94", 16);
|
||||
var b = new BigInteger("00000000000000000000000000000000000000000000000000000000000000A6", 16);
|
||||
var n = new BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF6C611070995AD10045841B09B761B893", 16);
|
||||
var h = BigInteger.One;
|
||||
var gx = new BigInteger("0000000000000000000000000000000000000000000000000000000000000001", 16);
|
||||
var gy = new BigInteger("8D91E471E0989CDA27DF505A453F2B7635294F2DDF23E3B122ACC99C9E9F1E14", 16);
|
||||
|
||||
var curve = new FpCurve(p, a, b, n, h);
|
||||
var g = curve.CreatePoint(gx, gy);
|
||||
return new ECDomainParameters(curve, g, n, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
// id-tc26-gost-3410-2012-512-paramSetA (simplified)
|
||||
throw new NotImplementedException("512-bit GOST parameters not implemented in this example");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] EncodeSignature(BigInteger[] signature)
|
||||
{
|
||||
// Encode r and s as fixed-length byte arrays concatenated
|
||||
var r = signature[0].ToByteArrayUnsigned();
|
||||
var s = signature[1].ToByteArrayUnsigned();
|
||||
|
||||
// Pad to 32 bytes each for 256-bit
|
||||
var encoded = new byte[64];
|
||||
Array.Copy(r, 0, encoded, 32 - r.Length, r.Length);
|
||||
Array.Copy(s, 0, encoded, 64 - s.Length, s.Length);
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
private static BigInteger[] DecodeSignature(byte[] signature)
|
||||
{
|
||||
var r = new BigInteger(1, signature.Take(32).ToArray());
|
||||
var s = new BigInteger(1, signature.Skip(32).Take(32).ToArray());
|
||||
return new[] { r, s };
|
||||
}
|
||||
|
||||
private byte[] GetEncryptionKey(string keyId)
|
||||
{
|
||||
// In production, retrieve from secure key store
|
||||
// For now, derive a key from the key ID
|
||||
var digest = new Gost3411_2012_256Digest();
|
||||
var keyIdBytes = System.Text.Encoding.UTF8.GetBytes(keyId);
|
||||
digest.BlockUpdate(keyIdBytes, 0, keyIdBytes.Length);
|
||||
var key = new byte[32];
|
||||
digest.DoFinal(key, 0);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static byte[] ProcessBlocks(IBlockCipher engine, byte[] data)
|
||||
{
|
||||
var blockSize = engine.GetBlockSize();
|
||||
var paddedLength = ((data.Length + blockSize - 1) / blockSize) * blockSize;
|
||||
var padded = new byte[paddedLength];
|
||||
Array.Copy(data, padded, data.Length);
|
||||
|
||||
var output = new byte[paddedLength];
|
||||
for (var i = 0; i < paddedLength; i += blockSize)
|
||||
{
|
||||
engine.ProcessBlock(padded, i, output, i);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for GOST cryptography plugin.
|
||||
/// </summary>
|
||||
public sealed class GostOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to key store file.
|
||||
/// </summary>
|
||||
public string? KeyStorePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default key identifier for signing operations.
|
||||
/// </summary>
|
||||
public string? DefaultKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded private key (if not using key store).
|
||||
/// </summary>
|
||||
public string? PrivateKeyBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate a new key pair on initialization if no key is configured.
|
||||
/// </summary>
|
||||
public bool GenerateKeyOnInit { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Key size in bits (256 or 512).
|
||||
/// </summary>
|
||||
public int KeySize { get; init; } = 256;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin\StellaOps.Cryptography.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="plugin.yaml" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,44 @@
|
||||
plugin:
|
||||
id: com.stellaops.crypto.gost
|
||||
name: GOST Cryptography Provider
|
||||
version: 1.0.0
|
||||
vendor: Stella Ops
|
||||
description: Russian GOST R 34.10-2012 and R 34.11-2012 cryptographic algorithms
|
||||
license: AGPL-3.0-or-later
|
||||
|
||||
entryPoint: StellaOps.Cryptography.Plugin.Gost.GostPlugin
|
||||
|
||||
minPlatformVersion: 1.0.0
|
||||
|
||||
capabilities:
|
||||
- type: crypto
|
||||
id: gost
|
||||
algorithms:
|
||||
- GOST-R34.10-2012-256
|
||||
- GOST-R34.10-2012-512
|
||||
- GOST-R34.11-2012-256
|
||||
- GOST-R34.11-2012-512
|
||||
- GOST-28147-89
|
||||
|
||||
configSchema:
|
||||
type: object
|
||||
properties:
|
||||
keyStorePath:
|
||||
type: string
|
||||
description: Path to GOST key store
|
||||
defaultKeyId:
|
||||
type: string
|
||||
description: Default key identifier for signing
|
||||
privateKeyBase64:
|
||||
type: string
|
||||
description: Base64-encoded private key
|
||||
generateKeyOnInit:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Generate new key pair on initialization if no key configured
|
||||
keySize:
|
||||
type: integer
|
||||
enum: [256, 512]
|
||||
default: 256
|
||||
description: Key size in bits
|
||||
required: []
|
||||
464
src/Cryptography/StellaOps.Cryptography.Plugin.Hsm/HsmPlugin.cs
Normal file
464
src/Cryptography/StellaOps.Cryptography.Plugin.Hsm/HsmPlugin.cs
Normal file
@@ -0,0 +1,464 @@
|
||||
namespace StellaOps.Cryptography.Plugin.Hsm;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Hardware Security Module (HSM) cryptography plugin.
|
||||
/// Provides integration with PKCS#11 compliant HSMs for secure key storage and operations.
|
||||
/// </summary>
|
||||
public sealed class HsmPlugin : CryptoPluginBase
|
||||
{
|
||||
private HsmOptions? _options;
|
||||
private IHsmClient? _hsmClient;
|
||||
private bool _isConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PluginInfo Info => new(
|
||||
Id: "com.stellaops.crypto.hsm",
|
||||
Name: "HSM Cryptography Provider",
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: "Hardware Security Module integration via PKCS#11",
|
||||
LicenseId: "AGPL-3.0-or-later");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> SupportedAlgorithms => new[]
|
||||
{
|
||||
"HSM-RSA-SHA256",
|
||||
"HSM-RSA-SHA384",
|
||||
"HSM-RSA-SHA512",
|
||||
"HSM-RSA-PSS-SHA256",
|
||||
"HSM-ECDSA-P256",
|
||||
"HSM-ECDSA-P384",
|
||||
"HSM-AES-128-GCM",
|
||||
"HSM-AES-256-GCM"
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_options = context.Configuration.Bind<HsmOptions>() ?? new HsmOptions();
|
||||
|
||||
if (string.IsNullOrEmpty(_options.LibraryPath))
|
||||
{
|
||||
Context?.Logger.Warning("HSM provider initialized in simulation mode (no library configured)");
|
||||
_hsmClient = new SimulatedHsmClient();
|
||||
}
|
||||
else
|
||||
{
|
||||
_hsmClient = new Pkcs11HsmClient(_options.LibraryPath, Context?.Logger);
|
||||
}
|
||||
|
||||
await _hsmClient.ConnectAsync(
|
||||
_options.SlotId,
|
||||
_options.Pin,
|
||||
ct);
|
||||
|
||||
_isConnected = true;
|
||||
Context?.Logger.Info("HSM provider connected to slot {SlotId}", _options.SlotId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
if (!_isConnected || _hsmClient == null)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("HSM not connected");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var isHealthy = await _hsmClient.PingAsync(ct);
|
||||
if (!isHealthy)
|
||||
{
|
||||
return HealthCheckResult.Degraded("HSM responding slowly");
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy().WithDetails(new Dictionary<string, object>
|
||||
{
|
||||
["slot"] = _options?.SlotId ?? 0,
|
||||
["library"] = _options?.LibraryPath ?? "simulated"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanHandle(CryptoOperation operation, string algorithm)
|
||||
{
|
||||
return algorithm.StartsWith("HSM-", StringComparison.OrdinalIgnoreCase) &&
|
||||
SupportedAlgorithms.Contains(algorithm, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
EnsureConnected();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var keyId = options.KeyId;
|
||||
var mechanism = GetSigningMechanism(options.Algorithm);
|
||||
|
||||
var signature = await _hsmClient!.SignAsync(keyId, data.ToArray(), mechanism, ct);
|
||||
|
||||
Context?.Logger.Debug("HSM signed {DataLength} bytes with key {KeyId}", data.Length, keyId);
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
EnsureConnected();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var keyId = options.KeyId;
|
||||
var mechanism = GetSigningMechanism(options.Algorithm);
|
||||
|
||||
var isValid = await _hsmClient!.VerifyAsync(keyId, data.ToArray(), signature.ToArray(), mechanism, ct);
|
||||
|
||||
Context?.Logger.Debug("HSM verified signature with key {KeyId}: {IsValid}", keyId, isValid);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
EnsureConnected();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var keyId = options.KeyId;
|
||||
var mechanism = GetEncryptionMechanism(options.Algorithm);
|
||||
|
||||
var encrypted = await _hsmClient!.EncryptAsync(keyId, data.ToArray(), mechanism, options.Iv, ct);
|
||||
|
||||
Context?.Logger.Debug("HSM encrypted {DataLength} bytes with key {KeyId}", data.Length, keyId);
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
EnsureConnected();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var keyId = options.KeyId;
|
||||
var mechanism = GetEncryptionMechanism(options.Algorithm);
|
||||
|
||||
var decrypted = await _hsmClient!.DecryptAsync(keyId, data.ToArray(), mechanism, options.Iv, ct);
|
||||
|
||||
Context?.Logger.Debug("HSM decrypted {DataLength} bytes with key {KeyId}", data.Length, keyId);
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Hashing can be done locally - no need to use HSM
|
||||
var hash = algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("512") => SHA512.HashData(data.Span),
|
||||
var a when a.Contains("384") => SHA384.HashData(data.Span),
|
||||
_ => SHA256.HashData(data.Span)
|
||||
};
|
||||
|
||||
Context?.Logger.Debug("Computed hash of {DataLength} bytes", data.Length);
|
||||
|
||||
return Task.FromResult(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hsmClient != null)
|
||||
{
|
||||
await _hsmClient.DisconnectAsync(CancellationToken.None);
|
||||
_hsmClient = null;
|
||||
}
|
||||
_isConnected = false;
|
||||
State = PluginLifecycleState.Stopped;
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (!_isConnected || _hsmClient == null)
|
||||
{
|
||||
throw new InvalidOperationException("HSM is not connected.");
|
||||
}
|
||||
}
|
||||
|
||||
private static HsmMechanism GetSigningMechanism(string algorithm)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("PSS") && a.Contains("256") => HsmMechanism.RsaPssSha256,
|
||||
var a when a.Contains("RSA") && a.Contains("512") => HsmMechanism.RsaSha512,
|
||||
var a when a.Contains("RSA") && a.Contains("384") => HsmMechanism.RsaSha384,
|
||||
var a when a.Contains("RSA") => HsmMechanism.RsaSha256,
|
||||
var a when a.Contains("ECDSA") && a.Contains("384") => HsmMechanism.EcdsaP384,
|
||||
var a when a.Contains("ECDSA") => HsmMechanism.EcdsaP256,
|
||||
_ => throw new NotSupportedException($"Signing mechanism not supported: {algorithm}")
|
||||
};
|
||||
}
|
||||
|
||||
private static HsmMechanism GetEncryptionMechanism(string algorithm)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
var a when a.Contains("256-GCM") => HsmMechanism.Aes256Gcm,
|
||||
var a when a.Contains("128-GCM") => HsmMechanism.Aes128Gcm,
|
||||
_ => throw new NotSupportedException($"Encryption mechanism not supported: {algorithm}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HSM mechanism identifiers.
|
||||
/// </summary>
|
||||
public enum HsmMechanism
|
||||
{
|
||||
RsaSha256,
|
||||
RsaSha384,
|
||||
RsaSha512,
|
||||
RsaPssSha256,
|
||||
EcdsaP256,
|
||||
EcdsaP384,
|
||||
Aes128Gcm,
|
||||
Aes256Gcm
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for HSM client implementations.
|
||||
/// </summary>
|
||||
public interface IHsmClient
|
||||
{
|
||||
Task ConnectAsync(int slotId, string? pin, CancellationToken ct);
|
||||
Task DisconnectAsync(CancellationToken ct);
|
||||
Task<bool> PingAsync(CancellationToken ct);
|
||||
Task<byte[]> SignAsync(string keyId, byte[] data, HsmMechanism mechanism, CancellationToken ct);
|
||||
Task<bool> VerifyAsync(string keyId, byte[] data, byte[] signature, HsmMechanism mechanism, CancellationToken ct);
|
||||
Task<byte[]> EncryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct);
|
||||
Task<byte[]> DecryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulated HSM client for testing without actual HSM hardware.
|
||||
/// </summary>
|
||||
internal sealed class SimulatedHsmClient : IHsmClient
|
||||
{
|
||||
private readonly Dictionary<string, RSA> _rsaKeys = new();
|
||||
private readonly Dictionary<string, byte[]> _aesKeys = new();
|
||||
private bool _connected;
|
||||
|
||||
public Task ConnectAsync(int slotId, string? pin, CancellationToken ct)
|
||||
{
|
||||
_connected = true;
|
||||
// Generate some default keys for simulation
|
||||
_rsaKeys["default"] = RSA.Create(2048);
|
||||
_aesKeys["default"] = RandomNumberGenerator.GetBytes(32);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisconnectAsync(CancellationToken ct)
|
||||
{
|
||||
foreach (var key in _rsaKeys.Values)
|
||||
{
|
||||
key.Dispose();
|
||||
}
|
||||
_rsaKeys.Clear();
|
||||
_aesKeys.Clear();
|
||||
_connected = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> PingAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_connected);
|
||||
}
|
||||
|
||||
public Task<byte[]> SignAsync(string keyId, byte[] data, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
if (!_rsaKeys.TryGetValue(keyId, out var rsa))
|
||||
{
|
||||
rsa = _rsaKeys["default"];
|
||||
}
|
||||
|
||||
var (hashAlg, padding) = GetRsaParameters(mechanism);
|
||||
var signature = rsa.SignData(data, hashAlg, padding);
|
||||
return Task.FromResult(signature);
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(string keyId, byte[] data, byte[] signature, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
if (!_rsaKeys.TryGetValue(keyId, out var rsa))
|
||||
{
|
||||
rsa = _rsaKeys["default"];
|
||||
}
|
||||
|
||||
var (hashAlg, padding) = GetRsaParameters(mechanism);
|
||||
var isValid = rsa.VerifyData(data, signature, hashAlg, padding);
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
|
||||
public Task<byte[]> EncryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
if (!_aesKeys.TryGetValue(keyId, out var key))
|
||||
{
|
||||
key = _aesKeys["default"];
|
||||
}
|
||||
|
||||
var keyToUse = mechanism == HsmMechanism.Aes128Gcm ? key.Take(16).ToArray() : key;
|
||||
iv ??= RandomNumberGenerator.GetBytes(12);
|
||||
var tag = new byte[16];
|
||||
|
||||
using var aesGcm = new AesGcm(keyToUse, 16);
|
||||
var ciphertext = new byte[data.Length];
|
||||
aesGcm.Encrypt(iv, data, ciphertext, tag);
|
||||
|
||||
var result = new byte[iv.Length + tag.Length + ciphertext.Length];
|
||||
Array.Copy(iv, 0, result, 0, iv.Length);
|
||||
Array.Copy(tag, 0, result, iv.Length, tag.Length);
|
||||
Array.Copy(ciphertext, 0, result, iv.Length + tag.Length, ciphertext.Length);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<byte[]> DecryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
if (!_aesKeys.TryGetValue(keyId, out var key))
|
||||
{
|
||||
key = _aesKeys["default"];
|
||||
}
|
||||
|
||||
var keyToUse = mechanism == HsmMechanism.Aes128Gcm ? key.Take(16).ToArray() : key;
|
||||
|
||||
iv ??= data.Take(12).ToArray();
|
||||
var tag = data.Skip(12).Take(16).ToArray();
|
||||
var ciphertext = data.Skip(28).ToArray();
|
||||
|
||||
using var aesGcm = new AesGcm(keyToUse, 16);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
aesGcm.Decrypt(iv, ciphertext, tag, plaintext);
|
||||
return Task.FromResult(plaintext);
|
||||
}
|
||||
|
||||
private static (HashAlgorithmName, RSASignaturePadding) GetRsaParameters(HsmMechanism mechanism)
|
||||
{
|
||||
return mechanism switch
|
||||
{
|
||||
HsmMechanism.RsaPssSha256 => (HashAlgorithmName.SHA256, RSASignaturePadding.Pss),
|
||||
HsmMechanism.RsaSha512 => (HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1),
|
||||
HsmMechanism.RsaSha384 => (HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1),
|
||||
_ => (HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#11 HSM client implementation stub.
|
||||
/// In production, this would use a PKCS#11 library like PKCS11Interop.
|
||||
/// </summary>
|
||||
internal sealed class Pkcs11HsmClient : IHsmClient
|
||||
{
|
||||
private readonly string _libraryPath;
|
||||
private readonly IPluginLogger? _logger;
|
||||
|
||||
public Pkcs11HsmClient(string libraryPath, IPluginLogger? logger)
|
||||
{
|
||||
_libraryPath = libraryPath;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task ConnectAsync(int slotId, string? pin, CancellationToken ct)
|
||||
{
|
||||
_logger?.Info("Connecting to HSM via PKCS#11 library: {LibraryPath}", _libraryPath);
|
||||
// In production: Load PKCS#11 library, open session, login
|
||||
throw new NotImplementedException(
|
||||
"PKCS#11 implementation requires Net.Pkcs11Interop package. " +
|
||||
"Use simulation mode for testing.");
|
||||
}
|
||||
|
||||
public Task DisconnectAsync(CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<bool> PingAsync(CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<byte[]> SignAsync(string keyId, byte[] data, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(string keyId, byte[] data, byte[] signature, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<byte[]> EncryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<byte[]> DecryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for HSM cryptography plugin.
|
||||
/// </summary>
|
||||
public sealed class HsmOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to PKCS#11 library (.so/.dll).
|
||||
/// Leave empty for simulation mode.
|
||||
/// </summary>
|
||||
public string? LibraryPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HSM slot identifier.
|
||||
/// </summary>
|
||||
public int SlotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PIN for HSM authentication.
|
||||
/// </summary>
|
||||
public string? Pin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Token label for identifying the HSM.
|
||||
/// </summary>
|
||||
public string? TokenLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connection timeout in seconds.
|
||||
/// </summary>
|
||||
public int ConnectionTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use read-only session (no key generation/modification).
|
||||
/// </summary>
|
||||
public bool ReadOnlySession { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin\StellaOps.Cryptography.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="plugin.yaml" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,50 @@
|
||||
plugin:
|
||||
id: com.stellaops.crypto.hsm
|
||||
name: HSM Cryptography Provider
|
||||
version: 1.0.0
|
||||
vendor: Stella Ops
|
||||
description: Hardware Security Module integration via PKCS#11
|
||||
license: AGPL-3.0-or-later
|
||||
|
||||
entryPoint: StellaOps.Cryptography.Plugin.Hsm.HsmPlugin
|
||||
|
||||
minPlatformVersion: 1.0.0
|
||||
|
||||
capabilities:
|
||||
- type: crypto
|
||||
id: hsm
|
||||
algorithms:
|
||||
- HSM-RSA-SHA256
|
||||
- HSM-RSA-SHA384
|
||||
- HSM-RSA-SHA512
|
||||
- HSM-RSA-PSS-SHA256
|
||||
- HSM-ECDSA-P256
|
||||
- HSM-ECDSA-P384
|
||||
- HSM-AES-128-GCM
|
||||
- HSM-AES-256-GCM
|
||||
|
||||
configSchema:
|
||||
type: object
|
||||
properties:
|
||||
libraryPath:
|
||||
type: string
|
||||
description: Path to PKCS#11 library (.so/.dll). Leave empty for simulation mode.
|
||||
slotId:
|
||||
type: integer
|
||||
default: 0
|
||||
description: HSM slot identifier
|
||||
pin:
|
||||
type: string
|
||||
description: PIN for HSM authentication
|
||||
tokenLabel:
|
||||
type: string
|
||||
description: Token label for identifying the HSM
|
||||
connectionTimeoutSeconds:
|
||||
type: integer
|
||||
default: 30
|
||||
description: Connection timeout in seconds
|
||||
readOnlySession:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Use read-only session (no key generation/modification)
|
||||
required: []
|
||||
364
src/Cryptography/StellaOps.Cryptography.Plugin.Sm/SmPlugin.cs
Normal file
364
src/Cryptography/StellaOps.Cryptography.Plugin.Sm/SmPlugin.cs
Normal file
@@ -0,0 +1,364 @@
|
||||
namespace StellaOps.Cryptography.Plugin.Sm;
|
||||
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Engines;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Modes;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Math;
|
||||
using Org.BouncyCastle.Math.EC;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Chinese national cryptographic standards plugin.
|
||||
/// Implements SM2 (signatures), SM3 (hash), and SM4 (symmetric encryption).
|
||||
/// </summary>
|
||||
public sealed class SmPlugin : CryptoPluginBase
|
||||
{
|
||||
private SmOptions? _options;
|
||||
private AsymmetricCipherKeyPair? _keyPair;
|
||||
private readonly SecureRandom _random = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PluginInfo Info => new(
|
||||
Id: "com.stellaops.crypto.sm",
|
||||
Name: "Chinese SM Cryptography Provider",
|
||||
Version: "1.0.0",
|
||||
Vendor: "Stella Ops",
|
||||
Description: "Chinese national cryptographic standards SM2/SM3/SM4 (GM/T 0003-0004)",
|
||||
LicenseId: "AGPL-3.0-or-later");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> SupportedAlgorithms => new[]
|
||||
{
|
||||
"SM2-SM3",
|
||||
"SM2-SHA256",
|
||||
"SM3",
|
||||
"SM4-CBC",
|
||||
"SM4-ECB",
|
||||
"SM4-GCM"
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_options = context.Configuration.Bind<SmOptions>() ?? new SmOptions();
|
||||
|
||||
if (!string.IsNullOrEmpty(_options.PrivateKeyHex))
|
||||
{
|
||||
LoadKeyFromHex(_options.PrivateKeyHex);
|
||||
Context?.Logger.Info("SM provider initialized with configured key");
|
||||
}
|
||||
else if (_options.GenerateKeyOnInit)
|
||||
{
|
||||
_keyPair = GenerateSm2KeyPair();
|
||||
Context?.Logger.Info("SM provider initialized with generated key pair");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanHandle(CryptoOperation operation, string algorithm)
|
||||
{
|
||||
return algorithm.StartsWith("SM", StringComparison.OrdinalIgnoreCase) &&
|
||||
SupportedAlgorithms.Contains(algorithm, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_keyPair == null)
|
||||
{
|
||||
throw new InvalidOperationException("No signing key available.");
|
||||
}
|
||||
|
||||
// SM2 signature with SM3 digest
|
||||
var signer = new SM2Signer();
|
||||
signer.Init(true, _keyPair.Private);
|
||||
signer.BlockUpdate(data.Span.ToArray(), 0, data.Length);
|
||||
var signature = signer.GenerateSignature();
|
||||
|
||||
Context?.Logger.Debug("Signed {DataLength} bytes with SM2", data.Length);
|
||||
|
||||
return Task.FromResult(signature);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_keyPair == null)
|
||||
{
|
||||
throw new InvalidOperationException("No verification key available.");
|
||||
}
|
||||
|
||||
var verifier = new SM2Signer();
|
||||
verifier.Init(false, _keyPair.Public);
|
||||
verifier.BlockUpdate(data.Span.ToArray(), 0, data.Length);
|
||||
var isValid = verifier.VerifySignature(signature.ToArray());
|
||||
|
||||
Context?.Logger.Debug("Verified SM2 signature: {IsValid}", isValid);
|
||||
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
var keyBytes = GetSymmetricKey(options.KeyId);
|
||||
byte[] encrypted;
|
||||
|
||||
if (algorithm.Contains("GCM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
encrypted = Sm4GcmEncrypt(data.ToArray(), keyBytes, options.Iv, options.Aad);
|
||||
}
|
||||
else if (algorithm.Contains("CBC", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
encrypted = Sm4CbcEncrypt(data.ToArray(), keyBytes, options.Iv);
|
||||
}
|
||||
else
|
||||
{
|
||||
encrypted = Sm4EcbEncrypt(data.ToArray(), keyBytes);
|
||||
}
|
||||
|
||||
Context?.Logger.Debug("Encrypted {DataLength} bytes with {Algorithm}", data.Length, algorithm);
|
||||
|
||||
return Task.FromResult(encrypted);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = options.Algorithm;
|
||||
var keyBytes = GetSymmetricKey(options.KeyId);
|
||||
byte[] decrypted;
|
||||
|
||||
if (algorithm.Contains("GCM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
decrypted = Sm4GcmDecrypt(data.ToArray(), keyBytes, options.Iv, options.Aad);
|
||||
}
|
||||
else if (algorithm.Contains("CBC", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
decrypted = Sm4CbcDecrypt(data.ToArray(), keyBytes, options.Iv);
|
||||
}
|
||||
else
|
||||
{
|
||||
decrypted = Sm4EcbDecrypt(data.ToArray(), keyBytes);
|
||||
}
|
||||
|
||||
Context?.Logger.Debug("Decrypted {DataLength} bytes with {Algorithm}", data.Length, algorithm);
|
||||
|
||||
return Task.FromResult(decrypted);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct)
|
||||
{
|
||||
EnsureActive();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var digest = new SM3Digest();
|
||||
digest.BlockUpdate(data.Span.ToArray(), 0, data.Length);
|
||||
var hash = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(hash, 0);
|
||||
|
||||
Context?.Logger.Debug("Computed SM3 hash of {DataLength} bytes", data.Length);
|
||||
|
||||
return Task.FromResult(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
_keyPair = null;
|
||||
State = PluginLifecycleState.Stopped;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private AsymmetricCipherKeyPair GenerateSm2KeyPair()
|
||||
{
|
||||
var domainParams = GetSm2DomainParameters();
|
||||
var generator = new ECKeyPairGenerator();
|
||||
generator.Init(new ECKeyGenerationParameters(domainParams, _random));
|
||||
return generator.GenerateKeyPair();
|
||||
}
|
||||
|
||||
private void LoadKeyFromHex(string privateKeyHex)
|
||||
{
|
||||
var d = new BigInteger(privateKeyHex, 16);
|
||||
var domainParams = GetSm2DomainParameters();
|
||||
var privateKey = new ECPrivateKeyParameters(d, domainParams);
|
||||
var q = domainParams.G.Multiply(d);
|
||||
var publicKey = new ECPublicKeyParameters(q, domainParams);
|
||||
_keyPair = new AsymmetricCipherKeyPair(publicKey, privateKey);
|
||||
}
|
||||
|
||||
private static ECDomainParameters GetSm2DomainParameters()
|
||||
{
|
||||
// SM2 recommended parameters (GM/T 0003.5-2012)
|
||||
var p = new BigInteger("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF", 16);
|
||||
var a = new BigInteger("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC", 16);
|
||||
var b = new BigInteger("28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93", 16);
|
||||
var n = new BigInteger("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123", 16);
|
||||
var h = BigInteger.One;
|
||||
var gx = new BigInteger("32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7", 16);
|
||||
var gy = new BigInteger("BC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0", 16);
|
||||
|
||||
var curve = new FpCurve(p, a, b, n, h);
|
||||
var g = curve.CreatePoint(gx, gy);
|
||||
return new ECDomainParameters(curve, g, n, h);
|
||||
}
|
||||
|
||||
private byte[] GetSymmetricKey(string keyId)
|
||||
{
|
||||
// Derive 128-bit key from key ID using SM3
|
||||
var digest = new SM3Digest();
|
||||
var keyIdBytes = System.Text.Encoding.UTF8.GetBytes(keyId);
|
||||
digest.BlockUpdate(keyIdBytes, 0, keyIdBytes.Length);
|
||||
var hash = new byte[32];
|
||||
digest.DoFinal(hash, 0);
|
||||
return hash.Take(16).ToArray(); // SM4 uses 128-bit keys
|
||||
}
|
||||
|
||||
private byte[] Sm4EcbEncrypt(byte[] data, byte[] key)
|
||||
{
|
||||
var engine = new SM4Engine();
|
||||
engine.Init(true, new KeyParameter(key));
|
||||
return ProcessBlocks(engine, data);
|
||||
}
|
||||
|
||||
private byte[] Sm4EcbDecrypt(byte[] data, byte[] key)
|
||||
{
|
||||
var engine = new SM4Engine();
|
||||
engine.Init(false, new KeyParameter(key));
|
||||
return ProcessBlocks(engine, data);
|
||||
}
|
||||
|
||||
private byte[] Sm4CbcEncrypt(byte[] data, byte[] key, byte[]? iv)
|
||||
{
|
||||
iv ??= GenerateIv(16);
|
||||
var cipher = new CbcBlockCipher(new SM4Engine());
|
||||
cipher.Init(true, new ParametersWithIV(new KeyParameter(key), iv));
|
||||
var encrypted = ProcessBlocks(cipher, data);
|
||||
|
||||
// Prepend IV to ciphertext
|
||||
var result = new byte[iv.Length + encrypted.Length];
|
||||
Array.Copy(iv, 0, result, 0, iv.Length);
|
||||
Array.Copy(encrypted, 0, result, iv.Length, encrypted.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private byte[] Sm4CbcDecrypt(byte[] data, byte[] key, byte[]? iv)
|
||||
{
|
||||
if (iv == null)
|
||||
{
|
||||
// Extract IV from ciphertext
|
||||
iv = data.Take(16).ToArray();
|
||||
data = data.Skip(16).ToArray();
|
||||
}
|
||||
|
||||
var cipher = new CbcBlockCipher(new SM4Engine());
|
||||
cipher.Init(false, new ParametersWithIV(new KeyParameter(key), iv));
|
||||
return ProcessBlocks(cipher, data);
|
||||
}
|
||||
|
||||
private byte[] Sm4GcmEncrypt(byte[] data, byte[] key, byte[]? iv, byte[]? aad)
|
||||
{
|
||||
iv ??= GenerateIv(12);
|
||||
var cipher = new GcmBlockCipher(new SM4Engine());
|
||||
var parameters = new AeadParameters(new KeyParameter(key), 128, iv, aad ?? Array.Empty<byte>());
|
||||
cipher.Init(true, parameters);
|
||||
|
||||
var output = new byte[cipher.GetOutputSize(data.Length)];
|
||||
var len = cipher.ProcessBytes(data, 0, data.Length, output, 0);
|
||||
cipher.DoFinal(output, len);
|
||||
|
||||
// Prepend IV to ciphertext
|
||||
var result = new byte[iv.Length + output.Length];
|
||||
Array.Copy(iv, 0, result, 0, iv.Length);
|
||||
Array.Copy(output, 0, result, iv.Length, output.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private byte[] Sm4GcmDecrypt(byte[] data, byte[] key, byte[]? iv, byte[]? aad)
|
||||
{
|
||||
if (iv == null)
|
||||
{
|
||||
iv = data.Take(12).ToArray();
|
||||
data = data.Skip(12).ToArray();
|
||||
}
|
||||
|
||||
var cipher = new GcmBlockCipher(new SM4Engine());
|
||||
var parameters = new AeadParameters(new KeyParameter(key), 128, iv, aad ?? Array.Empty<byte>());
|
||||
cipher.Init(false, parameters);
|
||||
|
||||
var output = new byte[cipher.GetOutputSize(data.Length)];
|
||||
var len = cipher.ProcessBytes(data, 0, data.Length, output, 0);
|
||||
cipher.DoFinal(output, len);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static byte[] ProcessBlocks(IBlockCipher engine, byte[] data)
|
||||
{
|
||||
var blockSize = engine.GetBlockSize();
|
||||
var paddedLength = ((data.Length + blockSize - 1) / blockSize) * blockSize;
|
||||
var padded = new byte[paddedLength];
|
||||
Array.Copy(data, padded, data.Length);
|
||||
|
||||
var output = new byte[paddedLength];
|
||||
for (var i = 0; i < paddedLength; i += blockSize)
|
||||
{
|
||||
engine.ProcessBlock(padded, i, output, i);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private byte[] GenerateIv(int length)
|
||||
{
|
||||
var iv = new byte[length];
|
||||
_random.NextBytes(iv);
|
||||
return iv;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for SM cryptography plugin.
|
||||
/// </summary>
|
||||
public sealed class SmOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Private key in hexadecimal format.
|
||||
/// </summary>
|
||||
public string? PrivateKeyHex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate a new key pair on initialization if no key configured.
|
||||
/// </summary>
|
||||
public bool GenerateKeyOnInit { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// User identifier for SM2 signature (ZA computation).
|
||||
/// </summary>
|
||||
public string UserId { get; init; } = "1234567812345678";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin\StellaOps.Cryptography.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="plugin.yaml" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
plugin:
|
||||
id: com.stellaops.crypto.sm
|
||||
name: Chinese SM Cryptography Provider
|
||||
version: 1.0.0
|
||||
vendor: Stella Ops
|
||||
description: Chinese national cryptographic standards SM2/SM3/SM4 (GM/T 0003-0004)
|
||||
license: AGPL-3.0-or-later
|
||||
|
||||
entryPoint: StellaOps.Cryptography.Plugin.Sm.SmPlugin
|
||||
|
||||
minPlatformVersion: 1.0.0
|
||||
|
||||
capabilities:
|
||||
- type: crypto
|
||||
id: sm
|
||||
algorithms:
|
||||
- SM2-SM3
|
||||
- SM2-SHA256
|
||||
- SM3
|
||||
- SM4-CBC
|
||||
- SM4-ECB
|
||||
- SM4-GCM
|
||||
|
||||
configSchema:
|
||||
type: object
|
||||
properties:
|
||||
privateKeyHex:
|
||||
type: string
|
||||
description: Private key in hexadecimal format
|
||||
generateKeyOnInit:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Generate new key pair on initialization
|
||||
userId:
|
||||
type: string
|
||||
default: "1234567812345678"
|
||||
description: User identifier for SM2 signature ZA computation
|
||||
required: []
|
||||
@@ -0,0 +1,167 @@
|
||||
namespace StellaOps.Cryptography.Plugin;
|
||||
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for cryptographic plugins providing common functionality.
|
||||
/// Implements IPlugin and ICryptoCapability interfaces.
|
||||
/// </summary>
|
||||
public abstract class CryptoPluginBase : IPlugin, ICryptoCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin context set during initialization.
|
||||
/// </summary>
|
||||
protected IPluginContext? Context { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin information including ID, name, version.
|
||||
/// </summary>
|
||||
public abstract PluginInfo Info { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust level for crypto plugins - always BuiltIn.
|
||||
/// </summary>
|
||||
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities provided by this plugin.
|
||||
/// </summary>
|
||||
public PluginCapabilities Capabilities => PluginCapabilities.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Current lifecycle state.
|
||||
/// </summary>
|
||||
public PluginLifecycleState State { get; protected set; } = PluginLifecycleState.Discovered;
|
||||
|
||||
/// <summary>
|
||||
/// List of algorithms supported by this crypto provider.
|
||||
/// </summary>
|
||||
public abstract IReadOnlyList<string> SupportedAlgorithms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the crypto plugin.
|
||||
/// </summary>
|
||||
/// <param name="context">Plugin context with configuration and services.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
Context = context;
|
||||
State = PluginLifecycleState.Initializing;
|
||||
|
||||
try
|
||||
{
|
||||
await InitializeCryptoServiceAsync(context, ct);
|
||||
State = PluginLifecycleState.Active;
|
||||
context.Logger.Info("{PluginName} initialized successfully", Info.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
State = PluginLifecycleState.Failed;
|
||||
context.Logger.Error(ex, "Failed to initialize {PluginName}", Info.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to initialize the specific crypto service.
|
||||
/// </summary>
|
||||
/// <param name="context">Plugin context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
protected abstract Task InitializeCryptoServiceAsync(IPluginContext context, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Perform health check on the crypto provider.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Health check result.</returns>
|
||||
public virtual async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
if (State != PluginLifecycleState.Active)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"Plugin is in state {State}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Default health check: verify we can hash test data
|
||||
var testData = "health-check-test"u8.ToArray();
|
||||
var algorithm = SelectHealthCheckAlgorithm();
|
||||
|
||||
if (algorithm != null)
|
||||
{
|
||||
await HashAsync(testData, algorithm, ct);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select an algorithm for health checks.
|
||||
/// </summary>
|
||||
protected virtual string? SelectHealthCheckAlgorithm()
|
||||
{
|
||||
return SupportedAlgorithms.FirstOrDefault(a =>
|
||||
a.Contains("256", StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Contains("SHA", StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Contains("HASH", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if this provider can handle the specified operation and algorithm.
|
||||
/// </summary>
|
||||
/// <param name="operation">Crypto operation type.</param>
|
||||
/// <param name="algorithm">Algorithm identifier.</param>
|
||||
/// <returns>True if supported.</returns>
|
||||
public abstract bool CanHandle(CryptoOperation operation, string algorithm);
|
||||
|
||||
/// <summary>
|
||||
/// Sign data using the specified algorithm and key.
|
||||
/// </summary>
|
||||
public abstract Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a signature.
|
||||
/// </summary>
|
||||
public abstract Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Encrypt data.
|
||||
/// </summary>
|
||||
public abstract Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Decrypt data.
|
||||
/// </summary>
|
||||
public abstract Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Compute hash of data.
|
||||
/// </summary>
|
||||
public abstract Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Dispose the crypto plugin.
|
||||
/// </summary>
|
||||
public abstract ValueTask DisposeAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Ensure the plugin is active before performing operations.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If plugin is not active.</exception>
|
||||
protected void EnsureActive()
|
||||
{
|
||||
if (State != PluginLifecycleState.Active)
|
||||
{
|
||||
throw new InvalidOperationException($"{Info.Name} is not active (current state: {State})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\Plugin\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -45,6 +45,9 @@
|
||||
NUGET AND RESTORE SETTINGS
|
||||
============================================================================ -->
|
||||
<PropertyGroup>
|
||||
<!-- Enforce TreatWarningsAsErrors globally per AGENTS.md Rule 8.1 -->
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
|
||||
<!-- Centralize NuGet package cache to prevent directory sprawl -->
|
||||
<RestorePackagesPath>$(MSBuildThisFileDirectory)../.nuget/packages</RestorePackagesPath>
|
||||
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
|
||||
@@ -148,6 +151,19 @@
|
||||
<PropertyGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseXunitV3)' == 'true'">
|
||||
<OutputType Condition="'$(OutputType)' == ''">Exe</OutputType>
|
||||
<UseAppHost>true</UseAppHost>
|
||||
<!-- Suppress xUnit analyzer warnings until tests are migrated:
|
||||
- xUnit1031: Blocking task operations
|
||||
- xUnit1041: Fixture issues
|
||||
- xUnit1051: CancellationToken usage
|
||||
- xUnit1026: Unused theory parameters
|
||||
- xUnit1013: Expected assertion methods
|
||||
- xUnit2013: Expected string assertions
|
||||
- xUnit3003: Assertion methods -->
|
||||
<NoWarn>$(NoWarn);xUnit1031;xUnit1041;xUnit1051;xUnit1026;xUnit1013;xUnit2013;xUnit3003</NoWarn>
|
||||
<!-- Suppress nullable warnings in test projects (CS8602, CS8604, CS8601, CS8634, CS8714, CS8424) until tests are fixed -->
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS8601;CS8634;CS8714;CS8424</NoWarn>
|
||||
<!-- Suppress custom analyzers in tests (EXCITITOR001 - obsolete VexConsensus usage in test mocks) -->
|
||||
<NoWarn>$(NoWarn);EXCITITOR001</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseXunitV3)' == 'true'">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AngleSharp" Version="1.2.0" />
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
||||
<PackageVersion Include="AWSSDK.CloudWatchLogs" Version="4.0.3" />
|
||||
<PackageVersion Include="AWSSDK.ECS" Version="4.0.4" />
|
||||
<PackageVersion Include="AWSSDK.KeyManagementService" Version="4.0.6" />
|
||||
<!-- B2R2 binary analysis framework (F#, MIT license) - version 0.9.x is .NET 9 compatible -->
|
||||
<PackageVersion Include="B2R2.Core" Version="0.9.1" />
|
||||
@@ -40,6 +42,7 @@
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.70.0" />
|
||||
<PackageVersion Include="Grpc.Net.Client" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
|
||||
<PackageVersion Include="Ical.Net" Version="4.2.0" />
|
||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="8.0.4" />
|
||||
<PackageVersion Include="Markdig" Version="0.31.0" />
|
||||
@@ -142,6 +145,7 @@
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.38.0" />
|
||||
<PackageVersion Include="SSH.NET" Version="2024.2.0" />
|
||||
<PackageVersion Include="Sodium.Core" Version="1.4.0" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||
<PackageVersion Include="Spectre.Console.Testing" Version="0.54.0" />
|
||||
|
||||
@@ -423,19 +423,22 @@ public sealed class AutoVexDowngradeService : IAutoVexDowngradeService
|
||||
private readonly IVulnerableSymbolCorrelator _correlator;
|
||||
private readonly IVexDowngradeGenerator _generator;
|
||||
private readonly AutoVexDowngradeOptions _defaultOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AutoVexDowngradeService(
|
||||
ILogger<AutoVexDowngradeService> logger,
|
||||
IHotSymbolQueryService hotSymbolService,
|
||||
IVulnerableSymbolCorrelator correlator,
|
||||
IVexDowngradeGenerator generator,
|
||||
IOptions<AutoVexDowngradeOptions>? options = null)
|
||||
IOptions<AutoVexDowngradeOptions>? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_hotSymbolService = hotSymbolService ?? throw new ArgumentNullException(nameof(hotSymbolService));
|
||||
_correlator = correlator ?? throw new ArgumentNullException(nameof(correlator));
|
||||
_generator = generator ?? throw new ArgumentNullException(nameof(generator));
|
||||
_defaultOptions = options?.Value ?? new AutoVexDowngradeOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -543,7 +546,7 @@ public sealed class AutoVexDowngradeService : IAutoVexDowngradeService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = options ?? _defaultOptions;
|
||||
var processedAt = DateTimeOffset.UtcNow;
|
||||
var processedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation("Processing auto-VEX downgrade for image {ImageDigest}", imageDigest);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Evidence;
|
||||
|
||||
@@ -106,6 +107,15 @@ public sealed class PortableEvidenceBundleBuilder : IPortableEvidenceBundleBuild
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PortableEvidenceBundleBuilder(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<PortableEvidenceBundleResult> BuildAsync(
|
||||
PortableEvidenceBundleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -113,7 +123,7 @@ public sealed class PortableEvidenceBundleBuilder : IPortableEvidenceBundleBuild
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.Manifest);
|
||||
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var bundleId = GenerateBundleId(request.Tenant, createdAt);
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
@@ -166,11 +176,11 @@ public sealed class PortableEvidenceBundleBuilder : IPortableEvidenceBundleBuild
|
||||
verification));
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(string tenant, DateTimeOffset timestamp)
|
||||
private string GenerateBundleId(string tenant, DateTimeOffset timestamp)
|
||||
{
|
||||
var normalizedTenant = string.IsNullOrWhiteSpace(tenant) ? "default" : tenant.Trim().ToLowerInvariant();
|
||||
var date = timestamp.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
|
||||
var randomSuffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var randomSuffix = _guidProvider.NewGuid().ToString("N")[..8];
|
||||
return $"portable-evidence:{normalizedTenant}:{date}:{randomSuffix}";
|
||||
}
|
||||
|
||||
|
||||
@@ -20,15 +20,18 @@ public sealed class ClaimScoreMerger
|
||||
private readonly ClaimScoreCalculator _calculator;
|
||||
private readonly TrustWeights _weights;
|
||||
private readonly double _conflictPenalty;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ClaimScoreMerger(
|
||||
ClaimScoreCalculator calculator,
|
||||
TrustWeights? weights = null,
|
||||
double conflictPenalty = 0.25)
|
||||
double conflictPenalty = 0.25,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_calculator = calculator ?? throw new ArgumentNullException(nameof(calculator));
|
||||
_weights = weights ?? TrustWeights.Default;
|
||||
_conflictPenalty = NormalizePenalty(conflictPenalty);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -48,7 +51,7 @@ public sealed class ClaimScoreMerger
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
HasConflict = false,
|
||||
ConflictPenaltyApplied = 0.0,
|
||||
MergeTimestampUtc = DateTime.UtcNow
|
||||
MergeTimestampUtc = _timeProvider.GetUtcNow().UtcDateTime
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,13 +77,13 @@ public sealed class ClaimScoreMerger
|
||||
AllClaims = sorted,
|
||||
HasConflict = hasConflict,
|
||||
ConflictPenaltyApplied = hasConflict ? _conflictPenalty : 0.0,
|
||||
MergeTimestampUtc = DateTime.UtcNow
|
||||
MergeTimestampUtc = _timeProvider.GetUtcNow().UtcDateTime
|
||||
};
|
||||
}
|
||||
|
||||
private ImmutableArray<ScoredClaim> ScoreClaims(ImmutableArray<ClaimWithContext> claimsWithContext)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow;
|
||||
var cutoff = _timeProvider.GetUtcNow();
|
||||
|
||||
return claimsWithContext.Select(cwc =>
|
||||
{
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
|
||||
<ProjectReference Include="../../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="FluentAssertions" /> <PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj" />
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTraceBundleBuilder.cs
|
||||
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
|
||||
// Description: Builds change trace evidence bundles for export.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.ChangeTrace;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Scanner.ChangeTrace.CycloneDx;
|
||||
|
||||
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.ChangeTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Builds change trace evidence bundles for export.
|
||||
/// </summary>
|
||||
public sealed class ChangeTraceBundleBuilder : IChangeTraceBundleBuilder
|
||||
{
|
||||
private const string ManifestVersion = "change-trace-bundle/v1";
|
||||
private const string RawTraceFileName = "change-trace.json";
|
||||
private const string AttestationFileName = "attestation.dsse.json";
|
||||
private const string CycloneDxFileName = "evidence.cdx.json";
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private const string ChecksumsFileName = "checksums.sha256";
|
||||
private const string VerifyScriptFileName = "verify.sh";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly IChangeTraceAttestationService? _attestationService;
|
||||
private readonly IChangeTraceEvidenceExtension _evidenceExtension;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new change trace bundle builder.
|
||||
/// </summary>
|
||||
/// <param name="attestationService">Optional attestation service for DSSE envelopes.</param>
|
||||
/// <param name="evidenceExtension">CycloneDX evidence extension service.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
public ChangeTraceBundleBuilder(
|
||||
IChangeTraceAttestationService? attestationService,
|
||||
IChangeTraceEvidenceExtension evidenceExtension,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_attestationService = attestationService;
|
||||
_evidenceExtension = evidenceExtension ?? throw new ArgumentNullException(nameof(evidenceExtension));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new change trace bundle builder with default dependencies.
|
||||
/// </summary>
|
||||
public ChangeTraceBundleBuilder()
|
||||
: this(null, new ChangeTraceEvidenceExtension(), TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChangeTraceBundle> BuildAsync(
|
||||
ChangeTraceModel trace,
|
||||
ChangeTraceBundleOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trace);
|
||||
options ??= ChangeTraceBundleOptions.Default;
|
||||
|
||||
var bundleId = GenerateBundleId(trace);
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var files = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
var entries = new List<ChangeTraceBundleEntry>();
|
||||
|
||||
// 1. Raw trace JSON
|
||||
if (options.IncludeRawTrace)
|
||||
{
|
||||
var traceJson = JsonSerializer.Serialize(trace, SerializerOptions);
|
||||
var traceBytes = Encoding.UTF8.GetBytes(traceJson);
|
||||
files[RawTraceFileName] = traceBytes;
|
||||
entries.Add(CreateEntry("change-traces", RawTraceFileName, traceBytes, "application/json"));
|
||||
}
|
||||
|
||||
// 2. DSSE Attestation
|
||||
if (options.IncludeAttestation && _attestationService is not null)
|
||||
{
|
||||
var attestationOptions = new ChangeTraceAttestationOptions
|
||||
{
|
||||
TenantId = options.TenantId,
|
||||
MaxDeltas = options.MaxDeltas
|
||||
};
|
||||
|
||||
var envelope = await _attestationService.GenerateAttestationAsync(
|
||||
trace, attestationOptions, ct).ConfigureAwait(false);
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
var envelopeBytes = Encoding.UTF8.GetBytes(envelopeJson);
|
||||
files[AttestationFileName] = envelopeBytes;
|
||||
entries.Add(CreateEntry("attestations", AttestationFileName, envelopeBytes, "application/vnd.in-toto+json"));
|
||||
}
|
||||
|
||||
// 3. CycloneDX Evidence
|
||||
if (options.IncludeCycloneDxEvidence)
|
||||
{
|
||||
var evidenceOptions = new ChangeTraceEvidenceOptions
|
||||
{
|
||||
MaxDeltas = options.MaxDeltas,
|
||||
IncludeProofSteps = true,
|
||||
IncludeSymbolDeltas = true
|
||||
};
|
||||
|
||||
using var cycloneDxDoc = _evidenceExtension.ExportAsStandalone(trace, evidenceOptions);
|
||||
var cdxJson = JsonSerializer.Serialize(cycloneDxDoc, SerializerOptions);
|
||||
var cdxBytes = Encoding.UTF8.GetBytes(cdxJson);
|
||||
files[CycloneDxFileName] = cdxBytes;
|
||||
entries.Add(CreateEntry("evidence", CycloneDxFileName, cdxBytes, "application/vnd.cyclonedx+json"));
|
||||
}
|
||||
|
||||
// 4. Verification script
|
||||
if (options.IncludeVerifyScript)
|
||||
{
|
||||
var verifyScript = BuildVerificationScript(bundleId);
|
||||
var scriptBytes = Encoding.UTF8.GetBytes(verifyScript);
|
||||
files[VerifyScriptFileName] = scriptBytes;
|
||||
entries.Add(CreateEntry("scripts", VerifyScriptFileName, scriptBytes, "application/x-sh"));
|
||||
}
|
||||
|
||||
// 5. Checksums file
|
||||
var checksums = BuildChecksums(entries);
|
||||
var checksumsBytes = Encoding.UTF8.GetBytes(checksums);
|
||||
files[ChecksumsFileName] = checksumsBytes;
|
||||
entries.Add(CreateEntry("metadata", ChecksumsFileName, checksumsBytes, "text/plain"));
|
||||
|
||||
// 6. Manifest
|
||||
var manifestHash = ComputeManifestHash(entries);
|
||||
var manifest = new ChangeTraceBundleManifest
|
||||
{
|
||||
Version = ManifestVersion,
|
||||
BundleId = bundleId,
|
||||
CreatedAt = createdAt,
|
||||
Entries = entries,
|
||||
ManifestHash = manifestHash,
|
||||
SubjectDigest = trace.Subject.Digest,
|
||||
TenantId = options.TenantId
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
var manifestBytes = Encoding.UTF8.GetBytes(manifestJson);
|
||||
files[ManifestFileName] = manifestBytes;
|
||||
|
||||
return new ChangeTraceBundle
|
||||
{
|
||||
BundleId = bundleId,
|
||||
CreatedAt = createdAt,
|
||||
Manifest = manifest,
|
||||
Files = files
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a deterministic bundle ID from the trace.
|
||||
/// </summary>
|
||||
private static string GenerateBundleId(ChangeTraceModel trace)
|
||||
{
|
||||
var input = $"{trace.Subject.Digest}:{trace.Basis.ScanId}:{trace.Commitment?.Sha256 ?? "none"}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"ctb-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a bundle entry from content.
|
||||
/// </summary>
|
||||
private static ChangeTraceBundleEntry CreateEntry(
|
||||
string category,
|
||||
string path,
|
||||
byte[] content,
|
||||
string contentType)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return new ChangeTraceBundleEntry
|
||||
{
|
||||
Category = category,
|
||||
Path = path,
|
||||
Sha256 = Convert.ToHexStringLower(hash),
|
||||
SizeBytes = content.Length,
|
||||
ContentType = contentType
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build checksums file content.
|
||||
/// </summary>
|
||||
private static string BuildChecksums(List<ChangeTraceBundleEntry> entries)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Change trace bundle checksums (SHA-256)");
|
||||
builder.AppendLine("# Generated by StellaOps ChangeTraceBundleBuilder");
|
||||
builder.AppendLine();
|
||||
|
||||
foreach (var entry in entries.OrderBy(e => e.Path, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.Sha256);
|
||||
builder.Append(" ");
|
||||
builder.AppendLine(entry.Path);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute manifest hash from all entries.
|
||||
/// </summary>
|
||||
private static string ComputeManifestHash(List<ChangeTraceBundleEntry> entries)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var entry in entries.OrderBy(e => e.Path, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.Sha256);
|
||||
builder.Append(':');
|
||||
builder.Append(entry.Path);
|
||||
builder.Append('\n');
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build verification script for the bundle.
|
||||
/// </summary>
|
||||
private static string BuildVerificationScript(string bundleId)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("# Change Trace Bundle Verification Script");
|
||||
builder.AppendLine("# No network access required");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("set -eu");
|
||||
builder.AppendLine();
|
||||
builder.Append("BUNDLE_ID=\"").Append(bundleId).AppendLine("\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"Verifying change trace bundle: $BUNDLE_ID\"");
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Step 1: Verify checksums");
|
||||
builder.AppendLine("echo \"Verifying file checksums...\"");
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" sha256sum --check checksums.sha256 --ignore-missing");
|
||||
builder.AppendLine("elif command -v shasum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" shasum -a 256 --check checksums.sha256 --ignore-missing");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" echo \"Error: sha256sum or shasum required\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine("echo \"Checksums verified.\"");
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Step 2: Verify DSSE attestation (if present)");
|
||||
builder.AppendLine("if [ -f \"attestation.dsse.json\" ]; then");
|
||||
builder.AppendLine(" if command -v stella >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" echo \"Verifying DSSE attestation with stella CLI...\"");
|
||||
builder.AppendLine(" stella attest verify --envelope attestation.dsse.json");
|
||||
builder.AppendLine(" else");
|
||||
builder.AppendLine(" echo \"Note: stella CLI not found. Manual DSSE verification recommended.\"");
|
||||
builder.AppendLine(" fi");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Step 3: Display summary");
|
||||
builder.AppendLine("echo \"Bundle verification complete.\"");
|
||||
builder.AppendLine("if [ -f \"change-trace.json\" ]; then");
|
||||
builder.AppendLine(" echo \"\"");
|
||||
builder.AppendLine(" echo \"Change trace summary:\"");
|
||||
builder.AppendLine(" if command -v jq >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" jq '.summary' change-trace.json");
|
||||
builder.AppendLine(" else");
|
||||
builder.AppendLine(" cat change-trace.json | grep -A5 '\"summary\"'");
|
||||
builder.AppendLine(" fi");
|
||||
builder.AppendLine("fi");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IChangeTraceBundleBuilder.cs
|
||||
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
|
||||
// Description: Interface for building change trace evidence bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.ChangeTrace.Models;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.ChangeTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Builds change trace evidence bundles for export.
|
||||
/// </summary>
|
||||
public interface IChangeTraceBundleBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a complete change trace evidence bundle.
|
||||
/// </summary>
|
||||
/// <param name="trace">The change trace to bundle.</param>
|
||||
/// <param name="options">Optional bundle options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The built change trace bundle.</returns>
|
||||
Task<ChangeTraceBundle> BuildAsync(
|
||||
Scanner.ChangeTrace.Models.ChangeTrace trace,
|
||||
ChangeTraceBundleOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for change trace bundle building.
|
||||
/// </summary>
|
||||
public sealed record ChangeTraceBundleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default bundle options.
|
||||
/// </summary>
|
||||
public static readonly ChangeTraceBundleOptions Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// Include DSSE attestation envelope.
|
||||
/// </summary>
|
||||
public bool IncludeAttestation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include CycloneDX evidence file (standalone mode).
|
||||
/// </summary>
|
||||
public bool IncludeCycloneDxEvidence { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include raw trace JSON.
|
||||
/// </summary>
|
||||
public bool IncludeRawTrace { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include verification script.
|
||||
/// </summary>
|
||||
public bool IncludeVerifyScript { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public string TenantId { get; init; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum deltas to include in exports.
|
||||
/// </summary>
|
||||
public int MaxDeltas { get; init; } = 1000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bundled change trace export package.
|
||||
/// </summary>
|
||||
public sealed record ChangeTraceBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this bundle.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle manifest with file entries and checksums.
|
||||
/// </summary>
|
||||
public required ChangeTraceBundleManifest Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle files keyed by path.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, byte[]> Files { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest describing the contents of a change trace bundle.
|
||||
/// </summary>
|
||||
public sealed record ChangeTraceBundleManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest format version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle identifier.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entries in this bundle.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ChangeTraceBundleEntry> Entries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash of all entries for integrity verification.
|
||||
/// </summary>
|
||||
public required string ManifestHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject artifact digest.
|
||||
/// </summary>
|
||||
public string? SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in a change trace bundle.
|
||||
/// </summary>
|
||||
public sealed record ChangeTraceBundleEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Category of the entry (change-traces, attestations, evidence).
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path within the bundle.
|
||||
/// </summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the entry content.
|
||||
/// </summary>
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content type (MIME type).
|
||||
/// </summary>
|
||||
public string? ContentType { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Distribution;
|
||||
@@ -10,16 +11,19 @@ public sealed class DistributionLifecycleService : IDistributionLifecycleService
|
||||
{
|
||||
private readonly IDistributionRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<DistributionLifecycleService> _logger;
|
||||
|
||||
public DistributionLifecycleService(
|
||||
IDistributionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DistributionLifecycleService> logger)
|
||||
ILogger<DistributionLifecycleService> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -58,7 +62,7 @@ public sealed class DistributionLifecycleService : IDistributionLifecycleService
|
||||
|
||||
var distribution = new ExportDistribution
|
||||
{
|
||||
DistributionId = Guid.NewGuid(),
|
||||
DistributionId = _guidProvider.NewGuid(),
|
||||
RunId = request.RunId,
|
||||
TenantId = request.TenantId,
|
||||
Kind = request.Kind,
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Cronos;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Scheduling;
|
||||
@@ -14,6 +15,7 @@ public sealed class ExportSchedulerService : IExportSchedulerService
|
||||
{
|
||||
private readonly IExportScheduleStore _scheduleStore;
|
||||
private readonly ILogger<ExportSchedulerService> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ConcurrentDictionary<string, CronExpression> _cronCache = new();
|
||||
|
||||
// Pause profiles after this many consecutive failures
|
||||
@@ -21,10 +23,12 @@ public sealed class ExportSchedulerService : IExportSchedulerService
|
||||
|
||||
public ExportSchedulerService(
|
||||
IExportScheduleStore scheduleStore,
|
||||
ILogger<ExportSchedulerService> logger)
|
||||
ILogger<ExportSchedulerService> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_scheduleStore = scheduleStore ?? throw new ArgumentNullException(nameof(scheduleStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -89,7 +93,7 @@ public sealed class ExportSchedulerService : IExportSchedulerService
|
||||
}
|
||||
|
||||
// Create new run
|
||||
var runId = Guid.NewGuid();
|
||||
var runId = _guidProvider.NewGuid();
|
||||
await _scheduleStore.RecordTriggerAsync(
|
||||
request.ProfileId,
|
||||
runId,
|
||||
|
||||
@@ -34,10 +34,14 @@ public sealed class EvidencePackSigningService : IEvidencePackSigningService
|
||||
};
|
||||
|
||||
private readonly ILogger<EvidencePackSigningService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidencePackSigningService(ILogger<EvidencePackSigningService> logger)
|
||||
public EvidencePackSigningService(
|
||||
ILogger<EvidencePackSigningService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -92,7 +96,7 @@ public sealed class EvidencePackSigningService : IEvidencePackSigningService
|
||||
var envelopeBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson));
|
||||
var envelopeDigest = ComputeDigest(envelopeJson);
|
||||
|
||||
var signedAt = DateTimeOffset.UtcNow;
|
||||
var signedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Build signature record
|
||||
var signature = new EvidencePackSignature
|
||||
@@ -318,11 +322,11 @@ public sealed class EvidencePackSigningService : IEvidencePackSigningService
|
||||
};
|
||||
}
|
||||
|
||||
private static Task<long?> UploadToRekorAsync(DsseEnvelope envelope, CancellationToken ct)
|
||||
private Task<long?> UploadToRekorAsync(DsseEnvelope envelope, CancellationToken ct)
|
||||
{
|
||||
// In real implementation, would upload to Rekor transparency log
|
||||
// For now, return placeholder index
|
||||
return Task.FromResult<long?>(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
return Task.FromResult<long?>(_timeProvider.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(ImmutableArray<ManifestEntry> entries)
|
||||
|
||||
@@ -21,5 +21,8 @@
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Provcache\StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.ChangeTrace\StellaOps.Scanner.ChangeTrace.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.Core.Planner;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
@@ -202,6 +203,8 @@ public static class ExportApiEndpoints
|
||||
ClaimsPrincipal user,
|
||||
IExportProfileRepository profileRepo,
|
||||
IExportAuditService auditService,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(user);
|
||||
@@ -212,10 +215,10 @@ public static class ExportApiEndpoints
|
||||
if (!await profileRepo.IsNameUniqueAsync(tenantId, request.Name, cancellationToken: cancellationToken))
|
||||
return TypedResults.Conflict($"Profile name '{request.Name}' already exists");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var profile = new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
ProfileId = guidProvider.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
@@ -255,6 +258,7 @@ public static class ExportApiEndpoints
|
||||
ClaimsPrincipal user,
|
||||
IExportProfileRepository profileRepo,
|
||||
IExportAuditService auditService,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(user);
|
||||
@@ -291,7 +295,7 @@ public static class ExportApiEndpoints
|
||||
? JsonSerializer.Serialize(request.Signing)
|
||||
: existing.SigningJson,
|
||||
Schedule = request.Schedule ?? existing.Schedule,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
UpdatedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
await profileRepo.UpdateAsync(updated, cancellationToken);
|
||||
@@ -347,6 +351,8 @@ public static class ExportApiEndpoints
|
||||
IExportRunRepository runRepo,
|
||||
IExportAuditService auditService,
|
||||
IOptions<ExportConcurrencyOptions> concurrencyOptions,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(user);
|
||||
@@ -396,18 +402,18 @@ public static class ExportApiEndpoints
|
||||
return TypedResults.StatusCode(429);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var shouldQueue = activeRunsCount >= options.MaxConcurrentRunsPerTenant ||
|
||||
profileActiveRuns >= options.MaxConcurrentRunsPerProfile;
|
||||
|
||||
var run = new ExportRun
|
||||
{
|
||||
RunId = Guid.NewGuid(),
|
||||
RunId = guidProvider.NewGuid(),
|
||||
ProfileId = profileId,
|
||||
TenantId = tenantId,
|
||||
Status = shouldQueue ? ExportRunStatus.Queued : ExportRunStatus.Running,
|
||||
Trigger = ExportRunTrigger.Api,
|
||||
CorrelationId = request.CorrelationId ?? Guid.NewGuid().ToString(),
|
||||
CorrelationId = request.CorrelationId ?? guidProvider.NewGuid().ToString(),
|
||||
InitiatedBy = GetUserId(user),
|
||||
TotalItems = 0,
|
||||
ProcessedItems = 0,
|
||||
@@ -632,6 +638,7 @@ public static class ExportApiEndpoints
|
||||
ClaimsPrincipal user,
|
||||
HttpContext httpContext,
|
||||
IExportRunRepository runRepo,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(user);
|
||||
@@ -661,7 +668,7 @@ public static class ExportApiEndpoints
|
||||
{
|
||||
EventType = "connected",
|
||||
RunId = runId,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Data = MapToRunResponse(run, null)
|
||||
}, cancellationToken);
|
||||
|
||||
@@ -685,7 +692,7 @@ public static class ExportApiEndpoints
|
||||
{
|
||||
EventType = ExportRunSseEventTypes.RunProgress,
|
||||
RunId = runId,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Data = new ExportRunProgress
|
||||
{
|
||||
TotalItems = run.TotalItems,
|
||||
@@ -714,7 +721,7 @@ public static class ExportApiEndpoints
|
||||
{
|
||||
EventType = eventType,
|
||||
RunId = runId,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Data = MapToRunResponse(run, null)
|
||||
}, cancellationToken);
|
||||
|
||||
@@ -729,7 +736,7 @@ public static class ExportApiEndpoints
|
||||
{
|
||||
EventType = "disconnected",
|
||||
RunId = runId,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Data = MapToRunResponse(run, null)
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user