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

- 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:
master
2025-11-08 20:53:45 +02:00
parent 515975edc5
commit 536f6249a6
837 changed files with 37279 additions and 14675 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,4 +19,8 @@
<PackageReference Include="Npgsql" Version="7.0.7" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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