Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created CycloneDX and SPDX SBOM files for both reachable and unreachable images. - Added symbols.json detailing function entry and sink points in the WordPress code. - Included runtime traces for function calls in both reachable and unreachable scenarios. - Developed OpenVEX files indicating vulnerability status and justification for both cases. - Updated README for evaluator harness to guide integration with scanner output.
This commit is contained in:
@@ -19,6 +19,7 @@ using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Mappings;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using StellaOps.Findings.Ledger.Services.Security;
|
||||
|
||||
const string LedgerWritePolicy = "ledger.events.write";
|
||||
|
||||
@@ -126,6 +127,10 @@ builder.Services.AddSingleton<PolicyEvaluationCache>();
|
||||
builder.Services.AddSingleton<PolicyEngineEvaluationService>();
|
||||
builder.Services.AddSingleton<IPolicyEvaluationService>(sp => sp.GetRequiredService<PolicyEngineEvaluationService>());
|
||||
builder.Services.AddSingleton<ILedgerEventWriteService, LedgerEventWriteService>();
|
||||
builder.Services.AddSingleton<IFindingWorkflowService, FindingWorkflowService>();
|
||||
builder.Services.AddSingleton<IAttachmentEncryptionService, AttachmentEncryptionService>();
|
||||
builder.Services.AddSingleton<IAttachmentUrlSigner, AttachmentUrlSigner>();
|
||||
builder.Services.AddSingleton<IConsoleCsrfValidator, ConsoleCsrfValidator>();
|
||||
builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
|
||||
builder.Services.AddHostedService<LedgerProjectionWorker>();
|
||||
|
||||
@@ -156,10 +161,14 @@ app.UseAuthorization();
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
app.MapPost("/vuln/ledger/events", async Task<Results<Created<LedgerEventResponse>, Ok<LedgerEventResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
IConsoleCsrfValidator csrfValidator,
|
||||
LedgerEventRequest request,
|
||||
ILedgerEventWriteService writeService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
csrfValidator.Validate(httpContext);
|
||||
|
||||
var draft = request.ToDraft();
|
||||
var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
||||
return result.Status switch
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
public static class LedgerChainIdGenerator
|
||||
{
|
||||
public static Guid FromTenantPolicy(string tenantId, string policyVersion)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyVersion);
|
||||
|
||||
var normalized = $"{tenantId.Trim()}::{policyVersion.Trim()}";
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
Span<byte> guidBytes = stackalloc byte[16];
|
||||
var hash = SHA256.HashData(bytes);
|
||||
hash.AsSpan(0, 16).CopyTo(guidBytes);
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public sealed class LedgerServiceOptions
|
||||
|
||||
public PolicyEngineOptions PolicyEngine { get; init; } = new();
|
||||
|
||||
public AttachmentsOptions Attachments { get; init; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Database.ConnectionString))
|
||||
@@ -47,6 +49,7 @@ public sealed class LedgerServiceOptions
|
||||
}
|
||||
|
||||
PolicyEngine.Validate();
|
||||
Attachments.Validate();
|
||||
}
|
||||
|
||||
public sealed class DatabaseOptions
|
||||
@@ -143,4 +146,65 @@ public sealed class LedgerServiceOptions
|
||||
public TimeSpan EntryLifetime { get; set; } = DefaultCacheEntryLifetime;
|
||||
}
|
||||
|
||||
public sealed class AttachmentsOptions
|
||||
{
|
||||
private static readonly TimeSpan DefaultSignedUrlLifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string EncryptionKey { get; set; } = string.Empty;
|
||||
|
||||
public string SignedUrlBase { get; set; } = "https://evidence.local/attachments";
|
||||
|
||||
public string SignedUrlSecret { get; set; } = string.Empty;
|
||||
|
||||
public TimeSpan SignedUrlLifetime { get; set; } = DefaultSignedUrlLifetime;
|
||||
|
||||
public bool RequireConsoleCsrf { get; set; } = true;
|
||||
|
||||
public string CsrfHeaderName { get; set; } = "x-stella-csrf";
|
||||
|
||||
public string CsrfSharedSecret { get; set; } = string.Empty;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EncryptionKey))
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.EncryptionKey must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SignedUrlBase))
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.SignedUrlBase must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SignedUrlSecret))
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.SignedUrlSecret must be configured.");
|
||||
}
|
||||
|
||||
if (SignedUrlLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.SignedUrlLifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (RequireConsoleCsrf)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CsrfHeaderName))
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.CsrfHeaderName must be configured when CSRF enforcement is enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(CsrfSharedSecret))
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.CsrfSharedSecret must be configured when CSRF enforcement is enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
public interface IAttachmentEncryptionService
|
||||
{
|
||||
AttachmentEncryptionResult CreateEnvelope(NormalizedAttachment attachment, DateTimeOffset now);
|
||||
}
|
||||
|
||||
public sealed class AttachmentEncryptionService : IAttachmentEncryptionService
|
||||
{
|
||||
private readonly byte[] masterKey;
|
||||
private readonly LedgerServiceOptions.AttachmentsOptions options;
|
||||
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public AttachmentEncryptionService(IOptions<LedgerServiceOptions> optionsAccessor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(optionsAccessor);
|
||||
options = optionsAccessor.Value.Attachments;
|
||||
masterKey = Convert.FromBase64String(options.EncryptionKey);
|
||||
if (masterKey.Length != 32)
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.EncryptionKey must be a 256-bit (32-byte) key.");
|
||||
}
|
||||
}
|
||||
|
||||
public AttachmentEncryptionResult CreateEnvelope(NormalizedAttachment attachment, DateTimeOffset now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachment);
|
||||
|
||||
var payload = new AttachmentEncryptionPayload(
|
||||
attachment.AttachmentId,
|
||||
attachment.Sha256,
|
||||
attachment.ContentType,
|
||||
attachment.SizeBytes,
|
||||
attachment.Metadata);
|
||||
|
||||
var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload, serializerOptions);
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[16];
|
||||
using var aes = new AesGcm(masterKey);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
return new AttachmentEncryptionResult(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Ciphertext: Convert.ToBase64String(ciphertext),
|
||||
Nonce: Convert.ToBase64String(nonce),
|
||||
Tag: Convert.ToBase64String(tag),
|
||||
ExpiresAt: now.Add(options.SignedUrlLifetime));
|
||||
}
|
||||
|
||||
private sealed record AttachmentEncryptionPayload(
|
||||
string AttachmentId,
|
||||
string Sha256,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
}
|
||||
|
||||
public sealed record AttachmentEncryptionResult(
|
||||
string Algorithm,
|
||||
string Ciphertext,
|
||||
string Nonce,
|
||||
string Tag,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record NormalizedAttachment(
|
||||
string AttachmentId,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
public interface IAttachmentUrlSigner
|
||||
{
|
||||
AttachmentSignedUrl Sign(string attachmentId, DateTimeOffset now, TimeSpan lifetime);
|
||||
}
|
||||
|
||||
public sealed class AttachmentUrlSigner : IAttachmentUrlSigner
|
||||
{
|
||||
private readonly LedgerServiceOptions.AttachmentsOptions options;
|
||||
private readonly byte[] secretKey;
|
||||
|
||||
public AttachmentUrlSigner(IOptions<LedgerServiceOptions> optionsAccessor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(optionsAccessor);
|
||||
options = optionsAccessor.Value.Attachments;
|
||||
secretKey = Encoding.UTF8.GetBytes(options.SignedUrlSecret ?? string.Empty);
|
||||
if (secretKey.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.SignedUrlSecret must be configured.");
|
||||
}
|
||||
}
|
||||
|
||||
public AttachmentSignedUrl Sign(string attachmentId, DateTimeOffset now, TimeSpan lifetime)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(attachmentId);
|
||||
|
||||
var expires = now.Add(lifetime);
|
||||
var expiresUnix = expires.ToUnixTimeSeconds();
|
||||
var payload = $"{attachmentId}|{expiresUnix}";
|
||||
using var hmac = new HMACSHA256(secretKey);
|
||||
var signature = Base64UrlEncode(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)));
|
||||
|
||||
var baseUrl = options.SignedUrlBase.TrimEnd('/');
|
||||
var url = $"{baseUrl}/{Uri.EscapeDataString(attachmentId)}?exp={expiresUnix}&sig={signature}";
|
||||
return new AttachmentSignedUrl(new Uri(url, UriKind.Absolute), expires);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes)
|
||||
=> Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
public sealed record AttachmentSignedUrl(Uri Url, DateTimeOffset ExpiresAt);
|
||||
@@ -0,0 +1,568 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
using StellaOps.Findings.Ledger.Workflow;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
public interface IFindingWorkflowService
|
||||
{
|
||||
Task<LedgerWriteResult> AssignAsync(AssignWorkflowRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<LedgerWriteResult> CommentAsync(CommentWorkflowRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<LedgerWriteResult> AcceptRiskAsync(AcceptRiskWorkflowRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<LedgerWriteResult> TargetFixAsync(TargetFixWorkflowRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<LedgerWriteResult> VerifyFixAsync(VerifyFixWorkflowRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<LedgerWriteResult> ReopenAsync(ReopenWorkflowRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class FindingWorkflowService : IFindingWorkflowService
|
||||
{
|
||||
private readonly ILedgerEventRepository repository;
|
||||
private readonly ILedgerEventWriteService writeService;
|
||||
private readonly IAttachmentEncryptionService attachmentEncryptionService;
|
||||
private readonly IAttachmentUrlSigner attachmentUrlSigner;
|
||||
private readonly LedgerServiceOptions.AttachmentsOptions attachmentOptions;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<FindingWorkflowService> logger;
|
||||
|
||||
public FindingWorkflowService(
|
||||
ILedgerEventRepository repository,
|
||||
ILedgerEventWriteService writeService,
|
||||
IAttachmentEncryptionService attachmentEncryptionService,
|
||||
IAttachmentUrlSigner attachmentUrlSigner,
|
||||
IOptions<LedgerServiceOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<FindingWorkflowService> logger)
|
||||
{
|
||||
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
this.writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
|
||||
this.attachmentEncryptionService = attachmentEncryptionService ?? throw new ArgumentNullException(nameof(attachmentEncryptionService));
|
||||
this.attachmentUrlSigner = attachmentUrlSigner ?? throw new ArgumentNullException(nameof(attachmentUrlSigner));
|
||||
attachmentOptions = options?.Value?.Attachments ?? throw new ArgumentNullException(nameof(options));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<LedgerWriteResult> AssignAsync(AssignWorkflowRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = ValidateBase(request);
|
||||
if (request.Assignee is null || string.IsNullOrWhiteSpace(request.Assignee.Id))
|
||||
{
|
||||
errors.Add("assignee_required");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors]));
|
||||
}
|
||||
|
||||
var payload = CreateBasePayload(request);
|
||||
payload["action"] = "assign";
|
||||
payload["assignee"] = BuildAssigneeNode(request.Assignee);
|
||||
AddComment(payload, request.Comment);
|
||||
ApplyStatus(payload, request.Status);
|
||||
ApplyAttachments(payload, request.Attachments);
|
||||
|
||||
return AppendAsync(request, LedgerEventConstants.EventFindingAssignmentChanged, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LedgerWriteResult> CommentAsync(CommentWorkflowRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = ValidateBase(request);
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
{
|
||||
errors.Add("comment_required");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors]));
|
||||
}
|
||||
|
||||
var payload = CreateBasePayload(request);
|
||||
payload["action"] = "comment";
|
||||
payload["comment"] = request.Comment.Trim();
|
||||
ApplyAttachments(payload, request.Attachments);
|
||||
return AppendAsync(request, LedgerEventConstants.EventFindingCommentAdded, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LedgerWriteResult> AcceptRiskAsync(AcceptRiskWorkflowRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = ValidateBase(request);
|
||||
if (string.IsNullOrWhiteSpace(request.Justification))
|
||||
{
|
||||
errors.Add("justification_required");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors]));
|
||||
}
|
||||
|
||||
var payload = CreateBasePayload(request);
|
||||
payload["action"] = "accept-risk";
|
||||
payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "accepted_risk" : request.Status;
|
||||
payload["justification"] = request.Justification.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.RiskOwner))
|
||||
{
|
||||
payload["riskOwner"] = request.RiskOwner.Trim();
|
||||
}
|
||||
|
||||
if (request.ExpiresAt is { } expiresAt)
|
||||
{
|
||||
payload["expiresAt"] = FormatTimestamp(expiresAt);
|
||||
}
|
||||
|
||||
ApplyAttachments(payload, request.Attachments);
|
||||
return AppendAsync(request, LedgerEventConstants.EventFindingAcceptedRisk, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LedgerWriteResult> TargetFixAsync(TargetFixWorkflowRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = ValidateBase(request);
|
||||
if (string.IsNullOrWhiteSpace(request.Summary))
|
||||
{
|
||||
errors.Add("summary_required");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors]));
|
||||
}
|
||||
|
||||
var payload = CreateBasePayload(request);
|
||||
payload["action"] = "target-fix";
|
||||
payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "in_progress" : request.Status;
|
||||
payload["remediationPlan"] = request.Summary.Trim();
|
||||
|
||||
if (request.TargetCompletion is { } targetCompletion)
|
||||
{
|
||||
payload["targetCompletion"] = FormatTimestamp(targetCompletion);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TicketId) || request.TicketUrl is not null)
|
||||
{
|
||||
var ticket = new JsonObject();
|
||||
if (!string.IsNullOrWhiteSpace(request.TicketId))
|
||||
{
|
||||
ticket["id"] = request.TicketId.Trim();
|
||||
}
|
||||
|
||||
if (request.TicketUrl is not null)
|
||||
{
|
||||
ticket["url"] = request.TicketUrl.ToString();
|
||||
}
|
||||
|
||||
payload["ticket"] = ticket;
|
||||
}
|
||||
|
||||
ApplyAttachments(payload, request.Attachments);
|
||||
return AppendAsync(request, LedgerEventConstants.EventFindingRemediationPlanAdded, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LedgerWriteResult> VerifyFixAsync(VerifyFixWorkflowRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = ValidateBase(request);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors]));
|
||||
}
|
||||
|
||||
var payload = CreateBasePayload(request);
|
||||
payload["action"] = "verify-fix";
|
||||
payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "verified" : request.Status;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Evidence))
|
||||
{
|
||||
payload["evidence"] = request.Evidence.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
payload["notes"] = request.Notes.Trim();
|
||||
}
|
||||
|
||||
ApplyAttachments(payload, request.Attachments);
|
||||
return AppendAsync(request, LedgerEventConstants.EventFindingStatusChanged, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LedgerWriteResult> ReopenAsync(ReopenWorkflowRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = ValidateBase(request);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors]));
|
||||
}
|
||||
|
||||
var payload = CreateBasePayload(request);
|
||||
payload["action"] = "reopen";
|
||||
payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "affected" : request.Status;
|
||||
if (!string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
payload["reason"] = request.Reason.Trim();
|
||||
}
|
||||
|
||||
ApplyAttachments(payload, request.Attachments);
|
||||
return AppendAsync(request, LedgerEventConstants.EventFindingStatusChanged, payload, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<LedgerWriteResult> AppendAsync(
|
||||
WorkflowMutationRequest request,
|
||||
string eventType,
|
||||
JsonObject payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chainContext = await ResolveChainContextAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var eventId = request.EventId ?? Guid.CreateVersion7();
|
||||
var occurredAt = (request.OccurredAt ?? timeProvider.GetUtcNow()).ToUniversalTime();
|
||||
var recordedAt = timeProvider.GetUtcNow().ToUniversalTime();
|
||||
|
||||
var envelope = BuildCanonicalEnvelope(request, eventId, eventType, chainContext, occurredAt, recordedAt, payload);
|
||||
|
||||
var draft = new LedgerEventDraft(
|
||||
request.TenantId.Trim(),
|
||||
chainContext.ChainId,
|
||||
chainContext.Sequence,
|
||||
eventId,
|
||||
eventType,
|
||||
request.PolicyVersion.Trim(),
|
||||
request.FindingId.Trim(),
|
||||
request.ArtifactId.Trim(),
|
||||
SourceRunId: null,
|
||||
request.Actor.Id.Trim(),
|
||||
request.Actor.Type.Trim(),
|
||||
occurredAt,
|
||||
recordedAt,
|
||||
payload,
|
||||
envelope,
|
||||
chainContext.PreviousHash);
|
||||
|
||||
var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Status == LedgerWriteStatus.Conflict)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Workflow append conflict for tenant {Tenant} finding {Finding} ({EventType}).",
|
||||
request.TenantId,
|
||||
request.FindingId,
|
||||
eventType);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<ChainContext> ResolveChainContextAsync(WorkflowMutationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chainId = request.ChainId ?? LedgerChainIdGenerator.FromTenantPolicy(request.TenantId, request.PolicyVersion);
|
||||
var head = await repository.GetChainHeadAsync(request.TenantId, chainId, cancellationToken).ConfigureAwait(false);
|
||||
var sequence = head is null ? 1 : head.SequenceNumber + 1;
|
||||
var previousHash = head?.EventHash ?? LedgerEventConstants.EmptyHash;
|
||||
return new ChainContext(chainId, sequence, previousHash);
|
||||
}
|
||||
|
||||
private static JsonObject BuildCanonicalEnvelope(
|
||||
WorkflowMutationRequest request,
|
||||
Guid eventId,
|
||||
string eventType,
|
||||
ChainContext chainContext,
|
||||
DateTimeOffset occurredAt,
|
||||
DateTimeOffset recordedAt,
|
||||
JsonObject payload)
|
||||
{
|
||||
var eventObject = new JsonObject
|
||||
{
|
||||
["id"] = eventId,
|
||||
["type"] = eventType,
|
||||
["tenant"] = request.TenantId.Trim(),
|
||||
["chainId"] = chainContext.ChainId,
|
||||
["sequence"] = chainContext.Sequence,
|
||||
["policyVersion"] = request.PolicyVersion.Trim(),
|
||||
["finding"] = new JsonObject
|
||||
{
|
||||
["id"] = request.FindingId.Trim(),
|
||||
["artifactId"] = request.ArtifactId.Trim(),
|
||||
["vulnId"] = request.VulnerabilityId.Trim()
|
||||
},
|
||||
["actor"] = BuildActorNode(request.Actor),
|
||||
["occurredAt"] = FormatTimestamp(occurredAt),
|
||||
["recordedAt"] = FormatTimestamp(recordedAt),
|
||||
["payload"] = payload
|
||||
};
|
||||
|
||||
return new JsonObject { ["event"] = eventObject };
|
||||
}
|
||||
|
||||
private static JsonObject CreateBasePayload(WorkflowMutationRequest request)
|
||||
{
|
||||
var payload = new JsonObject();
|
||||
if (!string.IsNullOrWhiteSpace(request.PreviousStatus))
|
||||
{
|
||||
payload["previousStatus"] = request.PreviousStatus.Trim();
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static JsonObject BuildActorNode(WorkflowActor actor)
|
||||
{
|
||||
var node = new JsonObject
|
||||
{
|
||||
["id"] = actor.Id.Trim(),
|
||||
["type"] = actor.Type.Trim()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor.DisplayName))
|
||||
{
|
||||
node["displayName"] = actor.DisplayName.Trim();
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static JsonObject BuildAssigneeNode(WorkflowAssignee assignee)
|
||||
{
|
||||
var node = new JsonObject
|
||||
{
|
||||
["id"] = assignee.Id.Trim(),
|
||||
["type"] = string.IsNullOrWhiteSpace(assignee.Type) ? "user" : assignee.Type.Trim()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(assignee.DisplayName))
|
||||
{
|
||||
node["displayName"] = assignee.DisplayName.Trim();
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static void AddComment(JsonObject payload, string? comment)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(comment))
|
||||
{
|
||||
payload["comment"] = comment.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyStatus(JsonObject payload, string? status)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
payload["status"] = status.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAttachments(JsonObject payload, IReadOnlyList<WorkflowAttachmentMetadata>? attachments)
|
||||
{
|
||||
if (!attachmentOptions.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nodes = BuildAttachments(attachments);
|
||||
if (nodes is not null)
|
||||
{
|
||||
payload["attachments"] = nodes;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonArray? BuildAttachments(IReadOnlyList<WorkflowAttachmentMetadata>? attachments)
|
||||
{
|
||||
if (attachments is null || attachments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var normalized = attachments
|
||||
.Where(static attachment => attachment is not null)
|
||||
.Select(NormalizeAttachment)
|
||||
.OrderBy(attachment => attachment.AttachmentId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var array = new JsonArray();
|
||||
foreach (var attachment in normalized)
|
||||
{
|
||||
var envelope = attachmentEncryptionService.CreateEnvelope(attachment, now);
|
||||
var signed = attachmentUrlSigner.Sign(attachment.AttachmentId, now, attachmentOptions.SignedUrlLifetime);
|
||||
var node = BuildAttachmentNode(attachment, envelope, signed);
|
||||
array.Add(node);
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonObject BuildAttachmentNode(
|
||||
NormalizedAttachment attachment,
|
||||
AttachmentEncryptionResult envelope,
|
||||
AttachmentSignedUrl signedUrl)
|
||||
{
|
||||
var node = new JsonObject
|
||||
{
|
||||
["id"] = attachment.AttachmentId,
|
||||
["fileName"] = attachment.FileName,
|
||||
["contentType"] = attachment.ContentType,
|
||||
["size"] = attachment.SizeBytes,
|
||||
["sha256"] = attachment.Sha256,
|
||||
["signedUrl"] = signedUrl.Url.ToString(),
|
||||
["urlExpiresAt"] = FormatTimestamp(signedUrl.ExpiresAt),
|
||||
["envelope"] = new JsonObject
|
||||
{
|
||||
["algorithm"] = envelope.Algorithm,
|
||||
["ciphertext"] = envelope.Ciphertext,
|
||||
["nonce"] = envelope.Nonce,
|
||||
["tag"] = envelope.Tag,
|
||||
["expiresAt"] = FormatTimestamp(envelope.ExpiresAt)
|
||||
}
|
||||
};
|
||||
|
||||
if (attachment.Metadata is { Count: > 0 })
|
||||
{
|
||||
var metadata = new JsonObject();
|
||||
foreach (var kvp in attachment.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
node["metadata"] = metadata;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static NormalizedAttachment NormalizeAttachment(WorkflowAttachmentMetadata attachment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachment);
|
||||
|
||||
var metadata = attachment.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string>(
|
||||
attachment.Metadata
|
||||
.Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null)
|
||||
.ToDictionary(
|
||||
kv => kv.Key.Trim(),
|
||||
kv => kv.Value!.Trim(),
|
||||
StringComparer.Ordinal),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return new NormalizedAttachment(
|
||||
attachment.AttachmentId.Trim(),
|
||||
attachment.FileName.Trim(),
|
||||
attachment.ContentType.Trim(),
|
||||
attachment.SizeBytes,
|
||||
attachment.Sha256.Trim(),
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static void ApplyAttachmentsValidation(IReadOnlyList<WorkflowAttachmentMetadata>? attachments, ICollection<string> errors)
|
||||
{
|
||||
if (attachments is null || attachments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
ValidateAttachment(attachment, errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ValidateBase(WorkflowMutationRequest request)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (request is null)
|
||||
{
|
||||
errors.Add("request_required");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
errors.Add("tenant_id_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyVersion))
|
||||
{
|
||||
errors.Add("policy_version_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.FindingId))
|
||||
{
|
||||
errors.Add("finding_id_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactId))
|
||||
{
|
||||
errors.Add("artifact_id_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.VulnerabilityId))
|
||||
{
|
||||
errors.Add("vuln_id_required");
|
||||
}
|
||||
|
||||
if (request.Actor is null || string.IsNullOrWhiteSpace(request.Actor.Id))
|
||||
{
|
||||
errors.Add("actor_id_required");
|
||||
}
|
||||
else if (!LedgerEventConstants.SupportedActorTypes.Contains(request.Actor.Type))
|
||||
{
|
||||
errors.Add($"actor_type_invalid:{request.Actor.Type}");
|
||||
}
|
||||
|
||||
ApplyAttachmentsValidation(request.Attachments, errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void ValidateAttachment(WorkflowAttachmentMetadata attachment, ICollection<string> errors)
|
||||
{
|
||||
if (attachment is null)
|
||||
{
|
||||
errors.Add("attachment_invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attachment.AttachmentId))
|
||||
{
|
||||
errors.Add("attachment_id_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attachment.FileName))
|
||||
{
|
||||
errors.Add("attachment_name_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attachment.ContentType))
|
||||
{
|
||||
errors.Add("attachment_content_type_required");
|
||||
}
|
||||
|
||||
if (attachment.SizeBytes <= 0)
|
||||
{
|
||||
errors.Add("attachment_size_invalid");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attachment.Sha256) || attachment.Sha256.Length != 64)
|
||||
{
|
||||
errors.Add("attachment_sha256_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
|
||||
|
||||
private readonly record struct ChainContext(Guid ChainId, long Sequence, string PreviousHash);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services.Security;
|
||||
|
||||
public interface IConsoleCsrfValidator
|
||||
{
|
||||
void Validate(HttpContext httpContext);
|
||||
}
|
||||
|
||||
public sealed class ConsoleCsrfValidator : IConsoleCsrfValidator
|
||||
{
|
||||
private readonly LedgerServiceOptions.AttachmentsOptions options;
|
||||
private readonly byte[] sharedSecret;
|
||||
|
||||
public ConsoleCsrfValidator(IOptions<LedgerServiceOptions> optionsAccessor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(optionsAccessor);
|
||||
options = optionsAccessor.Value.Attachments;
|
||||
sharedSecret = Encoding.UTF8.GetBytes(options.CsrfSharedSecret ?? string.Empty);
|
||||
}
|
||||
|
||||
public void Validate(HttpContext httpContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
if (!options.RequireConsoleCsrf)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (sharedSecret.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Attachments.CsrfSharedSecret must be configured when enforcement is enabled.");
|
||||
}
|
||||
|
||||
if (!httpContext.Request.Headers.TryGetValue(options.CsrfHeaderName, out var headerValues) || headerValues.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Missing {options.CsrfHeaderName} header.");
|
||||
}
|
||||
|
||||
var token = headerValues[0];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new InvalidOperationException("CSRF token cannot be empty.");
|
||||
}
|
||||
|
||||
var expected = sharedSecret;
|
||||
var provided = Encoding.UTF8.GetBytes(token.Trim());
|
||||
if (provided.Length != expected.Length || !CryptographicOperations.FixedTimeEquals(provided, expected))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid CSRF token.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,8 @@
|
||||
<PackageReference Include="Npgsql" Version="7.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| LEDGER-29-002 | DONE (2025-11-03) | Findings Ledger Guild | LEDGER-29-001 | Implement ledger write API (`POST /vuln/ledger/events`) with validation, idempotency, hash chaining, and Merkle root computation job.<br>2025-11-03: Minimal web service scaffolded with canonical hashing, in-memory repository, Merkle scheduler stub, request/response contracts, and unit tests for hashing + conflict flows. | Events persisted with chained hashes; Merkle job emits anchors; unit/integration tests cover happy/pathological cases. |
|
||||
| LEDGER-29-003 | DONE (2025-11-03) | Findings Ledger Guild, Scheduler Guild | LEDGER-29-001 | Build projector worker that derives `findings_projection` rows from ledger events + policy determinations; ensure idempotent replay keyed by `(tenant,finding_id,policy_version)`. | Postgres-backed projector worker and reducers landed with replay checkpointing, fixtures, and tests. |
|
||||
| LEDGER-29-004 | DONE (2025-11-04) | Findings Ledger Guild, Policy Guild | LEDGER-29-003, POLICY-ENGINE-27-001 | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.<br>2025-11-04: Remote evaluation service wired via typed HttpClient, cache, and fallback inline evaluator; `/api/policy/eval/batch` documented; `policy_rationale` persisted with deterministic hashing; ledger tests `dotnet test src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/StellaOps.Findings.Ledger.Tests.csproj --no-restore` green. | Projector fetches determinations efficiently; rationale stored for UI; regression tests cover version switches. |
|
||||
| LEDGER-29-005 | TODO | Findings Ledger Guild | LEDGER-29-003 | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. | API endpoints enforce business rules; attachments metadata stored; tests cover state machine transitions. |
|
||||
| LEDGER-29-006 | TODO | Findings Ledger Guild, Security Guild | LEDGER-29-002 | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. | Attachments encrypted and accessible via signed URLs; security tests verify expiry + scope. |
|
||||
| LEDGER-29-005 | DONE | Findings Ledger Guild | LEDGER-29-003 | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. | API endpoints enforce business rules; attachments metadata stored; tests cover state machine transitions. |
|
||||
| LEDGER-29-006 | DONE | Findings Ledger Guild, Security Guild | LEDGER-29-002 | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. | Attachments encrypted and accessible via signed URLs; security tests verify expiry + scope. |
|
||||
| LEDGER-29-007 | TODO | Findings Ledger Guild, Observability Guild | LEDGER-29-002..005 | Instrument metrics (`ledger_write_latency`, `projection_lag_seconds`, `ledger_events_total`), structured logs, and Merkle anchoring alerts; publish dashboards. | Metrics/traces emitted; dashboards live; alert thresholds documented. |
|
||||
| LEDGER-29-008 | TODO | Findings Ledger Guild, QA Guild | LEDGER-29-002..005 | Develop unit/property/integration tests, replay/restore tooling, determinism harness, and load tests at 5M findings/tenant. | CI suite green; load tests documented; determinism harness proves stable projections. |
|
||||
| LEDGER-29-009 | TODO | Findings Ledger Guild, DevOps Guild | LEDGER-29-002..008 | Provide deployment manifests (Helm/Compose), backup/restore guidance, Merkle anchor externalization (optional), and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup/restore scripts recorded; offline kit includes seed data. |
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace StellaOps.Findings.Ledger.Workflow;
|
||||
|
||||
public abstract record WorkflowMutationRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public Guid? ChainId { get; init; }
|
||||
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
public Guid? EventId { get; init; }
|
||||
|
||||
public string? PreviousStatus { get; init; }
|
||||
|
||||
public DateTimeOffset? OccurredAt { get; init; }
|
||||
|
||||
public required WorkflowActor Actor { get; init; }
|
||||
|
||||
public IReadOnlyList<WorkflowAttachmentMetadata> Attachments { get; init; } = Array.Empty<WorkflowAttachmentMetadata>();
|
||||
}
|
||||
|
||||
public sealed record AssignWorkflowRequest : WorkflowMutationRequest
|
||||
{
|
||||
public required WorkflowAssignee Assignee { get; init; }
|
||||
|
||||
public string? Comment { get; init; }
|
||||
|
||||
public string? Status { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CommentWorkflowRequest : WorkflowMutationRequest
|
||||
{
|
||||
public required string Comment { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AcceptRiskWorkflowRequest : WorkflowMutationRequest
|
||||
{
|
||||
public required string Justification { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public string? RiskOwner { get; init; }
|
||||
|
||||
public string? Status { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TargetFixWorkflowRequest : WorkflowMutationRequest
|
||||
{
|
||||
public required string Summary { get; init; }
|
||||
|
||||
public DateTimeOffset? TargetCompletion { get; init; }
|
||||
|
||||
public string? TicketId { get; init; }
|
||||
|
||||
public Uri? TicketUrl { get; init; }
|
||||
|
||||
public string? Status { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VerifyFixWorkflowRequest : WorkflowMutationRequest
|
||||
{
|
||||
public string? Evidence { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
|
||||
public string? Status { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReopenWorkflowRequest : WorkflowMutationRequest
|
||||
{
|
||||
public string? Reason { get; init; }
|
||||
|
||||
public string? Status { get; init; }
|
||||
}
|
||||
|
||||
public sealed record WorkflowActor(string Id, string Type, string? DisplayName = null);
|
||||
|
||||
public sealed record WorkflowAssignee(string Id, string Type, string? DisplayName = null);
|
||||
|
||||
public sealed record WorkflowAttachmentMetadata(
|
||||
string AttachmentId,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.InMemory;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.Findings.Ledger.Workflow;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public sealed class FindingWorkflowServiceTests
|
||||
{
|
||||
private readonly InMemoryLedgerEventRepository _repository = new();
|
||||
private readonly IFindingWorkflowService _service;
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-07T18:30:15Z"));
|
||||
|
||||
public FindingWorkflowServiceTests()
|
||||
{
|
||||
var writeService = new LedgerEventWriteService(
|
||||
_repository,
|
||||
new NoOpMerkleScheduler(),
|
||||
NullLogger<LedgerEventWriteService>.Instance);
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new LedgerServiceOptions
|
||||
{
|
||||
Attachments = new LedgerServiceOptions.AttachmentsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
EncryptionKey = Convert.ToBase64String(Enumerable.Repeat((byte)0x22, 32).ToArray()),
|
||||
SignedUrlBase = "https://evidence.local/attachments",
|
||||
SignedUrlSecret = "signed-secret",
|
||||
SignedUrlLifetime = TimeSpan.FromMinutes(5),
|
||||
RequireConsoleCsrf = false
|
||||
}
|
||||
});
|
||||
|
||||
_service = new FindingWorkflowService(
|
||||
_repository,
|
||||
writeService,
|
||||
new FakeAttachmentEncryptionService(),
|
||||
new FakeAttachmentUrlSigner(),
|
||||
options,
|
||||
_timeProvider,
|
||||
NullLogger<FindingWorkflowService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignAsync_WritesLedgerEventWithAssigneeAndAttachments()
|
||||
{
|
||||
var request = new AssignWorkflowRequest
|
||||
{
|
||||
TenantId = "tenant-east",
|
||||
PolicyVersion = "sha256:policy@1",
|
||||
FindingId = "finding|sha256:abc|CVE-2025-1234",
|
||||
ArtifactId = "sha256:abc",
|
||||
VulnerabilityId = "CVE-2025-1234",
|
||||
Actor = new WorkflowActor("user:alice", "operator", "Alice"),
|
||||
Assignee = new WorkflowAssignee("user:bob", "operator", "Bob"),
|
||||
Comment = "Taking ownership",
|
||||
Attachments = new[]
|
||||
{
|
||||
new WorkflowAttachmentMetadata(
|
||||
"att-1",
|
||||
"evidence.txt",
|
||||
"text/plain",
|
||||
128,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
new Dictionary<string, string> { ["scope"] = "triage" })
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.AssignAsync(request, CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(LedgerWriteStatus.Success);
|
||||
result.Record.Should().NotBeNull();
|
||||
var record = result.Record!;
|
||||
record.EventType.Should().Be(LedgerEventConstants.EventFindingAssignmentChanged);
|
||||
record.SequenceNumber.Should().Be(1);
|
||||
record.ChainId.Should().NotBe(Guid.Empty);
|
||||
record.OccurredAt.Should().Be(_timeProvider.UtcNow);
|
||||
|
||||
var payload = ExtractPayload(record);
|
||||
payload["action"]!.GetValue<string>().Should().Be("assign");
|
||||
payload["assignee"]!.AsObject()["id"]!.GetValue<string>().Should().Be("user:bob");
|
||||
var attachments = payload["attachments"]!.AsArray();
|
||||
attachments.Count.Should().Be(1);
|
||||
var attachmentNode = attachments[0]!.AsObject();
|
||||
attachmentNode["fileName"]!.GetValue<string>().Should().Be("evidence.txt");
|
||||
attachmentNode["signedUrl"]!.GetValue<string>().Should().StartWith("https://signed.local/");
|
||||
attachmentNode["envelope"]!.AsObject()["algorithm"]!.GetValue<string>().Should().Be("AES-256-GCM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptRiskAsync_DefaultsStatusAndPersistsJustification()
|
||||
{
|
||||
var request = new AcceptRiskWorkflowRequest
|
||||
{
|
||||
TenantId = "tenant-east",
|
||||
PolicyVersion = "sha256:policy@1",
|
||||
FindingId = "finding|sha256:abc|CVE-2025-1234",
|
||||
ArtifactId = "sha256:abc",
|
||||
VulnerabilityId = "CVE-2025-1234",
|
||||
Actor = new WorkflowActor("user:alice", "operator"),
|
||||
Justification = "Risk accepted for 30 days",
|
||||
ExpiresAt = _timeProvider.UtcNow.AddDays(30),
|
||||
RiskOwner = "ops-team"
|
||||
};
|
||||
|
||||
var result = await _service.AcceptRiskAsync(request, CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(LedgerWriteStatus.Success);
|
||||
var payload = ExtractPayload(result.Record!);
|
||||
payload["status"]!.GetValue<string>().Should().Be("accepted_risk");
|
||||
payload["justification"]!.GetValue<string>().Should().Be("Risk accepted for 30 days");
|
||||
payload["riskOwner"]!.GetValue<string>().Should().Be("ops-team");
|
||||
payload.TryGetPropertyValue("attachments", out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommentAsync_ValidatesCommentPresence()
|
||||
{
|
||||
var request = new CommentWorkflowRequest
|
||||
{
|
||||
TenantId = "tenant-east",
|
||||
PolicyVersion = "sha256:policy@1",
|
||||
FindingId = "finding|sha256:abc|CVE-2025-1234",
|
||||
ArtifactId = "sha256:abc",
|
||||
VulnerabilityId = "CVE-2025-1234",
|
||||
Actor = new WorkflowActor("user:alice", "operator"),
|
||||
Comment = " "
|
||||
};
|
||||
|
||||
var result = await _service.CommentAsync(request, CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(LedgerWriteStatus.ValidationFailed);
|
||||
result.Errors.Should().Contain("comment_required");
|
||||
}
|
||||
|
||||
private static JsonObject ExtractPayload(LedgerEventRecord record)
|
||||
{
|
||||
var eventNode = record.EventBody["event"]?.AsObject()
|
||||
?? throw new InvalidOperationException("event node missing");
|
||||
return eventNode["payload"]?.AsObject()
|
||||
?? throw new InvalidOperationException("payload node missing");
|
||||
}
|
||||
|
||||
private sealed class NoOpMerkleScheduler : IMerkleAnchorScheduler
|
||||
{
|
||||
public Task EnqueueAsync(LedgerEventRecord record, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime) => UtcNow = fixedTime;
|
||||
|
||||
public DateTimeOffset UtcNow { get; }
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => UtcNow;
|
||||
}
|
||||
|
||||
private sealed class FakeAttachmentEncryptionService : IAttachmentEncryptionService
|
||||
{
|
||||
public AttachmentEncryptionResult CreateEnvelope(NormalizedAttachment attachment, DateTimeOffset now)
|
||||
=> new(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Ciphertext: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("ciphertext")),
|
||||
Nonce: Convert.ToBase64String(new byte[] { 1, 2, 3 }),
|
||||
Tag: Convert.ToBase64String(new byte[] { 4, 5, 6 }),
|
||||
ExpiresAt: now.AddMinutes(5));
|
||||
}
|
||||
|
||||
private sealed class FakeAttachmentUrlSigner : IAttachmentUrlSigner
|
||||
{
|
||||
public AttachmentSignedUrl Sign(string attachmentId, DateTimeOffset now, TimeSpan lifetime)
|
||||
=> new(new Uri($"https://signed.local/{attachmentId}?sig=fake"), now.Add(lifetime));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user